mirror of
https://github.com/aphrody-labs/effectcraft.git
synced 2026-10-14 03:29:02 +00:00
Merge pull request #598 from storytold/claude/community-pr-review-lwtrlr
Land 49 community PRs, with follow-ups
This commit is contained in:
134 files changed
+12299
-599
No files matched your search
@@ -28,9 +28,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a
|
||||
# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -46,7 +47,7 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/effectcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
@@ -66,3 +67,16 @@ jobs:
|
||||
EOF
|
||||
- name: FreeBSD package layout (stub binaries)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
|
||||
windows-msi:
|
||||
name: Windows MSI install folder (stub binaries)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Install, upgrade, repair and uninstall
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-msi.ps1
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -72,11 +72,11 @@ jobs:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -125,11 +125,11 @@ jobs:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -185,8 +185,8 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -230,8 +230,8 @@ jobs:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/effectcraft-$EFFECTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -263,10 +263,10 @@ jobs:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/effectcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/effectcraft$|/bin/effectcraft-cli$|/share/applications/ai\.storyteller\.effectcraft\.desktop$|/share/doc/effectcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -305,11 +305,11 @@ jobs:
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
run: cargo xtask version set "$EFFECTCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -38,11 +38,11 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -118,3 +118,5 @@ under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
|
||||
- Appearance theme previews, drawn procedurally from original shapes: `crates/ui-egui/src/panels/settings.rs` (original work, MIT OR Apache-2.0).
|
||||
Generated
+14
@@ -1142,6 +1142,7 @@ dependencies = [
|
||||
"effectcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
@@ -1151,6 +1152,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1419,6 +1421,7 @@ dependencies = [
|
||||
"filmcraft-frame",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -2280,6 +2283,17 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
|
||||
@@ -264,7 +264,9 @@ send us right now.
|
||||
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
Installers and executables are code-signed. The installer asks where to install EffectCraft
|
||||
(Program Files by default), and later versions install into the same folder. To install silently
|
||||
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -283,6 +285,20 @@ Installers and executables are code-signed.
|
||||
| Fedora/RHEL/openSUSE | `effectcraft-<ver>-linux-x86_64.rpm` | `effectcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `effectcraft-<ver>-linux-x86_64.tar.gz` | `effectcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
**File drag-and-drop on Linux:** The current eframe/winit 0.30 native Wayland backend does not
|
||||
deliver file-hover or file-drop events. On Hyprland and other native Wayland sessions, use
|
||||
**File ▸ Import ▸ File…** (which accepts multiple files), or start EffectCraft via XWayland if
|
||||
XWayland is installed and `DISPLAY` is set:
|
||||
|
||||
```sh
|
||||
env -u WAYLAND_DISPLAY effectcraft
|
||||
```
|
||||
|
||||
When the backend delivers events (X11/XWayland, Windows and macOS), dropping media anywhere
|
||||
in the window imports it, and a drop-target hint appears while hovering. On native Wayland the
|
||||
app cannot detect an attempt that generates no event; it shows a startup notice with the
|
||||
File ▸ Import fallback instead.
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Roadmap
|
||||
|
||||
- 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.
|
||||
- 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows.
|
||||
- 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged.
|
||||
- 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
|
||||
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
@@ -39,6 +41,8 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
mod template;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
@@ -102,6 +106,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
render/render-frame --template T.ectemplate [--values V.json] render one template instance
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
@@ -137,6 +142,8 @@ const VALUED: &[&str] = &[
|
||||
"--depth",
|
||||
"--eval",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
"--format",
|
||||
"--start",
|
||||
"--end",
|
||||
@@ -419,6 +426,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if args.flag("--autosave") && cmd != "mcp" {
|
||||
return usage_err("--autosave is only supported by mcp");
|
||||
}
|
||||
template::validate(cmd, args)?;
|
||||
match cmd {
|
||||
"render" => render(args, json_out)?,
|
||||
"bench" => bench_cmd(args)?,
|
||||
@@ -550,7 +558,13 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
emit(&with_saved(v, saved), json_out);
|
||||
}
|
||||
"render-frame" | "frame" => {
|
||||
let mut b = backend(args, true)?;
|
||||
let mut b = if args.opt("--template").is_some() {
|
||||
let mut s = session(args)?;
|
||||
template::prepare(&mut s, args)?;
|
||||
Backend::headless(s)
|
||||
} else {
|
||||
backend(args, true)?
|
||||
};
|
||||
let comp = args.opt("--comp").and_then(reference);
|
||||
let mut time = args.num("--time")?;
|
||||
let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0);
|
||||
@@ -624,15 +638,19 @@ fn from_cwd(path: &str) -> Result<String, Failure> {
|
||||
/// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then
|
||||
/// render the queue with a progress line on stderr. Fails if any item fails.
|
||||
fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
use effectcraft_engine::project::render_queue::RenderStatus;
|
||||
use effectcraft_engine::project::render_queue::{OutputFormat, RenderStatus};
|
||||
if args.opt("--bridge").is_some() {
|
||||
return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge");
|
||||
}
|
||||
let mut s = session(args)?;
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
};
|
||||
if args.opt("--template").is_some() {
|
||||
template::prepare(&mut s, args)?;
|
||||
} else {
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
|
||||
};
|
||||
}
|
||||
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
|
||||
if !args.flag("--queue") {
|
||||
let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") };
|
||||
@@ -670,6 +688,11 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
if let Some(v) = args.num("--bitrate")? {
|
||||
p["bitrate"] = json!(v);
|
||||
// VP9 WebM encodes by quality unless told to target the bitrate (#440).
|
||||
// The format parser decides what is WebM, so its aliases (`vp9`, `webmvp9opus`) count.
|
||||
if p["format"].as_str().and_then(OutputFormat::from_name) == Some(OutputFormat::WebM) {
|
||||
p["webmBitrate"] = json!(true);
|
||||
}
|
||||
}
|
||||
for (flag, key) in [
|
||||
("--profile", "profile"),
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! CLI-only setup of an Essential Graphics template instance using engine commands.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use effectcraft_engine::{
|
||||
EngineError, Session,
|
||||
project::{ItemId, LayerId},
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{Args, Failure, usage_err};
|
||||
|
||||
pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> {
|
||||
if args.opt("--values").is_some() && args.opt("--template").is_none() {
|
||||
return usage_err("--values requires --template");
|
||||
}
|
||||
if args.opt("--template").is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(cmd, "render" | "render-frame" | "frame") {
|
||||
return usage_err("--template is available for render and render-frame");
|
||||
}
|
||||
if args.project.is_some() || args.flag("--demo") || args.opt("--bridge").is_some() || args.flag("--queue") || args.opt("--comp").is_some() {
|
||||
return usage_err("--template renders a new instance; it cannot be combined with --project, --demo, --bridge, --queue or --comp");
|
||||
}
|
||||
if !args.pos.is_empty() {
|
||||
return usage_err("template rendering takes --template FILE.ectemplate, without positional arguments");
|
||||
}
|
||||
if cmd == "render" && args.opt("--out").is_none() {
|
||||
return usage_err("render: --out FILE is required (or --queue)");
|
||||
}
|
||||
// Resolve existing outputs before importing or rendering can overwrite an input.
|
||||
let out = args.opt("--out").or_else(|| matches!(cmd, "render-frame" | "frame").then_some("frame.png"));
|
||||
if let Some(out) = out
|
||||
&& let Ok(output) = std::fs::canonicalize(out)
|
||||
{
|
||||
for input in ["--template", "--values"] {
|
||||
if let Some(path) = args.opt(input)
|
||||
&& std::fs::canonicalize(path).is_ok_and(|path| path == output)
|
||||
{
|
||||
return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn values(args: &Args) -> Result<Map<String, Value>, Failure> {
|
||||
let Some(path) = args.opt("--values") else { return Ok(Map::new()) };
|
||||
// A bounded read also handles a file that grows after opening.
|
||||
const MAX_BYTES: u64 = 1024 * 1024;
|
||||
let file = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err(Failure::Error(format!("values {path}: JSON exceeds 1 MiB")));
|
||||
}
|
||||
let value: Value = serde_json::from_slice(&bytes).map_err(|e| Failure::Error(format!("values {path}: invalid JSON: {e}")))?;
|
||||
match value {
|
||||
Value::Object(values) => Ok(values),
|
||||
_ => Err(Failure::Error(format!("values {path}: expected a JSON object mapping control names to values"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(s: &mut Session, command: &str, params: Value) -> Result<Value, Failure> {
|
||||
s.execute_checked(command, params).map_err(|e| Failure::Error(e.to_string()))
|
||||
}
|
||||
|
||||
// Use the same instance commands to reject invalid values before import extracts media.
|
||||
fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(), Failure> {
|
||||
let bytes = s.services.read_file(path).map_err(|e| Failure::Error(format!("cannot read {path}: {e}")))?;
|
||||
effectcraft_engine::guard::guarded("template value validation", || {
|
||||
let bad = |msg: String| EngineError::BadParams { cmd: "essential.importTemplate".into(), msg };
|
||||
let (manifest, mut project, _) = effectcraft_engine::commands::essential::read_template(&bytes).map_err(bad)?;
|
||||
let source = manifest.get("comp").and_then(Value::as_u64).ok_or_else(|| bad("manifest has no `comp`".into()))?;
|
||||
let base = s.project.next_id;
|
||||
let source = source.checked_add(base).ok_or_else(|| bad("template composition ID overflows the import offset".into()))?;
|
||||
effectcraft_engine::project::essential::offset_ids(&mut project, base);
|
||||
project.fix_next_id();
|
||||
let mut check = Session::new();
|
||||
check.project = project.into();
|
||||
Ok(instantiate(&mut check, source, values))
|
||||
})
|
||||
.map_err(|e| Failure::Error(e.to_string()))?
|
||||
}
|
||||
|
||||
pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
|
||||
let Some(path) = args.opt("--template") else { return Ok(()) };
|
||||
let values = values(args)?;
|
||||
if !values.is_empty() {
|
||||
preflight(s, path, &values)?;
|
||||
}
|
||||
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
||||
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
||||
instantiate(s, source, &values)
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
|
||||
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
||||
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
||||
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
||||
let parent = duplicate
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| Failure::Error("cannot create template render composition".into()))?;
|
||||
execute(s, "comp.open", json!({"comp": parent}))?;
|
||||
let layers: Vec<u64> = s
|
||||
.project
|
||||
.comp(ItemId(parent))
|
||||
.ok_or_else(|| Failure::Error("template render composition is missing".into()))?
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| layer.id.0)
|
||||
.collect();
|
||||
if !layers.is_empty() {
|
||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": layers, "switch": "lock", "value": false}))?;
|
||||
execute(s, "edit.clear", json!({"comp": parent, "layers": layers}))?;
|
||||
}
|
||||
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
|
||||
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
|
||||
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
|
||||
s.edit("Template Duration", None, |project, _| {
|
||||
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
|
||||
let duration = comp.duration;
|
||||
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
|
||||
layer.out_point = duration;
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| Failure::Error(e.to_string()))?;
|
||||
// Let the wrapper carry the source composition's authored motion blur.
|
||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
||||
for (control, value) in values {
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -28,7 +28,9 @@ fn ok_json(args: &[&str]) -> Value {
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
// macOS's `/var` is a symlink to `/private/var`: a child process resolves its working
|
||||
// directory through `current_dir()`, which is canonical, so compare canonical paths.
|
||||
std::fs::canonicalize(&d).unwrap_or(d).join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -373,7 +375,9 @@ fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() {
|
||||
assert_eq!(manifest["ended"], false);
|
||||
let mut restarted = McpChild::start(&config, true);
|
||||
let init = restarted.rpc("initialize", json!({}));
|
||||
assert!(init["instructions"].as_str().unwrap().contains(path));
|
||||
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
|
||||
// are escaped.
|
||||
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
|
||||
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
|
||||
restarted.tool("open_project", json!({"path":path}));
|
||||
let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"}));
|
||||
@@ -454,3 +458,33 @@ fn mcp_autosave_is_opt_in_and_rejects_invalid_combinations() {
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
|
||||
std::fs::remove_dir_all(config).unwrap();
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
let root = tmp("webm-bitrate");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let project = root.join("x.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"comp.new",
|
||||
r##"{"name":"T","width":320,"height":180,"duration":1}"##,
|
||||
"layer.newSolid",
|
||||
r##"{"width":320,"height":180}"##,
|
||||
"effect.apply",
|
||||
r##"{"layer":"#1","effect":"ec.noise.fractal"}"##,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
let size = |kbps: &str| {
|
||||
let out = root.join(format!("b{kbps}.webm"));
|
||||
ok_json(&["render", "--project", p, "--start", "0", "--end", "0.5", "--bitrate", kbps, "--out", out.to_str().unwrap()]);
|
||||
std::fs::metadata(&out).unwrap().len()
|
||||
};
|
||||
let (low, high) = (size("100"), size("8000"));
|
||||
assert!(high > low, "100 kbps: {low} bytes, 8000 kbps: {high} bytes");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an
|
||||
//! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on
|
||||
//! an instance, rendered, exported as a template and imported into a fresh project.
|
||||
//! an instance, rendered, exported as a template and imported into a fresh project. Direct
|
||||
//! template rendering uses the same original, procedural fixture under the project licence.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -111,5 +112,543 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
assert!(imp["saved"].is_string(), "{imp}");
|
||||
let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]);
|
||||
assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}");
|
||||
|
||||
// One-shot rendering applies the JSON values to an instance, preserving the template defaults.
|
||||
let original_template = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
|
||||
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "direct.png"]);
|
||||
assert_eq!(image::open(dir.join("direct.png")).unwrap().to_rgba8(), edit);
|
||||
ok(&dir, &["frame", "--template", "lt.ectemplate", "--frame", "12", "--out", "defaults.png"]);
|
||||
assert_eq!(image::open(dir.join("defaults.png")).unwrap().to_rgba8(), plain);
|
||||
|
||||
let output = dir.join("sequence/direct_[#####].png");
|
||||
let rendered = ok(
|
||||
&dir,
|
||||
&[
|
||||
"render",
|
||||
"--template",
|
||||
"lt.ectemplate",
|
||||
"--values",
|
||||
"values.json",
|
||||
"--out",
|
||||
output.to_str().unwrap(),
|
||||
"--format",
|
||||
"png",
|
||||
"--channels",
|
||||
"rgb",
|
||||
"--start",
|
||||
"1",
|
||||
"--end",
|
||||
"1.0833333333333333",
|
||||
],
|
||||
);
|
||||
assert_eq!(rendered["rendered"].as_array().unwrap().len(), 1, "{rendered}");
|
||||
let frames: Vec<_> = std::fs::read_dir(dir.join("sequence"))
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| path.extension().is_some_and(|extension| extension == "png"))
|
||||
.collect();
|
||||
assert_eq!(frames.len(), 1, "{frames:?}");
|
||||
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
|
||||
|
||||
// Invalid values fail before the queue creates an output directory, or frame rendering
|
||||
// overwrites an existing output. A JSON object and existing control names are required.
|
||||
for (name, values, expected) in [
|
||||
("malformed", "{", "invalid JSON"),
|
||||
("array", "[]", "JSON object"),
|
||||
("unknown", r#"{"Subtitle":"x"}"#, "controls: Title, Bar Color, Bar Opacity"),
|
||||
("invalid-value", r#"{"Bar Opacity":"bad"}"#, "can't use"),
|
||||
] {
|
||||
let values_file = format!("{name}.json");
|
||||
std::fs::write(dir.join(&values_file), values).unwrap();
|
||||
let output_dir = dir.join(format!("failed-{name}"));
|
||||
let out = output_dir.join("frame_[#####].png");
|
||||
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{name}: {error}");
|
||||
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
|
||||
assert!(!output_dir.exists(), "{name}: invalid input created render output");
|
||||
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
|
||||
assert_eq!(code, 1, "{name}: {error}");
|
||||
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
|
||||
}
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
|
||||
assert_eq!(code, 1, "{error}");
|
||||
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
|
||||
assert!(!dir.join("missing.png").exists());
|
||||
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
|
||||
assert_eq!(code, 1, "{error}");
|
||||
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
|
||||
assert!(!dir.join("large.png").exists());
|
||||
|
||||
for args in [
|
||||
vec!["render-frame", "--values", "values.json"],
|
||||
vec!["info", "--template", "lt.ectemplate"],
|
||||
vec!["render-frame", "--template", "lt.ectemplate", "--comp", "Lower Third"],
|
||||
vec!["render-frame", "t.ecproj", "--template", "lt.ectemplate"],
|
||||
vec!["render", "--template", "lt.ectemplate", "--queue"],
|
||||
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
|
||||
// Preserve that rate, Tick duration, work area, background and locked source layers.
|
||||
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
|
||||
let source = project.find_by_name("Lower Third").unwrap().id;
|
||||
let rate = effectcraft_time::FrameRate::new(24001, 1001);
|
||||
let comp = project.comp_mut(source).unwrap();
|
||||
comp.frame_rate = rate;
|
||||
comp.duration = rate.tick_of(24);
|
||||
comp.work_area = (rate.tick_of(3), rate.tick_of(7));
|
||||
comp.background = [0.08, 0.16, 0.24];
|
||||
comp.layers.first_mut().unwrap().switches.locked = true;
|
||||
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
|
||||
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
|
||||
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
|
||||
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
|
||||
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
|
||||
let output = dir.join("rational-work-area/frame_[#####].png");
|
||||
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
|
||||
let count = std::fs::read_dir(dir.join("rational-work-area"))
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.path().extension().is_some_and(|extension| extension == "png"))
|
||||
.count();
|
||||
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
|
||||
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
fn template_output_alias_fixture(case: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
assert_eq!(exported["controls"], 3, "{exported}");
|
||||
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
|
||||
// The same valid inputs must still render to a distinct output before testing aliases.
|
||||
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
|
||||
let image = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
|
||||
let bar = image.get_pixel(10, 75).0;
|
||||
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}");
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
|
||||
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), values_before);
|
||||
dir
|
||||
}
|
||||
|
||||
fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) {
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
// Read the inputs before interpreting the subprocess status or error text.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
.current_dir(dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_after = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let template_preserved = template_after == template_before;
|
||||
let values_preserved = values_after == values_before;
|
||||
eprintln!(
|
||||
"output={out:?} status={:?} template_preserved={template_preserved} values_preserved={values_preserved} template_bytes={}->{} values_bytes={}->{} stdout={} stderr={}",
|
||||
result.status.code(),
|
||||
template_before.len(),
|
||||
template_after.len(),
|
||||
values_before.len(),
|
||||
values_after.len(),
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr),
|
||||
);
|
||||
assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file");
|
||||
assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file");
|
||||
assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_frame_output_cannot_replace_its_template_input() {
|
||||
let dir = template_output_alias_fixture("template");
|
||||
assert_template_output_preserves_inputs(&dir, "lt.ectemplate");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_frame_output_cannot_replace_its_values_input() {
|
||||
let dir = template_output_alias_fixture("values");
|
||||
assert_template_output_preserves_inputs(&dir, "values.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_frame_output_cannot_alias_its_template_through_parent_components() {
|
||||
let dir = template_output_alias_fixture("normalized-template");
|
||||
std::fs::create_dir(dir.join("alias")).unwrap();
|
||||
let out = "alias/../lt.ectemplate";
|
||||
assert_eq!(dir.join(out).canonicalize().unwrap(), dir.join("lt.ectemplate").canonicalize().unwrap());
|
||||
assert_template_output_preserves_inputs(&dir, out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn timeline_tail_render_pair(dir: &PathBuf, name: &str, duration: effectcraft_time::Tick) {
|
||||
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("base.ecproj")).unwrap()).unwrap();
|
||||
let source = project.find_by_name("Lower Third").unwrap().id;
|
||||
let comp = project.comp_mut(source).unwrap();
|
||||
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(12, 1));
|
||||
comp.duration = duration;
|
||||
comp.work_area = (effectcraft_time::Tick::ZERO, duration);
|
||||
for layer in &mut comp.layers {
|
||||
layer.out_point = duration;
|
||||
}
|
||||
assert!(comp.layers.iter().all(|layer| layer.is_active_at(effectcraft_time::Tick::from_seconds_f64(1.0))));
|
||||
let project_name = format!("{name}.ecproj");
|
||||
let template_name = format!("{name}.ectemplate");
|
||||
let source_frame = format!("{name}-source.png");
|
||||
let template_frame = format!("{name}-template.png");
|
||||
std::fs::write(dir.join(&project_name), project.to_json()).unwrap();
|
||||
let params = serde_json::json!({"comp":"Lower Third", "path":template_name}).to_string();
|
||||
let exported = ok(dir, &["exec", "essential.exportTemplate", ¶ms, &project_name]);
|
||||
assert_eq!(exported["controls"], 3, "{exported}");
|
||||
let project_before = std::fs::read(dir.join(&project_name)).unwrap();
|
||||
let template_before = std::fs::read(dir.join(&template_name)).unwrap();
|
||||
|
||||
let direct = ok(dir, &["render-frame", &project_name, "--comp", "Lower Third", "--time", "1", "--out", &source_frame]);
|
||||
let instance = ok(dir, &["render-frame", "--template", &template_name, "--time", "1", "--out", &template_frame]);
|
||||
for result in [&direct, &instance] {
|
||||
assert_eq!(result["time"].as_f64(), Some(1.0), "{name}: {result}");
|
||||
assert_eq!((result["width"].as_u64(), result["height"].as_u64()), (Some(320), Some(90)), "{name}: {result}");
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join(&project_name)).unwrap(), project_before, "{name}: rendering preserves the source project bytes");
|
||||
assert_eq!(std::fs::read(dir.join(&template_name)).unwrap(), template_before, "{name}: rendering preserves the exported template bytes");
|
||||
let direct = image::open(dir.join(&source_frame)).unwrap().to_rgba8();
|
||||
let instance = image::open(dir.join(&template_frame)).unwrap().to_rgba8();
|
||||
assert_eq!(direct.dimensions(), (320, 90));
|
||||
assert_eq!(instance.dimensions(), direct.dimensions());
|
||||
let direct_bar = direct.get_pixel(10, 75).0;
|
||||
let instance_bar = instance.get_pixel(10, 75).0;
|
||||
assert!(
|
||||
direct_bar[0] > 240 && direct_bar[1] < 15 && direct_bar[2] < 15,
|
||||
"{name}: direct source has the authored red bar at the requested valid time: {direct_bar:?}"
|
||||
);
|
||||
let different = direct.pixels().zip(instance.pixels()).filter(|(a, b)| a != b).count();
|
||||
eprintln!("{name}: duration={duration:?}, direct_bar={direct_bar:?}, instance_bar={instance_bar:?}, different_pixels={different}");
|
||||
assert_eq!(different, 0, "{name}: the template instance must preserve every source pixel at the same valid time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_instance_preserves_the_partial_final_source_frame() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-partial-tail-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "base.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
|
||||
// Establish ordinary instance rendering before testing the final partial source frame.
|
||||
timeline_tail_render_pair(&dir, "aligned", effectcraft_time::Tick::from_seconds_f64(2.0));
|
||||
let partial = effectcraft_time::Tick(effectcraft_time::TICKS_PER_SECOND * 101 / 100);
|
||||
assert_eq!(partial.seconds(), 1.01);
|
||||
timeline_tail_render_pair(&dir, "partial", partial);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// Original procedural motion: the renderer supplies the expected pixels for both states.
|
||||
const GEOMETRY_BLUR_BUILD: &str = r#"
|
||||
app.beginUndoGroup("Build Blur Template");
|
||||
var comp = app.project.items.addComp("Blur Template", 160, 64, 1, 2, 24);
|
||||
var mover = comp.layers.addSolid([1, 0.3, 0.1], "Mover", 12, 16, 1, 2);
|
||||
var position = mover.property("ADBE Transform Group").property("ADBE Position");
|
||||
position.setValueAtTime(0, [16, 32]);
|
||||
position.setValueAtTime(1, [144, 32]);
|
||||
position.setInterpolationTypeAtKey(1, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
|
||||
position.setInterpolationTypeAtKey(2, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR);
|
||||
mover.motionBlur = true;
|
||||
comp.motionBlur = true;
|
||||
comp.shutterAngle = 180;
|
||||
comp.shutterPhase = -90;
|
||||
mover.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity");
|
||||
app.endUndoGroup();
|
||||
"#;
|
||||
|
||||
fn geometry_blur_difference(a: &image::RgbaImage, b: &image::RgbaImage) -> usize {
|
||||
assert_eq!(a.dimensions(), b.dimensions());
|
||||
a.pixels().zip(b.pixels()).filter(|(a, b)| a != b).count()
|
||||
}
|
||||
|
||||
fn geometry_blur_assert_inputs(dir: &std::path::Path, inputs: &[(&str, Vec<u8>)]) {
|
||||
for (name, before) in inputs {
|
||||
assert_eq!(&std::fs::read(dir.join(name)).unwrap(), before, "template rendering preserves input {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_wrapper_preserves_authored_motion_blur() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-authored-blur-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("motion.jsx"), GEOMETRY_BLUR_BUILD).unwrap();
|
||||
let built = ok(&dir, &["script", "motion.jsx", "--save-as", "moving.ecproj"]);
|
||||
assert_eq!(built["ok"], true, "{built}");
|
||||
|
||||
// Check the authored switch/key setup, then use an unchanged top-level composition as
|
||||
// the production pixel oracle. No expected raster or threshold is synthesized here.
|
||||
let project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("moving.ecproj")).unwrap()).unwrap();
|
||||
let source = project.find_by_name("Blur Template").unwrap().id;
|
||||
let comp = project.comp(source).unwrap();
|
||||
assert!(comp.enable_motion_blur);
|
||||
assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(24, 1));
|
||||
assert_eq!(comp.layers.len(), 1);
|
||||
assert!(comp.layers[0].switches.motion_blur);
|
||||
assert_eq!(comp.layers[0].props.prop("transform/position").unwrap().keys.len(), 2);
|
||||
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"moving.ectemplate"}"#, "moving.ecproj"]);
|
||||
assert_eq!(exported["controls"], 1, "{exported}");
|
||||
|
||||
// The positive control disables only the source layer's authored Motion Blur switch.
|
||||
// It retains the same geometry, timing, source comp settings and template-control values.
|
||||
ok(
|
||||
&dir,
|
||||
&[
|
||||
"run",
|
||||
"moving.ecproj",
|
||||
"layer.setSwitch",
|
||||
r#"{"comp":"Blur Template","layers":["Mover"],"switch":"motionBlur","value":false}"#,
|
||||
"--save-as",
|
||||
"unblurred.ecproj",
|
||||
],
|
||||
);
|
||||
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"unblurred.ectemplate"}"#, "unblurred.ecproj"]);
|
||||
assert_eq!(exported["controls"], 1, "{exported}");
|
||||
let inputs: Vec<_> = ["moving.ecproj", "moving.ectemplate", "unblurred.ecproj", "unblurred.ectemplate"]
|
||||
.into_iter()
|
||||
.map(|name| (name, std::fs::read(dir.join(name)).unwrap()))
|
||||
.collect();
|
||||
|
||||
ok(&dir, &["render-frame", "moving.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-blur.png"]);
|
||||
geometry_blur_assert_inputs(&dir, &inputs);
|
||||
ok(&dir, &["render-frame", "unblurred.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-unblurred.png"]);
|
||||
geometry_blur_assert_inputs(&dir, &inputs);
|
||||
ok(&dir, &["render-frame", "--template", "unblurred.ectemplate", "--time", "0.5", "--transparent", "--out", "template-unblurred.png"]);
|
||||
geometry_blur_assert_inputs(&dir, &inputs);
|
||||
let blurred = image::open(dir.join("direct-blur.png")).unwrap().to_rgba8();
|
||||
let unblurred = image::open(dir.join("direct-unblurred.png")).unwrap().to_rgba8();
|
||||
let template_unblurred = image::open(dir.join("template-unblurred.png")).unwrap().to_rgba8();
|
||||
assert_eq!(blurred.dimensions(), (160, 64));
|
||||
assert_eq!(unblurred.dimensions(), (160, 64));
|
||||
assert!(blurred.pixels().any(|p| p.0[3] > 0), "the direct blurred source is visible");
|
||||
assert!(unblurred.pixels().any(|p| p.0[3] > 0), "the direct unblurred source is visible");
|
||||
let authored_blur_difference = geometry_blur_difference(&blurred, &unblurred);
|
||||
assert!(authored_blur_difference > 0, "the real source renderer must show the authored motion blur");
|
||||
assert_eq!(template_unblurred, unblurred, "an unblurred template preserves its source pixels");
|
||||
eprintln!("CLI_TEMPLATE_BLUR_CONTROLS_PASSED source_blur_vs_source_unblurred={authored_blur_difference}");
|
||||
|
||||
ok(&dir, &["render-frame", "--template", "moving.ectemplate", "--time", "0.5", "--transparent", "--out", "template-blur.png"]);
|
||||
geometry_blur_assert_inputs(&dir, &inputs);
|
||||
let template_blurred = image::open(dir.join("template-blur.png")).unwrap().to_rgba8();
|
||||
let differing_pixels = geometry_blur_difference(&template_blurred, &blurred);
|
||||
let unblurred_difference = geometry_blur_difference(&template_blurred, &unblurred);
|
||||
eprintln!(
|
||||
"template authored-blur result: source_vs_template_differing_pixels={differing_pixels}, template_vs_unblurred_differing_pixels={unblurred_difference}"
|
||||
);
|
||||
assert_eq!(differing_pixels, 0, "template wrapper must preserve authored source motion blur");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_unknown_control_does_not_replace_extracted_media() {
|
||||
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||
let dir = temp_root.join(format!("ec-qa-template-embedded-media-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
|
||||
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||
let imported = &imported_reply["result"];
|
||||
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
|
||||
let items = imported["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1, "{imported}");
|
||||
let item = items[0].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||
ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
assert_eq!(exported["controls"], 3, "{exported}");
|
||||
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
|
||||
let media = info["media"].as_array().unwrap();
|
||||
assert_eq!(media.len(), 1, "{info}");
|
||||
let files = media[0]["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1, "{info}");
|
||||
let entry = files[0].as_str().unwrap();
|
||||
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
|
||||
assert_eq!(relative.components().count(), 2, "{entry}");
|
||||
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
|
||||
assert_eq!(relative.file_name().unwrap(), "source.png");
|
||||
let extracted = dir.join("lt Media").join(relative);
|
||||
assert!(extracted.starts_with(&dir));
|
||||
|
||||
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let source_before = std::fs::read(dir.join("source.png")).unwrap();
|
||||
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
|
||||
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
|
||||
let bar = ordinary.get_pixel(10, 75).0;
|
||||
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
|
||||
let footage = ordinary.get_pixel(8, 8).0;
|
||||
assert!(
|
||||
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
|
||||
"embedded footage rendered: {footage:?}"
|
||||
);
|
||||
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
|
||||
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
|
||||
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
|
||||
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
|
||||
|
||||
// Keep a different valid PNG at the exact extraction path and an existing render output.
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
|
||||
let sentinel_before = std::fs::read(&extracted).unwrap();
|
||||
assert_ne!(sentinel_before, source_before);
|
||||
std::fs::copy(dir.join("ordinary.png"), dir.join("failed.png")).unwrap();
|
||||
let output_before = std::fs::read(dir.join("failed.png")).unwrap();
|
||||
std::fs::write(dir.join("bad-values.json"), r#"{"Subtitle":"x"}"#).unwrap();
|
||||
let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap();
|
||||
|
||||
// Capture every file before interpreting status/JSON, so a failed command cannot hide a write.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
.current_dir(&dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let valid_values_after = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let invalid_values_after = std::fs::read(dir.join("bad-values.json")).unwrap();
|
||||
let source_after = std::fs::read(dir.join("source.png")).unwrap();
|
||||
let ordinary_after = std::fs::read(dir.join("ordinary.png")).unwrap();
|
||||
let output_after = std::fs::read(dir.join("failed.png")).unwrap();
|
||||
let sentinel_after = std::fs::read(&extracted).unwrap();
|
||||
let media_preserved = sentinel_after == sentinel_before;
|
||||
eprintln!(
|
||||
"EMBEDDED_MEDIA_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={}",
|
||||
result.status.code(),
|
||||
source_after == source_before,
|
||||
output_after == output_before,
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert_eq!(result.status.code(), Some(1));
|
||||
let reply: Value = serde_json::from_slice(&result.stdout).unwrap();
|
||||
let error = reply["error"].as_str().unwrap();
|
||||
assert!(error.contains("no control named `Subtitle`") && error.contains("controls: Title, Bar Color, Bar Opacity"), "{reply}");
|
||||
assert_eq!(template_after, template_before);
|
||||
assert_eq!(valid_values_after, valid_values_before);
|
||||
assert_eq!(invalid_values_after, invalid_values_before);
|
||||
assert_eq!(source_after, source_before);
|
||||
assert_eq!(ordinary_after, ordinary_before);
|
||||
assert_eq!(output_after, output_before);
|
||||
eprintln!("EMBEDDED_MEDIA_UNKNOWN_CONTROL: valid ordinary render and actual unknown-control failure verified");
|
||||
assert!(media_preserved, "invalid template values must fail before overwriting previously extracted media");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_missing_output_does_not_replace_extracted_media() {
|
||||
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||
let dir = temp_root.join(format!("ec-qa-template-missing-output-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
|
||||
// Both PNGs are generated here; the template uses an ordinary file-backed footage layer.
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||
let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||
let imported = &imported_reply["result"];
|
||||
assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}");
|
||||
let items = imported["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 1, "{imported}");
|
||||
let item = items[0].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||
ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||
let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
assert_eq!(exported["controls"], 3, "{exported}");
|
||||
let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]);
|
||||
let media = info["media"].as_array().unwrap();
|
||||
assert_eq!(media.len(), 1, "{info}");
|
||||
let files = media[0]["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1, "{info}");
|
||||
let entry = files[0].as_str().unwrap();
|
||||
let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap());
|
||||
assert_eq!(relative.components().count(), 2, "{entry}");
|
||||
assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}");
|
||||
assert_eq!(relative.file_name().unwrap(), "source.png");
|
||||
let extracted = dir.join("lt Media").join(relative);
|
||||
assert!(extracted.starts_with(&dir));
|
||||
|
||||
std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap();
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let valid_values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let source_before = std::fs::read(dir.join("source.png")).unwrap();
|
||||
ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]);
|
||||
let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8();
|
||||
let bar = ordinary.get_pixel(10, 75).0;
|
||||
assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}");
|
||||
let footage = ordinary.get_pixel(8, 8).0;
|
||||
assert!(
|
||||
(footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255,
|
||||
"embedded footage rendered: {footage:?}"
|
||||
);
|
||||
assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG");
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before);
|
||||
assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before);
|
||||
assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before);
|
||||
let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap();
|
||||
|
||||
// Keep a different valid PNG at the normal extraction path before a usage error.
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap();
|
||||
let sentinel_before = std::fs::read(&extracted).unwrap();
|
||||
assert_ne!(sentinel_before, source_before);
|
||||
|
||||
// Observe writes before interpreting the actual CLI usage exit.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
.current_dir(&dir)
|
||||
.args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_after = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let source_after = std::fs::read(dir.join("source.png")).unwrap();
|
||||
let output_after = std::fs::read(dir.join("ordinary.png")).unwrap();
|
||||
let sentinel_after = std::fs::read(&extracted).unwrap();
|
||||
let media_preserved = sentinel_after == sentinel_before;
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
eprintln!(
|
||||
"MISSING_OUTPUT_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={stderr}",
|
||||
result.status.code(),
|
||||
source_after == source_before,
|
||||
output_after == ordinary_before,
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
);
|
||||
assert_eq!(result.status.code(), Some(2), "{stderr}");
|
||||
assert!(stderr.contains("render: --out FILE is required (or --queue)"), "{stderr}");
|
||||
assert_eq!(template_after, template_before);
|
||||
assert_eq!(values_after, valid_values_before);
|
||||
assert_eq!(source_after, source_before);
|
||||
assert_eq!(output_after, ordinary_before);
|
||||
assert!(!dir.join("frame.png").exists(), "a missing render output must not create a default frame");
|
||||
eprintln!("MISSING_OUTPUT_REQUIRED_OUT: valid ordinary render/extraction and actual required-output usage failure verified");
|
||||
assert!(media_preserved, "missing --out must fail before overwriting previously extracted media");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -21,6 +21,11 @@ rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
|
||||
# Cargo.lock (accesskit, through eframe), so it adds no new crates.
|
||||
zbus = "5.19"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
@@ -35,3 +40,8 @@ muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
# NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] }
|
||||
# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
|
||||
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸
|
||||
//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG
|
||||
//! desktop portal instead. Other platforms use what egui reports.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The colour-scheme number inside a portal reply.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
// `Settings.Read` answers with a variant, and some portals nest another variant inside it.
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for desktops without the settings portal. It runs once at start-up, by absolute path,
|
||||
/// and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
|
||||
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
|
||||
pub fn service() -> Option<effectcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("appearance-portal".into())
|
||||
.spawn(move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before reading so a change during startup stays queued.
|
||||
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
// Startup can finish after the UI's bounded wait: wake its first frame too.
|
||||
if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial)
|
||||
&& let Some(ctx) = worker_wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
if worker_value.swap(code, Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = worker_wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The first frame already has the answer when the portal replies quickly.
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_and_gsettings_values_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::code_of(super::decode(2)), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, so the project never opened. Handling it ourselves needs Objective-C
|
||||
//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events`
|
||||
//! crate (also used by PhotoCraft and PdfCraft) wraps exactly that, an `NSAppleEventManager`
|
||||
//! handler registered before Finder's launch event that leaves winit's delegate alone, behind a
|
||||
//! safe main-thread API.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`Opened::feed`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Opened {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
Opened { inbox: self.inbox.clone(), pending: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Documents macOS asked us to open, waiting for the UI to take them.
|
||||
pub struct Opened {
|
||||
inbox: Inbox,
|
||||
/// Paths that arrived before the UI was ready (the Finder event that launched the app comes
|
||||
/// before the first frame is drawn).
|
||||
pending: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
/// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens,
|
||||
/// asking about unsaved changes first; media is imported), and act on quit requests.
|
||||
pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for e in self.inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => self.pending.extend(paths),
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
if self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !ready {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle));
|
||||
}
|
||||
}
|
||||
|
||||
/// A document macOS asked us to open, presented to egui as a dropped file.
|
||||
#[derive(Debug)]
|
||||
struct OpenedFile(PathBuf);
|
||||
|
||||
impl egui::DroppedFile for OpenedFile {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
std::fs::read(&self.0).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
mod appearance;
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod appimage;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod audio_out;
|
||||
mod control_server;
|
||||
mod launch_guard;
|
||||
@@ -36,6 +39,22 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
|
||||
/// Keep this classification shared by project opening and the subsequent media-import pass.
|
||||
fn is_project_file(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
|
||||
}
|
||||
|
||||
/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
|
||||
(wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
|
||||
.then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
|
||||
}
|
||||
|
||||
/// The command line.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
@@ -101,6 +120,12 @@ fn main() -> eframe::Result {
|
||||
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
|
||||
// appears shows how far start-up got (#234).
|
||||
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
let result = eframe::run_native(
|
||||
"EffectCraft",
|
||||
options,
|
||||
@@ -143,7 +168,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
session.load_settings();
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
@@ -151,7 +176,7 @@ fn main() -> eframe::Result {
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
|
||||
let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
|
||||
if !media.is_empty()
|
||||
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
||||
{
|
||||
@@ -170,6 +195,12 @@ fn main() -> eframe::Result {
|
||||
if let Some(r) = recovery {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
|
||||
&& app.ui.status.is_empty()
|
||||
{
|
||||
app.ui.status = note.into();
|
||||
}
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
@@ -179,6 +210,7 @@ fn main() -> eframe::Result {
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.audio_device = Some(Box::new(audio_out::open));
|
||||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
@@ -207,6 +239,8 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(Desktop {
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: native_menu::NativeBar::new(&cc.egui_ctx),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
app,
|
||||
launch,
|
||||
drawn: false,
|
||||
@@ -274,6 +308,9 @@ struct Desktop {
|
||||
app: EffectcraftApp,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: native_menu::NativeBar,
|
||||
/// Documents opened from Finder, the Dock or `open` (macOS).
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events::Opened,
|
||||
launch: launch_guard::Launch,
|
||||
/// The first frame was drawn (logged once).
|
||||
drawn: bool,
|
||||
@@ -300,6 +337,8 @@ impl eframe::App for Desktop {
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
}
|
||||
@@ -340,6 +379,17 @@ mod tests {
|
||||
parse_args(args.iter().map(|a| a.to_string()), None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn wayland_drop_notice_recommends_import_or_xwayland() {
|
||||
use std::ffi::OsStr;
|
||||
let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
|
||||
assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
|
||||
assert!(super::wayland_drop_notice(None, None).is_none());
|
||||
assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
|
||||
assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
|
||||
}
|
||||
|
||||
/// The window's device never asks for more than the adapter offers (#198).
|
||||
#[test]
|
||||
fn device_limits_fit_the_adapter() {
|
||||
@@ -353,6 +403,20 @@ mod tests {
|
||||
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
|
||||
}
|
||||
|
||||
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
|
||||
#[test]
|
||||
fn launch_classifies_xml_and_json_projects_as_projects() {
|
||||
for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
|
||||
assert!(super::is_project_file(path), "{path}");
|
||||
}
|
||||
for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
|
||||
assert!(!super::is_project_file(path), "{path}");
|
||||
}
|
||||
let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
|
||||
assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
|
||||
assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
|
||||
}
|
||||
|
||||
/// Launching without a project opens an empty project, not the demo (#204).
|
||||
#[test]
|
||||
fn no_arguments_start_an_empty_project() {
|
||||
|
||||
@@ -36,3 +36,16 @@ display_name = "ShirokoWaWu"
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Aavirash]
|
||||
display_name = "Asquareportal"
|
||||
|
||||
[people.tylerjenningsw]
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
sync_github_name = false
|
||||
|
||||
[people.llopjulen]
|
||||
real_name = "Julen Llop"
|
||||
display_name = "Julen Llop"
|
||||
sync_github_name = false
|
||||
@@ -102,28 +102,30 @@ impl McpServer {
|
||||
}
|
||||
|
||||
/// Serve until EOF on `input`. Input is read on its own thread so a long render can report
|
||||
/// progress, be cancelled and let other requests through while it runs (`long_job`).
|
||||
pub fn serve(&mut self, input: impl BufRead + Send, mut output: impl Write) -> std::io::Result<()> {
|
||||
/// progress, be cancelled and let other requests through while it runs (`long_job`). The
|
||||
/// reader thread is detached: when writing a reply fails, the server returns at once instead
|
||||
/// of waiting for a read that may never complete.
|
||||
pub fn serve(&mut self, input: impl BufRead + Send + 'static, mut output: impl Write) -> std::io::Result<()> {
|
||||
let (tx, inbox) = std::sync::mpsc::channel::<Option<String>>();
|
||||
// A read error ends the input like EOF and is returned once the queued requests are answered.
|
||||
let read_error = std::sync::Mutex::new(None::<std::io::Error>);
|
||||
let served = std::thread::scope(|scope| {
|
||||
let read_error = &read_error;
|
||||
std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || {
|
||||
for line in input.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(e) => {
|
||||
*read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if tx.send(Some(line)).is_err() {
|
||||
return;
|
||||
let read_error = std::sync::Arc::new(std::sync::Mutex::new(None::<std::io::Error>));
|
||||
let reader_error = read_error.clone();
|
||||
std::thread::Builder::new().name("mcp-input".into()).spawn(move || {
|
||||
for line in input.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(e) => {
|
||||
*reader_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if tx.send(Some(line)).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(None);
|
||||
})?;
|
||||
}
|
||||
let _ = tx.send(None);
|
||||
})?;
|
||||
let served = (|| {
|
||||
while let Ok(Some(line)) = inbox.recv() {
|
||||
let long = serde_json::from_str::<Value>(line.trim()).ok().and_then(|m| crate::long_job::long_call(self, &m));
|
||||
if let Some(call) = long {
|
||||
@@ -142,7 +144,7 @@ impl McpServer {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(()),
|
||||
}
|
||||
});
|
||||
})();
|
||||
match (served, self.checkpoint(true)) {
|
||||
(r, Ok(())) => r,
|
||||
(Ok(()), Err(e)) => Err(e),
|
||||
|
||||
@@ -35,6 +35,28 @@ fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value {
|
||||
serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_error_points_to_layer_inspection() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main"}}));
|
||||
let l = call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"text": "Hi"}}))["layer"].clone();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.renderer", "params": {"renderer": "advanced3d"}}));
|
||||
for tool in ["get_property", "set_property", "add_keyframe"] {
|
||||
let mut args = json!({"layer": l, "path": "geometryOptions", "time": 0});
|
||||
if tool != "get_property" {
|
||||
args["value"] = json!(0);
|
||||
}
|
||||
let (content, err) = call(&mut s, tool, args);
|
||||
assert!(err, "{tool}: {content:?}");
|
||||
let text = content[0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("is a property group"), "{tool}: {text}");
|
||||
assert!(text.contains("get_layer"), "{tool}: {text}");
|
||||
}
|
||||
let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true}));
|
||||
assert!(tree["properties"].as_array().unwrap().iter().any(|p| p["path"] == "geometryOptions/extrusionDepth"));
|
||||
assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_and_list_tools() {
|
||||
let mut s = server();
|
||||
@@ -135,6 +157,21 @@ fn headless_workflow() {
|
||||
let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0}));
|
||||
assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}");
|
||||
|
||||
// Comp-time keys on an offset layer (#257): the key lands where the Timeline shows it.
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.timing", "params": {"layer": l, "start": 1.0}}));
|
||||
let p = call_json(
|
||||
&mut s,
|
||||
"add_keyframe",
|
||||
json!({"layer": l, "path": "transform/scale", "keys": [{"time": 1.5, "value": [50, 50]}, {"time": 2.5, "value": [100, 100]}], "timeBase": "comp", "interpolation": "hold"}),
|
||||
);
|
||||
let times: Vec<f64> = p["keys"].as_array().unwrap().iter().map(|k| k["time"].as_f64().unwrap()).collect();
|
||||
// Comp times snap to the comp's frames (29.97 fps) before becoming layer time.
|
||||
assert!((times[0] - 0.5).abs() < 0.02 && (times[1] - 1.5).abs() < 0.02, "layer-time keys: {times:?}");
|
||||
assert_eq!(p["keys"][0]["out"], "Hold", "the comp-time keys were selected for the interpolation");
|
||||
call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/scale", "value": [75, 75], "time": 3.0, "timeBase": "comp"}));
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/scale"}));
|
||||
assert!(p["keys"].as_array().unwrap().iter().any(|k| (k["time"].as_f64().unwrap() - 2.0).abs() < 0.02), "{p}");
|
||||
|
||||
// Expression via set_property.
|
||||
let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"}));
|
||||
assert_eq!(p["expression"], "time * 90");
|
||||
@@ -211,7 +248,7 @@ fn stdio_loop() {
|
||||
.map(|v| v.to_string() + "\n")
|
||||
.collect::<String>();
|
||||
let mut out = Vec::new();
|
||||
s.serve(input.as_bytes(), &mut out).unwrap();
|
||||
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
|
||||
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[1]["id"], 2);
|
||||
@@ -229,7 +266,7 @@ fn headless_autosave_on_eof_preserves_unsaved_project() {
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "execute_command", "arguments": {"command": "layer.newText", "params": {"text": "Unsaved title"}}}}),
|
||||
].iter().map(|v| v.to_string() + "\n").collect::<String>();
|
||||
let mut output = Vec::new();
|
||||
s.serve(input.as_bytes(), &mut output).unwrap();
|
||||
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
|
||||
let path = s.backend().session().unwrap().autosave.last_path.clone();
|
||||
assert!(path.is_some(), "disconnect discarded the dirty project without an auto-save");
|
||||
let path = path.unwrap();
|
||||
@@ -269,7 +306,9 @@ fn autosave_is_durable_before_reply_and_queries_do_not_rotate() {
|
||||
drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists.
|
||||
let mut restarted = server().with_autosave(&dir).unwrap();
|
||||
let init = rpc(&mut restarted, 1, "initialize", json!({}));
|
||||
assert!(init["instructions"].as_str().unwrap().contains(path));
|
||||
// The instructions carry the auto-save state as JSON, where a Windows path's backslashes
|
||||
// are escaped.
|
||||
assert!(init["instructions"].as_str().unwrap().contains(&Value::from(path).to_string()));
|
||||
assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path);
|
||||
call_json(&mut restarted, "open_project", json!({"path": path}));
|
||||
assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable");
|
||||
@@ -351,6 +390,30 @@ fn autosave_marks_saved_or_undone_work_clean_without_losing_its_path() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A reply that cannot be written ends `serve` with the write error, even while stdin stays open
|
||||
/// (the client is gone but never closed its end): the reader thread must not be waited for.
|
||||
#[test]
|
||||
fn broken_output_returns_while_stdin_stays_open() {
|
||||
struct Broken;
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let (in_r, mut in_w) = std::io::pipe().unwrap();
|
||||
writeln!(in_w, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap();
|
||||
let (done_tx, done) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(server().serve(BufReader::new(in_r), Broken));
|
||||
});
|
||||
let result = done.recv_timeout(std::time::Duration::from_secs(10)).expect("serve must return while stdin is still open");
|
||||
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::BrokenPipe);
|
||||
drop(in_w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_flushes_on_read_errors_and_broken_output() {
|
||||
struct Broken;
|
||||
@@ -897,6 +960,61 @@ impl effectcraft_engine::Exporter for SlowExporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/mcp.md "Requests during a render": while a blocking render runs, every way of starting a
|
||||
/// second render is refused (blocking, `wait: false`, inside a batch), and edits are applied to
|
||||
/// the project without changing the running job, which renders the snapshot it started with.
|
||||
#[test]
|
||||
fn second_render_is_refused_and_edits_apply_during_a_render() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-busy-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (in_r, mut in_w) = std::io::pipe().unwrap();
|
||||
let (out_r, out_w) = std::io::pipe().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
|
||||
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
|
||||
});
|
||||
let mut lines = BufReader::new(out_r).lines();
|
||||
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
|
||||
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
|
||||
let call =
|
||||
|id: u64, name: &str, arguments: Value| json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
|
||||
next();
|
||||
send(call(2, "command_run", json!({"id": "comp.new", "params": {"width": 64, "height": 64, "duration": 1}})));
|
||||
assert_eq!(next()["result"]["isError"], false);
|
||||
let a = dir.join("a.mp4").to_string_lossy().to_string();
|
||||
send(call(3, "command_run", json!({"id": "renderQueue.add", "params": {"output": a}})));
|
||||
assert_eq!(next()["result"]["isError"], false);
|
||||
send(call(4, "command_run", json!({"id": "renderQueue.render"})));
|
||||
send(call(5, "command_run", json!({"id": "renderQueue.render", "params": {"wait": false}})));
|
||||
send(call(6, "command_run", json!({"id": "renderQueue.render"})));
|
||||
send(call(7, "command_batch", json!({"steps": [{"id": "renderQueue.render", "params": {"wait": false}}]})));
|
||||
send(call(8, "command_run", json!({"id": "comp.new", "params": {"name": "Added while rendering", "width": 32, "height": 32, "duration": 1}})));
|
||||
let mut replies = std::collections::HashMap::new();
|
||||
while !replies.contains_key(&4) {
|
||||
let m = next();
|
||||
replies.insert(m["id"].as_u64().unwrap(), m);
|
||||
}
|
||||
for id in [5, 6, 7] {
|
||||
let m = &replies[&id];
|
||||
assert_eq!(m["result"]["isError"], true, "a second render is refused: {m}");
|
||||
let text = m["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("already in progress") || text.contains("is rendering"), "{m}");
|
||||
}
|
||||
assert_eq!(replies[&8]["result"]["isError"], false, "edits are applied during a render");
|
||||
let done = &replies[&4];
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(v["items"].as_array().unwrap().len(), 1, "{v}");
|
||||
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
|
||||
send(call(9, "doc_inspect", json!({})));
|
||||
let doc = next();
|
||||
assert!(doc.to_string().contains("Added while rendering"), "{doc}");
|
||||
drop(in_w);
|
||||
server.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// docs/mcp.md "Progress and cancellation" over the stdio loop: a blocking `renderQueue.render`
|
||||
/// reports `notifications/progress` for its token, other requests are answered while it renders,
|
||||
/// and `notifications/cancelled` stops it, deletes the partial file and sends no response.
|
||||
@@ -991,7 +1109,6 @@ fn render_progress_and_cancel() {
|
||||
assert!(!b.exists(), "partial output deleted");
|
||||
assert!(std::path::Path::new(&a).exists(), "completed earlier queue item is retained");
|
||||
assert_eq!(v["items"][1]["statusLabel"], "User Stopped", "{v}");
|
||||
drop(send);
|
||||
drop(in_w);
|
||||
server.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -319,6 +319,9 @@ fn set_property(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
match time {
|
||||
Some(t) => {
|
||||
p["time"] = t.clone();
|
||||
if let Some(tb) = get(a, "timeBase") {
|
||||
p["timeBase"] = tb.clone();
|
||||
}
|
||||
b.exec("prop.addKey", p)?;
|
||||
}
|
||||
None => {
|
||||
@@ -351,6 +354,9 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
if let Some(v) = get(k, "value") {
|
||||
p["value"] = v.clone();
|
||||
}
|
||||
if let Some(tb) = get(a, "timeBase") {
|
||||
p["timeBase"] = tb.clone();
|
||||
}
|
||||
b.exec("prop.addKey", p)?;
|
||||
}
|
||||
let info = b.exec("prop.get", obj(&base))?;
|
||||
@@ -360,7 +366,11 @@ fn add_keyframe(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
b.exec("comp.open", json!({"comp": c}))?;
|
||||
}
|
||||
let sel: Vec<Value> = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect();
|
||||
b.exec("keys.select", json!({"keys": sel}))?;
|
||||
let mut select = json!({"keys": sel});
|
||||
if let Some(tb) = get(a, "timeBase") {
|
||||
select["timeBase"] = tb.clone();
|
||||
}
|
||||
b.exec("keys.select", select)?;
|
||||
match interp.to_ascii_lowercase().as_str() {
|
||||
"easyease" | "ease" => b.exec("keys.easyEase", json!({}))?,
|
||||
"easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?,
|
||||
@@ -592,7 +602,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_layer",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
@@ -628,6 +638,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
json!({
|
||||
"layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(),
|
||||
"time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."},
|
||||
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
|
||||
"expression": {"type": "string", "description": "Expression text; empty string removes the expression."}
|
||||
}),
|
||||
&["layer", "path"],
|
||||
@@ -644,6 +655,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
json!({
|
||||
"layer": layer_s(), "path": path_s(), "comp": comp_s(),
|
||||
"time": {"type": "number", "description": "Key time in seconds (layer time)."},
|
||||
"timeBase": {"type": "string", "enum": ["layer", "comp"], "description": "What `time` counts: layer time (the default) or comp time, as the Timeline shows it."},
|
||||
"value": value_s(),
|
||||
"keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."},
|
||||
"interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]}
|
||||
|
||||
@@ -171,8 +171,7 @@ const CC_COMPOSITE_MODES: [&str; 16] = [
|
||||
"Stencil Alpha",
|
||||
];
|
||||
|
||||
/// Composites the original over/under/with the effect result. The original is the image as it
|
||||
/// enters this effect (the host does not expose the pre-effect source).
|
||||
/// Composites the original stack input over/under/with the result of preceding effects.
|
||||
fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
|
||||
let m = ctx.params.e("compositeOriginal");
|
||||
@@ -194,9 +193,10 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
15 => BlendMode::StencilAlpha,
|
||||
_ => BlendMode::Normal,
|
||||
};
|
||||
b.img.data.par_iter_mut().for_each(|px| {
|
||||
let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale));
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let result = *px;
|
||||
let orig = result.map(|v| v * op);
|
||||
let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op);
|
||||
let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };
|
||||
o[3] = o[3].clamp(0.0, 1.0);
|
||||
if rgb_only {
|
||||
@@ -446,3 +446,53 @@ pub(crate) mod tests {
|
||||
assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod original_tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, Image, LayerPixels};
|
||||
struct Original(Buf);
|
||||
impl EffectHost for Original {
|
||||
fn original(&self) -> Option<&Buf> {
|
||||
Some(&self.0)
|
||||
}
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn composite_preserves_alpha_opacity_and_padded_alignment() {
|
||||
let spec = crate::find("ec.channel.cccomposite").unwrap();
|
||||
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
let host = Original(Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 0.5]), offset: [0.0; 2], scale: 1.0 });
|
||||
for (mode, opacity, rgb, want) in [
|
||||
(0, 100.0, false, [0.5, 0.0, 0.125, 0.625]),
|
||||
(1, 100.0, false, [0.375, 0.0, 0.25, 0.625]),
|
||||
(0, 50.0, false, [0.25, 0.0, 0.1875, 0.4375]),
|
||||
(0, 100.0, true, [0.2, 0.0, 0.05, 0.25]),
|
||||
] {
|
||||
params.values.insert("compositeOriginal".into(), Value::Enum(mode));
|
||||
params.values.insert("opacity".into(), num(opacity));
|
||||
params.values.insert("rgbOnly".into(), Value::Bool(rgb));
|
||||
let ctx = EffectCtx {
|
||||
params: ¶ms,
|
||||
time: 0.0,
|
||||
layer_size: [1.0; 2],
|
||||
seed: 0,
|
||||
adjustment: false,
|
||||
env: EffectEnv { host: Some(&host), ..Default::default() },
|
||||
};
|
||||
let mut input = Buf { img: Image::filled(1, 1, [0.0, 0.0, 0.25, 0.25]), offset: [0.0; 2], scale: 1.0 };
|
||||
input.pad(1);
|
||||
let out = cc_composite(&ctx, input);
|
||||
for (a, b) in out.img.get(1, 1).iter().zip(want) {
|
||||
assert!((a - b).abs() < 1e-6, "{:?} vs {want:?}", out.img.get(1, 1));
|
||||
}
|
||||
assert_eq!(out.img.get(0, 0), [0.0; 4]);
|
||||
assert_eq!(out.offset, [1.0; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Deterministic rendered-frame comparison helpers for the fidelity corpus.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
/// How far a rendered frame is from its reference.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FrameScore {
|
||||
/// Pixels compared (the frames' width × height).
|
||||
pub compared_pixels: usize,
|
||||
/// Mean absolute difference over every channel sample.
|
||||
pub mean_absolute_error: f64,
|
||||
/// Largest absolute difference of any channel sample.
|
||||
pub max_error: f32,
|
||||
}
|
||||
|
||||
/// Compare two premultiplied rendered frames. A size mismatch is reported as an error instead of
|
||||
/// silently producing a misleading score.
|
||||
pub fn compare_frames(actual: &Image, reference: &Image) -> Result<FrameScore, String> {
|
||||
if actual.width != reference.width || actual.height != reference.height || actual.data.len() != reference.data.len() {
|
||||
return Err(format!("frame size mismatch: {}x{} vs {}x{}", actual.width, actual.height, reference.width, reference.height));
|
||||
}
|
||||
let mut sum = 0.0f64;
|
||||
let mut max = 0.0f32;
|
||||
for (a, b) in actual.data.iter().zip(&reference.data) {
|
||||
for (&x, &y) in a.iter().zip(b) {
|
||||
let error = (x - y).abs();
|
||||
// A NaN would make the mean NaN, which a `mean > tolerance` check reads as a pass.
|
||||
if !error.is_finite() {
|
||||
return Err("frame has a non-finite sample".into());
|
||||
}
|
||||
sum += f64::from(error);
|
||||
max = max.max(error);
|
||||
}
|
||||
}
|
||||
let compared_pixels = actual.data.len();
|
||||
let samples = compared_pixels.saturating_mul(4).max(1);
|
||||
Ok(FrameScore { compared_pixels, mean_absolute_error: sum / samples as f64, max_error: max })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_pixel_error_and_rejects_size_mismatch() {
|
||||
let mut actual = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
|
||||
let reference = Image::filled(2, 1, [0.0, 0.0, 0.0, 1.0]);
|
||||
actual.data[1][0] = 0.5;
|
||||
let score = compare_frames(&actual, &reference).expect("same size");
|
||||
assert_eq!(score.compared_pixels, 2);
|
||||
assert!((score.mean_absolute_error - 0.0625).abs() < f64::EPSILON);
|
||||
assert_eq!(score.max_error, 0.5);
|
||||
assert!(compare_frames(&actual, &Image::new(1, 1)).is_err());
|
||||
actual.data[0][1] = f32::NAN;
|
||||
assert!(compare_frames(&actual, &reference).is_err(), "NaN is an error, not a pass");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod distort2;
|
||||
mod distort3;
|
||||
pub mod distort4;
|
||||
pub mod face_track;
|
||||
pub mod fidelity;
|
||||
mod generate;
|
||||
mod generate2;
|
||||
mod generate3;
|
||||
@@ -299,8 +300,14 @@ pub struct LayerPixels {
|
||||
|
||||
/// Services the renderer offers effects that look beyond their own pixels.
|
||||
pub trait EffectHost: Sync {
|
||||
/// Pixels entering the effect stack, in effect space, before any effects. For an
|
||||
/// adjustment layer this is the composition below it, in the effect working space.
|
||||
fn original(&self) -> Option<&Buf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
|
||||
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
|
||||
/// selects "Effects & Masks" over "Source". `None` for missing/recursive references; own Source reads are supported.
|
||||
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
|
||||
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
|
||||
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.
|
||||
|
||||
@@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option<FileXform> {
|
||||
}
|
||||
let mut m = [0.0; 16];
|
||||
let mut offset = [0.0; 4];
|
||||
for (r, row) in v.chunks_exact(4).enumerate() {
|
||||
for (r, row) in v.as_chunks::<4>().0.iter().enumerate() {
|
||||
m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]);
|
||||
offset[r] = row[3] / 65535.0;
|
||||
}
|
||||
|
||||
@@ -450,6 +450,17 @@ pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPix
|
||||
})
|
||||
}
|
||||
|
||||
/// Resample a buffer into another buffer's pixel grid without stretching its layer space.
|
||||
/// Used to composite the pre-effect image after earlier effects expand or shift the bounds.
|
||||
pub fn fit_buffer(source: &crate::Buf, width: u32, height: u32, offset: [f64; 2], scale: f64) -> Image {
|
||||
let inv = 1.0 / scale.max(1e-9);
|
||||
gen_image(width, height, |x, y| {
|
||||
let sx = (x as f64 + 0.5 - offset[0]) * inv * source.scale + source.offset[0];
|
||||
let sy = (y as f64 + 0.5 - offset[1]) * inv * source.scale + source.offset[1];
|
||||
source.img.sample_bilinear(sx, sy)
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
|
||||
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
|
||||
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {
|
||||
|
||||
@@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::prop::{KeyTimeBase, key_time, key_timing, prop_ref};
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p};
|
||||
use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
@@ -206,11 +206,13 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "keys.set")?;
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?);
|
||||
let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64);
|
||||
let base = KeyTimeBase::from_params(p, "keys.set")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
|
||||
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
|
||||
let value = p.get("value").cloned();
|
||||
let fr = s.project.comp(cid).map(|c| c.frame_rate);
|
||||
s.edit("Edit Keyframe", merge_p(p), |proj, st| {
|
||||
let nt = s.edit("Edit Keyframe", 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.set", "no property"))?;
|
||||
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?;
|
||||
@@ -221,10 +223,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut nt = old_t;
|
||||
if let Some(n) = new_t {
|
||||
nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n);
|
||||
nt = n;
|
||||
// Keys can't pass their neighbours or share a time.
|
||||
let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN };
|
||||
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX };
|
||||
let lo = if i > 0 { pr.keys[i - 1].time.0.saturating_add(1) } else { i64::MIN };
|
||||
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0.saturating_sub(1) } else { i64::MAX };
|
||||
nt = Tick(nt.0.clamp(lo, hi));
|
||||
pr.keys[i].time = nt;
|
||||
}
|
||||
@@ -233,8 +235,10 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) {
|
||||
k.time = nt;
|
||||
}
|
||||
Ok(json!({"time": nt.seconds()}))
|
||||
})
|
||||
Ok(nt)
|
||||
})?;
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
Ok(key_timing(layer, nt))
|
||||
}
|
||||
|
||||
fn nearest_key(keys: &[Keyframe], t: Tick) -> Option<usize> {
|
||||
@@ -449,7 +453,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s,
|
||||
&json!({"frames": -10})
|
||||
)),
|
||||
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
|
||||
cmd!(
|
||||
"keys.set",
|
||||
"Edit Keyframe",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, time (layer s by default), newTime? (same base), timeBase?: layer|comp, value?, merge?}",
|
||||
has_comp,
|
||||
set_key
|
||||
),
|
||||
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
|
||||
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
|
||||
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
|
||||
|
||||
@@ -911,41 +911,7 @@ fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if s.importer.is_none() {
|
||||
return Err(EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
let mut updates = vec![];
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
// An image sequence picks up frames added to (or removed from) its run.
|
||||
let mut nf = match s.probe_footage(&f.path) {
|
||||
Ok(n) => n,
|
||||
Err(_) => Footage { missing: true, ..f.clone() },
|
||||
};
|
||||
// Keep the interpretation.
|
||||
nf.alpha = f.alpha;
|
||||
nf.loop_count = f.loop_count;
|
||||
nf.pixel_aspect = f.pixel_aspect;
|
||||
nf.start_timecode = f.start_timecode;
|
||||
if nf.kind == FootageKind::Sequence && f.kind == FootageKind::Sequence {
|
||||
nf.frame_rate = f.frame_rate;
|
||||
nf.native_rate = f.native_rate;
|
||||
nf.alphabetical = f.alphabetical;
|
||||
nf.sync_sequence_duration();
|
||||
}
|
||||
updates.push((*i, nf));
|
||||
}
|
||||
}
|
||||
let n = updates.len();
|
||||
s.edit("Reload Footage", None, |proj, _| {
|
||||
for (i, f) in updates {
|
||||
if let Some(it) = proj.item_mut(i) {
|
||||
it.kind = ItemKind::Footage(f);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
let n = crate::footage_reload::reload_items(s, &items)?;
|
||||
Ok(json!({"reloaded": n}))
|
||||
}
|
||||
|
||||
@@ -1009,6 +975,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_items,
|
||||
crate::footage_check::command
|
||||
),
|
||||
cmd!(
|
||||
"footage.reloadChanged",
|
||||
"Reload Changed Footage",
|
||||
[],
|
||||
None,
|
||||
"{} — reload the footage whose file changed on disk since it was last seen (size or modification time; Settings ▸ Import ▸ Automatically Reload Footage runs this in the background; image sequences only with All Footage); files seen for the first time are only remembered. Returns {files, reloaded}",
|
||||
has_items,
|
||||
crate::footage_reload::command
|
||||
),
|
||||
cmd!(
|
||||
"file.runScript",
|
||||
"Run Script File...",
|
||||
|
||||
@@ -106,5 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fe!("layer.openLayer", "Open Layer", ["Layer"], None, "{layer?}", has_layers),
|
||||
fe!("effect.manage", "Manage Effects...", ["Effect"], None, "{}", always),
|
||||
fe!("anim.browsePresets", "Browse Presets...", ["Animation"], None, "{}", always),
|
||||
// Project panel: delete the selected items without the prompt that Delete shows when
|
||||
// compositions use them. The UI runs `project.delete` (agents can run it directly).
|
||||
fe!(
|
||||
"project.deleteWithoutConfirmation",
|
||||
"Delete Project Items Without Confirmation",
|
||||
[],
|
||||
Some("Shift+Delete"),
|
||||
"{} — the Project panel's selection, with the layers that use it; one undo step",
|
||||
always
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -173,7 +173,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer_name = str_p(p, "name").map(str::to_string);
|
||||
apply_text_params("layer.newText", &q, &mut doc, None)?;
|
||||
let mut pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
|
||||
let mut pos = position_p(p, "layer.newText")?;
|
||||
if let Some([x, y, w, h]) = bx {
|
||||
let (w, h) = (w.abs().max(1.0), h.abs().max(1.0));
|
||||
doc.box_size = Some([w, h]);
|
||||
@@ -542,7 +542,7 @@ fn track_matte(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// How a 2D layer's transform changes so it stays where it is when its parent changes.
|
||||
/// Re-express a layer in its new parent space without moving its anchor.
|
||||
pub(crate) struct ParentFix {
|
||||
pub layer: LayerId,
|
||||
/// Maps Position from the old parent's space into the new one's.
|
||||
@@ -551,12 +551,80 @@ pub(crate) struct ParentFix {
|
||||
rotation: f64,
|
||||
/// Multiply Scale.
|
||||
scale: [f64; 2],
|
||||
/// 3D AV layers retain their world-space anchor, including Z.
|
||||
position_3d: Option<effectcraft_geom::Mat4>,
|
||||
/// A uniform relative parent transform can preserve static Orientation and Scale.
|
||||
pose_3d: Option<([f64; 3], f64)>,
|
||||
}
|
||||
|
||||
impl ParentFix {
|
||||
/// Re-express the layer's Position, Rotation and Scale (every key) in the new parent's space.
|
||||
pub fn apply(&self, l: &mut Layer) {
|
||||
let Some(tr) = l.props.sub_mut("transform") else { return };
|
||||
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 map_position = |v: &KV| {
|
||||
let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3()));
|
||||
KV::Vec3([q.x, q.y, q.z])
|
||||
};
|
||||
// Read all separated axes before writing any of them.
|
||||
let names = ["positionX", "positionY", "positionZ"];
|
||||
if tr.get("positionX").is_some() {
|
||||
let axes = names.map(|name| tr.get(name).cloned());
|
||||
let current = axes.each_ref().map(|pr| pr.as_ref().map(|pr| pr.value.as_f64()).unwrap_or(0.0));
|
||||
let q = m.apply(effectcraft_geom::Vec3::from(current));
|
||||
for (i, name) in names.into_iter().enumerate() {
|
||||
if let Some(pr) = tr.get_mut(name) {
|
||||
pr.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
|
||||
for key in &mut pr.keys {
|
||||
let p = axes.each_ref().map(|axis| axis.as_ref().map(|axis| axis.value_at(key.time).as_f64()).unwrap_or(0.0));
|
||||
let q = m.apply(effectcraft_geom::Vec3::from(p));
|
||||
key.value = KV::Scalar([q.x, q.y, q.z].get(i).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("position") {
|
||||
pr.value = map_position(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = map_position(&key.value);
|
||||
// Spatial tangents are vectors, so the parent's translation is excluded.
|
||||
for tangent in [&mut key.spatial_in, &mut key.spatial_out] {
|
||||
let v = m.apply_vec(effectcraft_geom::Vec3::from(*tangent));
|
||||
*tangent = [v.x, v.y, v.z];
|
||||
}
|
||||
}
|
||||
if let Some(factor) = position_speed
|
||||
.filter(|_| pr.spatial)
|
||||
.filter(|factor| pr.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * *factor).is_finite()))
|
||||
{
|
||||
for key in &mut pr.keys {
|
||||
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
|
||||
ease.speed *= factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((orientation, factor)) = self.pose_3d {
|
||||
if let Some(pr) = tr.get_mut("orientation") {
|
||||
pr.value = KV::Vec3(orientation);
|
||||
}
|
||||
if let Some(pr) = tr.get_mut("scale") {
|
||||
let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor));
|
||||
pr.value = scaled(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
key.value = scaled(&key.value);
|
||||
// Scale's temporal speeds use the same units as its values.
|
||||
for ease in key.in_ease.iter_mut().chain(&mut key.out_ease) {
|
||||
ease.speed *= factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
|
||||
pr.value = f(&pr.value);
|
||||
for key in &mut pr.keys {
|
||||
@@ -606,7 +674,42 @@ impl ParentFix {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like the pick-whip, a parent change keeps 2D layers where they are: their transform is
|
||||
/// 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;
|
||||
}
|
||||
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);
|
||||
if !magnitude.is_finite() || magnitude <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let c = columns.map(|c| c / magnitude);
|
||||
if c.iter().any(|c| (c.dot(*c) - 1.0).abs() > 1e-9) || c[0].dot(c[1]).abs() > 1e-9 || c[0].dot(c[2]).abs() > 1e-9 || c[1].dot(c[2]).abs() > 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let cross = vec3(c[1].y * c[2].z - c[1].z * c[2].y, c[1].z * c[2].x - c[1].x * c[2].z, c[1].x * c[2].y - c[1].y * c[2].x);
|
||||
let factor = if c[0].dot(cross) < 0.0 { -magnitude } else { magnitude };
|
||||
let mut rotation = Mat4::IDENTITY;
|
||||
for row in 0..3 {
|
||||
for col in 0..3 {
|
||||
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;
|
||||
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) };
|
||||
Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor))
|
||||
}
|
||||
|
||||
/// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is
|
||||
/// re-expressed in the new parent's space (`None`: the composition), evaluated at the current
|
||||
/// time.
|
||||
pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option<LayerId>) -> Vec<ParentFix> {
|
||||
@@ -625,16 +728,59 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
||||
let new_m = space(parent);
|
||||
let Some(inv) = new_m.inverse() else { return vec![] };
|
||||
let (rn, sxn, syn) = decompose(&new_m);
|
||||
comp.layers
|
||||
let mut fixes: Vec<_> = comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent)
|
||||
.map(|l| {
|
||||
let old_m = space(l.parent);
|
||||
let (ro, sxo, syo) = decompose(&old_m);
|
||||
let scale = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
|
||||
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale }
|
||||
ParentFix { layer: l.id, m: inv * old_m, rotation: ro - rn, scale, position_3d: None, pose_3d: None }
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let space_3d = |id: Option<LayerId>| {
|
||||
id.and_then(|id| comp.layer(id)).map_or(effectcraft_geom::Mat4::IDENTITY, |layer| {
|
||||
// Parent transforms do not inherit the parent's source pixel-aspect stretch.
|
||||
let world = ectx.world_matrix(layer);
|
||||
let par = ectx.par_ratio(layer);
|
||||
let a = layer.transform().map(|tr| ectx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
||||
let anchor = effectcraft_geom::vec3(a[0], a[1], if layer.is_3d() { a[2] } else { 0.0 });
|
||||
world
|
||||
* effectcraft_geom::Mat4::translate(anchor)
|
||||
* effectcraft_geom::Mat4::scale(effectcraft_geom::vec3(1.0 / par, 1.0, 1.0))
|
||||
* effectcraft_geom::Mat4::translate(-anchor)
|
||||
})
|
||||
};
|
||||
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) {
|
||||
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| {
|
||||
let orientation = tr.get("orientation")?;
|
||||
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.
|
||||
if !orientation.keys.is_empty()
|
||||
|| orientation.expr.is_some()
|
||||
|| scale.expr.is_some()
|
||||
|| layer.auto_orient != effectcraft_project::AutoOrient::Off
|
||||
{
|
||||
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)
|
||||
});
|
||||
fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d });
|
||||
}
|
||||
}
|
||||
}
|
||||
fixes
|
||||
}
|
||||
|
||||
/// Validate the proposed ancestor chain before any authored edit, including when compensation
|
||||
@@ -1347,7 +1493,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
track_matte
|
||||
),
|
||||
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null}", has_layers, set_parent),
|
||||
cmd!("layer.setParent", "Parent", [], None, "{layers?, parent: layer|null, compensate?: bool}", has_layers, set_parent),
|
||||
cmd!(
|
||||
"layer.timing",
|
||||
"Layer Timing",
|
||||
@@ -1402,7 +1548,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, OpenType: discretionaryLigatures?, contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01…ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -48,6 +48,7 @@ mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod relink;
|
||||
mod render_queue;
|
||||
pub(crate) mod rig3d;
|
||||
pub mod roto_cmds;
|
||||
@@ -169,6 +170,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(key_labels::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(relink::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(timeline::specs());
|
||||
v.extend(comp_more::specs());
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
|
||||
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
|
||||
|
||||
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
|
||||
@@ -13,10 +13,23 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
if let Some(u) = p.get("prop").and_then(Value::as_u64) {
|
||||
return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| bad(cmd, format!("no property @{u}")));
|
||||
return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| {
|
||||
if layer.props.find_group(u).is_some() {
|
||||
bad(cmd, format!("@{u} is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property"))
|
||||
} else {
|
||||
bad(cmd, format!("no property @{u}"))
|
||||
}
|
||||
});
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
|
||||
let pr = layer.props.prop(path).ok_or_else(|| bad(cmd, format!("no property `{path}`")))?;
|
||||
let pr = layer.props.prop(path).ok_or_else(|| {
|
||||
let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid)));
|
||||
if group.is_some() {
|
||||
bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property"))
|
||||
} else {
|
||||
bad(cmd, format!("no property `{path}`"))
|
||||
}
|
||||
})?;
|
||||
Ok((cid, lid, pr.uid))
|
||||
}
|
||||
|
||||
@@ -29,7 +42,7 @@ fn with_prop<T>(
|
||||
uid: Uid,
|
||||
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let t = s.time();
|
||||
let t = s.time_of(cid);
|
||||
let linear = s.prefs.general.default_spatial_linear;
|
||||
s.edit(label, merge, |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
@@ -50,12 +63,106 @@ fn with_prop<T>(
|
||||
})
|
||||
}
|
||||
|
||||
/// An explicit key time (layer seconds) moved onto the nearest comp frame.
|
||||
fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option<Tick> {
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time")?);
|
||||
let comp = s.project.comp(cid)?;
|
||||
let l = comp.layer(lid)?;
|
||||
Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t))))
|
||||
/// Explicit command key times default to layer time; readers and the CTI use comp time.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum KeyTimeBase {
|
||||
Layer,
|
||||
Comp,
|
||||
}
|
||||
|
||||
impl KeyTimeBase {
|
||||
pub(crate) fn from_params(p: &Value, cmd: &str) -> Result<Self> {
|
||||
match p.get("timeBase") {
|
||||
None => Ok(Self::Layer),
|
||||
Some(Value::String(b)) if b == "layer" => Ok(Self::Layer),
|
||||
Some(Value::String(b)) if b == "comp" => Ok(Self::Comp),
|
||||
Some(_) => Err(bad(cmd, "`timeBase` must be layer or comp")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a supplied time to stored layer ticks, snapping writes in the comp's frame grid.
|
||||
pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: &str, base: KeyTimeBase, cmd: &str, snap: bool) -> Result<Option<Tick>> {
|
||||
let Some(value) = p.get(param) else { return Ok(None) };
|
||||
let seconds = value
|
||||
.as_f64()
|
||||
.filter(|t| t.is_finite() && *t >= Tick::MIN.seconds() && *t <= Tick::MAX.seconds())
|
||||
.ok_or_else(|| bad(cmd, format!("`{param}` must be finite seconds within the supported time range")))?;
|
||||
if !layer.stretch.is_finite() || layer.stretch == 0.0 {
|
||||
return Err(bad(cmd, "the layer has an invalid time stretch"));
|
||||
}
|
||||
let t = Tick::from_seconds_f64(seconds);
|
||||
let conversion_error = || bad(cmd, format!("`{param}` converts outside the supported time range"));
|
||||
let in_range = |ticks: i64| (Tick::MIN.0..=Tick::MAX.0).contains(&ticks);
|
||||
// Validate before Layer's saturating conversion: otherwise a huge stretch can silently
|
||||
// clamp an intended key to a different comp time while still producing a valid Tick.
|
||||
let scaled_tick = |ticks: f64| -> Result<Tick> {
|
||||
if !ticks.is_finite() || ticks < Tick::MIN.0 as f64 || ticks > Tick::MAX.0 as f64 {
|
||||
return Err(conversion_error());
|
||||
}
|
||||
let ticks = ticks as i64;
|
||||
if !in_range(ticks) {
|
||||
return Err(conversion_error());
|
||||
}
|
||||
Ok(Tick(ticks))
|
||||
};
|
||||
let normal = (layer.stretch - 100.0).abs() < 1e-9;
|
||||
let ct = match base {
|
||||
KeyTimeBase::Layer => {
|
||||
let offset = if normal { t } else { scaled_tick(t.0 as f64 * layer.stretch / 100.0)? };
|
||||
Tick(layer.start_time.0.checked_add(offset.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?)
|
||||
}
|
||||
KeyTimeBase::Comp => t,
|
||||
};
|
||||
let ct = if snap { frame_rate.snap_nearest(ct) } else { ct };
|
||||
if !in_range(ct.0) {
|
||||
return Err(conversion_error());
|
||||
}
|
||||
let offset = ct.0.checked_sub(layer.start_time.0).filter(|t| in_range(*t)).ok_or_else(conversion_error)?;
|
||||
let lt = if normal { Tick(offset) } else { scaled_tick(offset as f64 * 100.0 / layer.stretch)? };
|
||||
// Unsnapped legacy layer times remain exact, rather than making a float round-trip.
|
||||
Ok(Some(if !snap && matches!(base, KeyTimeBase::Layer) { t } else { lt }))
|
||||
}
|
||||
|
||||
pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value {
|
||||
let ct = layer.comp_time(t);
|
||||
let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()});
|
||||
if !layer.is_active_at(ct) {
|
||||
out["warning"] = json!(format!(
|
||||
"Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).",
|
||||
ct.seconds(),
|
||||
layer.in_point.seconds(),
|
||||
layer.out_point.seconds()
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Omitted bases keep the original JSON reply; explicit bases opt in to timing metadata.
|
||||
fn key_reply(p: &Value, mut timing: Value, field: &str, result: Value) -> Value {
|
||||
if p.get("timeBase").is_some() {
|
||||
timing[field] = result;
|
||||
timing
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value, cmd: &str) -> Result<Option<Tick>> {
|
||||
let base = KeyTimeBase::from_params(p, cmd)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
key_time(layer, comp.frame_rate, p, "time", base, cmd, true)
|
||||
}
|
||||
|
||||
fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Value) -> Value {
|
||||
if p.get("timeBase").is_some() {
|
||||
let mut timing = key_timing(layer, time);
|
||||
timing["animated"] = json!(pr.is_animated());
|
||||
key_reply(p, timing, "value", value)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -87,7 +194,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!(out));
|
||||
}
|
||||
let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone();
|
||||
let at = snapped_key_time(s, cid, lid, p);
|
||||
let at = snapped_key_time(s, cid, lid, p, "prop.set")?;
|
||||
let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| {
|
||||
let cur = pr.value_at(lt);
|
||||
let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?;
|
||||
@@ -100,7 +207,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pr.set_value_at(at.unwrap_or(lt), nv.clone());
|
||||
Ok(nv.to_json())
|
||||
})?;
|
||||
Ok(out)
|
||||
if p.get("timeBase").is_none() {
|
||||
return Ok(out);
|
||||
}
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let pr = layer.props.find(uid).ok_or_else(|| bad("prop.set", "property vanished"))?;
|
||||
Ok(property_reply(p, layer, pr, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid))), out))
|
||||
}
|
||||
|
||||
/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression.
|
||||
@@ -108,9 +220,18 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.get")?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
|
||||
let keys: Vec<Value> =
|
||||
pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect();
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let keys: Vec<Value> = pr
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let mut info = key_timing(l, k.time);
|
||||
info["value"] = k.value.to_json();
|
||||
info["in"] = json!(k.in_interp.label());
|
||||
info["out"] = json!(k.out_interp.label());
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
let raw = pr.value_at(l.layer_time(t));
|
||||
let mut out = json!({
|
||||
"layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(),
|
||||
@@ -156,9 +277,9 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?;
|
||||
let at = snapped_key_time(s, cid, lid, p);
|
||||
let at = snapped_key_time(s, cid, lid, p, "prop.addKey")?;
|
||||
let value = p.get("value").cloned();
|
||||
with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
|
||||
let count = with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
|
||||
let t = at.unwrap_or(lt);
|
||||
let cur = pr.value_at(t);
|
||||
let v = match &value {
|
||||
@@ -171,7 +292,12 @@ fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
set_key(&mut pr.keys, k);
|
||||
Ok(json!(pr.keys.len()))
|
||||
})
|
||||
})?;
|
||||
if p.get("timeBase").is_none() {
|
||||
return Ok(count);
|
||||
}
|
||||
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
Ok(key_reply(p, key_timing(layer, at.unwrap_or_else(|| layer.layer_time(s.time_of(cid)))), "count", count))
|
||||
}
|
||||
|
||||
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -233,8 +359,38 @@ fn toggle_transform_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(added))
|
||||
}
|
||||
|
||||
/// Add, toggle or set one property's expression (`def` is the starting text of a new one).
|
||||
fn apply_expr(pr: &mut Property, text: &Option<String>, enabled: Option<bool>, def: &str) {
|
||||
match (text, enabled) {
|
||||
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
|
||||
(Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }),
|
||||
(None, Some(e)) => {
|
||||
if let Some(x) = &mut pr.expr {
|
||||
x.enabled = e;
|
||||
} else if e {
|
||||
pr.expr = Some(effectcraft_project::Expression { text: def.to_string(), enabled: true });
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.to_string(), enabled: true }) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `{layer?, path|prop, ...}` for one property, or (Animation > Add Expression) no `path`/`prop`
|
||||
/// for the properties selected in the timeline, all in one undo step.
|
||||
fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?;
|
||||
const C: &str = "prop.setExpression";
|
||||
let explicit = p.get("path").is_some() || p.get("prop").is_some();
|
||||
let targets: Vec<(ItemId, LayerId, Uid)> = if explicit {
|
||||
vec![prop_ref(s, p, C)?]
|
||||
} else {
|
||||
let cid = comp_id(s, p)?;
|
||||
selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)"));
|
||||
}
|
||||
let text = p.get("expression").and_then(Value::as_str).map(str::to_string);
|
||||
let mut enabled = b_p(p, "enabled");
|
||||
// A syntax error keeps the text but disables the expression (AE shows the warning bar).
|
||||
@@ -246,24 +402,22 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
enabled = Some(false);
|
||||
}
|
||||
// Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`).
|
||||
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into());
|
||||
let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
|
||||
match (&text, enabled) {
|
||||
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
|
||||
(Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }),
|
||||
(None, Some(e)) => {
|
||||
if let Some(x) = &mut pr.expr {
|
||||
x.enabled = e;
|
||||
} else if e {
|
||||
pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true });
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) };
|
||||
}
|
||||
let defs: Vec<String> = targets
|
||||
.iter()
|
||||
.map(|(cid, lid, uid)| {
|
||||
s.project.comp(*cid).and_then(|c| c.layer(*lid).and_then(|l| super::link::reference(c, l, l, *uid, true))).unwrap_or_else(|| "value".into())
|
||||
})
|
||||
.collect();
|
||||
let results = s.edit("Expression", merge_p(p), |proj, _| {
|
||||
let mut out = vec![];
|
||||
for ((cid, lid, uid), def) in targets.iter().zip(&defs) {
|
||||
let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?;
|
||||
apply_expr(pr, &text, enabled, def);
|
||||
out.push(json!(pr.expr.as_ref().map(|e| e.text.clone())));
|
||||
}
|
||||
Ok(json!(pr.expr.as_ref().map(|e| e.text.clone())))
|
||||
Ok(out)
|
||||
})?;
|
||||
let r = if explicit { results.into_iter().next().unwrap_or(Value::Null) } else { Value::Array(results) };
|
||||
if let Some(e) = error {
|
||||
s.toast(format!("Expression disabled: {e}"));
|
||||
return Ok(json!({"expression": r, "error": e}));
|
||||
@@ -346,6 +500,24 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Deselect only the named property, preserving unrelated property, layer, key and
|
||||
/// mask-vertex selections. Selected keys also imply selectedProperties in the script API.
|
||||
fn deselect_prop(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
// Groups (masks, effects) are selected by uid too, so resolve the uid as prop.select does.
|
||||
let (_, lid) = layer_p(s, params, "prop.deselect")?;
|
||||
let uid = match params.get("prop").and_then(Value::as_u64) {
|
||||
Some(u) => u,
|
||||
None => prop_ref(s, params, "prop.deselect")?.2,
|
||||
};
|
||||
let mask = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(s.time()))).map(|(mask, _)| mask);
|
||||
s.state.selected_props.retain(|(layer, prop)| *layer != lid || *prop != uid);
|
||||
s.state.selected_keys.retain(|key| key.layer != lid || key.prop != uid);
|
||||
if let Some(mask) = mask {
|
||||
s.state.selected_vertices.retain(|v| v.layer != lid || v.mask != mask);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
|
||||
/// of points at layer time `lt`.
|
||||
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
|
||||
@@ -366,6 +538,7 @@ fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
|
||||
// ---------------------------------------------------------------- keyframes
|
||||
|
||||
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = KeyTimeBase::from_params(p, "keys.select")?;
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
if let Some(Value::Array(keys)) = p.get("keys") {
|
||||
@@ -382,7 +555,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))),
|
||||
}
|
||||
.ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?;
|
||||
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
|
||||
let t = key_time(ly, comp.frame_rate, k, "time", base, "keys.select", false)?.ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
|
||||
// Snap to the stored key time.
|
||||
let kt = pr
|
||||
.keys
|
||||
@@ -432,7 +605,28 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
if p.get("timeBase").is_none() {
|
||||
return Ok(json!(s.state.selected_keys.len()));
|
||||
}
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let keys: Vec<Value> = s
|
||||
.state
|
||||
.selected_keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let layer = comp.layer(k.layer)?;
|
||||
let mut timing = key_timing(layer, k.time);
|
||||
timing["layer"] = json!(k.layer.0);
|
||||
timing["prop"] = json!(k.prop);
|
||||
Some(timing)
|
||||
})
|
||||
.collect();
|
||||
let warnings: Vec<&str> = keys.iter().filter_map(|k| k.get("warning").and_then(Value::as_str)).collect();
|
||||
let mut out = json!({"count": s.state.selected_keys.len(), "keys": keys});
|
||||
if !warnings.is_empty() {
|
||||
out["warning"] = json!(warnings.join(" "));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
|
||||
@@ -441,49 +635,53 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
||||
let sel = s.state.selected_keys.clone();
|
||||
s.edit(label, merge, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut new_sel = vec![];
|
||||
let mut remapped: std::collections::HashMap<KeyRef, KeyRef> = Default::default();
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
// Process in an order that avoids collisions when moving.
|
||||
// Process each stored key once, in an order that avoids collisions when moving.
|
||||
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
|
||||
idx.sort_by_key(|x| x.0);
|
||||
let mut moved: Vec<Keyframe> = vec![];
|
||||
let mut keep_times = vec![];
|
||||
for (i, _) in idx.iter().rev() {
|
||||
let mut t = pr.keys[*i].time;
|
||||
idx.dedup_by_key(|x| x.0);
|
||||
let mut moved: Vec<(Tick, Keyframe)> = vec![];
|
||||
// Target time -> surviving source time. A moved key replaces an existing key at
|
||||
// its destination, including its selection identity.
|
||||
let mut targets: std::collections::HashMap<Tick, Tick> = Default::default();
|
||||
for (i, old) in idx.iter().rev() {
|
||||
let Some(key) = pr.keys.get(*i) else { continue };
|
||||
let mut t = key.time;
|
||||
if f(&mut pr.keys, *i, &mut t) {
|
||||
let mut k = pr.keys.remove(*i);
|
||||
k.time = t;
|
||||
moved.push(k);
|
||||
} else if *i < pr.keys.len() {
|
||||
keep_times.push(pr.keys[*i].time);
|
||||
if *i < pr.keys.len() {
|
||||
let mut k = pr.keys.remove(*i);
|
||||
k.time = t;
|
||||
moved.push((*old, k));
|
||||
}
|
||||
} else if key_at(&pr.keys, *old).is_some() {
|
||||
targets.insert(*old, *old);
|
||||
}
|
||||
}
|
||||
for k in moved {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
for (old, k) in moved {
|
||||
targets.insert(k.time, old);
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
|
||||
Ok(j) => pr.keys[j] = k,
|
||||
Err(j) => pr.keys.insert(j, k),
|
||||
}
|
||||
}
|
||||
for t in keep_times {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: t });
|
||||
}
|
||||
// Roving keys follow their neighbours: re-time and keep the selection on them.
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
|
||||
for r in new_sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
|
||||
if let Some(i) = before.iter().position(|t| *t == r.time) {
|
||||
r.time = pr.keys[i].time;
|
||||
}
|
||||
// Resolve exact stored indices before roving changes their times. Deleted or
|
||||
// stale references cannot select a neighbouring key.
|
||||
let tracked: Vec<(Tick, usize)> = targets.into_iter().filter_map(|(at, old)| key_at(&pr.keys, at).map(|i| (old, i))).collect();
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
||||
for (old, i) in tracked {
|
||||
if let Some(key) = pr.keys.get(i) {
|
||||
remapped.insert(KeyRef { layer: lid, prop: uid, time: old }, KeyRef { layer: lid, prop: uid, time: key.time });
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys = new_sel;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
st.selected_keys = sel.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
@@ -796,9 +994,17 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
|
||||
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
|
||||
cmd!(
|
||||
"prop.set",
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}",
|
||||
has_layers,
|
||||
set
|
||||
),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
||||
cmd!(
|
||||
"keys.toggleTransform",
|
||||
@@ -809,7 +1015,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
toggle_transform_key
|
||||
),
|
||||
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
|
||||
cmd!(
|
||||
"prop.setExpression",
|
||||
"Add Expression",
|
||||
["Animation"],
|
||||
Some("Alt+Shift+="),
|
||||
"{layer?, path|prop?, expression?, enabled?} (no path/prop: the selected properties)",
|
||||
has_layers,
|
||||
set_expr
|
||||
),
|
||||
cmd!(
|
||||
"prop.reset",
|
||||
"Reset Property",
|
||||
@@ -820,6 +1034,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
reset
|
||||
),
|
||||
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
|
||||
cmd!("prop.deselect", "Deselect Property", [], None, "{layer?, path|prop}", has_layers, deselect_prop),
|
||||
cmd!(
|
||||
"prop.convertExpressionToKeyframes",
|
||||
"Convert Expression to Keyframes",
|
||||
@@ -834,7 +1049,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s by default)}], timeBase?: layer|comp, add?, toggle?: bool (Shift+click: in or out of the selection), selectProperties?: bool (their properties and layers too, as a Timeline click does)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
//! Recover moved media without replacing interpretation, sequence ranges or layer timing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, str_p};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
const COMMAND: &str = "file.relinkFootage";
|
||||
const ENTRY_LIMIT: usize = 100_000;
|
||||
|
||||
fn enabled(_: &Session) -> std::result::Result<(), String> {
|
||||
if cfg!(target_arch = "wasm32") { Err("folder relinking requires the desktop filesystem".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn files(folder: &Path, ctl: &crate::jobs::TaskCtl) -> std::result::Result<BTreeMap<String, Vec<String>>, String> {
|
||||
let mut pending = vec![folder.to_path_buf()];
|
||||
let mut found: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let mut count = 0usize;
|
||||
while let Some(dir) = pending.pop() {
|
||||
ctl.message(dir.display().to_string());
|
||||
let entries = std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
if ctl.cancelled() {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
count = count.saturating_add(1);
|
||||
if count > ENTRY_LIMIT {
|
||||
return Err(format!("search exceeds {ENTRY_LIMIT} entries; choose a smaller folder"));
|
||||
}
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let ty = entry.file_type().map_err(|e| format!("{}: {e}", entry.path().display()))?;
|
||||
// Do not follow symlinks: no cycles or traversal outside the chosen tree.
|
||||
if ty.is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else if ty.is_file()
|
||||
&& let (Some(name), Some(path)) = (entry.file_name().to_str(), entry.path().to_str())
|
||||
{
|
||||
found.entry(name.to_string()).or_default().push(path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for paths in found.values_mut() {
|
||||
paths.sort();
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn compatible(old: &Footage, new: &Footage) -> bool {
|
||||
let kind = if old.kind == FootageKind::Sequence { FootageKind::Still } else { old.kind };
|
||||
kind == new.kind
|
||||
&& old.width == new.width
|
||||
&& old.height == new.height
|
||||
&& old.has_video == new.has_video
|
||||
&& old.has_audio == new.has_audio
|
||||
&& (old.kind == FootageKind::Audio || (old.width > 0 && old.height > 0))
|
||||
&& (old.codec.is_empty() || old.codec == new.codec)
|
||||
&& (!matches!(old.kind, FootageKind::Video | FootageKind::Audio)
|
||||
|| (old.native_rate.unwrap_or(old.frame_rate) == new.frame_rate && old.frame_rate.frame_at(old.duration) == new.frame_rate.frame_at(new.duration)))
|
||||
}
|
||||
|
||||
fn sequence_at(old: &Footage, path: &str) -> Option<Vec<String>> {
|
||||
let parent = Path::new(path).parent()?;
|
||||
old.sequence
|
||||
.iter()
|
||||
.map(|file| {
|
||||
let mapped = parent.join(Path::new(file).file_name()?);
|
||||
std::fs::symlink_metadata(&mapped).is_ok_and(|m| m.is_file()).then(|| mapped.to_str().map(str::to_string)).flatten()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let folder = str_p(p, "folder").filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(COMMAND, "missing `folder`"))?;
|
||||
let folder = std::fs::canonicalize(folder).map_err(|e| bad(COMMAND, format!("{folder}: {e}")))?;
|
||||
if !folder.is_dir() {
|
||||
return Err(bad(COMMAND, "folder must be a directory"));
|
||||
}
|
||||
let importer = s.importer.clone().ok_or_else(|| bad(COMMAND, "media import is not available in this build"))?;
|
||||
let services = s.services.clone();
|
||||
let missing: Vec<(ItemId, Footage)> = s
|
||||
.project
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|item| match &item.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() && !services.exists(&f.path) => Some((item.id, f.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dry_run = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.spawn_task("relink", "Relinking missing footage", wait, move |ctl| {
|
||||
let index = files(&folder, ctl)?;
|
||||
let mut rows = vec![];
|
||||
let mut updates = vec![];
|
||||
for (n, (id, old)) in missing.iter().enumerate() {
|
||||
if !ctl.progress(n as u64, missing.len() as u64) {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let supported = matches!(old.kind, FootageKind::Still | FootageKind::Sequence | FootageKind::Video | FootageKind::Audio)
|
||||
&& old.layer.is_none() && old.page == 0;
|
||||
let name = Path::new(&old.path).file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let paths = index.get(name).cloned().unwrap_or_default();
|
||||
let mut candidates = vec![];
|
||||
let mut accepted = vec![];
|
||||
for path in paths {
|
||||
if ctl.cancelled() {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
let mut reason = "source type requires manual replacement".to_string();
|
||||
let mut confidence = "low";
|
||||
if supported {
|
||||
match importer.probe(&path) {
|
||||
Ok(f) if compatible(old, &f) => {
|
||||
if old.kind != FootageKind::Sequence || (!old.sequence.is_empty() && sequence_at(old, &path).is_some()) {
|
||||
confidence = "high";
|
||||
reason = "exact filename and compatible source metadata".into();
|
||||
accepted.push(path.clone());
|
||||
} else {
|
||||
reason = "sequence is incomplete".into();
|
||||
}
|
||||
}
|
||||
Ok(_) => reason = "source metadata differs".into(),
|
||||
Err(e) => reason = format!("cannot decode candidate: {e}"),
|
||||
}
|
||||
}
|
||||
if ctl.cancelled() {
|
||||
return Err(crate::render_queue::CANCELLED.into());
|
||||
}
|
||||
candidates.push(json!({"path": path, "confidence": confidence, "reason": reason}));
|
||||
}
|
||||
let status = if !supported { "unsupported" } else if candidates.is_empty() { "notFound" } else if candidates.len() > 1 { "ambiguous" } else if accepted.len() == 1 { "matched" } else { "incompatible" };
|
||||
if status == "matched" && let Some(path) = accepted.first() {
|
||||
let mut new = old.clone();
|
||||
new.path = path.clone();
|
||||
if old.kind == FootageKind::Sequence {
|
||||
new.sequence = sequence_at(old, path).ok_or_else(|| "sequence changed during search; run the search again".to_string())?;
|
||||
}
|
||||
new.missing = false;
|
||||
updates.push((*id, old.clone(), new));
|
||||
}
|
||||
rows.push(json!({"item": id.0, "original": old.path, "status": status, "candidates": candidates}));
|
||||
}
|
||||
ctl.progress(missing.len() as u64, missing.len() as u64);
|
||||
let apply: crate::jobs::Apply = Box::new(move |s| {
|
||||
// A background search must not overwrite edits made while it was running.
|
||||
for (id, old, new) in &updates {
|
||||
if s.project.item(*id).is_none_or(|i| !matches!(&i.kind, ItemKind::Footage(f) if f == old))
|
||||
|| services.exists(&old.path) || !services.exists(&new.path)
|
||||
|| (new.kind == FootageKind::Sequence && new.sequence.iter().any(|p| !services.exists(p)))
|
||||
{
|
||||
return Err(bad(COMMAND, "footage changed during search; run the search again"));
|
||||
}
|
||||
}
|
||||
let matched = updates.len();
|
||||
if !dry_run && !updates.is_empty() {
|
||||
s.edit("Relink Missing Footage", None, |project, _| {
|
||||
for (id, _, new) in updates {
|
||||
if let Some(item) = project.item_mut(id) {
|
||||
item.kind = ItemKind::Footage(new);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let unresolved = rows.len().saturating_sub(matched);
|
||||
let toast = format!("{} {matched} high-confidence match(es); {unresolved} unresolved. Duplicate filenames are left for Replace Footage.", if dry_run { "Found" } else { "Relinked" });
|
||||
Ok(json!({"dryRun": dry_run, "matched": matched, "relinked": if dry_run { 0 } else { matched }, "unresolved": unresolved, "items": rows, "toast": toast}))
|
||||
});
|
||||
Ok(apply)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"file.relinkFootage",
|
||||
"Relink Missing Footage...",
|
||||
["File", "Dependencies"],
|
||||
None,
|
||||
"{folder: string (recursive desktop search, no symlinks; max 100000 entries), dryRun?: bool (report only), wait?: bool (return results inline; otherwise jobs.list/jobs.wait)} — relinks only unique exact filenames with compatible metadata; preserves interpretation and sequence ranges. Reports high/low confidence and ambiguous, incompatible, unsupported or notFound items.",
|
||||
enabled,
|
||||
run
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> std::result::Result<Footage, String> {
|
||||
if std::fs::read(path).map_err(|e| e.to_string())? != b"valid" {
|
||||
return Err("invalid media".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!("ec-relink-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(std::fs::canonicalize(dir).unwrap())
|
||||
}
|
||||
fn file(&self, name: &str, bytes: &[u8]) -> String {
|
||||
let path = self.0.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path.to_str().unwrap().into()
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(dir: &Fixture, name: &str) -> (Session, ItemId, Footage) {
|
||||
let mut s = Session { importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let path = dir.file(&format!("old/{name}"), b"valid");
|
||||
let id = s.execute("file.import", json!({"paths": [path], "sequence": false})).unwrap()["items"][0].as_u64().unwrap();
|
||||
let id = ItemId(id);
|
||||
let ItemKind::Footage(f) = &mut Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind else { panic!("footage") };
|
||||
f.invert_alpha = true;
|
||||
f.loop_count = 3;
|
||||
f.start_timecode = Some(1001);
|
||||
let old = f.clone();
|
||||
std::fs::remove_file(&old.path).unwrap();
|
||||
(s, id, old)
|
||||
}
|
||||
|
||||
fn search(s: &mut Session, dir: &Fixture, dry: bool) -> Value {
|
||||
s.execute(COMMAND, json!({"folder": dir.0.join("new"), "wait": true, "dryRun": dry})).unwrap()
|
||||
}
|
||||
|
||||
fn get(s: &Session, id: ItemId) -> Footage {
|
||||
let ItemKind::Footage(f) = &s.project.item(id).unwrap().kind else { panic!("footage") };
|
||||
f.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_relink_preserves_interpretation_and_supports_undo_redo() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, old) = setup(&dir, "plate.png");
|
||||
let new = dir.file("new/deep/shots/plate.png", b"valid");
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "duration": 1})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": id.0})).unwrap();
|
||||
let key = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(cid).unwrap();
|
||||
let ctx = crate::render::eval::EvalCtx::new(&s.project, cid, c, crate::time::Tick::ZERO);
|
||||
crate::render::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 0).unwrap()
|
||||
};
|
||||
let original_key = key(&s);
|
||||
let revision = s.revision;
|
||||
let before = s.history.undo.len();
|
||||
let report = search(&mut s, &dir, true);
|
||||
assert_eq!(report["items"][0]["candidates"][0]["confidence"], "high");
|
||||
assert_eq!(report["relinked"], 0);
|
||||
assert_eq!(get(&s, id), old);
|
||||
assert_eq!(s.history.undo.len(), before);
|
||||
assert_eq!(search(&mut s, &dir, false)["relinked"], 1);
|
||||
let expected = Footage { path: new, missing: false, ..old.clone() };
|
||||
assert_eq!(get(&s, id), expected);
|
||||
assert!(s.revision > revision);
|
||||
assert_ne!(key(&s), original_key, "restored footage cannot reuse the missing source's rendered layer");
|
||||
assert_eq!(s.history.undo.len(), before + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, id), old);
|
||||
assert_eq!(key(&s), original_key);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, id), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_and_invalid_media_are_reported_without_edits() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, old) = setup(&dir, "plate.png");
|
||||
dir.file("new/a/plate.png", b"valid");
|
||||
let b = dir.file("new/b/plate.png", b"valid");
|
||||
assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "ambiguous");
|
||||
assert_eq!(get(&s, id), old);
|
||||
std::fs::remove_file(b).unwrap();
|
||||
dir.file("new/a/plate.png", b"corrupt");
|
||||
let r = search(&mut s, &dir, false);
|
||||
assert_eq!(r["items"][0]["status"], "incompatible");
|
||||
assert_eq!(r["items"][0]["candidates"][0]["confidence"], "low");
|
||||
assert_eq!(get(&s, id), old);
|
||||
std::fs::remove_file(dir.0.join("new/a/plate.png")).unwrap();
|
||||
assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_relink_preserves_picked_range_and_requires_every_saved_frame() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, mut old) = setup(&dir, "shot_0002.png");
|
||||
old.kind = FootageKind::Sequence;
|
||||
old.sequence = vec![old.path.clone(), dir.0.join("old/shot_0004.png").to_str().unwrap().into()];
|
||||
Arc::make_mut(&mut s.project).item_mut(id).unwrap().kind = ItemKind::Footage(old.clone());
|
||||
dir.file("new/shot_0002.png", b"valid");
|
||||
assert_eq!(search(&mut s, &dir, false)["relinked"], 0);
|
||||
dir.file("new/shot_0004.png", b"valid");
|
||||
dir.file("new/shot_0001.png", b"valid");
|
||||
assert_eq!(search(&mut s, &dir, false)["relinked"], 1);
|
||||
let f = get(&s, id);
|
||||
assert_eq!(f.sequence.len(), 2);
|
||||
assert!(f.sequence[0].ends_with("shot_0002.png"));
|
||||
assert!(f.sequence[1].ends_with("shot_0004.png"));
|
||||
assert_eq!(f.start_timecode, old.start_timecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_folder_errors_and_existing_media_is_untouched() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, old) = setup(&dir, "plate.png");
|
||||
assert!(s.execute(COMMAND, json!({"folder": dir.0.join("absent"), "wait": true})).is_err());
|
||||
assert!(s.execute(COMMAND, json!({"wait": true})).is_err());
|
||||
dir.file("old/plate.png", b"valid");
|
||||
dir.file("new/plate.png", b"valid");
|
||||
assert_eq!(search(&mut s, &dir, false)["relinked"], 0);
|
||||
assert_eq!(get(&s, id), old);
|
||||
}
|
||||
|
||||
struct BlockingProbe {
|
||||
started: std::sync::mpsc::Sender<()>,
|
||||
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
|
||||
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
impl crate::Importer for BlockingProbe {
|
||||
fn probe(&self, path: &str) -> std::result::Result<Footage, String> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.started.send(()).map_err(|e| e.to_string())?;
|
||||
self.release.lock().map_err(|e| e.to_string())?.recv_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?;
|
||||
Probe.probe(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_between_candidate_probes_leaves_project_untouched() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, old) = setup(&dir, "plate.png");
|
||||
dir.file("new/a/plate.png", b"valid");
|
||||
dir.file("new/b/plate.png", b"valid");
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: calls.clone() }));
|
||||
let r = s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap();
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert!(s.cancel_job(r["job"].as_str().unwrap()));
|
||||
release_tx.send(()).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
assert_eq!(get(&s, id), old);
|
||||
assert_eq!(s.job_log.last().unwrap().status, "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_result_does_not_overwrite_interpretation_edited_during_search() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, id, old) = setup(&dir, "plate.png");
|
||||
dir.file("new/plate.png", b"valid");
|
||||
let (started_tx, started_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
s.importer = Some(Arc::new(BlockingProbe { started: started_tx, release: std::sync::Mutex::new(release_rx), calls: Arc::default() }));
|
||||
s.execute(COMMAND, json!({"folder": dir.0.join("new")})).unwrap();
|
||||
started_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
s.execute("file.interpretFootage", json!({"items": [id.0], "invertAlpha": false})).unwrap();
|
||||
release_tx.send(()).unwrap();
|
||||
s.execute("jobs.wait", json!({})).unwrap();
|
||||
assert_eq!(get(&s, id).path, old.path);
|
||||
assert!(!get(&s, id).invert_alpha);
|
||||
assert_eq!(s.job_log.last().unwrap().status, "failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_confidence_checks_native_frame_count_and_rate() {
|
||||
let new = Footage {
|
||||
kind: FootageKind::Video,
|
||||
width: 64,
|
||||
height: 32,
|
||||
has_video: true,
|
||||
frame_rate: crate::time::FrameRate::FPS_30,
|
||||
duration: crate::time::Tick::from_seconds_f64(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
let mut old = new.clone();
|
||||
old.native_rate = Some(old.frame_rate);
|
||||
old.frame_rate = crate::time::FrameRate::FPS_24;
|
||||
old.duration = old.frame_rate.tick_of(30);
|
||||
assert!(compatible(&old, &new), "conformed playback preserves native frame count");
|
||||
assert!(!compatible(&old, &Footage { duration: crate::time::Tick::from_seconds_f64(2.0), ..new.clone() }));
|
||||
assert!(!compatible(&old, &Footage { width: 128, ..new }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_cycles_and_external_files_are_skipped() {
|
||||
let dir = Fixture::new();
|
||||
let (mut s, _, _) = setup(&dir, "plate.png");
|
||||
let outside = dir.file("outside/plate.png", b"valid");
|
||||
std::fs::create_dir(dir.0.join("new")).unwrap();
|
||||
std::os::unix::fs::symlink(dir.0.join("new"), dir.0.join("new/cycle")).unwrap();
|
||||
std::os::unix::fs::symlink(outside, dir.0.join("new/plate.png")).unwrap();
|
||||
assert_eq!(search(&mut s, &dir, false)["items"][0]["status"], "notFound");
|
||||
}
|
||||
}
|
||||
@@ -526,7 +526,12 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "renderQueue.add")?;
|
||||
// "template" belongs to Render Settings on add, not Output Module.
|
||||
let mut output_params = p.clone();
|
||||
if let Some(object) = output_params.as_object_mut() {
|
||||
object.remove("template");
|
||||
}
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, &output_params, "renderQueue.add")?;
|
||||
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
|
||||
it.log = l;
|
||||
}
|
||||
@@ -583,6 +588,22 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = module_p(s, i, p, "renderQueue.setOutputModule")?;
|
||||
let mut om = module_ref(&s.project.render_queue[i], m).clone();
|
||||
let changed = apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "renderQueue.setOutputModule")?;
|
||||
|
||||
// When aspect is locked, an explicit height-only edit must drive the size.
|
||||
// frame_size() derives height from width; calculate the matching width from the
|
||||
// cropped render bounds before storing the updated module.
|
||||
let width_given =
|
||||
p.get("resizeWidth").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("width") || r.contains_key("preset"));
|
||||
let height_given = p.get("resizeHeight").is_some() || p.get("resize").and_then(Value::as_object).is_some_and(|r| r.contains_key("height"));
|
||||
if om.resize.enabled && om.resize.lock_aspect && height_given && !width_given {
|
||||
let item = &s.project.render_queue[i];
|
||||
let comp = s.project.comp(item.comp).ok_or(EngineError::NoComp)?;
|
||||
let (w, h) = item.settings.output_size(comp);
|
||||
let mut crop_only = om.clone();
|
||||
crop_only.resize.enabled = false;
|
||||
let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0));
|
||||
om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000);
|
||||
}
|
||||
let post = match str_p(p, "postRenderAction") {
|
||||
Some(a) => Some(post_render_parse(a).ok_or_else(|| bad("renderQueue.setOutputModule", "postRenderAction: none|import|importAndReplace|setProxy"))?),
|
||||
None => None,
|
||||
|
||||
@@ -124,12 +124,38 @@ fn is_script(name: &str) -> bool {
|
||||
l.ends_with(".jsx") || l.ends_with(".js")
|
||||
}
|
||||
|
||||
/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
|
||||
/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
|
||||
/// Windows none of the names [`windows_unsafe_name`] lists.
|
||||
fn is_script_name(name: &str) -> bool {
|
||||
is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
|
||||
}
|
||||
|
||||
/// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
|
||||
fn is_bare_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
/// Names that are not ordinary file names on Windows (elsewhere they are): with `:` (a drive or a
|
||||
/// stream), ending in `.` or a space (Windows drops them), or a device name, which names a device
|
||||
/// in any folder and with any extension (`con.jsx`): `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`,
|
||||
/// `CONOUT$`, `COM0`–`COM9` and `LPT0`–`LPT9` (also with `¹²³`).
|
||||
fn windows_unsafe_name(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_uppercase();
|
||||
let port = |p: &str| {
|
||||
let mut n = stem.strip_prefix(p).unwrap_or_default().chars();
|
||||
matches!((n.next(), n.next()), (Some('0'..='9' | '¹' | '²' | '³'), None))
|
||||
};
|
||||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$") || port("COM") || port("LPT");
|
||||
name.contains(':') || name.ends_with(['.', ' ']) || device
|
||||
}
|
||||
|
||||
/// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name.
|
||||
pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n)) {
|
||||
for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
|
||||
v.push(ScriptEntry { name: n, panel, source: "installed" });
|
||||
}
|
||||
}
|
||||
@@ -145,6 +171,9 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
|
||||
/// The code of an installed or bundled script / panel.
|
||||
pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> Option<(bool, String)> {
|
||||
if !is_script(name) || !is_script_name(name) {
|
||||
return None;
|
||||
}
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
if panel.is_some_and(|p| p != is_panel) {
|
||||
@@ -165,6 +194,10 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result<Value> {
|
||||
if !is_script(&name) {
|
||||
return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)"));
|
||||
}
|
||||
// A name it could not be run or uninstalled by (`\` is a file-name character on Unix).
|
||||
if !is_script_name(&name) {
|
||||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?;
|
||||
let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?;
|
||||
@@ -186,6 +219,9 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "file.uninstallScript";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
|
||||
if !is_script(name) || !is_script_name(name) {
|
||||
return Err(bad(c, format!("`{name}` is not a script file name")));
|
||||
}
|
||||
let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
|
||||
let mut removed = false;
|
||||
for dir in [SCRIPTS_DIR, PANELS_DIR] {
|
||||
@@ -384,3 +420,168 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BUNDLED, PANELS_DIR, SCRIPTS_DIR, is_bare_name, is_script, is_script_name, script_code, windows_unsafe_name};
|
||||
use crate::config::DirConfig;
|
||||
use crate::{EngineError, Result, ScriptRequest, Session};
|
||||
|
||||
/// Stands in for the JavaScript runtime: reports the code it was asked to run.
|
||||
fn echo(_: &mut Session, r: &ScriptRequest) -> Value {
|
||||
json!({"ran": r.code})
|
||||
}
|
||||
|
||||
/// A session whose settings store is a fresh `<root>/settings/` (with both Scripts folders, as
|
||||
/// after installing a script and a panel), next to a script `<root>/outside.jsx`.
|
||||
fn session_in_tmp(name: &str) -> (Session, PathBuf) {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let root = std::env::temp_dir().join(format!("ec-scripts-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("settings").join(PANELS_DIR)).unwrap();
|
||||
std::fs::write(root.join("outside.jsx"), "outside").unwrap();
|
||||
let s = Session { config: Some(Arc::new(DirConfig::new(root.join("settings")))), script: Some(echo), ..Default::default() };
|
||||
(s, root)
|
||||
}
|
||||
|
||||
fn bad_params(r: &Result<Value>) -> bool {
|
||||
matches!(r, Err(EngineError::BadParams { .. }))
|
||||
}
|
||||
|
||||
/// Paths, refused on every platform.
|
||||
const PATHS: [&str; 10] = ["", ".", "..", "../x.jsx", "a/b.jsx", "/abs/x.jsx", "a\\b.jsx", "..\\x.jsx", "C:\\x.jsx", "a\0.jsx"];
|
||||
/// Ordinary file names except on Windows.
|
||||
const WINDOWS_ONLY: [&str; 8] = ["Step 1: Setup.jsx", "In:Out.jsx", "C:x.jsx", "x.jsx.", "x.jsx ", "Aux.Tools.jsx", "con.jsx", "COM1.jsx"];
|
||||
|
||||
#[test]
|
||||
fn bare_names_are_one_path_component() {
|
||||
for name in ["Rename Layers.jsx", "..jsx", "a..b.js", "Äpfel ✓.jsx"].into_iter().chain(WINDOWS_ONLY) {
|
||||
assert!(is_bare_name(name), "{name:?}");
|
||||
}
|
||||
for name in PATHS {
|
||||
assert!(!is_bare_name(name), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_unsafe_names_are_drives_streams_trailing_dots_and_devices() {
|
||||
let devices = ["CON", "Con.tar.jsx", "CON .jsx", "PRN.js", "aux.jsx", "NUL.jsx", "CONIN$.jsx", "conout$.js"];
|
||||
let ports = ["COM0.jsx", "com9.js", "LPT0.jsx", "lpt9.jsx", "COM¹.jsx", "LPT³.js"];
|
||||
for name in WINDOWS_ONLY.into_iter().chain(devices).chain(ports) {
|
||||
assert!(windows_unsafe_name(name), "{name:?}");
|
||||
}
|
||||
let fine = ["console.jsx", "Concert.jsx", "NULL.jsx", "CONIN.jsx", "COM10.jsx", "LPT.jsx", "x .jsx", "..jsx"];
|
||||
for name in BUNDLED.iter().map(|b| b.0).chain(fine) {
|
||||
assert!(!windows_unsafe_name(name), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_names_are_bare_file_names() {
|
||||
let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
|
||||
for name in BUNDLED.iter().map(|b| b.0).chain(more) {
|
||||
assert!(is_script(name) && is_script_name(name), "{name}");
|
||||
}
|
||||
for name in PATHS {
|
||||
assert!(!is_script_name(name), "{name:?}");
|
||||
}
|
||||
for name in WINDOWS_ONLY {
|
||||
assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
|
||||
}
|
||||
// Install refuses a file name the script could not then be run or uninstalled by.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let (mut s, root) = session_in_tmp("odd-name");
|
||||
let odd = root.join("a\\b.jsx");
|
||||
std::fs::write(&odd, "x").unwrap();
|
||||
assert!(bad_params(&s.execute("file.installScript", json!({"path": odd.to_string_lossy()}))));
|
||||
assert!(!root.join("settings").join(SCRIPTS_DIR).join("a\\b.jsx").exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_takes_a_script_name_not_a_path() {
|
||||
let (mut s, root) = session_in_tmp("uninstall");
|
||||
let (outside, prefs) = (root.join("outside.jsx"), root.join("settings").join("prefs.json"));
|
||||
std::fs::write(&prefs, "{}").unwrap();
|
||||
// From `Scripts/`, `../../outside.jsx` is the script next to the settings folder and
|
||||
// `../prefs.json` a settings file; an absolute path is not a script name either.
|
||||
for name in ["../../outside.jsx".to_string(), "../prefs.json".into(), outside.to_string_lossy().into_owned()] {
|
||||
let r = s.execute("file.uninstallScript", json!({"name": name}));
|
||||
assert!(outside.is_file() && prefs.is_file(), "`{name}` deleted a file outside the Scripts folders: {r:?}");
|
||||
assert!(bad_params(&r), "`{name}`: {r:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_run_by_name_come_from_the_scripts_folders() {
|
||||
let (mut s, root) = session_in_tmp("run");
|
||||
// `Scripts/../../outside.jsx` and `Scripts/ScriptUI Panels/../../../outside.jsx` are the
|
||||
// script next to the settings folder.
|
||||
let r = s.execute("file.runScript", json!({"name": "../../outside.jsx"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "../../../outside.jsx"}));
|
||||
assert!(bad_params(&r), "{r:?}");
|
||||
assert_eq!(script_code(&s, "../../outside.jsx", None), None);
|
||||
assert_eq!(script_code(&s, "../../../outside.jsx", Some(true)), None);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_scripts_are_read_or_removed() {
|
||||
// A settings file in the Scripts folder that is not a script (.jsx / .js).
|
||||
let (mut s, root) = session_in_tmp("not-a-script");
|
||||
let notes = root.join("settings").join(SCRIPTS_DIR).join("notes.txt");
|
||||
std::fs::write(¬es, "notes").unwrap();
|
||||
assert_eq!(script_code(&s, "notes.txt", None), None);
|
||||
let r = s.execute("file.uninstallScript", json!({"name": "notes.txt"}));
|
||||
assert!(notes.is_file() && bad_params(&r), "{r:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// On Unix `\` is a file-name character: a script dropped into the folder under such a name is
|
||||
/// not offered, as it could not be run or uninstalled. Names only Windows refuses stay.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn listing_skips_names_run_and_uninstall_refuse() {
|
||||
let (mut s, root) = session_in_tmp("odd-names");
|
||||
let scripts = root.join("settings").join(SCRIPTS_DIR);
|
||||
std::fs::write(scripts.join("a\\b.jsx"), "x").unwrap();
|
||||
std::fs::write(scripts.join("In:Out.jsx"), "in-out").unwrap();
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
let listed = |n: &str| list.as_array().unwrap().iter().any(|e| e["name"] == n);
|
||||
assert!(!listed("a\\b.jsx") && listed("In:Out.jsx"), "{list}");
|
||||
assert_eq!(script_code(&s, "a\\b.jsx", None), None);
|
||||
assert_eq!(script_code(&s, "In:Out.jsx", None), Some((false, "in-out".to_string())));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_run_and_uninstall_by_name() {
|
||||
let (mut s, root) = session_in_tmp("install");
|
||||
let file = root.join("Hello.jsx");
|
||||
std::fs::write(&file, "hello").unwrap();
|
||||
s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
s.execute("file.installScriptUIPanel", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
let installed = [root.join("settings").join(SCRIPTS_DIR).join("Hello.jsx"), root.join("settings").join(PANELS_DIR).join("Hello.jsx")];
|
||||
assert!(installed.iter().all(|p| p.is_file()));
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap()["ran"], "hello");
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert!(r["result"]["ran"].as_str().is_some_and(|c| c.contains("hello")), "{r}");
|
||||
// The bundled samples still run by name.
|
||||
let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
|
||||
assert_eq!(s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap()["ran"], sample);
|
||||
s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap();
|
||||
assert!(installed.iter().all(|p| !p.exists()));
|
||||
assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha
|
||||
Some(build::shape_group(ids, gname, items))
|
||||
}
|
||||
|
||||
/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space).
|
||||
pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
|
||||
/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry
|
||||
/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's
|
||||
/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit.
|
||||
pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
|
||||
let bez = match kind {
|
||||
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
|
||||
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
|
||||
@@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<
|
||||
effectcraft_path::from_kurbo(&bez).into_iter().next()
|
||||
}
|
||||
|
||||
/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape
|
||||
/// layer, else none (a new shape layer).
|
||||
pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option<LayerId> {
|
||||
s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked))
|
||||
}
|
||||
|
||||
/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer),
|
||||
/// else the first selected unlocked shape layer, else none (a new shape layer).
|
||||
pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
|
||||
let target = match p.get("layer") {
|
||||
Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?),
|
||||
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
|
||||
None => selected_shape_target(s, comp),
|
||||
};
|
||||
if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) {
|
||||
return Err(bad(cmd, "the layer is not a shape layer"));
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Footage that changed on disk: File ▸ Reload Footage (Cmd+Alt+L) and Settings ▸ Import ▸
|
||||
//! Reload Footage Changed on Disk.
|
||||
//!
|
||||
//! Reloading re-reads what the file decides (its size, a movie's duration, streams and frame
|
||||
//! rate, the codec, a data file's text) and keeps what the project decided: the Photoshop layer
|
||||
//! or PDF page an item shows and its interpretation (alpha, conformed frame rate, pixel aspect,
|
||||
//! fields, colour). A Photoshop layer is found again by name when layers were added or removed
|
||||
//! around it. The media layer then forgets the file and the layer cache is emptied, so the next
|
||||
//! render reads the new pixels.
|
||||
//!
|
||||
//! The automatic part compares each footage file's size and modification time with what they
|
||||
//! were when the file was last seen. The desktop app takes those stamps every two seconds (and
|
||||
//! when its window comes back to the front) on a worker thread, so a file on a slow network share
|
||||
//! never holds up the interface ([`start_scan`], [`poll_scan`]); `footage.reloadChanged` takes them
|
||||
//! on the spot (CLI, agents, tests). Like the footage check after File ▸ Open, an automatic reload
|
||||
//! records the state of the disk: it is not an undo step and doesn't modify the project.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, SourceLayer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A file's size and modification time (see [`crate::Services::stamp`]).
|
||||
pub type Stamp = (u64, u128);
|
||||
|
||||
/// Stamps taken on a worker thread, waiting for [`poll_scan`].
|
||||
pub type Scan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
|
||||
|
||||
/// The files footage items read: each item's file and every frame of an image sequence. Image
|
||||
/// sequences count only with Automatically Reload Footage set to All Footage (After Effects'
|
||||
/// default, Non-Sequence Footage, leaves them alone: thousands of frames on a share add up).
|
||||
fn watched(s: &Session) -> BTreeSet<String> {
|
||||
let sequences = s.prefs.import.auto_reload_footage == "all";
|
||||
let mut paths = BTreeSet::new();
|
||||
for it in s.project.items.values() {
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
if !f.sequence.is_empty() && !sequences {
|
||||
continue;
|
||||
}
|
||||
if !f.path.is_empty() {
|
||||
paths.insert(f.path.clone());
|
||||
}
|
||||
paths.extend(f.sequence.iter().cloned());
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Size and position in the stack of Photoshop layer `l` in the file at `path` now: the layer
|
||||
/// at its recorded index when the name still matches, else the first layer with its name.
|
||||
fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u32, u32)>)> {
|
||||
let psd = effectcraft_psd::Psd::parse(s.services.read_file(path).ok()?).ok()?;
|
||||
let found = psd.layers.get(l.index as usize).filter(|x| x.name == l.name).or_else(|| psd.layers.iter().find(|x| x.name == l.name))?;
|
||||
let size = (!found.rect.is_empty()).then(|| (found.rect.width(), found.rect.height()));
|
||||
Some((u32::try_from(found.index).ok()?, size))
|
||||
}
|
||||
|
||||
/// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`).
|
||||
fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage {
|
||||
let mut nf = f.clone();
|
||||
nf.missing = false;
|
||||
// An image sequence picks up frames added to (or removed from) its run; its frame rate and
|
||||
// order are the interpretation's.
|
||||
if f.kind == FootageKind::Sequence {
|
||||
if probed.kind == FootageKind::Sequence {
|
||||
(nf.sequence, nf.width, nf.height) = (probed.sequence, probed.width, probed.height);
|
||||
nf.sync_sequence_duration();
|
||||
}
|
||||
return nf;
|
||||
}
|
||||
nf.codec = probed.codec;
|
||||
nf.has_video = probed.has_video;
|
||||
nf.has_audio = probed.has_audio;
|
||||
if matches!(f.kind, FootageKind::Video | FootageKind::Audio) {
|
||||
nf.duration = probed.duration;
|
||||
// Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed.
|
||||
match f.native_rate {
|
||||
Some(_) => nf.native_rate = Some(probed.native_rate.unwrap_or(probed.frame_rate)),
|
||||
None => nf.frame_rate = probed.frame_rate,
|
||||
}
|
||||
}
|
||||
if f.kind == FootageKind::Data {
|
||||
nf.data = probed.data;
|
||||
}
|
||||
let (mut w, mut h) = (probed.width, probed.height);
|
||||
match &f.layer {
|
||||
// A smart object's size is its embedded file's, recorded at import.
|
||||
Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height),
|
||||
Some(l) => match psd_layer(s, &f.path, l) {
|
||||
Some((index, size)) => {
|
||||
if let Some(nl) = nf.layer.as_mut() {
|
||||
nl.index = index;
|
||||
}
|
||||
// Retain Layer Sizes: the layer's own bounds; otherwise the document's.
|
||||
if l.layer_size {
|
||||
(w, h) = size.unwrap_or((f.width, f.height));
|
||||
}
|
||||
}
|
||||
// The layer is gone from the file: keep the item as it was.
|
||||
None => (w, h) = (f.width, f.height),
|
||||
},
|
||||
// A later page of a PDF: the probe measures the first page.
|
||||
None if f.page != 0 => (w, h) = (f.width, f.height),
|
||||
None => {}
|
||||
}
|
||||
(nf.width, nf.height) = (w, h);
|
||||
nf
|
||||
}
|
||||
|
||||
/// Reload footage items `ids` from their files (File ▸ Reload Footage): an undo step; items
|
||||
/// whose file can't be read are marked missing. Returns how many items were reloaded.
|
||||
pub fn reload_items(s: &mut Session, ids: &[ItemId]) -> crate::Result<usize> {
|
||||
Ok(reload(s, ids, true)?.0)
|
||||
}
|
||||
|
||||
/// Reload items `ids`. `undoable` makes it an edit; otherwise it records the disk without
|
||||
/// modifying the project, and an item whose file can't be read now (another app is still
|
||||
/// writing it) is left as it was. Returns the number reloaded and the files that couldn't be read
|
||||
/// (only without `undoable`).
|
||||
fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usize, BTreeSet<String>)> {
|
||||
if s.importer.is_none() {
|
||||
return Err(crate::EngineError::Other("media import is not available in this build".into()));
|
||||
}
|
||||
let mut updates = vec![];
|
||||
let mut paths = BTreeSet::new();
|
||||
let mut failed = BTreeSet::new();
|
||||
for i in ids {
|
||||
if let Some(ItemKind::Footage(f)) = s.project.item(*i).map(|x| &x.kind) {
|
||||
// Probed the way it imports by default, so a sequence is found as one.
|
||||
let nf = match s.probe_footage(&f.path) {
|
||||
Ok(p) => refreshed(s, f, p),
|
||||
Err(_) if undoable => Footage { missing: true, ..f.clone() },
|
||||
Err(_) => {
|
||||
failed.insert(f.path.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
paths.insert(f.path.clone());
|
||||
paths.extend(f.sequence.iter().chain(&nf.sequence).cloned());
|
||||
updates.push((*i, nf));
|
||||
}
|
||||
}
|
||||
let n = updates.len();
|
||||
if undoable {
|
||||
s.edit("Reload Footage", None, |proj, _| {
|
||||
for (i, f) in updates {
|
||||
if let Some(it) = proj.item_mut(i) {
|
||||
it.kind = ItemKind::Footage(f);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
} else {
|
||||
let p = Arc::make_mut(&mut s.project);
|
||||
let mut changed = false;
|
||||
for (i, f) in updates {
|
||||
if let Some(it) = p.item_mut(i)
|
||||
&& !matches!(&it.kind, ItemKind::Footage(cur) if *cur == f)
|
||||
{
|
||||
it.kind = ItemKind::Footage(f);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let dirty = s.is_dirty();
|
||||
s.bump();
|
||||
if !dirty {
|
||||
// Only the disk changed: the project isn't modified.
|
||||
s.mark_saved();
|
||||
}
|
||||
}
|
||||
}
|
||||
forget(s, &paths);
|
||||
Ok((n, failed))
|
||||
}
|
||||
|
||||
/// Make the media layer and the layer cache let go of `paths`, so renders read them again.
|
||||
fn forget(s: &mut Session, paths: &BTreeSet<String>) {
|
||||
for p in paths {
|
||||
s.footage.forget(p);
|
||||
}
|
||||
s.layer_cache.clear();
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
}
|
||||
|
||||
/// Compare fresh stamps of the watched files with the ones last seen. A file seen for the first
|
||||
/// time is only remembered; one whose stamp moved is reloaded: the items reading it get their
|
||||
/// size and duration from it again and its pixels are read again. A file that can't be stamped
|
||||
/// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be
|
||||
/// read yet (still being written) keeps its old stamp so the next scan tries again; missing
|
||||
/// files are left to the footage check. Returns `{files, reloaded}`.
|
||||
pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option<Stamp>)>) -> crate::Result<Value> {
|
||||
let before = std::mem::take(&mut s.footage_stamps);
|
||||
let mut changed = BTreeSet::new();
|
||||
// Only what is watched now is remembered (files no longer used are dropped).
|
||||
for (path, stamp) in now {
|
||||
match (before.get(&path), stamp) {
|
||||
(Some(old), None) => {
|
||||
s.footage_stamps.insert(path, *old);
|
||||
}
|
||||
(old, Some(stamp)) => {
|
||||
if old.is_some_and(|o| *o != stamp) {
|
||||
changed.insert(path.clone());
|
||||
}
|
||||
s.footage_stamps.insert(path, stamp);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(json!({"files": 0, "reloaded": 0}));
|
||||
}
|
||||
let ids: Vec<ItemId> = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect();
|
||||
let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? };
|
||||
for p in &failed {
|
||||
if let Some(old) = before.get(p) {
|
||||
s.footage_stamps.insert(p.clone(), *old);
|
||||
}
|
||||
changed.remove(p);
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(json!({"files": 0, "reloaded": 0}));
|
||||
}
|
||||
// Changed frames of image sequences (their items keep their metadata).
|
||||
forget(s, &changed);
|
||||
let files = changed.len();
|
||||
s.toast(if reloaded > 0 {
|
||||
format!("Reloaded {reloaded} footage item(s) changed on disk")
|
||||
} else {
|
||||
format!("Reloaded {files} footage file(s) changed on disk")
|
||||
});
|
||||
Ok(json!({"files": files, "reloaded": reloaded}))
|
||||
}
|
||||
|
||||
/// `footage.reloadChanged`: stamp the watched files now and reload the ones that changed.
|
||||
pub(crate) fn command(s: &mut Session, _: &Value) -> crate::Result<Value> {
|
||||
let services = s.services.clone();
|
||||
let now = watched(s).into_iter().map(|p| (p.clone(), services.stamp(&p))).collect();
|
||||
apply_stamps(s, now)
|
||||
}
|
||||
|
||||
/// Start stamping the watched files on a worker thread, unless a scan is still running (or the
|
||||
/// build has no threads: the web app, whose footage lives in browser storage).
|
||||
pub fn start_scan(s: &mut Session) {
|
||||
if s.footage_scan.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let paths = watched(s);
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let services = s.services.clone();
|
||||
let slot: Scan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("footage-watch".into()).spawn(move || {
|
||||
let stamps = paths.into_iter().map(|p| (p.clone(), services.stamp(&p))).collect();
|
||||
*out.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stamps);
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
s.footage_scan = Some(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished scan (`None` while there is none, or it is still running).
|
||||
pub fn poll_scan(s: &mut Session) -> Option<crate::Result<Value>> {
|
||||
let stamps = s.footage_scan.as_ref()?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;
|
||||
s.footage_scan = None;
|
||||
Some(apply_stamps(s, stamps))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod footage_check;
|
||||
pub mod footage_reload;
|
||||
pub mod guard;
|
||||
pub mod history;
|
||||
pub mod jobs;
|
||||
@@ -101,6 +102,13 @@ pub trait Services: Send + Sync {
|
||||
fn exists(&self, path: &str) -> bool {
|
||||
std::path::Path::new(path).exists()
|
||||
}
|
||||
/// A file's size and modification time (nanoseconds since 1970), to notice that it changed
|
||||
/// on disk (Settings ▸ Import ▸ Reload Footage Changed on Disk); `None` when it can't be read.
|
||||
fn stamp(&self, path: &str) -> Option<(u64, u128)> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
/// Keep a file the app made for itself (footage extracted from a template): like
|
||||
/// [`Services::write_file`], creating its folder, but never offered as a download (web: it
|
||||
/// goes to browser storage only).
|
||||
@@ -459,6 +467,11 @@ pub struct Session {
|
||||
/// event loop (the desktop app) turn this on; headless sessions run `footage.check` when
|
||||
/// they want it.
|
||||
pub check_footage_on_open: bool,
|
||||
/// Each footage file's size and modification time when last seen (Settings ▸ Import ▸
|
||||
/// Reload Footage Changed on Disk, [`footage_reload`]).
|
||||
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
|
||||
pub footage_scan: Option<footage_reload::Scan>,
|
||||
}
|
||||
|
||||
/// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8).
|
||||
@@ -544,6 +557,8 @@ impl Default for Session {
|
||||
next_task_id: 1,
|
||||
learn: None,
|
||||
check_footage_on_open: false,
|
||||
footage_stamps: Default::default(),
|
||||
footage_scan: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1020,6 +1035,8 @@ mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_key_metadata_order;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
@@ -1165,3 +1182,6 @@ mod tests_paint;
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_roto;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_key_time;
|
||||
@@ -553,6 +553,7 @@ File
|
||||
Find Missing Effects | file.findMissing {"what":"effects"}
|
||||
Find Missing Fonts | file.findMissing {"what":"fonts"}
|
||||
Find Missing Footage | file.findMissing {"what":"footage"}
|
||||
Relink Missing Footage... | file.relinkFootage
|
||||
Watch Folder... | file.watchFolder
|
||||
---
|
||||
Scripts
|
||||
|
||||
+145
-4
@@ -23,8 +23,16 @@ pub const PREFS_VERSION: u32 = 2;
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
|
||||
/// Settings ▸ General ▸ Language: (label, `general.language` value).
|
||||
pub const LANGUAGES: &[(&str, &str)] =
|
||||
&[("Match System", "system"), ("English", "en"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant"), ("Українська", "uk")];
|
||||
pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Match System", "system"),
|
||||
("English", "en"),
|
||||
("日本語", "ja"),
|
||||
("简体中文", "zh-hans"),
|
||||
("繁體中文", "zh-hant"),
|
||||
("Українська", "uk"),
|
||||
("Español", "es"),
|
||||
("Português (Brasil)", "pt-br"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
|
||||
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
|
||||
@@ -32,11 +40,28 @@ pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%"
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
|
||||
|
||||
/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto
|
||||
/// follows the operating system's light or dark appearance.
|
||||
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")];
|
||||
|
||||
/// The dark family of themes: (label, `appearance.darkTheme` value).
|
||||
pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")];
|
||||
|
||||
/// The light family of themes: (label, `appearance.lightTheme` value).
|
||||
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")];
|
||||
|
||||
/// Every theme (the legacy single-theme `appearance.theme`).
|
||||
pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")];
|
||||
|
||||
/// The settings whose value must be one of their choices.
|
||||
fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> {
|
||||
match key {
|
||||
"general.language" => Some(LANGUAGES),
|
||||
"startup.windowGraphics" => Some(WINDOW_GRAPHICS),
|
||||
"appearance.appearanceMode" => Some(APPEARANCE_MODES),
|
||||
"appearance.darkTheme" => Some(DARK_THEMES),
|
||||
"appearance.lightTheme" => Some(LIGHT_THEMES),
|
||||
"appearance.theme" => Some(THEMES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -140,8 +165,16 @@ page!(Previews {
|
||||
});
|
||||
|
||||
page!(Appearance {
|
||||
/// `dark`, `darker` or `light`.
|
||||
/// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older
|
||||
/// settings files and automation: setting it picks its family's mode and theme. While the mode
|
||||
/// is Dark or Light it mirrors the theme in use.
|
||||
theme: String = "dark".into(),
|
||||
/// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark.
|
||||
appearance_mode: String = "dark".into(),
|
||||
/// The theme used in dark appearance: `dark` or `darker`.
|
||||
dark_theme: String = "dark".into(),
|
||||
/// The theme used in light appearance: `light`.
|
||||
light_theme: String = "light".into(),
|
||||
/// User interface brightness, -1 (darker) … 1 (lighter).
|
||||
brightness: f64 = 0.0,
|
||||
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
|
||||
@@ -182,6 +215,10 @@ page!(Import {
|
||||
still_seconds: f64 = 5.0,
|
||||
sequence_fps: f64 = 30.0,
|
||||
report_missing_frames: bool = true,
|
||||
/// Automatically Reload Footage whose file another app changed (size or modification time),
|
||||
/// checked every two seconds and when the window comes back to the front: `off`,
|
||||
/// `nonSequence` (the default, as in After Effects: image sequences are left alone) or `all`.
|
||||
auto_reload_footage: String = "nonSequence".into(),
|
||||
/// Interpret unlabeled alpha as: `ask`, `guess`, `ignore`, `straight`, `premultiplied`.
|
||||
unlabeled_alpha: String = "ask".into(),
|
||||
/// Default drag import as: `footage`, `comp`, `compLayerSizes`.
|
||||
@@ -408,10 +445,61 @@ pub fn migrate(mut v: Value) -> Value {
|
||||
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to Auto.
|
||||
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
&& !a.contains_key("appearanceMode")
|
||||
&& let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string)
|
||||
&& let Some((mode, slot)) = theme_family(&theme)
|
||||
{
|
||||
a.insert("appearanceMode".into(), json!(mode));
|
||||
a.insert(slot.into(), json!(theme));
|
||||
}
|
||||
obj.insert("version".into(), json!(PREFS_VERSION));
|
||||
v
|
||||
}
|
||||
|
||||
/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`,
|
||||
/// `darkTheme`)); `None` for an unknown theme.
|
||||
pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> {
|
||||
if is_choice(DARK_THEMES, theme) {
|
||||
Some(("dark", "darkTheme"))
|
||||
} else if is_choice(LIGHT_THEMES, theme) {
|
||||
Some(("light", "lightTheme"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
/// Show the legacy single `theme`: fix the mode to its family and remember it as that
|
||||
/// family's theme. An unknown theme changes nothing.
|
||||
pub fn select_theme(&mut self) {
|
||||
match theme_family(&self.theme) {
|
||||
Some((mode, "darkTheme")) => {
|
||||
self.appearance_mode = mode.into();
|
||||
self.dark_theme = self.theme.clone();
|
||||
}
|
||||
Some((mode, _)) => {
|
||||
self.appearance_mode = mode.into();
|
||||
self.light_theme = self.theme.clone();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light`
|
||||
/// (`None` = the system said nothing: dark).
|
||||
pub fn resolved_theme(&self, system_light: Option<bool>) -> &str {
|
||||
let light = match self.appearance_mode.as_str() {
|
||||
"light" => true,
|
||||
"auto" => system_light == Some(true),
|
||||
_ => false,
|
||||
};
|
||||
if light { &self.light_theme } else { &self.dark_theme }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Parse a settings document (migrating older layouts; unknown keys are kept, bad values
|
||||
/// fall back to defaults).
|
||||
@@ -459,6 +547,24 @@ impl Prefs {
|
||||
let a = &mut self.auto_save;
|
||||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
let ap = &mut self.appearance;
|
||||
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
|
||||
ap.appearance_mode = Appearance::default().appearance_mode;
|
||||
}
|
||||
if !is_choice(DARK_THEMES, &ap.dark_theme) {
|
||||
ap.dark_theme = Appearance::default().dark_theme;
|
||||
}
|
||||
if !is_choice(LIGHT_THEMES, &ap.light_theme) {
|
||||
ap.light_theme = Appearance::default().light_theme;
|
||||
}
|
||||
// The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the
|
||||
// system, so it keeps the last one).
|
||||
match ap.appearance_mode.as_str() {
|
||||
"dark" => ap.theme = ap.dark_theme.clone(),
|
||||
"light" => ap.theme = ap.light_theme.clone(),
|
||||
_ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(),
|
||||
_ => {}
|
||||
}
|
||||
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
|
||||
self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200);
|
||||
let defaults = default_labels();
|
||||
@@ -498,12 +604,20 @@ impl Prefs {
|
||||
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
|
||||
return Err(format!("`{key}` expects one of {}", all.join(", ")));
|
||||
}
|
||||
// Older clients set the single theme (by key, or inside the page object): it still picks
|
||||
// a visible theme, fixing the mode to its family.
|
||||
let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none());
|
||||
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
|
||||
let ptr = format!("/{}", key.replace('.', "/"));
|
||||
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
|
||||
let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?;
|
||||
*slot = value;
|
||||
let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
let (a, b) = (&self.appearance, &next.appearance);
|
||||
let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme;
|
||||
if legacy_theme || only_theme_changed {
|
||||
next.appearance.select_theme();
|
||||
}
|
||||
next.normalize();
|
||||
*self = next;
|
||||
Ok(())
|
||||
@@ -586,8 +700,18 @@ impl Prefs {
|
||||
/// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free,
|
||||
/// and halved while the system is low on memory when **Reduce Cache Size When System Is Low
|
||||
/// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets).
|
||||
/// In the browser the layer and footage caches also stay within [`WEB_LAYER_CACHE`] and
|
||||
/// [`WEB_MEDIA_CACHE`].
|
||||
pub fn cache_budgets(&self, mem: Option<crate::sysinfo::SysMemory>) -> CacheBudgets {
|
||||
self.cache_budgets_in(mem, cfg!(target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// [`Prefs::cache_budgets`], in the browser's memory (`web`) or not.
|
||||
pub(crate) fn cache_budgets_in(&self, mem: Option<crate::sysinfo::SysMemory>, web: bool) -> CacheBudgets {
|
||||
let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64);
|
||||
if web {
|
||||
(l, m) = (l.min(WEB_LAYER_CACHE), m.min(WEB_MEDIA_CACHE));
|
||||
}
|
||||
let floor = 64u64 << 20;
|
||||
let mut capped = false;
|
||||
let mut reduced = false;
|
||||
@@ -732,6 +856,15 @@ fn push_mru(list: &mut Vec<String>, v: &str, max: usize) {
|
||||
list.truncate(max.max(1));
|
||||
}
|
||||
|
||||
/// The most the layer cache holds in the browser. A wasm32 page addresses at most 4 GiB, which
|
||||
/// these caches share with the app and with the decoders' own decoded frames (FilmCraft keeps
|
||||
/// up to 384 MB per open movie): with the desktop's 1 GB defaults, eight stacked 1080p clips ran
|
||||
/// the page out of memory a few seconds into playback.
|
||||
pub const WEB_LAYER_CACHE: u64 = 512 << 20;
|
||||
|
||||
/// The most the footage frame cache holds in the browser (see [`WEB_LAYER_CACHE`]).
|
||||
pub const WEB_MEDIA_CACHE: u64 = 256 << 20;
|
||||
|
||||
/// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct CacheBudgets {
|
||||
@@ -1014,7 +1147,9 @@ pub fn pages() -> Vec<Page> {
|
||||
id: "appearance",
|
||||
title: "Appearance",
|
||||
items: vec![
|
||||
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
|
||||
s("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true),
|
||||
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
Section("Labels and Colors"),
|
||||
@@ -1065,6 +1200,12 @@ pub fn pages() -> Vec<Page> {
|
||||
s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true),
|
||||
s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true),
|
||||
s("import.reportMissingFrames", "Report Missing Frames", B, true),
|
||||
s(
|
||||
"import.autoReloadFootage",
|
||||
"Automatically Reload Footage",
|
||||
Kind::Choice(&[("Off", "off"), ("Non-Sequence Footage", "nonSequence"), ("All Footage", "all")]),
|
||||
true,
|
||||
),
|
||||
s(
|
||||
"import.unlabeledAlpha",
|
||||
"Interpret Unlabeled Alpha As",
|
||||
|
||||
@@ -165,3 +165,43 @@ fn without_exporter() {
|
||||
assert_eq!(f["formats"].as_array().unwrap().len(), 12);
|
||||
assert_eq!(f["formats"][0]["available"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_add_accepts_render_settings_templates_without_output_module_namesakes() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"C","width":64,"height":64,"duration":1})).unwrap();
|
||||
let all = s.execute("renderQueue.templates", json!({})).unwrap();
|
||||
let names = all["renderSettings"].as_array().unwrap();
|
||||
assert!(!names.is_empty());
|
||||
for row in names {
|
||||
let name = row["name"].as_str().unwrap();
|
||||
let expected = s.project.render_templates.render_settings(name).unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp":"C","template":name})).unwrap();
|
||||
assert_eq!(s.project.render_queue.last().unwrap().settings, expected);
|
||||
}
|
||||
let count = s.project.render_queue.len();
|
||||
assert!(s.execute("renderQueue.add", json!({"comp":"C","template":"Unknown Settings"})).is_err());
|
||||
assert_eq!(s.project.render_queue.len(), count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_resize_uses_height_for_height_only_output_module_updates() {
|
||||
for change in [json!({"resizeHeight": 32}), json!({"resize": {"height": 32, "lockAspect": true}})] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"frameRate":10,"duration":1})).unwrap();
|
||||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 64, "lockAspect": true}})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", change).unwrap();
|
||||
let item = &s.project.render_queue[0];
|
||||
let comp = s.project.comp(item.comp).unwrap();
|
||||
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32));
|
||||
assert_eq!(item.output.resize.height, 32);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Square","width":64,"height":64,"duration":1})).unwrap();
|
||||
s.execute("renderQueue.add", json!({})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"resize": {"width": 32, "height": 54, "lockAspect": true}})).unwrap();
|
||||
let item = &s.project.render_queue[0];
|
||||
let comp = s.project.comp(item.comp).unwrap();
|
||||
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence");
|
||||
}
|
||||
@@ -402,6 +402,26 @@ fn set_text_paragraph_fill_and_leading() {
|
||||
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_groups_report_actionable_errors() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T"})).unwrap();
|
||||
let l = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let uid = s.active_comp().unwrap().layer(crate::project::LayerId(l)).unwrap().props.group("text/pathOptions").unwrap().uid;
|
||||
for selector in [json!({"path": "text"}), json!({"path": "text/pathOptions"}), json!({"path": format!("@{uid}")}), json!({"prop": uid})] {
|
||||
let mut params = selector;
|
||||
params["layer"] = json!(l);
|
||||
for cmd in ["prop.get", "prop.set"] {
|
||||
let err = s.execute(cmd, params.clone()).unwrap_err().to_string();
|
||||
assert!(err.contains("is a property group"), "{cmd}: {err}");
|
||||
assert!(err.contains("get_layer"), "{cmd}: {err}");
|
||||
}
|
||||
}
|
||||
assert!(s.execute("prop.get", json!({"layer": l, "path": "text/sourceText"})).is_ok());
|
||||
let err = s.execute("prop.get", json!({"layer": l, "path": "missing"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no property `missing`"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_get_and_render_rgba8() {
|
||||
let mut s = Session::default();
|
||||
@@ -542,3 +562,18 @@ fn render_queue_add_accepts_documented_settings_when_checked() {
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(s.execute_checked("renderQueue.add", json!({"bogus": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_rejects_malformed_positions_without_creating_a_layer() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
for position in [json!([]), json!([10]), json!(["x", 20]), json!([null, 20])] {
|
||||
let err = s.execute("layer.newText", json!({"text": "Hello", "position": position})).unwrap_err();
|
||||
assert!(err.to_string().contains("position: [x, y]"), "{err}");
|
||||
assert!(s.active_comp().unwrap().layers.is_empty());
|
||||
}
|
||||
s.execute("layer.newText", json!({"name": "Valid", "text": "Hello", "position": [10, 20]})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value, effectcraft_keyframe::Value::Vec3([10.0, 20.0, 0.0]));
|
||||
s.execute("layer.newText", json!({"text": "No explicit position"})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Key attributes preserve the order used by the next stagger gesture.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Interp;
|
||||
use effectcraft_project::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session, time::FrameRate};
|
||||
|
||||
fn setup() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let layer = s.execute_checked("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, layer)
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, layer: u64, path: &str, time: i64, value: serde_json::Value) {
|
||||
s.execute_checked("prop.addKey", json!({"layer": layer, "path": path, "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
fn uid(s: &Session, layer: u64, path: &str) -> u64 {
|
||||
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn key(layer: u64, prop: u64, frame: i64) -> KeyRef {
|
||||
KeyRef { layer: LayerId(layer), prop, time: FrameRate::FPS_30.tick_of(frame) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_preserves_interleaved_layer_and_property_order_with_one_undo() {
|
||||
let (mut s, first) = setup();
|
||||
let second = s.execute_checked("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for layer in [first, second] {
|
||||
for time in [0, 1] {
|
||||
add(&mut s, layer, "transform/opacity", time, json!(time * 25));
|
||||
}
|
||||
}
|
||||
add(&mut s, first, "transform/position", 1, json!([10, 20]));
|
||||
let a = uid(&s, first, "transform/opacity");
|
||||
let b = uid(&s, second, "transform/opacity");
|
||||
let position = uid(&s, first, "transform/position");
|
||||
s.execute_checked(
|
||||
"keys.select",
|
||||
json!({"keys": [
|
||||
{"layer": second, "prop": b, "time": 1},
|
||||
{"layer": first, "prop": position, "time": 1},
|
||||
{"layer": first, "prop": a, "time": 0},
|
||||
{"layer": second, "prop": b, "time": 0}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let order = s.state.selected_keys.clone();
|
||||
let before = s.project.clone();
|
||||
s.history.clear();
|
||||
s.execute_checked("keys.easyEase", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_keys, order);
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
for selected in &order {
|
||||
let keys = &s.active_comp().unwrap().layer(selected.layer).unwrap().props.find(selected.prop).unwrap().keys;
|
||||
assert_eq!(keys.iter().find(|k| k.time == selected.time).unwrap().out_interp, Interp::Bezier);
|
||||
}
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.project, &before));
|
||||
assert_eq!(s.state.selected_keys, order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roving_keeps_the_selected_key_identity_and_arbitrary_order() {
|
||||
let (mut s, layer) = setup();
|
||||
for (time, x) in [(0, 0), (1, 25), (2, 100)] {
|
||||
add(&mut s, layer, "transform/position", time, json!([x, 0]));
|
||||
}
|
||||
let prop = uid(&s, layer, "transform/position");
|
||||
s.execute_checked(
|
||||
"keys.select",
|
||||
json!({"keys": [
|
||||
{"layer": layer, "prop": prop, "time": 1},
|
||||
{"layer": layer, "prop": prop, "time": 2},
|
||||
{"layer": layer, "prop": prop, "time": 0}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute_checked("keys.interpolation", json!({"roving": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys, vec![key(layer, prop, 15), key(layer, prop, 60), key(layer, prop, 0)]);
|
||||
let position = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap();
|
||||
assert_eq!(position.keys[1].value.as_vec2(), [25.0, 0.0]);
|
||||
assert_eq!(position.keys[1].time, FrameRate::FPS_30.tick_of(15));
|
||||
assert!(position.keys[1].roving);
|
||||
assert!(!position.keys[0].roving && !position.keys[2].roving);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_edit_deduplicates_legacy_refs_and_drops_stale_refs_without_snapping() {
|
||||
let (mut s, layer) = setup();
|
||||
for time in [0, 1] {
|
||||
add(&mut s, layer, "transform/opacity", time, json!(20));
|
||||
}
|
||||
let prop = uid(&s, layer, "transform/opacity");
|
||||
let first = key(layer, prop, 0);
|
||||
let second = key(layer, prop, 30);
|
||||
s.state.selected_keys = vec![second, key(layer, prop, 10), first, second, key(layer, u64::MAX, 0), first, key(u64::MAX, prop, 0)];
|
||||
s.history.clear();
|
||||
let result = s.execute_checked("keys.toggleHold", json!({})).unwrap();
|
||||
assert_eq!(result, json!(2));
|
||||
assert_eq!(s.state.selected_keys, vec![second, first]);
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap();
|
||||
assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Hold));
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap();
|
||||
assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Linear));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Agent keyframe times on offset, stretched and reversed layers (#257).
|
||||
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, tests_timeline::prop};
|
||||
|
||||
fn offset_layer(start: f64, stretch: f64) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Offset", "duration": 20, "frameRate": 30})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layer": layer, "start": start, "in": 10.5, "out": 20})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap().stretch = stretch;
|
||||
(s, layer)
|
||||
}
|
||||
|
||||
fn assert_seconds(value: &Value, seconds: f64) {
|
||||
assert!((value.as_f64().unwrap() - seconds).abs() < 1e-8, "{value} != {seconds}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_key_times_on_an_offset_layer_read_at_the_intended_time_and_undo() {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
for (time, value) in [(15.3, 0), (16.0, 100)] {
|
||||
let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": value})).unwrap();
|
||||
assert_seconds(&reply["compTime"], time);
|
||||
assert_seconds(&reply["layerTime"], time - 10.5);
|
||||
assert!(reply.get("warning").is_none());
|
||||
}
|
||||
let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity", "time": 15.65})).unwrap();
|
||||
assert_seconds(&read["value"], 50.0);
|
||||
assert_seconds(&read["keys"][0]["compTime"], 15.3);
|
||||
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.5, "timeBase": "comp", "value": 45})).unwrap();
|
||||
assert_seconds(&reply["compTime"], 15.5);
|
||||
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 2);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, layer, "transform/opacity").keys.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_layer_times_are_preserved_and_outside_keys_warn() {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
let legacy = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 0})).unwrap();
|
||||
assert_eq!(legacy, json!(1), "ordinary legacy replies stay scalar");
|
||||
let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "value": 100})).unwrap();
|
||||
assert_eq!(outside, json!(2), "legacy replies keep their raw count outside the active range");
|
||||
let read = s.execute("prop.get", json!({"layer": layer, "path": "transform/opacity"})).unwrap();
|
||||
assert_seconds(&read["keys"][1]["time"], 15.3);
|
||||
assert_seconds(&read["keys"][1]["compTime"], 25.8);
|
||||
assert!(read["keys"][1].get("warning").is_some());
|
||||
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 25.8}], "timeBase": "comp"})).unwrap();
|
||||
assert_seconds(&selected["keys"][0]["compTime"], 25.8);
|
||||
assert!(selected.get("warning").is_some());
|
||||
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap();
|
||||
assert_seconds(&moved["compTime"], 20.0);
|
||||
assert!(moved.get("warning").is_some(), "out point is exclusive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stretched_and_reversed_keys_use_the_comp_frame_grid_for_both_bases() {
|
||||
for (start, stretch) in [(10.5, 200.0), (18.0, -200.0)] {
|
||||
let (mut s, layer) = offset_layer(start, stretch);
|
||||
for time in [15.3, 16.0] {
|
||||
let layer_time = (time - start) * 100.0 / stretch;
|
||||
let comp_reply =
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": "comp", "value": 20})).unwrap();
|
||||
assert_seconds(&comp_reply["layerTime"], layer_time);
|
||||
let layer_reply =
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": layer_time, "timeBase": "layer", "value": 20})).unwrap();
|
||||
assert_seconds(&layer_reply["compTime"], time);
|
||||
}
|
||||
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": 15.3}], "timeBase": "comp"})).unwrap();
|
||||
assert_seconds(&selected["keys"][0]["compTime"], 15.3);
|
||||
let moved = s
|
||||
.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 15.41, "timeBase": "comp", "value": 60}))
|
||||
.unwrap();
|
||||
assert_seconds(&moved["compTime"], 15.4);
|
||||
let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
assert_eq!(ly.comp_time(s.state.selected_keys[0].time), Tick::from_seconds_f64(15.4));
|
||||
let layer_reply = s.execute("keys.set", json!({"layer": layer, "path": "transform/rotation", "time": (15.3 - start) * 100.0 / stretch, "newTime": (15.41 - start) * 100.0 / stretch, "timeBase": "layer"})).unwrap();
|
||||
assert_seconds(&layer_reply["compTime"], 15.4);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let ly = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
let restored = prop(&s, layer, "transform/opacity");
|
||||
assert!(restored.keys.iter().any(|k| ly.comp_time(k.time) == Tick::from_seconds_f64(15.3)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_key_time_arguments_leave_properties_and_history_unchanged() {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
let before = prop(&s, layer, "transform/opacity");
|
||||
let history_len = s.history.undo.len();
|
||||
for cmd in ["prop.addKey", "prop.set", "keys.set"] {
|
||||
for extra in [json!({"time": "oops"}), json!({"time": 1e300}), json!({"timeBase": "seconds", "time": 0})] {
|
||||
let mut p = json!({"layer": layer, "path": "transform/opacity", "value": 10});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute(cmd, p).is_err(), "{cmd}: {extra}");
|
||||
assert_eq!(prop(&s, layer, "transform/opacity"), before);
|
||||
assert_eq!(s.history.undo.len(), history_len);
|
||||
}
|
||||
}
|
||||
assert!(s.execute("keys.select", json!({"keys": [], "timeBase": null})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_key_times_and_reads_use_the_target_comps_cti() {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
let target = s.active_comp_id().unwrap().0;
|
||||
s.execute("time.set", json!({"time": 15.3})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Other", "duration": 20})).unwrap();
|
||||
s.execute("time.set", json!({"time": 1})).unwrap();
|
||||
let reply = s.execute("prop.addKey", json!({"comp": target, "layer": layer, "path": "transform/opacity", "timeBase": "comp", "value": 25})).unwrap();
|
||||
assert_seconds(&reply["compTime"], 15.3);
|
||||
let read = s.execute("prop.get", json!({"comp": target, "layer": layer, "path": "transform/opacity"})).unwrap();
|
||||
assert_seconds(&read["time"], 15.3);
|
||||
assert_seconds(&read["value"], 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_stretch_or_start_conversions_fail_without_clamping_keys() {
|
||||
for (start, stretch, base, time) in [(10.5, 1e300, "layer", 1.0), (10.5, 1e-300, "comp", 15.3), (Tick::MAX.seconds(), 100.0, "layer", 1.0)] {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let ly = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(layer)).unwrap();
|
||||
ly.start_time = Tick::from_seconds_f64(start);
|
||||
ly.stretch = stretch;
|
||||
let before = s.history.undo.len();
|
||||
let result = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": time, "timeBase": base, "value": 10}));
|
||||
assert!(result.is_err(), "start {start}, stretch {stretch}: {result:?}");
|
||||
assert_eq!(s.history.undo.len(), before);
|
||||
assert!(prop(&s, layer, "transform/opacity").keys.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_base_on_static_property_reports_timing_without_enabling_animation() {
|
||||
let (mut s, layer) = offset_layer(10.5, 100.0);
|
||||
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 25})).unwrap();
|
||||
assert_seconds(&reply["compTime"], 15.3);
|
||||
assert_eq!(reply["animated"], false);
|
||||
assert_eq!(reply["value"], 25.0);
|
||||
assert!(prop(&s, layer, "transform/opacity").keys.is_empty());
|
||||
}
|
||||
@@ -202,6 +202,23 @@ fn cache_budgets_leave_reserved_ram_and_shrink_when_low() {
|
||||
assert!(!p.cache_budgets(Some(low)).reduced);
|
||||
}
|
||||
|
||||
/// Eight stacked 1080p clips ran the browser build out of its 4 GiB of memory: the footage and
|
||||
/// layer caches kept their desktop sizes there, beside the decoders' own frames.
|
||||
#[test]
|
||||
fn web_cache_budgets_leave_room_for_the_decoders() {
|
||||
use crate::prefs::{WEB_LAYER_CACHE, WEB_MEDIA_CACHE};
|
||||
let mut p = crate::prefs::Prefs::default();
|
||||
p.memory.layer_cache_mb = 4096;
|
||||
p.memory.media_cache_mb = 4096;
|
||||
let web = p.cache_budgets_in(None, true);
|
||||
assert_eq!((web.layer as u64, web.media as u64), (WEB_LAYER_CACHE, WEB_MEDIA_CACHE));
|
||||
assert_eq!(web.preview, p.cache_budgets_in(None, false).preview, "the RAM preview keeps its budget");
|
||||
// Smaller settings stay as they are; the desktop is unchanged.
|
||||
p.memory.media_cache_mb = 128;
|
||||
assert_eq!(p.cache_budgets_in(None, true).media, 128 << 20);
|
||||
assert_eq!(p.cache_budgets_in(None, false).layer, 4096 << 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_span_follows_the_composition_setting() {
|
||||
let mut p = crate::prefs::Prefs::default();
|
||||
|
||||
@@ -109,6 +109,60 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance: new installs stay Dark (Sync with System is opt-in), and settings saved
|
||||
/// with a single theme load as a fixed mode showing that theme.
|
||||
#[test]
|
||||
fn appearance_defaults_and_legacy_theme_migrate() {
|
||||
let p = Prefs::default();
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light"));
|
||||
assert_eq!(p.appearance.resolved_theme(Some(true)), "dark");
|
||||
for (saved, mode, dark, light) in [("darker", "dark", "darker", "light"), ("light", "light", "dark", "light"), ("dark", "dark", "dark", "light")] {
|
||||
let p = Prefs::from_json(&json!({"version": 2, "appearance": {"theme": saved}}).to_string());
|
||||
assert_eq!(p.appearance.appearance_mode, mode, "{saved}");
|
||||
assert_eq!(p.appearance.dark_theme, dark, "{saved}");
|
||||
assert_eq!(p.appearance.light_theme, light, "{saved}");
|
||||
assert_eq!(p.appearance.theme, saved);
|
||||
}
|
||||
// The flat layout of the earliest builds migrates too.
|
||||
assert_eq!(Prefs::from_json(r#"{"theme": "light"}"#).appearance.appearance_mode, "light");
|
||||
// A file that already has a mode keeps it; garbage falls back to the defaults.
|
||||
let p = Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "dark", "appearanceMode": "auto", "darkTheme": "darker"}}"#);
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str()), ("auto", "darker"));
|
||||
let p = Prefs::from_json(r#"{"version": 2, "appearance": {"appearanceMode": "sepia", "darkTheme": "light", "lightTheme": 7}}"#);
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light"));
|
||||
}
|
||||
|
||||
/// The appearance settings take only their choices, survive a restart, and old clients that set
|
||||
/// the single theme still pick a visible theme without losing the other family's choice.
|
||||
#[test]
|
||||
fn appearance_choices_validate_and_legacy_theme_still_selects() {
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "darker", "appearance.lightTheme": "light"}}))
|
||||
.unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "auto");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(Some(true)), "light");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(Some(false)), "darker");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(None), "darker", "no system answer: dark");
|
||||
assert_eq!(Prefs::from_json(&s.prefs.to_json()).appearance, s.prefs.appearance);
|
||||
for (key, bad) in
|
||||
[("appearance.darkTheme", "light"), ("appearance.lightTheme", "darker"), ("appearance.appearanceMode", "sepia"), ("appearance.theme", "neon")]
|
||||
{
|
||||
assert!(s.execute("prefs.set", json!({"key": key, "value": bad})).is_err(), "{key} = {bad}");
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "appearance.theme", "value": "light"})).unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "light");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "darker");
|
||||
s.execute("prefs.set", json!({"key": "appearance", "value": {"theme": "dark"}})).unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "dark");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "dark");
|
||||
// A fixed mode mirrors its theme in the single-theme key for old readers.
|
||||
s.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "light"})).unwrap();
|
||||
assert_eq!(s.prefs.get("appearance.theme"), Some(json!("light")));
|
||||
s.prefs.reset(Some("appearance")).unwrap();
|
||||
assert_eq!(s.prefs.appearance, crate::prefs::Appearance::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
|
||||
let p = Prefs::default();
|
||||
@@ -530,6 +584,10 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("ja"));
|
||||
// pt-br is registered alongside en and ja, and round-trips through the file like any other.
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "pt-br"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br"));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let saved = store.read(PREFS_FILE).unwrap();
|
||||
for bad in [json!("fr"), json!(""), json!(17), json!(null)] {
|
||||
assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err());
|
||||
@@ -552,6 +610,11 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
uk.load_settings();
|
||||
assert_eq!(uk.prefs.general.language, "uk");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"uk"}}"#).general.language, "uk");
|
||||
// es is registered the same way.
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "es"})).unwrap();
|
||||
let mut es = Session { config: Some(store.clone()), ..Default::default() };
|
||||
es.load_settings();
|
||||
assert_eq!(es.prefs.general.language, "es");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
||||
@@ -259,3 +259,19 @@ fn fonts_lists_bundled_and_installed_families() {
|
||||
assert_eq!(crate::font_styles("NOTO SERIF"), styles);
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
||||
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
|
||||
/// channel, MCP) accepts them and requires none (#520).
|
||||
#[test]
|
||||
fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
|
||||
let spec = crate::commands::find("layer.setText").unwrap();
|
||||
let schema = crate::commands::params_schema(spec);
|
||||
assert_eq!(schema["required"], json!([]));
|
||||
for key in ["discretionaryLigatures", "ss01", "ss02", "ss20", "swash"] {
|
||||
assert!(schema["properties"].get(key).is_some(), "{key} is not in the schema");
|
||||
}
|
||||
assert!(schema["properties"].get("OpenType").is_none());
|
||||
let (mut s, t) = session("Office 1/2");
|
||||
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
|
||||
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
|
||||
}
|
||||
@@ -322,6 +322,22 @@ fn alt_click_stopwatch_toggles_a_self_reference_expression() {
|
||||
assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_expression_without_path_uses_the_selected_property() {
|
||||
let (mut s, l) = setup();
|
||||
// Nothing selected: the same hint as Add Property to Essential Graphics.
|
||||
let err = s.execute("prop.setExpression", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("select a property in the timeline"), "{err}");
|
||||
assert!(prop(&s, l, "transform/opacity").expr.is_none());
|
||||
// Animation > Add Expression sends no parameters.
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("prop.setExpression", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity");
|
||||
// One undo step removes it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(prop(&s, l, "transform/opacity").expr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_comp_and_key_times_stay_frame_aligned() {
|
||||
let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! A checked comp time must remain supported after nearest-frame rounding.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn project(s: &Session) -> Value {
|
||||
serde_json::to_value(s.project.as_ref()).unwrap()
|
||||
}
|
||||
|
||||
fn stored_comp_time(s: &Session, layer: u64, value: f64) -> Tick {
|
||||
let owner = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
let property = owner.props.prop("transform/opacity").unwrap();
|
||||
let key = property.keys.iter().find(|key| key.value.to_json().as_f64() == Some(value)).unwrap();
|
||||
owner.comp_time(key.time)
|
||||
}
|
||||
|
||||
fn check_bound(upper: bool) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Post-snap bound", "width": 80, "height": 60, "duration": 20, "frameRate": 25})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let start = if upper { 10.0 } else { -10.0 };
|
||||
s.execute("layer.timing", json!({"layer": layer, "start": start})).unwrap();
|
||||
let owner = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
assert_eq!(owner.start_time, Tick::from_seconds_f64(start), "ordinary frame-aligned start");
|
||||
assert_eq!(owner.stretch, 100.0);
|
||||
|
||||
let bound = if upper { Tick::MAX } else { Tick::MIN };
|
||||
let nearby = if upper { bound - Tick(TICKS_PER_SECOND) } else { bound + Tick(TICKS_PER_SECOND) };
|
||||
let control_history = s.history.undo.len();
|
||||
let control =
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": nearby.seconds(), "timeBase": "comp", "value": 25})).unwrap();
|
||||
assert_eq!(control["count"].as_u64(), Some(1), "nearby in-range key succeeds");
|
||||
let actual_control = stored_comp_time(&s, layer, 25.0);
|
||||
assert!((Tick::MIN..=Tick::MAX).contains(&actual_control), "actual nearby comp time remains supported");
|
||||
assert!(
|
||||
actual_control.0.abs_diff(nearby.0) < (TICKS_PER_SECOND / 25) as u64,
|
||||
"nearby control remains within one ordinary 25fps frame of its requested time"
|
||||
);
|
||||
assert_eq!(s.history.undo.len(), control_history + 1, "control records one edit");
|
||||
eprintln!("BOUND_NEARBY_CONTROL upper={upper} requested={} actual={} reply={control}", nearby.0, actual_control.0);
|
||||
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let redo_before = s.history.redo.len();
|
||||
let outcome = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": bound.seconds(), "timeBase": "comp", "value": 75}));
|
||||
if let Ok(reply) = &outcome {
|
||||
assert_eq!(reply["count"].as_u64(), Some(2), "the accepted boundary call added its key");
|
||||
let actual = stored_comp_time(&s, layer, 75.0);
|
||||
let exceeds = if upper { actual > Tick::MAX } else { actual < Tick::MIN };
|
||||
assert!(exceeds, "an accepted boundary call must prove the predicted outward comp-time snap");
|
||||
assert_eq!(s.history.undo.len(), history_before + 1, "the accepted boundary call is an actual edit");
|
||||
eprintln!(
|
||||
"BOUND_OUTWARD_ACCEPTED upper={upper} requested={} actual={} history_before={} history_after={} reply={reply}",
|
||||
bound.0,
|
||||
actual.0,
|
||||
history_before,
|
||||
s.history.undo.len()
|
||||
);
|
||||
}
|
||||
assert!(outcome.is_err(), "nearest-frame rounding beyond the supported comp-time bound must reject before an edit: upper={upper}, outcome={outcome:?}");
|
||||
let error = outcome.unwrap_err().to_string();
|
||||
assert!(error.contains("converts outside the supported time range"), "contextual conversion error: {error}");
|
||||
assert_eq!(project(&s), before, "rejected post-snap time leaves the full project unchanged");
|
||||
assert_eq!(s.history.undo.len(), history_before, "rejected post-snap time records no Undo step");
|
||||
assert_eq!(s.history.redo.len(), redo_before, "rejected post-snap time leaves Redo unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_supported_comp_time_is_rejected_after_outward_frame_snap() {
|
||||
check_bound(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_supported_comp_time_is_rejected_after_outward_frame_snap() {
|
||||
check_bound(false);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Omitted time bases retain existing result shapes even for valid keys outside [in, out).
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const LAYER_TIME: f64 = 15.3;
|
||||
const COMP_TIME: f64 = 25.8;
|
||||
|
||||
fn setup(text: bool) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Reply contract", "width": 80, "height": 60, "duration": 20, "frameRate": 30})).unwrap();
|
||||
let created = if text {
|
||||
s.execute("layer.newText", json!({"name": "Document", "text": "Before"})).unwrap()
|
||||
} else {
|
||||
s.execute("layer.newSolid", json!({"name": "A", "color": "#ffffff"})).unwrap()
|
||||
};
|
||||
let layer = created["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layer": layer, "start": 10.5, "in": 10.5, "out": 20})).unwrap();
|
||||
(s, layer)
|
||||
}
|
||||
|
||||
fn project(s: &Session) -> Value {
|
||||
serde_json::to_value(s.project.as_ref()).unwrap()
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, layer: u64, path: &str, time: f64) -> Value {
|
||||
s.execute("prop.get", json!({"layer": layer, "path": path, "time": time})).unwrap()
|
||||
}
|
||||
|
||||
fn outside_key(s: &Session, layer: u64, path: &str, layer_time: f64, comp_time: f64) {
|
||||
let comp = s.active_comp().unwrap();
|
||||
let owner = comp.layer(LayerId(layer)).unwrap();
|
||||
let property = owner.props.prop(path).unwrap();
|
||||
let at = Tick::from_seconds_f64(layer_time);
|
||||
assert!(property.keys.iter().any(|key| key.time == at), "actual stored layer-time key");
|
||||
assert_eq!(owner.comp_time(at), Tick::from_seconds_f64(comp_time), "actual comp-time mapping");
|
||||
assert!(!owner.is_active_at(owner.comp_time(at)), "authored key is outside the active layer range");
|
||||
}
|
||||
|
||||
fn undo_redo(s: &mut Session, before: &Value, after: &Value, history_before: usize) {
|
||||
assert_ne!(before, after, "the public edit changed the project");
|
||||
assert_eq!(s.history.undo.len(), history_before + 1, "one public edit or batch Undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(project(s), *before, "complete project Undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(project(s), *after, "complete project Redo");
|
||||
}
|
||||
|
||||
fn seconds(actual: &Value, expected: f64) {
|
||||
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-8, "{actual} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_add_key_keeps_its_numeric_count_and_undo() {
|
||||
let (mut s, layer) = setup(false);
|
||||
let positive = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap();
|
||||
assert_eq!(positive.as_u64(), Some(1), "ordinary in-range numeric reply positive");
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let reply = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
|
||||
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
eprintln!("LEGACY_REPLY addKey outside: {reply}");
|
||||
assert_eq!(reply.as_u64(), Some(2), "omitted timeBase must preserve the numeric addKey count outside the active range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_scalar_set_keeps_its_value_and_undo() {
|
||||
let (mut s, layer) = setup(false);
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 4.8, "value": 20})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 40})).unwrap();
|
||||
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
|
||||
seconds(&read(&mut s, layer, "transform/opacity", COMP_TIME)["value"], 40.0);
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
eprintln!("LEGACY_REPLY scalar set outside: {reply}");
|
||||
assert_eq!(reply.as_f64(), Some(40.0), "omitted timeBase must preserve the scalar set value outside the active range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_selection_keeps_its_numeric_count_without_an_edit() {
|
||||
let (mut s, layer) = setup(false);
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60})).unwrap();
|
||||
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let reply = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": LAYER_TIME}]})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1, "actual owner selected");
|
||||
assert_eq!(s.state.selected_keys[0].layer, LayerId(layer));
|
||||
assert_eq!(s.state.selected_keys[0].time, Tick::from_seconds_f64(LAYER_TIME));
|
||||
assert_eq!(project(&s), before);
|
||||
assert_eq!(s.history.undo.len(), history_before);
|
||||
eprintln!("LEGACY_REPLY select outside: {reply}");
|
||||
assert_eq!(reply.as_u64(), Some(1), "omitted timeBase must preserve the numeric selection count outside the active range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_position_set_keeps_its_array_value_and_undo() {
|
||||
let (mut s, layer) = setup(false);
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [10, 20, 0]})).unwrap();
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let reply = s.execute("prop.set", json!({"layer": layer, "path": "transform/position", "time": LAYER_TIME, "value": [12, 34, 0]})).unwrap();
|
||||
outside_key(&s, layer, "transform/position", LAYER_TIME, COMP_TIME);
|
||||
let actual = read(&mut s, layer, "transform/position", COMP_TIME)["value"].clone();
|
||||
let values = actual.as_array().unwrap();
|
||||
assert_eq!(values.len(), 3, "ordinary Position value remains a vector");
|
||||
seconds(&values[0], 12.0);
|
||||
seconds(&values[1], 34.0);
|
||||
seconds(&values[2], 0.0);
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
eprintln!("LEGACY_REPLY array set outside: {reply}");
|
||||
assert_eq!(reply, actual, "omitted timeBase must return the raw array value rather than a timing wrapper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_text_set_keeps_its_string_value_and_undo() {
|
||||
let (mut s, layer) = setup(true);
|
||||
let source = read(&mut s, layer, "text/sourceText", 10.5)["value"].clone();
|
||||
assert_eq!(source.as_str(), Some("Before"), "public Source Text returns its text string");
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": source})).unwrap();
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let reply = s.execute("prop.set", json!({"layer": layer, "path": "text/sourceText", "time": LAYER_TIME, "value": "Outside final"})).unwrap();
|
||||
outside_key(&s, layer, "text/sourceText", LAYER_TIME, COMP_TIME);
|
||||
let actual = read(&mut s, layer, "text/sourceText", COMP_TIME)["value"].clone();
|
||||
assert_eq!(actual.as_str(), Some("Outside final"), "actual Source Text edit");
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
eprintln!("LEGACY_REPLY text set outside: {reply}");
|
||||
assert_eq!(reply, actual, "omitted timeBase must return the raw Source Text string unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_outside_count_remains_usable_by_the_documented_batch_reference() {
|
||||
let (mut s, layer) = setup(false);
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let outcome = s.execute(
|
||||
"engine.batch",
|
||||
json!({"label": "Use key count", "steps": [
|
||||
{"command": "prop.addKey", "params": {"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "value": 60}},
|
||||
{"command": "prop.set", "params": {"layer": layer, "path": "transform/rotation", "value": "$1"}}
|
||||
]}),
|
||||
);
|
||||
eprintln!("LEGACY_REPLY batch count substitution: {outcome:?}");
|
||||
assert!(outcome.is_ok(), "a valid outside addKey count must remain usable as the documented $1 numeric value: {outcome:?}");
|
||||
let reply = outcome.unwrap();
|
||||
assert_eq!(reply["steps"].as_u64(), Some(2));
|
||||
assert_eq!(reply["results"][0].as_u64(), Some(1));
|
||||
assert_eq!(reply["results"][1].as_f64(), Some(1.0));
|
||||
seconds(&read(&mut s, layer, "transform/rotation", 10.5)["value"], 1.0);
|
||||
outside_key(&s, layer, "transform/opacity", LAYER_TIME, COMP_TIME);
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_time_bases_keep_timing_and_existing_object_warnings() {
|
||||
let (mut s, layer) = setup(false);
|
||||
let outside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "timeBase": "layer", "value": 60})).unwrap();
|
||||
assert_eq!(outside["count"].as_u64(), Some(1));
|
||||
seconds(&outside["layerTime"], LAYER_TIME);
|
||||
seconds(&outside["compTime"], COMP_TIME);
|
||||
assert!(outside["warning"].as_str().unwrap().contains("outside"));
|
||||
let inside = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "timeBase": "comp", "value": 20})).unwrap();
|
||||
seconds(&inside["layerTime"], 4.8);
|
||||
seconds(&inside["compTime"], 15.3);
|
||||
assert_eq!(inside["count"].as_u64(), Some(2));
|
||||
assert!(inside.get("warning").is_none());
|
||||
let selected = s.execute("keys.select", json!({"keys": [{"layer": layer, "path": "transform/opacity", "time": COMP_TIME}], "timeBase": "comp"})).unwrap();
|
||||
assert_eq!(selected["count"].as_u64(), Some(1));
|
||||
seconds(&selected["keys"][0]["compTime"], COMP_TIME);
|
||||
assert!(selected["warning"].as_str().unwrap().contains("outside"));
|
||||
let changed = s.execute("prop.set", json!({"layer": layer, "path": "transform/opacity", "time": COMP_TIME, "timeBase": "comp", "value": 40})).unwrap();
|
||||
seconds(&changed["value"], 40.0);
|
||||
seconds(&changed["compTime"], COMP_TIME);
|
||||
assert!(changed["warning"].as_str().unwrap().contains("outside"));
|
||||
let info = read(&mut s, layer, "transform/opacity", COMP_TIME);
|
||||
let keys = info["keys"].as_array().unwrap();
|
||||
let key = keys.iter().find(|key| (key["time"].as_f64().unwrap() - LAYER_TIME).abs() < 1e-8).unwrap();
|
||||
seconds(&key["layerTime"], LAYER_TIME);
|
||||
seconds(&key["compTime"], COMP_TIME);
|
||||
assert!(key["warning"].as_str().unwrap().contains("outside"), "prop.get already has an object response");
|
||||
let before = project(&s);
|
||||
let history_before = s.history.undo.len();
|
||||
let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": LAYER_TIME, "newTime": 15.4})).unwrap();
|
||||
assert!(moved.is_object(), "keys.set already returned an object without timeBase");
|
||||
seconds(&moved["layerTime"], 15.4);
|
||||
seconds(&moved["compTime"], 25.9);
|
||||
assert!(moved["warning"].as_str().unwrap().contains("outside"));
|
||||
outside_key(&s, layer, "transform/opacity", 15.4, 25.9);
|
||||
let after = project(&s);
|
||||
undo_redo(&mut s, &before, &after, history_before);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! 3D parenting preserves Position and static poses in uniform parent spaces.
|
||||
use effectcraft_engine::{
|
||||
Session,
|
||||
geom::{Mat4, Vec3, vec3},
|
||||
project::{BitDepth, LayerId},
|
||||
raster::Image,
|
||||
render::{Backend, EvalCtx, NoFootage, RenderOpts, Renderer},
|
||||
time::Tick,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn command(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, p).unwrap()
|
||||
}
|
||||
fn set(s: &mut Session, layer: u64, path: &str, value: Value) {
|
||||
command(s, "prop.set", json!({"layer":layer,"path":path,"value":value}));
|
||||
}
|
||||
fn solid(s: &mut Session, name: &str) -> u64 {
|
||||
command(s, "layer.newSolid", json!({"name":name,"width":12,"height":10,"color":[0.8,0.3,0.1]}))["layer"].as_u64().unwrap()
|
||||
}
|
||||
fn fixture(three: bool, rotated: bool) -> (Session, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
command(&mut s, "comp.new", json!({"name":"Synthetic 3D parent","width":64,"height":48,"duration":2,"frameRate":24}));
|
||||
Arc::make_mut(&mut s.project).settings.bit_depth = BitDepth::Bpc32;
|
||||
let parent = solid(&mut s, "Parent");
|
||||
let child = solid(&mut s, "Child");
|
||||
command(&mut s, "layer.setSwitch", json!({"layers":[parent],"switch":"video","value":false}));
|
||||
if three {
|
||||
command(&mut s, "layer.setSwitch", json!({"layers":[parent,child],"switch":"threeD","value":true}));
|
||||
}
|
||||
set(&mut s, parent, "transform/position", json!([20.0, 14.0, if three { 8.0 } else { 0.0 }]));
|
||||
set(&mut s, child, "transform/position", json!([30.0, 25.0, if three { 3.0 } else { 0.0 }]));
|
||||
if rotated {
|
||||
set(&mut s, parent, "transform/orientation", json!([15.0, -18.0, 23.0]));
|
||||
set(&mut s, parent, "transform/rotationX", json!(7.0));
|
||||
set(&mut s, parent, "transform/rotationY", json!(-11.0));
|
||||
set(&mut s, parent, "transform/rotation", json!(9.0));
|
||||
set(&mut s, parent, "transform/scale", json!([120.0, 120.0, 120.0]));
|
||||
set(&mut s, child, "transform/orientation", json!([-8.0, 12.0, -13.0]));
|
||||
set(&mut s, child, "transform/rotationX", json!(4.0));
|
||||
set(&mut s, child, "transform/rotationY", json!(6.0));
|
||||
set(&mut s, child, "transform/rotation", json!(-5.0));
|
||||
}
|
||||
(s, parent, child)
|
||||
}
|
||||
fn world(s: &Session, child: u64, t: Tick) -> Mat4 {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(cid).unwrap();
|
||||
EvalCtx::new(&s.project, cid, c, t).world_matrix(c.layer(LayerId(child)).unwrap())
|
||||
}
|
||||
fn points(m: Mat4) -> [Vec3; 5] {
|
||||
[vec3(6.0, 5.0, 0.0), vec3(0.0, 0.0, 0.0), vec3(12.0, 0.0, 0.0), vec3(0.0, 10.0, 0.0), vec3(12.0, 10.0, 0.0)].map(|p| m.apply(p))
|
||||
}
|
||||
fn displacement(a: Mat4, b: Mat4) -> f64 {
|
||||
points(a).into_iter().zip(points(b)).map(|(a, b)| (a - b).length()).fold(0.0, f64::max)
|
||||
}
|
||||
fn cpu(s: &Session, t: Tick) -> Image {
|
||||
Renderer::new(&s.project, &NoFootage, RenderOpts { backend: Backend::Cpu, motion_blur: false, ..Default::default() })
|
||||
.comp_frame_cpu(s.active_comp_id().unwrap(), t)
|
||||
}
|
||||
fn finite_visible(i: &Image) {
|
||||
assert_eq!([i.width, i.height], [64, 48]);
|
||||
assert!(i.data.iter().flatten().all(|v| v.is_finite()));
|
||||
assert!(i.data.iter().any(|p| p[3] > 0.9 && p[0] > 0.2), "synthetic child must be visibly rendered");
|
||||
}
|
||||
fn diff(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!([a.width, a.height], [b.width, b.height]);
|
||||
a.data.iter().flatten().zip(b.data.iter().flatten()).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
fn parent(s: &mut Session, child: u64, to: Option<u64>, compensate: bool) {
|
||||
command(s, "layer.setParent", json!({"layers":[child],"parent":to,"compensate":compensate}));
|
||||
}
|
||||
#[test]
|
||||
fn parenting_2d_compensation_positive_control() {
|
||||
let (mut s, p, c) = fixture(false, false);
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
let old = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
parent(&mut s, c, Some(p), true);
|
||||
assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9);
|
||||
assert!(diff(&image, &cpu(&s, Tick::ZERO)) < 1e-6);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
let attached = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&old, &s.project));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&attached, &s.project));
|
||||
parent(&mut s, c, None, true);
|
||||
assert!(displacement(before, world(&s, c, Tick::ZERO)) < 1e-9);
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_explicit_uncompensated_positive_control() {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
let old = world(&s, c, Tick::ZERO);
|
||||
let pm = world(&s, p, Tick::ZERO);
|
||||
let old_transform = s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap().clone();
|
||||
parent(&mut s, c, Some(p), false);
|
||||
let after = world(&s, c, Tick::ZERO);
|
||||
assert!(displacement(pm * old, after) < 1e-9);
|
||||
assert!(displacement(old, after) > 1.0, "control must visibly follow its parent");
|
||||
assert_eq!(&old_transform, s.active_comp().unwrap().layer(LayerId(c)).unwrap().transform().unwrap());
|
||||
assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_default_parent_preserves_position_and_cpu_pixels() {
|
||||
let (mut s, p, c) = fixture(true, false);
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
// Omit compensate to exercise the same public default as timeline pick-whip.
|
||||
command(&mut s, "layer.setParent", json!({"layers":[c],"parent":p}));
|
||||
let out = cpu(&s, Tick::ZERO);
|
||||
assert!(out.data.iter().flatten().all(|v| v.is_finite()));
|
||||
let motion = displacement(before, world(&s, c, Tick::ZERO));
|
||||
let pixels = diff(&image, &out);
|
||||
eprintln!("DEFAULT_PARENT motion={motion} pixels={pixels}");
|
||||
assert!(motion < 1e-9 && pixels < 1e-6, "3D default parenting must preserve world position and rendered pixels");
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_rotated_uniform_parent_preserves_pose() {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let motion = displacement(before, world(&s, c, Tick::ZERO));
|
||||
let out = cpu(&s, Tick::ZERO);
|
||||
let pixels = diff(&image, &out);
|
||||
eprintln!("ROTATED_PARENT motion={motion} pixels={pixels}");
|
||||
assert!(motion < 1e-8 && pixels < 2e-5, "3D rotated uniform parent must preserve child pose");
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_unparent_preserves_position() {
|
||||
let (mut s, p, c) = fixture(true, false);
|
||||
parent(&mut s, c, Some(p), false);
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
parent(&mut s, c, None, true);
|
||||
let motion = displacement(before, world(&s, c, Tick::ZERO));
|
||||
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
|
||||
eprintln!("UNPARENT motion={motion} pixels={pixels}");
|
||||
assert!(motion < 1e-9 && pixels < 1e-6, "3D unparenting must preserve world position");
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_animated_position_preserves_authored_keys() {
|
||||
let (mut s, p, c) = fixture(true, false);
|
||||
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
|
||||
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
|
||||
}
|
||||
let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| world(&s, c, t));
|
||||
let undo = s.history.undo.len();
|
||||
let old = s.project.clone();
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let motion = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max);
|
||||
eprintln!("ANIMATED_PARENT motion={motion}");
|
||||
assert!(motion < 1e-8, "3D parenting must re-express ordinary Position keys");
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
let attached = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&old, &s.project));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&attached, &s.project));
|
||||
}
|
||||
|
||||
fn anchor_motion(a: Mat4, b: Mat4) -> f64 {
|
||||
(a.apply(vec3(6.0, 5.0, 0.0)) - b.apply(vec3(6.0, 5.0, 0.0))).length()
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_rotated_parent_preserves_anchor() {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let after = world(&s, c, Tick::ZERO);
|
||||
let anchor = anchor_motion(before, after);
|
||||
eprintln!("ROTATED_ANCHOR anchor={anchor} corners={} pixels={}", displacement(before, after), diff(&image, &cpu(&s, Tick::ZERO)));
|
||||
assert!(anchor < 1e-8, "3D rotated parenting must retain the world-space anchor");
|
||||
parent(&mut s, c, None, true);
|
||||
assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "3D rotated unparent must retain the world-space anchor");
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_separated_position_preserves_each_axis_and_undo() {
|
||||
let (mut s, p, c) = fixture(true, false);
|
||||
command(&mut s, "prop.separateDimensions", json!({"layer":c,"value":true}));
|
||||
for (name, keys) in [
|
||||
("positionX", vec![(0.0, 27.0), (1.0, 35.0)]),
|
||||
("positionY", vec![(0.0, 22.0), (0.5, 27.0), (1.0, 29.0)]),
|
||||
("positionZ", vec![(0.0, 2.0), (0.25, 3.0), (1.0, 5.0)]),
|
||||
] {
|
||||
for (time, value) in keys {
|
||||
command(&mut s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"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 old = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
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);
|
||||
eprintln!("SEPARATED_PARENT motion={error}");
|
||||
assert!(error < 1e-8, "3D parenting must preserve separated Position animation");
|
||||
for name in ["positionX", "positionY", "positionZ"] {
|
||||
let a = &old.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys;
|
||||
let b = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().keys;
|
||||
assert_eq!(a.iter().map(|k| k.time).collect::<Vec<_>>(), b.iter().map(|k| k.time).collect::<Vec<_>>(), "parent compensation does not retime axes");
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
let attached = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&old, &s.project));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&attached, &s.project));
|
||||
parent(&mut s, c, None, true);
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
assert!(displacement(b, world(&s, c, t)) < 1e-8);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_position_spatial_tangents_follow_relative_parent_space() {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
|
||||
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
|
||||
}
|
||||
// Generated manual spatial-curve data; temporal interpolation remains linear.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let pr = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap();
|
||||
for key in &mut pr.keys {
|
||||
key.spatial_auto = false;
|
||||
}
|
||||
pr.keys[0].spatial_out = [3.0, 5.0, -1.0];
|
||||
pr.keys[1].spatial_in = [-4.0, 2.0, 1.0];
|
||||
let tangents = pr.keys.iter().map(|k| (k.spatial_in, k.spatial_out)).collect::<Vec<_>>();
|
||||
let relative = world(&s, p, Tick::ZERO).inverse().unwrap();
|
||||
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));
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let error = times.into_iter().zip(before).map(|(t, b)| anchor_motion(b, world(&s, c, t))).fold(0.0, f64::max);
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/position").unwrap().keys;
|
||||
let tangent_error = keys
|
||||
.iter()
|
||||
.zip(tangents)
|
||||
.flat_map(|(key, (tin, tout))| [(key.spatial_in, tin), (key.spatial_out, tout)])
|
||||
.map(|(after, before)| (Vec3::from(after) - relative.apply_vec(Vec3::from(before))).length())
|
||||
.fold(0.0, f64::max);
|
||||
eprintln!("SPATIAL_PARENT anchor={error} tangent={tangent_error}");
|
||||
assert!(error < 1e-8 && tangent_error < 1e-8, "3D Position spatial tangents must remain relative vectors");
|
||||
}
|
||||
#[test]
|
||||
fn parenting_3d_parent_source_pixel_aspect_is_not_inherited() {
|
||||
let (mut s, _, c) = fixture(true, false);
|
||||
let p = command(&mut s, "layer.newSolid", json!({"name":"Wide pixels parent","width":12,"height":10,"color":[0.1,0.2,0.3],"pixelAspect":2.0}))["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"threeD","value":true}));
|
||||
command(&mut s, "layer.setSwitch", json!({"layers":[p],"switch":"video","value":false}));
|
||||
set(&mut s, p, "transform/position", json!([20.0, 14.0, 8.0]));
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
finite_visible(&image);
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let error = displacement(before, world(&s, c, Tick::ZERO));
|
||||
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
|
||||
eprintln!("PAR_PARENT motion={error} pixels={pixels}");
|
||||
assert!(error < 1e-8 && pixels < 1e-6, "3D children must not inherit parent source pixel aspect");
|
||||
}
|
||||
|
||||
fn rotation_properties(s: &Session, c: u64) -> Vec<effectcraft_engine::project::Property> {
|
||||
["rotationX", "rotationY", "rotation"]
|
||||
.map(|name| s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop(&format!("transform/{name}")).unwrap().clone())
|
||||
.into()
|
||||
}
|
||||
fn axis_animation(s: &mut Session, c: u64) {
|
||||
for (name, values) in [("rotationX", [-21.0, 35.0]), ("rotationY", [9.0, -30.0]), ("rotation", [12.0, 28.0])] {
|
||||
for (time, value) in [(0.0, values[0]), (1.0, values[1])] {
|
||||
command(s, "prop.addKey", json!({"layer":c,"path":format!("transform/{name}"),"time":time,"value":value}));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parenting_static_orientation_preserves_nonuniform_child_scale_and_axis_rotation_keys() {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
set(&mut s, c, "transform/scale", json!([83.0, 127.0, 61.0]));
|
||||
axis_animation(&mut s, c);
|
||||
let rotations = rotation_properties(&s, c);
|
||||
let old = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
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));
|
||||
for i in &images {
|
||||
finite_visible(i);
|
||||
}
|
||||
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);
|
||||
eprintln!("STATIC_AXIS_POSE corners={error} pixels={pixels}");
|
||||
assert!(error < 1e-8 && pixels < 2e-5, "static Orientation must preserve pose with nonuniform child Scale and authored axis Rotation animation");
|
||||
assert_eq!(rotation_properties(&s, c), rotations, "axis Rotation properties are byte-for-byte authored");
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
let attached = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&s.project, &old));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&s.project, &attached));
|
||||
parent(&mut s, c, None, true);
|
||||
for (t, b) in times.into_iter().zip(before) {
|
||||
assert!(displacement(b, world(&s, c, t)) < 1e-8);
|
||||
}
|
||||
assert_eq!(rotation_properties(&s, c), rotations);
|
||||
}
|
||||
#[test]
|
||||
fn parenting_uniform_parent_preserves_animated_eased_scale() {
|
||||
use effectcraft_engine::keyframe::{Ease, Interp};
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
for (time, value) in [(0.0, json!([80.0, 120.0, 95.0])), (1.0, json!([135.0, 75.0, 150.0]))] {
|
||||
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/scale","time":time,"value":value}));
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let scale = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/scale").unwrap();
|
||||
for key in &mut scale.keys {
|
||||
key.in_interp = Interp::Bezier;
|
||||
key.out_interp = Interp::Bezier;
|
||||
key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }, Ease { speed: -13.0, influence: 0.6 }, Ease { speed: 7.0, influence: 0.4 }];
|
||||
key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }, Ease { speed: -19.0, influence: 0.2 }, Ease { speed: 13.0, influence: 0.5 }];
|
||||
}
|
||||
let old_scale = scale.clone();
|
||||
let rotations = rotation_properties(&s, c);
|
||||
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));
|
||||
for image in &images {
|
||||
finite_visible(image);
|
||||
}
|
||||
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);
|
||||
eprintln!("EASED_SCALE_POSE corners={error} pixels={pixels}");
|
||||
assert!(error < 1e-8 && pixels < 2e-5, "uniform parent compensation must preserve animated eased Scale poses");
|
||||
assert_eq!(rotation_properties(&s, c), rotations);
|
||||
let scale = s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/scale").unwrap();
|
||||
assert_eq!(scale.keys.iter().map(|k| k.time).collect::<Vec<_>>(), old_scale.keys.iter().map(|k| k.time).collect::<Vec<_>>());
|
||||
for (old, new) in old_scale.keys.iter().zip(&scale.keys) {
|
||||
for (old, new) in old.in_ease.iter().chain(&old.out_ease).zip(new.in_ease.iter().chain(&new.out_ease)) {
|
||||
assert!((new.speed - old.speed / 1.2).abs() < 1e-9);
|
||||
assert_eq!(new.influence, old.influence);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() {
|
||||
let mut max_corners = 0.0f64;
|
||||
let mut max_pixels = 0.0f32;
|
||||
for factor in [120.0, -120.0] {
|
||||
for orientation in [[23.0, 90.0, -37.0], [-41.0, -90.0, 16.0]] {
|
||||
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!(orientation));
|
||||
set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0]));
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
let image = cpu(&s, Tick::ZERO);
|
||||
assert!(image.data.iter().flatten().all(|v| v.is_finite()));
|
||||
assert!(image.data.iter().any(|p| p[3] > 0.05), "gimbal control must render visible pixels");
|
||||
let rotations = rotation_properties(&s, c);
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let error = displacement(before, world(&s, c, Tick::ZERO));
|
||||
let pixels = diff(&image, &cpu(&s, Tick::ZERO));
|
||||
max_corners = max_corners.max(error);
|
||||
max_pixels = max_pixels.max(pixels);
|
||||
assert_eq!(rotation_properties(&s, c), rotations);
|
||||
}
|
||||
}
|
||||
eprintln!("SIGNED_GIMBAL_POSE corners={max_corners} pixels={max_pixels}");
|
||||
assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() {
|
||||
for animated in [true, false] {
|
||||
let (mut s, p, c) = fixture(true, true);
|
||||
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();
|
||||
let scale = layer.props.prop("transform/scale").unwrap().clone();
|
||||
let before = world(&s, c, Tick::ZERO);
|
||||
parent(&mut s, c, Some(p), true);
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap();
|
||||
assert_eq!(layer.props.prop("transform/orientation").unwrap(), &orientation, "unsupported animated Euler/shear receives no blind Orientation rewrite");
|
||||
assert_eq!(layer.props.prop("transform/scale").unwrap(), &scale, "unsupported animated Euler/shear receives no blind Scale rewrite");
|
||||
assert!(anchor_motion(before, world(&s, c, Tick::ZERO)) < 1e-8, "boundary retains already-proved Position behavior");
|
||||
assert!(cpu(&s, Tick::ZERO).data.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
use effectcraft_engine::keyframe::{Ease, Interp};
|
||||
let mut maximum_error = 0.0f64;
|
||||
for factor in [100.0, 200.0] {
|
||||
let (mut s, p, c) = fixture(true, false);
|
||||
set(&mut s, p, "transform/scale", json!([factor, factor, factor]));
|
||||
for (time, value) in [(0.0, json!([27.0, 22.0, 2.0])), (1.0, json!([35.0, 29.0, 5.0]))] {
|
||||
command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/position","time":time,"value":value}));
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let position = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/position").unwrap();
|
||||
for key in &mut position.keys {
|
||||
key.in_interp = Interp::Bezier;
|
||||
key.out_interp = Interp::Bezier;
|
||||
key.in_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }];
|
||||
key.out_ease = vec![Ease { speed: 10.0, influence: 1.0 / 3.0 }];
|
||||
key.auto_bezier = false;
|
||||
key.spatial_auto = false;
|
||||
key.spatial_in = [0.0; 3];
|
||||
key.spatial_out = [0.0; 3];
|
||||
}
|
||||
let times = [0.0, 0.25, 0.75, 1.0].map(Tick::from_seconds_f64);
|
||||
let before = times.map(|t| world(&s, c, t));
|
||||
assert!(anchor_motion(before[0], before[1]) > 1.0, "the eased Position fixture must actually move at quarter time");
|
||||
let old = s.project.clone();
|
||||
let undo = s.history.undo.len();
|
||||
// Omit compensate to exercise the public default without depending on the schema-only fix.
|
||||
s.execute_checked("layer.setParent", json!({"layers":[c],"parent":p})).unwrap();
|
||||
let after = times.map(|t| world(&s, c, t));
|
||||
assert!(after.iter().all(|m| m.0.iter().flatten().all(|v| v.is_finite())));
|
||||
for i in [0, 3] {
|
||||
assert!(anchor_motion(before[i], after[i]) < 1e-8, "parent compensation must preserve the authored Position endpoints");
|
||||
}
|
||||
for i in [1, 2] {
|
||||
let error = anchor_motion(before[i], after[i]);
|
||||
maximum_error = maximum_error.max(error);
|
||||
eprintln!("EASED_POSITION_PARENT factor={factor} time={} anchor_error={error}", times[i].seconds());
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "parent compensation is one undo action");
|
||||
let attached = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&old, &s.project), "Undo restores the exact authored project");
|
||||
for (t, expected) in times.into_iter().zip(before) {
|
||||
assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Undo restores all sampled world anchors");
|
||||
}
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&attached, &s.project), "Redo restores the exact compensated project");
|
||||
for (t, expected) in times.into_iter().zip(after) {
|
||||
assert!(anchor_motion(expected, world(&s, c, t)) < 1e-8, "Redo retains the same compensated samples");
|
||||
}
|
||||
}
|
||||
assert!(maximum_error < 1e-8, "3D uniform parent compensation must preserve eased Position at quarter and three-quarter times");
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::io::Write;
|
||||
|
||||
use effectcraft_project::Comp;
|
||||
use effectcraft_project::render_queue::{AudioFormat, Channels, OutputFormat, ProResProfile};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -331,7 +331,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
return Ok(());
|
||||
}
|
||||
let n = (end - *cursor) as usize;
|
||||
let start = Tick(((*cursor as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
let start = Tick::from_units(*cursor, i64::from(sr));
|
||||
let start = if start.to_units_floor(i64::from(sr)) < *cursor { Tick(start.0.saturating_add(1)) } else { start };
|
||||
let buf = mix(job, start, n, sr);
|
||||
*cursor = end;
|
||||
match a {
|
||||
|
||||
@@ -344,7 +344,9 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R
|
||||
let mut reported = 0u64;
|
||||
while done < n {
|
||||
let k = block.min(n - done);
|
||||
let t = Tick((((start + done as i64) as i128 * TICKS_PER_SECOND as i128) / sr as i128) as i64);
|
||||
let sample = start + done as i64;
|
||||
let t = Tick::from_units(sample, i64::from(sr));
|
||||
let t = if t.to_units_floor(i64::from(sr)) < sample { Tick(t.0.saturating_add(1)) } else { t };
|
||||
pcm.extend(pcm_bytes(&mix(job, t, k, sr), fmt, aiff));
|
||||
done += k;
|
||||
let now = (done as u64 * total_frames / n.max(1) as u64).min(total_frames);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Sample positions through the public native WAV exporter, using a tiny recorded impulse.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct Impulse {
|
||||
first: i64,
|
||||
last: i64,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for Impulse {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
let index = start.to_units_floor(i64::from(rate));
|
||||
self.calls.lock().unwrap().push((start, index, frames, rate));
|
||||
let mut samples = Vec::with_capacity(frames * 2);
|
||||
for offset in 0..frames {
|
||||
let sample = index + offset as i64;
|
||||
let value = if sample == self.first {
|
||||
0.75
|
||||
} else if sample == self.last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
samples.extend([value, -value]);
|
||||
}
|
||||
Some(samples)
|
||||
}
|
||||
}
|
||||
fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let mut offset = 12;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize;
|
||||
if &bytes[offset..offset + 4] == name {
|
||||
return &bytes[offset + 8..offset + 8 + length];
|
||||
}
|
||||
offset += 8 + length + length % 2;
|
||||
}
|
||||
panic!("native WAV chunk missing");
|
||||
}
|
||||
fn check(rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Match the exporter's existing floor-to-sample span convention; this proof tests whether
|
||||
// its selected samples survive subsequent Tick conversions, without changing that convention.
|
||||
let first = span.0.to_units_floor(i64::from(rate));
|
||||
let end = span.1.to_units_floor(i64::from(rate));
|
||||
let expected_frames = usize::try_from(end - first).unwrap();
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item(
|
||||
"Impulse",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Footage(Footage { path: "recorded-sample-impulse.wav".into(), kind: FootageKind::Audio, duration, has_audio: true, ..Default::default() }),
|
||||
);
|
||||
let mut comp = Comp::new(4, 4, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Impulse", LayerSource::Footage { item }, (4, 4), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::Wav);
|
||||
output.audio_sample_rate = rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let provider = Impulse { first, last: end - 1, calls: Mutex::default() };
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "impulse.wav",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio, "the production native audio exporter ran");
|
||||
let files = files.lock().unwrap();
|
||||
let bytes = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1;
|
||||
assert_eq!(&bytes[..4], b"RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE");
|
||||
let format = chunk(bytes, b"fmt ");
|
||||
assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3, "float PCM");
|
||||
assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), rate);
|
||||
let pcm = chunk(bytes, b"data");
|
||||
assert_eq!(pcm.len(), expected_frames * 8, "the selected work-area sample count is unchanged");
|
||||
let samples: Vec<f32> = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect();
|
||||
let impulses: Vec<usize> = samples.as_chunks::<2>().0.iter().enumerate().filter_map(|(i, pair)| (pair[0] != 0.0).then_some(i)).collect();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
eprintln!("rate={rate} first={first} end={end} frames={expected_frames} calls={calls:?} exported_impulses={impulses:?}");
|
||||
assert!(!calls.is_empty(), "native export actually requested provider samples");
|
||||
assert_eq!(
|
||||
samples[..2],
|
||||
[0.75, -0.75],
|
||||
"the first selected source sample must be the first exported sample at {rate} Hz; impulses={impulses:?}, calls={calls:?}"
|
||||
);
|
||||
assert_eq!(samples[samples.len() - 2..], [0.25, -0.25], "the final selected source sample must be preserved at {rate} Hz");
|
||||
assert_eq!(impulses, [0, expected_frames - 1], "no shifted or duplicated impulse");
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_44100_sample_positions() {
|
||||
check(44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_48000_sample_positions() {
|
||||
check(48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_wav_work_area_preserves_44117_sample_positions() {
|
||||
check(44_117);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Tiny native WAV to ProRes PCM MOV sample-position proof.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingNative {
|
||||
pool: MediaPool,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate));
|
||||
Some(self.pool.audio_samples(footage, start, frames, rate))
|
||||
}
|
||||
}
|
||||
fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> {
|
||||
let mut pcm = Vec::with_capacity(native_rate as usize * 8);
|
||||
for index in 0..i64::from(native_rate) {
|
||||
let pair = if impulses {
|
||||
let value: f32 = if index == first {
|
||||
0.75
|
||||
} else if index == last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
[value, -value]
|
||||
} else {
|
||||
[(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0]
|
||||
};
|
||||
pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes));
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend((36 + pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16u32.to_le_bytes());
|
||||
wav.extend(3u16.to_le_bytes());
|
||||
wav.extend(2u16.to_le_bytes());
|
||||
wav.extend(native_rate.to_le_bytes());
|
||||
wav.extend((native_rate * 8).to_le_bytes());
|
||||
wav.extend(8u16.to_le_bytes());
|
||||
wav.extend(32u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend((pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(pcm);
|
||||
wav.into()
|
||||
}
|
||||
fn boxes(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 0usize;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let size = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap());
|
||||
let name = bytes[offset + 4..offset + 8].try_into().unwrap();
|
||||
let (size, header) = if size == 1 {
|
||||
(usize::try_from(u64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().unwrap())).unwrap(), 16)
|
||||
} else if size == 0 {
|
||||
(bytes.len() - offset, 8)
|
||||
} else {
|
||||
(size as usize, 8)
|
||||
};
|
||||
assert!(size >= header && offset + size <= bytes.len(), "valid returned MOV boxes");
|
||||
out.push((name, &bytes[offset + header..offset + size]));
|
||||
offset += size;
|
||||
}
|
||||
assert_eq!(offset, bytes.len());
|
||||
out
|
||||
}
|
||||
fn child<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
boxes(bytes).into_iter().find(|(kind, _)| kind == name).unwrap().1
|
||||
}
|
||||
fn movie_pcm_count(bytes: &[u8], rate: u32, frames: usize) {
|
||||
let moov = child(bytes, b"moov");
|
||||
let mdia = boxes(moov)
|
||||
.into_iter()
|
||||
.filter(|(kind, _)| kind == b"trak")
|
||||
.map(|(_, track)| child(track, b"mdia"))
|
||||
.find(|media| &child(media, b"hdlr")[8..12] == b"soun")
|
||||
.expect("the returned MOV has a sound track");
|
||||
let header = child(mdia, b"mdhd");
|
||||
let (timescale, duration) = if header[0] == 1 {
|
||||
(u32::from_be_bytes(header[20..24].try_into().unwrap()), u64::from_be_bytes(header[24..32].try_into().unwrap()))
|
||||
} else {
|
||||
(u32::from_be_bytes(header[12..16].try_into().unwrap()), u64::from(u32::from_be_bytes(header[16..20].try_into().unwrap())))
|
||||
};
|
||||
assert_eq!(timescale, rate, "PCM MOV audio timescale");
|
||||
assert_eq!(duration, frames as u64, "PCM MOV selected sample count");
|
||||
let table = child(child(mdia, b"minf"), b"stbl");
|
||||
let timing = child(table, b"stts");
|
||||
let count = u32::from_be_bytes(timing[4..8].try_into().unwrap()) as usize;
|
||||
let duration: u64 = timing[8..]
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|entry| u64::from(u32::from_be_bytes(entry[..4].try_into().unwrap())) * u64::from(u32::from_be_bytes(entry[4..].try_into().unwrap())))
|
||||
.sum();
|
||||
assert_eq!(duration, frames as u64, "all encoded PCM samples are counted");
|
||||
let sizes = child(table, b"stsz");
|
||||
let fixed = u32::from_be_bytes(sizes[4..8].try_into().unwrap()) as u64;
|
||||
let count = u32::from_be_bytes(sizes[8..12].try_into().unwrap()) as usize;
|
||||
let bytes = if fixed != 0 {
|
||||
fixed * count as u64
|
||||
} else {
|
||||
sizes[12..].as_chunks::<4>().0.iter().take(count).map(|entry| u64::from(u32::from_be_bytes(*entry))).sum()
|
||||
};
|
||||
assert_eq!(bytes, (frames * 8) as u64, "native stereo float PCM bytes");
|
||||
}
|
||||
fn first_sample_tick(index: i64, rate: u32) -> Tick {
|
||||
let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND);
|
||||
let denominator = i128::from(rate);
|
||||
Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64)
|
||||
}
|
||||
fn check(native_rate: u32, output_rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Keep the production exporter's floor-to-sample span selection. The oracle below requests
|
||||
// that same first selected integer sample directly from the actual native decoder.
|
||||
let first = span.0.to_units_floor(i64::from(output_rate));
|
||||
let end = span.1.to_units_floor(i64::from(output_rate));
|
||||
let frames = usize::try_from(end - first).unwrap();
|
||||
let impulses = native_rate == output_rate;
|
||||
let bytes = native_wav(native_rate, first, end - 1, impulses);
|
||||
let mut mismatches = Vec::new();
|
||||
for loops in [1, 2] {
|
||||
let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav");
|
||||
let mut footage = probe_bytes(&path, bytes.clone()).unwrap();
|
||||
assert!(footage.has_audio && !footage.missing);
|
||||
footage.loop_count = loops;
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes.clone());
|
||||
let oracle_tick = first_sample_tick(first, output_rate);
|
||||
assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first);
|
||||
let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate);
|
||||
assert_eq!(expected.len(), frames * 2);
|
||||
assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent");
|
||||
if impulses {
|
||||
assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse");
|
||||
assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse");
|
||||
}
|
||||
let provider = RecordingNative { pool, calls: Mutex::default() };
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage));
|
||||
let mut comp = Comp::new(16, 16, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (16, 16), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
output.audio_sample_rate = output_rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "native-impulse.mov",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio);
|
||||
let files = files.lock().unwrap();
|
||||
let movie = &files.iter().find(|(name, _)| name.ends_with(".mov")).unwrap().1;
|
||||
assert_eq!(report.frames, 1, "the one-frame ProRes movie was actually encoded");
|
||||
movie_pcm_count(movie, output_rate, frames);
|
||||
let decoded = probe_bytes("returned-native-impulse.mov", Arc::from(movie.as_slice())).unwrap();
|
||||
assert!(decoded.has_audio && decoded.has_video, "the native movie decoder recognized both tracks");
|
||||
let decoder = MediaPool::new();
|
||||
decoder.add_bytes(&decoded.path, Arc::from(movie.as_slice()));
|
||||
let decoded_pcm = decoder.audio_samples(&decoded, Tick::ZERO, frames + 8, output_rate);
|
||||
assert_eq!(decoded_pcm.len(), (frames + 8) * 2);
|
||||
assert!(decoded_pcm[frames * 2..].iter().all(|value| *value == 0.0), "native PCM ends at the selected sample count");
|
||||
let actual = decoded_pcm[..frames * 2].to_vec();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
assert!(!calls.is_empty(), "production export actually called the real native provider");
|
||||
eprintln!(
|
||||
"native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}",
|
||||
&actual[..2],
|
||||
&expected[..2],
|
||||
&actual[actual.len() - 2..],
|
||||
&expected[expected.len() - 2..]
|
||||
);
|
||||
if actual != expected {
|
||||
let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b);
|
||||
mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec()));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "production native MOV PCM export must preserve the native provider's selected sample positions: {mismatches:?}");
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_44100_positions() {
|
||||
check(44_100, 44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_48000_positions() {
|
||||
check(48_000, 48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_mov_pcm_work_area_preserves_44117_positions() {
|
||||
check(44_117, 44_117);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
//! Native MediaPool decoding and native WAV export preserve selected sample positions.
|
||||
use effectcraft_export::render_queue::{AudioFormat, OutputFormat, OutputModule, RenderSettings};
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_media::{MediaPool, probe_bytes};
|
||||
use effectcraft_project::{Comp, Footage, ItemId, ItemKind, LayerSource, Project, build};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct RecordingNative {
|
||||
pool: MediaPool,
|
||||
calls: Mutex<Vec<(Tick, i64, usize, u32)>>,
|
||||
}
|
||||
impl FootageSource for RecordingNative {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
self.calls.lock().unwrap().push((start, start.to_units_floor(i64::from(rate)), frames, rate));
|
||||
Some(self.pool.audio_samples(footage, start, frames, rate))
|
||||
}
|
||||
}
|
||||
fn native_wav(native_rate: u32, first: i64, last: i64, impulses: bool) -> Arc<[u8]> {
|
||||
let mut pcm = Vec::with_capacity(native_rate as usize * 8);
|
||||
for index in 0..i64::from(native_rate) {
|
||||
let pair = if impulses {
|
||||
let value: f32 = if index == first {
|
||||
0.75
|
||||
} else if index == last {
|
||||
0.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
[value, -value]
|
||||
} else {
|
||||
[(index % 97) as f32 / 128.0, -((index % 53) as f32) / 128.0]
|
||||
};
|
||||
pcm.extend(pair.into_iter().flat_map(f32::to_le_bytes));
|
||||
}
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend((36 + pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16u32.to_le_bytes());
|
||||
wav.extend(3u16.to_le_bytes());
|
||||
wav.extend(2u16.to_le_bytes());
|
||||
wav.extend(native_rate.to_le_bytes());
|
||||
wav.extend((native_rate * 8).to_le_bytes());
|
||||
wav.extend(8u16.to_le_bytes());
|
||||
wav.extend(32u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend((pcm.len() as u32).to_le_bytes());
|
||||
wav.extend(pcm);
|
||||
wav.into()
|
||||
}
|
||||
fn chunk<'a>(bytes: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let mut offset = 12;
|
||||
while offset + 8 <= bytes.len() {
|
||||
let length = u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize;
|
||||
if &bytes[offset..offset + 4] == name {
|
||||
return &bytes[offset + 8..offset + 8 + length];
|
||||
}
|
||||
offset += 8 + length + length % 2;
|
||||
}
|
||||
panic!("native WAV chunk missing");
|
||||
}
|
||||
fn first_sample_tick(index: i64, rate: u32) -> Tick {
|
||||
let numerator = i128::from(index) * i128::from(TICKS_PER_SECOND);
|
||||
let denominator = i128::from(rate);
|
||||
Tick((numerator.div_euclid(denominator) + i128::from(numerator.rem_euclid(denominator) != 0)) as i64)
|
||||
}
|
||||
fn check(native_rate: u32, output_rate: u32) {
|
||||
let frame_rate = FrameRate::FPS_24;
|
||||
let span = (frame_rate.tick_of(1), frame_rate.tick_of(2));
|
||||
// Keep the production exporter's floor-to-sample span selection. The oracle below requests
|
||||
// that same first selected integer sample directly from the actual native decoder.
|
||||
let first = span.0.to_units_floor(i64::from(output_rate));
|
||||
let end = span.1.to_units_floor(i64::from(output_rate));
|
||||
let frames = usize::try_from(end - first).unwrap();
|
||||
let impulses = native_rate == output_rate;
|
||||
let bytes = native_wav(native_rate, first, end - 1, impulses);
|
||||
let mut mismatches = Vec::new();
|
||||
for loops in [1, 2] {
|
||||
let path = format!("indexed-native-{native_rate}-{output_rate}-{loops}.wav");
|
||||
let mut footage = probe_bytes(&path, bytes.clone()).unwrap();
|
||||
assert!(footage.has_audio && !footage.missing);
|
||||
footage.loop_count = loops;
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes(&footage.path, bytes.clone());
|
||||
let oracle_tick = first_sample_tick(first, output_rate);
|
||||
assert_eq!(oracle_tick.to_units_floor(i64::from(output_rate)), first);
|
||||
let expected = pool.audio_samples(&footage, oracle_tick, frames, output_rate);
|
||||
assert_eq!(expected.len(), frames * 2);
|
||||
assert!(expected.iter().any(|sample| *sample != 0.0), "the real native decoding/resampling control must not be silent");
|
||||
if impulses {
|
||||
assert_eq!(expected[..2], [0.75, -0.75], "the actual native provider decodes the first indexed source impulse");
|
||||
assert_eq!(expected[expected.len() - 2..], [0.25, -0.25], "the actual native provider decodes the last indexed source impulse");
|
||||
}
|
||||
let provider = RecordingNative { pool, calls: Mutex::default() };
|
||||
let mut project = Project::default();
|
||||
let duration = Tick::from_seconds_f64(1.0);
|
||||
let item = project.add_item("Native WAV", Default::default(), None, ItemKind::Footage(footage));
|
||||
let mut comp = Comp::new(4, 4, frame_rate, duration);
|
||||
comp.work_area = span;
|
||||
comp.layers.push(build::layer(&mut project, &comp, "Native WAV", LayerSource::Footage { item }, (4, 4), None));
|
||||
let comp = project.add_item("Comp", Default::default(), None, ItemKind::Comp(comp.into()));
|
||||
let settings = RenderSettings::default();
|
||||
let mut output = OutputModule::for_format(OutputFormat::Wav);
|
||||
output.audio_sample_rate = output_rate;
|
||||
output.audio_channels = 2;
|
||||
output.audio_format = AudioFormat::F32;
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let received = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |name: &str, bytes: Vec<u8>| received.lock().unwrap().push((name.into(), bytes)));
|
||||
let job = Job {
|
||||
project: &project,
|
||||
footage: &provider,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp,
|
||||
settings: &settings,
|
||||
output: &output,
|
||||
path: "native-impulse.wav",
|
||||
sink: Some(&*sink),
|
||||
nested_switches: true,
|
||||
options: JobOptions::default(),
|
||||
};
|
||||
let report = export(&job, &mut |_| true).unwrap();
|
||||
assert!(report.audio);
|
||||
let files = files.lock().unwrap();
|
||||
let wav = &files.iter().find(|(name, _)| name.ends_with(".wav")).unwrap().1;
|
||||
assert_eq!(&wav[..4], b"RIFF");
|
||||
assert_eq!(&wav[8..12], b"WAVE");
|
||||
let format = chunk(wav, b"fmt ");
|
||||
assert_eq!(u16::from_le_bytes(format[..2].try_into().unwrap()), 3);
|
||||
assert_eq!(u32::from_le_bytes(format[4..8].try_into().unwrap()), output_rate);
|
||||
let pcm = chunk(wav, b"data");
|
||||
assert_eq!(pcm.len(), frames * 8, "native work-area sample count");
|
||||
let actual: Vec<f32> = pcm.as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes)).collect();
|
||||
let calls = provider.calls.lock().unwrap();
|
||||
assert!(!calls.is_empty(), "production export actually called the real native provider");
|
||||
eprintln!(
|
||||
"native_rate={native_rate} output_rate={output_rate} loops={loops} first={first} end={end} count={frames} provider_calls={calls:?} first_actual={:?} first_expected={:?} last_actual={:?} last_expected={:?}",
|
||||
&actual[..2],
|
||||
&expected[..2],
|
||||
&actual[actual.len() - 2..],
|
||||
&expected[expected.len() - 2..]
|
||||
);
|
||||
if actual != expected {
|
||||
let first_mismatch = actual.iter().zip(&expected).position(|(a, b)| a != b);
|
||||
mismatches.push((loops, first_mismatch, actual[..2].to_vec(), expected[..2].to_vec()));
|
||||
}
|
||||
}
|
||||
assert!(mismatches.is_empty(), "production native WAV export must preserve the native provider's selected sample positions: {mismatches:?}");
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_44100_positions() {
|
||||
check(44_100, 44_100);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_48000_positions() {
|
||||
check(48_000, 48_000);
|
||||
}
|
||||
#[test]
|
||||
fn native_media_wav_work_area_preserves_44117_positions() {
|
||||
check(44_117, 44_117);
|
||||
}
|
||||
@@ -932,8 +932,10 @@ __TextStyle.prototype = {
|
||||
setVerticalScaling: __styleSetter('vScale'),
|
||||
setTsume: __styleSetter('tsume'),
|
||||
setBaselineOption: __styleSetter('baseline', function (v) { return String(v).toLowerCase().replace('_baseline', ''); }),
|
||||
setSuperscript: __styleSetter('superscript', Boolean),
|
||||
setSubscript: __styleSetter('subscript', Boolean),
|
||||
// Canonical baseline operations also make the returned TextStyle's getters reflect
|
||||
// the pending edit, rather than waiting for the host to apply a superscript flag.
|
||||
setSuperscript: __styleSetter('baseline', function (v) { return v ? 'superscript' : 'normal'; }),
|
||||
setSubscript: __styleSetter('baseline', function (v) { return v ? 'subscript' : 'normal'; }),
|
||||
setKerningType: __styleSetter('kerning', function (v) { v = String(v).toLowerCase(); return v === 'manual' ? 0 : v; }),
|
||||
setKerning: __styleSetter('kerning', Number),
|
||||
setLigature: __styleSetter('ligatures', Boolean),
|
||||
|
||||
@@ -1160,3 +1160,15 @@ fn thousands_of_host_reads_on_one_thread_keep_working() {
|
||||
assert_eq!(f.num("function a() { return effect(1)(1); } function b() { return a(); } b() + 1", t), 43.0, "evaluation {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_text_style_baseline_setters_are_visible_to_expression_getters() {
|
||||
let x = fx();
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).isSuperscript ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSuperscript ? 1 : 0", 0.0), 0.0);
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSuperscript(true).setSubscript(true).isSubscript ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setSubscript(true).setSubscript(false).isSubscript ? 1 : 0", 0.0), 0.0);
|
||||
assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0);
|
||||
assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super");
|
||||
}
|
||||
@@ -216,6 +216,7 @@ fn composite_mode(m: u32) -> BlendMode {
|
||||
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let pr = ctx.params;
|
||||
let mut p = Params::default();
|
||||
let mut original = None;
|
||||
match id {
|
||||
"ec.utility.cineon" => {
|
||||
// utility::Cineon's offset and knee, in the CPU's f32.
|
||||
@@ -246,6 +247,11 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
p.f[0] = pr.color("highlightColor");
|
||||
}
|
||||
"ec.channel.cccomposite" => {
|
||||
if let Some(source) = ctx.env.host.and_then(|h| h.original()) {
|
||||
let fitted = effectcraft_effects::util::fit_buffer(source, b.img.width, b.img.height, b.offset, b.scale);
|
||||
original = Some(e.g.upload_image(&fitted)?);
|
||||
}
|
||||
p.u[1][0] = original.is_some() as u32;
|
||||
let m = pr.e("compositeOriginal");
|
||||
let mode = if m == 1 { BlendMode::Normal } else { composite_mode(m) };
|
||||
p.u[0] = [6, (m == 1) as u32, mode_id(mode), pr.b("rgbOnly") as u32];
|
||||
@@ -267,7 +273,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
run(e, "fp2_point", &p, b, None, None)
|
||||
run(e, "fp2_point", &p, b, original.as_ref(), None)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Block Load (stylize3)
|
||||
|
||||
@@ -402,7 +402,9 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
// u[0] = (_, composite original, mode, rgb only); f[0].x = opacity.
|
||||
default: {
|
||||
let orig = px * P.f[0].x;
|
||||
var source = px;
|
||||
if (P.u[1].x != 0u) { source = textureLoad(aux, p, 0); }
|
||||
let orig = source * P.f[0].x;
|
||||
if (P.u[0].y == 1u) {
|
||||
o = blend_pixel(P.u[0].z, orig, px, 0.5);
|
||||
} else {
|
||||
|
||||
@@ -103,3 +103,19 @@ fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() {
|
||||
r.run_effects_on(&ctx, adj, true, &mut p);
|
||||
assert_eq!((p.gpu, p.cpu), (3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_reads_the_original_adjustment_input_on_gpu() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
for mode in [0, 1, 2, 4, 15] {
|
||||
for rgb in [false, true] {
|
||||
let stack = vec![
|
||||
("ec.channel.invert", vec![]),
|
||||
("ec.channel.cccomposite", vec![("compositeOriginal", Value::Enum(mode)), ("opacity", n(65.0)), ("rgbOnly", Value::Bool(rgb))]),
|
||||
];
|
||||
let s = adjustment_scene(depth, &stack, true);
|
||||
check(&format!("original adjustment {depth:?} mode {mode} rgb {rgb}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Footage changed on disk by another app, through the fully wired session (media decoding
|
||||
//! included): File ▸ Reload Footage shows the new pixels and keeps a Photoshop layer item on its
|
||||
//! layer, and `footage.reloadChanged` (Settings ▸ Import ▸ Automatically Reload Footage) finds
|
||||
//! changed files by itself.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_engine::{Session, footage_reload};
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind};
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::*;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-reload-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
/// A one-colour document; `extra` adds a hidden layer, so the file's size changes too.
|
||||
fn flat(rgba: [f32; 4], extra: bool) -> Vec<u8> {
|
||||
let mut d = WDoc::new(64, 48);
|
||||
d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), rgba)];
|
||||
if extra {
|
||||
let mut hidden = WLayer::solid("Hidden", Rect::new(0, 0, 8, 8), RED);
|
||||
hidden.hidden = true;
|
||||
d.layers.push(hidden);
|
||||
}
|
||||
d.composite = Some(vec![rgba; 64 * 48]);
|
||||
write(&d)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
|
||||
fn save(path: &str, bytes: &[u8]) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn footage(s: &Session, id: ItemId) -> Footage {
|
||||
match &s.project.item(id).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
k => panic!("not footage: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour (straight RGB) at `(x, y)` of comp `cid` at time 0.
|
||||
fn pixel(s: &Session, cid: ItemId, x: usize, y: usize) -> [f32; 3] {
|
||||
let img = s.render(cid, Tick::ZERO, RenderOpts::default());
|
||||
let p = img.data[y * img.width as usize + x];
|
||||
[p[0], p[1], p[2]].map(|c| if p[3] > 0.0 { c / p[3] } else { 0.0 })
|
||||
}
|
||||
|
||||
fn near(a: [f32; 3], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
|
||||
}
|
||||
|
||||
/// A flat file as footage in a comp of its size: (session, footage item, comp).
|
||||
fn flat_in_comp(path: &str) -> (Session, ItemId, ItemId) {
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute_checked("layer.addItem", json!({"item": item.0})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
(s, item, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_footage_shows_the_new_pixels() {
|
||||
let path = tmp("flat.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, item, cid) = flat_in_comp(&path);
|
||||
assert!(near(pixel(&s, cid, 32, 24), RED));
|
||||
// Same size, new colour: the frames decoded before must not be shown again.
|
||||
save(&path, &flat(BLUE, false));
|
||||
let r = s.execute_checked("file.reloadFootage", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(r["reloaded"], 1);
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE), "{:?}", pixel(&s, cid, 32, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_a_photoshop_layer_item_on_its_layer() {
|
||||
let path = tmp("layers.psd");
|
||||
let mut d = WDoc::new(64, 48);
|
||||
d.layers = vec![WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED), WLayer::solid("Patch", Rect::new(0, 0, 32, 48), BLUE)];
|
||||
std::fs::write(&path, write(&d)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap();
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let items: Vec<ItemId> = r["items"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(ItemId).collect();
|
||||
let patch = *items.iter().find(|i| s.project.item(**i).is_some_and(|it| it.name.starts_with("Patch/"))).unwrap();
|
||||
assert_eq!((footage(&s, patch).width, footage(&s, patch).layer.unwrap().index), (32, 1));
|
||||
assert!(near(pixel(&s, cid, 8, 24), BLUE));
|
||||
// In the other app: a layer added under the patch, and the patch narrower and green.
|
||||
d.layers = vec![
|
||||
WLayer::solid("Background", Rect::new(0, 0, 64, 48), RED),
|
||||
WLayer::solid("Added", Rect::new(40, 0, 8, 8), RED),
|
||||
WLayer::solid("Patch", Rect::new(0, 0, 24, 48), GREEN),
|
||||
];
|
||||
save(&path, &write(&d));
|
||||
let footage_items: Vec<u64> = items.iter().filter(|i| matches!(s.project.item(**i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).map(|i| i.0).collect();
|
||||
s.execute_checked("file.reloadFootage", json!({"items": footage_items})).unwrap();
|
||||
let f = footage(&s, patch);
|
||||
let l = f.layer.clone().expect("still one layer of the file");
|
||||
assert_eq!((l.name.as_str(), l.index, f.width, f.height), ("Patch", 2, 24, 48));
|
||||
assert!(!f.missing);
|
||||
assert!(near(pixel(&s, cid, 8, 24), GREEN), "{:?}", pixel(&s, cid, 8, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_footage_reloads_by_itself_without_an_undo_step() {
|
||||
let path = tmp("auto.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _, cid) = flat_in_comp(&path);
|
||||
s.mark_saved();
|
||||
let undo = s.history.undo.len();
|
||||
// First look: remembered, nothing to reload.
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
save(&path, &flat(BLUE, true));
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE));
|
||||
assert_eq!(s.history.undo.len(), undo, "the disk changed, not the project");
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_still_being_written_is_tried_again() {
|
||||
let path = tmp("partial.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, item, cid) = flat_in_comp(&path);
|
||||
s.execute_checked("footage.reloadChanged", json!({})).unwrap();
|
||||
// Another app has only written the start of the file so far.
|
||||
let full = flat(BLUE, true);
|
||||
save(&path, &full[..12]);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
assert!(!footage(&s, item).missing, "a file being written isn't missing");
|
||||
// Meanwhile it renders as whatever part of it can be decoded (here nothing); the next scan,
|
||||
// after the other app has finished, reads it whole.
|
||||
save(&path, &full);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), BLUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_scan_reloads_changed_footage() {
|
||||
let path = tmp("scan.psd");
|
||||
std::fs::write(&path, flat(RED, false)).unwrap();
|
||||
let (mut s, _, cid) = flat_in_comp(&path);
|
||||
let scan = |s: &mut Session| {
|
||||
footage_reload::start_scan(s);
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(r) = footage_reload::poll_scan(s) {
|
||||
return r.unwrap();
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "scan never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
};
|
||||
assert_eq!(scan(&mut s), json!({"files": 0, "reloaded": 0}));
|
||||
save(&path, &flat(GREEN, true));
|
||||
assert_eq!(scan(&mut s), json!({"files": 1, "reloaded": 1}));
|
||||
assert!(near(pixel(&s, cid, 32, 24), GREEN));
|
||||
assert!(s.footage_scan.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequences_reload_by_themselves_only_with_all_footage() {
|
||||
let png = |path: &str, v: u8| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([v, 0, 0, 255])).save(path).unwrap();
|
||||
};
|
||||
let (a, b) = (tmp("seq_0001.png"), tmp("seq_0002.png"));
|
||||
png(&a, 255);
|
||||
png(&b, 255);
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [&a], "sequence": true})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
assert_eq!(footage(&s, item).sequence.len(), 2, "imported as a sequence");
|
||||
// Non-Sequence Footage (the default, as in After Effects): sequences are never stamped.
|
||||
assert_eq!(s.prefs.import.auto_reload_footage, "nonSequence");
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
png(&a, 0);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
// All Footage: every frame is watched (first seen now, so only remembered).
|
||||
s.prefs.import.auto_reload_footage = "all".into();
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap(), json!({"files": 0, "reloaded": 0}));
|
||||
std::fs::write(&b, b"not yet a png").unwrap();
|
||||
png(&b, 128);
|
||||
assert_eq!(s.execute_checked("footage.reloadChanged", json!({})).unwrap()["files"], json!(1));
|
||||
}
|
||||
@@ -24,6 +24,7 @@ filmcraft-frame = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
gif = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] }
|
||||
rayon = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -104,6 +104,67 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
|
||||
img
|
||||
}
|
||||
|
||||
/// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way:
|
||||
/// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels.
|
||||
/// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma,
|
||||
/// and the [1 6 1]/8 neighbourhood of its chroma sample: the mean of the upsampled chroma its
|
||||
/// pixels get), then resampled the rest of the way. Conversion is linear until it clips, so
|
||||
/// this is the RGB box filter `resample` starts with, except in blocks where some colours clip.
|
||||
/// `None` for other frames.
|
||||
pub(crate) fn frame_to_image_scaled(f: &VideoFrame, w: u32, h: u32) -> Option<Image> {
|
||||
let (fw, fh) = (f.width as usize, f.height as usize);
|
||||
if w == 0 || h == 0 || fw % 2 != 0 || fh % 2 != 0 || (w as usize).saturating_mul(2) > fw || (h as usize).saturating_mul(2) > fh {
|
||||
return None;
|
||||
}
|
||||
let half = match &f.data {
|
||||
PixelData::Yuv8 { planes, chroma: Chroma::C420, alpha: None } => {
|
||||
half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, 8))?
|
||||
}
|
||||
PixelData::Yuv16 { planes, chroma: Chroma::C420, bits, alpha: None } => {
|
||||
half_420(fw, fh, [&planes[0][..], &planes[1][..], &planes[2][..]], &Yuv::new(f.color.matrix, f.color.range, *bits))?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(if (half.width, half.height) == (w, h) { half } else { effectcraft_raster::resample(&half, w, h) })
|
||||
}
|
||||
|
||||
/// An opaque 4:2:0 frame of even size `w` × `h` converted at half that size (see
|
||||
/// [`frame_to_image_scaled`]); `None` when the planes are too short.
|
||||
fn half_420<T: Sample>(w: usize, h: usize, [luma, cb, cr]: [&[T]; 3], k: &Yuv) -> Option<Image> {
|
||||
/// Row `r` of a plane `n` samples wide (empty past its end).
|
||||
fn row_of<T>(p: &[T], r: usize, n: usize) -> &[T] {
|
||||
p.get(r.saturating_mul(n)..(r + 1).saturating_mul(n)).unwrap_or_default()
|
||||
}
|
||||
let (cw, ch) = (w / 2, h / 2);
|
||||
if luma.len() < w.checked_mul(h)? || cb.len() < cw * ch || cr.len() < cw * ch {
|
||||
return None;
|
||||
}
|
||||
let mut img = Image::new(cw as u32, ch as u32);
|
||||
img.data.par_chunks_mut(cw).enumerate().for_each_init(
|
||||
// Chroma rows with their edge samples repeated on both ends, for the horizontal [1 6 1].
|
||||
|| (vec![0f32; cw + 2], vec![0f32; cw + 2]),
|
||||
|(uc, vc), (cy, row)| {
|
||||
// The chroma rows above and below weigh 1/8, as `centred` upsampling averages out.
|
||||
let rows = [cy.saturating_sub(1), cy, (cy + 1).min(ch - 1)];
|
||||
for (dst, plane) in [(&mut *uc, cb), (&mut *vc, cr)] {
|
||||
let [above, at, below] = rows.map(|r| row_of(plane, r, cw));
|
||||
for (d, ((a, b), c)) in dst[1..=cw].iter_mut().zip(above.iter().zip(at).zip(below)) {
|
||||
*d = ((a.f() + c.f()) * 0.125 + b.f() * 0.75 - k.c_off) * k.c_scale;
|
||||
}
|
||||
(dst[0], dst[cw + 1]) = (dst[1], dst[cw]);
|
||||
}
|
||||
let (top, bottom) = (row_of(luma, 2 * cy, w), row_of(luma, 2 * cy + 1, w));
|
||||
let pixels = top.chunks_exact(2).zip(bottom.chunks_exact(2)).zip(uc.windows(3).zip(vc.windows(3)));
|
||||
for (o, ((a, b), (u, v))) in row.iter_mut().zip(pixels) {
|
||||
let (u, v) = ((u[0] + u[2]) * 0.125 + u[1] * 0.75, (v[0] + v[2]) * 0.125 + v[1] * 0.75);
|
||||
let yy = ((a[0].f() + a[1].f() + b[0].f() + b[1].f()) * 0.25 - k.y_off) * k.y_scale;
|
||||
*o = [(yy + k.cr_r * v).clamp(0.0, 1.0), (yy - k.cr_g * v - k.cb_g * u).clamp(0.0, 1.0), (yy + k.cb_b * u).clamp(0.0, 1.0), 1.0];
|
||||
}
|
||||
},
|
||||
);
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// 0..255 → 0..1.
|
||||
static U8: [f32; 256] = {
|
||||
let mut t = [0f32; 256];
|
||||
@@ -338,6 +399,47 @@ mod tests {
|
||||
assert!((img.data[0][0] - 110.0 / 219.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// A smooth opaque 4:2:0 frame whose colours never clip.
|
||||
fn smooth_420(w: usize, h: usize) -> VideoFrame {
|
||||
let plane = |w: usize, h: usize, base: f32, amp: f32, fx: f32, fy: f32| -> Arc<Vec<u8>> {
|
||||
Arc::new((0..w * h).map(|i| (base + amp * ((i % w) as f32 * fx + (i / w) as f32 * fy).sin()) as u8).collect())
|
||||
};
|
||||
VideoFrame {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
data: PixelData::Yuv8 {
|
||||
planes: [plane(w, h, 125.0, 45.0, 0.31, 0.17), plane(w / 2, h / 2, 128.0, 10.0, 0.4, -0.3), plane(w / 2, h / 2, 128.0, 10.0, -0.2, 0.5)],
|
||||
chroma: Chroma::C420,
|
||||
alpha: None,
|
||||
},
|
||||
color: filmcraft_color::ColorInfo::REC709,
|
||||
par: (1, 1),
|
||||
pts: filmcraft_time::Tick::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaled_conversion_matches_resampling_the_converted_frame() {
|
||||
let f = smooth_420(96, 64);
|
||||
let full = frame_to_image(&f, AlphaOp::new(AlphaMode::Straight, [0.0; 3]));
|
||||
for (w, h) in [(48, 32), (32, 21), (24, 16), (13, 9)] {
|
||||
let scaled = frame_to_image_scaled(&f, w, h).expect("4:2:0, at most half size");
|
||||
let want = effectcraft_raster::resample(&full, w, h);
|
||||
assert_eq!((scaled.width, scaled.height), (w, h));
|
||||
let worst = scaled.data.iter().zip(&want.data).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-5, "{w}×{h}: {worst}");
|
||||
}
|
||||
// More than half the size, odd sizes, other chroma layouts: convert the full frame.
|
||||
assert!(frame_to_image_scaled(&f, 49, 32).is_none());
|
||||
assert!(frame_to_image_scaled(&smooth_420(95, 64), 40, 30).is_none());
|
||||
assert!(frame_to_image_scaled(&yuv_frame(100, 128, 128, filmcraft_color::ColorInfo::REC709), 2, 1).is_some());
|
||||
let mut f444 = smooth_420(96, 64);
|
||||
if let PixelData::Yuv8 { chroma, .. } = &mut f444.data {
|
||||
*chroma = Chroma::C444;
|
||||
}
|
||||
assert!(frame_to_image_scaled(&f444, 48, 32).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bt709_red() {
|
||||
// 8-bit limited BT.709 red: Y 63, Cb 102, Cr 240
|
||||
@@ -402,6 +504,11 @@ mod tests {
|
||||
keep.push(frame_to_image(&f, op));
|
||||
}
|
||||
eprintln!("convert into fresh memory {:?}", t.elapsed() / 20);
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..20 {
|
||||
std::hint::black_box(frame_to_image_scaled(&f, 640, 360));
|
||||
}
|
||||
eprintln!("convert at a third {:?}", t.elapsed() / 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,17 @@ fn is_depth(name: &str) -> bool {
|
||||
last.eq_ignore_ascii_case("z") || name.to_ascii_lowercase().contains("depth")
|
||||
}
|
||||
|
||||
/// Full plane name of a channel in a part: `part.channel`, except that Blender 5 writes one part
|
||||
/// per pass whose channels already carry the full name (part `ViewLayer.Depth` holds
|
||||
/// `ViewLayer.Depth.Z`), which is kept as is instead of doubling the prefix.
|
||||
fn channel_name(part: Option<&str>, channel: &str) -> String {
|
||||
match part {
|
||||
Some(p) if channel.strip_prefix(p).is_some_and(|rest| rest.starts_with('.')) => channel.to_string(),
|
||||
Some(p) => format!("{p}.{channel}"),
|
||||
None => channel.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Cryptomatte manifest (`{"name": "hexhash", …}`).
|
||||
pub fn parse_manifest(json: &str) -> Vec<(String, u32)> {
|
||||
let Ok(serde_json::Value::Object(m)) = serde_json::from_str::<serde_json::Value>(json) else { return vec![] };
|
||||
@@ -59,10 +70,7 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
|
||||
let pos = layer.attributes.layer_position;
|
||||
let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64);
|
||||
for ch in layer.channel_data.list.iter() {
|
||||
let name = match &prefix {
|
||||
Some(p) => format!("{p}.{}", ch.name),
|
||||
None => ch.name.to_string(),
|
||||
};
|
||||
let name = channel_name(prefix.as_deref(), &ch.name.to_string());
|
||||
let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 };
|
||||
let mut plane = vec![fill; w * h];
|
||||
let vals: Vec<f32> = ch.sample_data.values_as_f32().collect();
|
||||
@@ -166,10 +174,21 @@ pub fn layered_image(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
|
||||
type Cache = Mutex<Vec<(String, Option<Arc<AuxChannels>>)>>;
|
||||
|
||||
fn cache() -> &'static Cache {
|
||||
static CACHE: OnceLock<Cache> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Drop the cached channels of `path` (the file changed on disk).
|
||||
pub(crate) fn forget(path: &str) {
|
||||
if let Ok(mut c) = cache().lock() {
|
||||
c.retain(|(p, _)| p != path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read (and cache, a few files) the channels of the EXR at `path` via `read`.
|
||||
pub(crate) fn cached(path: &str, read: impl FnOnce() -> Option<Arc<[u8]>>) -> Option<Arc<AuxChannels>> {
|
||||
static CACHE: OnceLock<Cache> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
|
||||
let cache = cache();
|
||||
if let Some(v) = cache.lock().ok().and_then(|c| c.iter().find(|(p, _)| p == path).map(|(_, v)| v.clone())) {
|
||||
return v;
|
||||
}
|
||||
@@ -244,6 +263,37 @@ mod tests {
|
||||
assert_eq!(aux.layer_rgba("diffuse"), ["diffuse.R", "diffuse.G", "diffuse.B", ""].map(String::from));
|
||||
}
|
||||
|
||||
/// #481: Blender 5 writes one part per pass, its channels keeping the full pass name
|
||||
/// (`ViewLayer.Depth.Z` in part `ViewLayer.Depth`); the name was doubled and the depth and
|
||||
/// Cryptomatte ranks were not found.
|
||||
#[test]
|
||||
fn part_name_is_not_doubled_when_channels_carry_it() {
|
||||
assert_eq!(channel_name(Some("ViewLayer.Depth"), "ViewLayer.Depth.Z"), "ViewLayer.Depth.Z");
|
||||
assert_eq!(channel_name(Some("depth"), "Z"), "depth.Z");
|
||||
assert_eq!(channel_name(Some("depth"), "depthZ"), "depth.depthZ");
|
||||
assert_eq!(channel_name(None, "R"), "R");
|
||||
assert_eq!(channel_name(None, "ViewLayer.Depth.Z"), "ViewLayer.Depth.Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender5_multipart_file_keeps_pass_names() {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let part = |name: &str, chans: &[&str], v: f32| {
|
||||
let list: Vec<AnyChannel<FlatSamples>> = chans.iter().map(|c| AnyChannel::new(*c, FlatSamples::F32(vec![v; w * h]))).collect();
|
||||
Layer::new((w, h), LayerAttributes::named(name), Encoding::FAST_LOSSLESS, AnyChannels::sort(list.into()))
|
||||
};
|
||||
let layers =
|
||||
vec![part("ViewLayer.Depth", &["ViewLayer.Depth.Z"], 4.0), part("ViewLayer.Combined", &["ViewLayer.Combined.R", "ViewLayer.Combined.G"], 0.5)];
|
||||
let image = Image::from_layers(ImageAttributes::with_size((w, h)), layers);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut bytes).unwrap();
|
||||
let aux = read_exr_channels(bytes.get_ref()).unwrap();
|
||||
assert_eq!(aux.get("ViewLayer.Depth.Z").unwrap(), [4.0; 4].as_slice());
|
||||
assert!(aux.get("ViewLayer.Depth.ViewLayer.Depth.Z").is_none());
|
||||
assert!(aux.get("ViewLayer.Combined.R").is_some());
|
||||
}
|
||||
|
||||
/// Straight RGBA of [`layered_image`] at pixel 0 for a file of single-value channels.
|
||||
fn picture<S: AsRef<str>>(channels: &[(S, f32)]) -> Vec<f32> {
|
||||
use exr::prelude::*;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Animated GIFs: probed as movies and decoded whole into 8-bit frames.
|
||||
//!
|
||||
//! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame
|
||||
//! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the
|
||||
//! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder};
|
||||
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
/// The `Footage::codec` of an animated GIF.
|
||||
pub(crate) const CODEC: &str = "GIF";
|
||||
|
||||
/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab,
|
||||
/// whose wasm32 heap is 4 GiB at most and shared with everything else).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_BYTES: usize = 1 << 30;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const MAX_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Frames read from a GIF's headers when probing.
|
||||
const MAX_FRAMES: usize = 100_000;
|
||||
|
||||
/// Whether `f` is an animated GIF imported by [`probe`].
|
||||
pub(crate) fn is_gif(f: &Footage) -> bool {
|
||||
f.kind == FootageKind::Video && f.codec == CODEC
|
||||
}
|
||||
|
||||
/// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders
|
||||
/// write for "as fast as possible" as 10, and so do we.
|
||||
fn delay_cs(raw: u32) -> u32 {
|
||||
if raw < 2 { 10 } else { raw }
|
||||
}
|
||||
|
||||
fn gcd(a: u32, b: u32) -> u32 {
|
||||
if b == 0 { a } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
/// Describe an animated GIF as a movie. `None` for a GIF with fewer than two frames (a still) or
|
||||
/// one whose headers can't be read.
|
||||
pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option<Footage> {
|
||||
let mut dec = gif::DecodeOptions::new().read_info(Cursor::new(bytes)).ok()?;
|
||||
let (width, height) = (u32::from(dec.width()), u32::from(dec.height()));
|
||||
let mut delays: Vec<u32> = Vec::new();
|
||||
while delays.len() < MAX_FRAMES {
|
||||
match dec.next_frame_info() {
|
||||
Ok(Some(f)) => delays.push(delay_cs(u32::from(f.delay))),
|
||||
// The end, or a damaged tail: keep the frames before it.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if delays.len() < 2 || width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1);
|
||||
let total: i64 = delays.iter().map(|&d| i64::from(d)).sum();
|
||||
Some(Footage {
|
||||
path: path.to_string(),
|
||||
kind: FootageKind::Video,
|
||||
width,
|
||||
height,
|
||||
frame_rate: FrameRate::new(100, i64::from(step)),
|
||||
duration: Tick::from_units(total, 100),
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Straight,
|
||||
codec: CODEC.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Every frame of a GIF, composited onto the full canvas.
|
||||
pub(crate) struct Anim {
|
||||
frames: Vec<DynamicImage>,
|
||||
/// When each frame starts, in hundredths of a second.
|
||||
starts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Anim {
|
||||
/// Decode all frames. A damaged tail keeps the frames before it.
|
||||
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
|
||||
let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
|
||||
let (mut frames, mut starts) = (Vec::new(), Vec::new());
|
||||
let (mut at, mut size) = (0u64, 0usize);
|
||||
for frame in dec.into_frames() {
|
||||
let frame = match frame {
|
||||
Ok(f) => f,
|
||||
Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("GIF: {e}"))),
|
||||
Err(e) => {
|
||||
log::warn!("media: GIF stops after {} frames: {e}", frames.len());
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (numer, denom) = frame.delay().numer_denom_ms();
|
||||
let cs = delay_cs(numer.checked_div(denom).unwrap_or(0) / 10);
|
||||
let buf = frame.into_buffer();
|
||||
size = size.saturating_add(buf.as_raw().len());
|
||||
if size > MAX_BYTES {
|
||||
return Err(MediaError::Unsupported("GIF is too large to hold in memory".into()));
|
||||
}
|
||||
starts.push(at);
|
||||
at = at.saturating_add(u64::from(cs));
|
||||
frames.push(DynamicImage::ImageRgba8(buf));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Err(MediaError::Decode("GIF: no frames".into()));
|
||||
}
|
||||
Ok(Anim { frames, starts })
|
||||
}
|
||||
|
||||
/// The frame shown at source time `t` (the last one past the end).
|
||||
pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> {
|
||||
let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128;
|
||||
let cs = u64::try_from(cs).unwrap_or(u64::MAX);
|
||||
let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1);
|
||||
self.frames.get(i).or(self.frames.last())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use effectcraft_project::{Footage, FootageKind};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{MediaPool, probe_bytes};
|
||||
|
||||
/// A 2×2 GIF with one solid-colour frame per `(rgb, delay)`.
|
||||
fn gif(frames: &[([u8; 3], u16)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = gif::Encoder::new(&mut out, 2, 2, &[]).unwrap();
|
||||
for (rgb, delay) in frames {
|
||||
let mut f = gif::Frame::from_rgb(2, 2, &rgb.repeat(4));
|
||||
f.delay = *delay;
|
||||
enc.write_frame(&f).unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
|
||||
fn colour_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 3] {
|
||||
let img = pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap();
|
||||
let p = img.data[0];
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animated_gif_is_a_movie_with_the_files_timing() {
|
||||
let bytes = gif(&[(RED, 5), (GREEN, 10), (BLUE, 5)]);
|
||||
let f = probe_bytes("a.gif", bytes.clone().into()).unwrap();
|
||||
assert_eq!(f.kind, FootageKind::Video);
|
||||
assert!(f.has_video && !f.has_audio);
|
||||
assert_eq!((f.width, f.height), (2, 2));
|
||||
// Delays of 5, 10 and 5 hundredths: 20 frames a second, 0.2 s in all.
|
||||
assert_eq!(f.frame_rate, FrameRate::new(20, 1));
|
||||
assert_eq!(f.duration, Tick::from_units(20, 100));
|
||||
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes("a.gif", bytes.into());
|
||||
assert_eq!(colour_at(&pool, &f, 0.01), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(colour_at(&pool, &f, 0.06), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(colour_at(&pool, &f, 0.14), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(colour_at(&pool, &f, 0.17), [0.0, 0.0, 1.0]);
|
||||
// Past the end it holds the last frame.
|
||||
assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_delay_is_ten_hundredths_like_in_browsers() {
|
||||
let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap();
|
||||
assert_eq!(f.frame_rate, FrameRate::new(10, 1));
|
||||
assert_eq!(f.duration, Tick::from_units(20, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_frame_gif_stays_a_still() {
|
||||
let f = probe_bytes("a.gif", gif(&[(RED, 10)]).into()).unwrap();
|
||||
assert_eq!(f.kind, FootageKind::Still);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_gif_is_an_error_not_a_panic() {
|
||||
let mut bytes = gif(&[(RED, 5), (GREEN, 5)]);
|
||||
bytes.truncate(20);
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes("a.gif", bytes.clone().into());
|
||||
let f = Footage {
|
||||
path: "a.gif".into(),
|
||||
kind: FootageKind::Video,
|
||||
codec: "GIF".into(),
|
||||
has_video: true,
|
||||
width: 2,
|
||||
height: 2,
|
||||
duration: Tick::from_units(10, 100),
|
||||
frame_rate: FrameRate::new(20, 1),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(pool.frame_at(&f, Tick::ZERO).is_err());
|
||||
let _ = probe_bytes("a.gif", bytes.into());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
mod convert;
|
||||
pub mod exr_channels;
|
||||
mod gif_anim;
|
||||
mod layered;
|
||||
pub use layered::vector_doc;
|
||||
mod pool;
|
||||
|
||||
+84
-23
@@ -10,7 +10,8 @@ use effectcraft_render::FootageSource;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_media::{FrameRequest, SharedSource};
|
||||
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in};
|
||||
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled};
|
||||
use crate::gif_anim::{self, Anim};
|
||||
use crate::{MediaError, Result};
|
||||
|
||||
/// Default frame-cache budget: 1 GiB of decoded frames.
|
||||
@@ -41,6 +42,8 @@ struct Key {
|
||||
/// pixels.
|
||||
interp: u8,
|
||||
matte: [u32; 3],
|
||||
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -169,6 +172,9 @@ struct CacheState {
|
||||
/// (`None`: failed to open; not retried until `forget`).
|
||||
type SourceSlot = Arc<OnceLock<Option<SharedSource>>>;
|
||||
|
||||
/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load).
|
||||
type GifSlot = Arc<OnceLock<std::result::Result<Arc<Anim>, String>>>;
|
||||
|
||||
struct Inner {
|
||||
/// Opened movie/audio sources by path.
|
||||
sources: Mutex<HashMap<Arc<str>, SourceSlot>>,
|
||||
@@ -178,6 +184,9 @@ struct Inner {
|
||||
files: Mutex<HashMap<String, Arc<[u8]>>>,
|
||||
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`).
|
||||
models: Mutex<HashMap<String, Option<Arc<effectcraft_model::Model>>>>,
|
||||
/// Decoded animated GIFs by path, loaded by the first caller while the others wait (an
|
||||
/// `Err` is not retried until `forget`).
|
||||
gifs: Mutex<HashMap<Arc<str>, GifSlot>>,
|
||||
cache: Mutex<CacheState>,
|
||||
done: Condvar,
|
||||
budget: AtomicU64,
|
||||
@@ -246,6 +255,7 @@ impl MediaPool {
|
||||
sources: Mutex::default(),
|
||||
files: Mutex::default(),
|
||||
models: Mutex::default(),
|
||||
gifs: Mutex::default(),
|
||||
cache: Mutex::default(),
|
||||
done: Condvar::new(),
|
||||
budget: AtomicU64::new(bytes as u64),
|
||||
@@ -294,6 +304,9 @@ impl MediaPool {
|
||||
let mut c = lock(&self.inner.cache);
|
||||
c.lru = Lru::default();
|
||||
c.last.clear();
|
||||
drop(c);
|
||||
// Decoded GIFs sit outside the frame budget; Purge frees them too.
|
||||
lock(&self.inner.gifs).clear();
|
||||
}
|
||||
|
||||
/// Drop every cached frame and decoder.
|
||||
@@ -306,12 +319,15 @@ impl MediaPool {
|
||||
pub fn forget(&self, path: &str) {
|
||||
lock(&self.inner.sources).remove(path);
|
||||
lock(&self.inner.models).remove(path);
|
||||
lock(&self.inner.gifs).remove(path);
|
||||
let mut c = lock(&self.inner.cache);
|
||||
let keys: Vec<Key> = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect();
|
||||
for k in keys {
|
||||
c.lru.remove(&k);
|
||||
}
|
||||
c.last.remove(path);
|
||||
drop(c);
|
||||
crate::exr_channels::forget(path);
|
||||
}
|
||||
|
||||
/// Provide the contents of `path` from memory (used instead of the file system; web builds).
|
||||
@@ -322,7 +338,21 @@ impl MediaPool {
|
||||
|
||||
/// Decode (or fetch from cache) the frame of `footage` at source time `t`.
|
||||
pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result<Arc<Image>> {
|
||||
Inner::frame_at(&self.inner, footage, t, true)
|
||||
Inner::frame_at(&self.inner, footage, t, true, None)
|
||||
}
|
||||
|
||||
/// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors.
|
||||
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||||
if footage.missing {
|
||||
return None;
|
||||
}
|
||||
match Inner::frame_at(&self.inner, footage, t, true, size) {
|
||||
Ok(img) => Some(img),
|
||||
Err(e) => {
|
||||
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Disk ▸ Conformed Audio Folder: write each footage file's decoded audio there
|
||||
@@ -469,7 +499,7 @@ impl MediaPool {
|
||||
|
||||
/// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept.
|
||||
pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result<Image> {
|
||||
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false)?;
|
||||
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?;
|
||||
let (w, h) = (img.width.max(1), img.height.max(1));
|
||||
let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0);
|
||||
if s >= 1.0 {
|
||||
@@ -539,6 +569,20 @@ impl Inner {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the
|
||||
/// map's lock; callers asking for the same file meanwhile wait for that.
|
||||
fn gif(&self, path: &Arc<str>) -> Result<Arc<Anim>> {
|
||||
let slot = lock(&self.gifs).entry(path.clone()).or_default().clone();
|
||||
let loaded = slot.get_or_init(|| {
|
||||
let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes));
|
||||
loaded.map(Arc::new).map_err(|e| {
|
||||
log::warn!("media: cannot decode {path}: {e}");
|
||||
e.to_string()
|
||||
})
|
||||
});
|
||||
loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The frame index (in the footage's interpreted rate) shown at source time `t`, after
|
||||
/// looping (`loop_count`) and clamping to the footage's frames; `n` = frames per loop.
|
||||
fn frame_index(rate: FrameRate, t: Tick, n: i64, loops: u32) -> i64 {
|
||||
@@ -561,7 +605,7 @@ impl Inner {
|
||||
};
|
||||
let interp = alpha | (u8::from(footage.preserve_rgb) << 2);
|
||||
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
|
||||
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte };
|
||||
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
|
||||
match footage.kind {
|
||||
FootageKind::Sequence if !footage.sequence.is_empty() => {
|
||||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
|
||||
@@ -591,21 +635,27 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool) -> Result<Arc<Image>> {
|
||||
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
|
||||
/// size directly (see [`FootageSource::frame_at_size`]).
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result<Arc<Image>> {
|
||||
if !footage.has_video {
|
||||
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
|
||||
}
|
||||
let loc = Self::locate(footage, t);
|
||||
let mut loc = Self::locate(footage, t);
|
||||
let movie = loc.media_t.is_some();
|
||||
if movie && let Some(size) = size {
|
||||
loc.key.size = size;
|
||||
}
|
||||
let mut c = lock(&self.cache);
|
||||
// sequential playback of a movie: read the next frame ahead
|
||||
if playback && movie {
|
||||
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
|
||||
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
|
||||
let next_t = t + footage.frame_rate.frame_duration();
|
||||
let next = Self::locate(footage, next_t);
|
||||
let mut next = Self::locate(footage, next_t);
|
||||
next.key.size = loc.key.size;
|
||||
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
|
||||
self.prefetch(footage, next_t);
|
||||
self.prefetch(footage, next_t, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,17 +688,17 @@ impl Inner {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick) {
|
||||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>) {
|
||||
let (me, f) = (self.clone(), footage.clone());
|
||||
self.prefetched.fetch_add(1, Ordering::Relaxed);
|
||||
// A plain thread, not a rayon job: the decode below parallelises with rayon itself.
|
||||
std::thread::spawn(move || {
|
||||
let _ = me.frame_at(&f, t, false);
|
||||
let _ = me.frame_at(&f, t, false, size);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick) {}
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {}
|
||||
|
||||
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
let op = AlphaOp::of(footage);
|
||||
@@ -667,9 +717,23 @@ impl Inner {
|
||||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
}
|
||||
Some(mt) if gif_anim::is_gif(footage) => {
|
||||
let anim = self.gif(path)?;
|
||||
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
|
||||
Ok(dynamic_to_image(frame, op))
|
||||
}
|
||||
Some(mt) => {
|
||||
let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
|
||||
let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?;
|
||||
// A reduced-resolution render's frame, made at its size (a frame of the footage's
|
||||
// own size only, as the renderer resamples only those).
|
||||
let (w, h) = loc.key.size;
|
||||
if w > 0
|
||||
&& (vf.width, vf.height) == (footage.width, footage.height)
|
||||
&& let Some(img) = frame_to_image_scaled(&vf, w, h)
|
||||
{
|
||||
return Ok(img);
|
||||
}
|
||||
let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize);
|
||||
Ok(frame_to_image_in(&vf, op, buf))
|
||||
}
|
||||
@@ -693,20 +757,17 @@ impl FootageSource for MediaPool {
|
||||
fn purge(&self) {
|
||||
self.clear_frames();
|
||||
}
|
||||
fn forget(&self, path: &str) {
|
||||
MediaPool::forget(self, path);
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
if footage.missing {
|
||||
return None;
|
||||
}
|
||||
match self.frame_at(footage, t) {
|
||||
Ok(img) => Some(img),
|
||||
Err(e) => {
|
||||
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
|
||||
None
|
||||
}
|
||||
}
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, None)
|
||||
}
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, Some((width, height)))
|
||||
}
|
||||
|
||||
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
@@ -834,7 +895,7 @@ mod tests {
|
||||
fn lru_respects_budget_and_recycles() {
|
||||
let mut c = Lru::default();
|
||||
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
|
||||
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3] };
|
||||
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) };
|
||||
let budget = 3 * (4096 + 64);
|
||||
for i in 0..10 {
|
||||
c.insert(k(i), img(), budget);
|
||||
|
||||
@@ -65,6 +65,12 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
|
||||
return probe_model(path, &bytes, &|_| None);
|
||||
}
|
||||
if let Ok(fmt) = image::guess_format(&bytes) {
|
||||
// An animated GIF is a movie; one with a single frame is a still.
|
||||
if fmt == image::ImageFormat::Gif
|
||||
&& let Some(f) = crate::gif_anim::probe(path, &bytes)
|
||||
{
|
||||
return Ok(f);
|
||||
}
|
||||
return probe_still_bytes(path, &bytes, fmt);
|
||||
}
|
||||
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
|
||||
@@ -236,6 +236,29 @@ fn h264_mp4_probe_and_frames() {
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
}
|
||||
|
||||
/// A reduced-resolution render's movie frames are converted at its size: they match resampling
|
||||
/// the full frames (testsrc2's saturated colours clip, where the two differ most), and are cached
|
||||
/// per size.
|
||||
#[test]
|
||||
fn h264_frames_at_reduced_sizes() {
|
||||
let Some(p) = h264_mp4() else { return };
|
||||
let f = probe(&p).expect("probe");
|
||||
let pool = MediaPool::new();
|
||||
for (n, (w, h)) in [(0i64, (960, 540)), (1, (640, 360)), (2, (480, 270)), (30, (323, 182))] {
|
||||
let t = t_of(f.frame_rate, n);
|
||||
let sized = pool.frame_at_size(ItemId(1), &f, t, w, h).expect("frame");
|
||||
let full = pool.frame(ItemId(1), &f, t).expect("frame");
|
||||
assert_eq!((sized.width, sized.height), (w, h));
|
||||
let db = psnr(&sized, &effectcraft_raster::resample(&full, w, h));
|
||||
eprintln!("h264 frame {n} at {w}×{h}: {db:.2} dB against the resampled full frame");
|
||||
// Converting at half size averages before the YUV→RGB clip, so saturated pixels differ
|
||||
// slightly; how much depends on the encoder that made the fixture (50 dB with x264 from
|
||||
// ffmpeg 6.1, above 55 with newer ones), all well past visible.
|
||||
assert!(db > 45.0, "frame {n} at {w}×{h}: {db:.2} dB");
|
||||
assert!(Arc::ptr_eq(&sized, &pool.frame_at_size(ItemId(1), &f, t, w, h).expect("frame")), "cached");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_audio_samples() {
|
||||
let Some(p) = h264_mp4() else { return };
|
||||
|
||||
+22
-13
@@ -493,10 +493,16 @@ fn pack(changes: &[usize], columns: usize, black_is_1: bool, out: &mut Vec<u8>)
|
||||
}
|
||||
|
||||
/// Decode CCITT fax data. Damaged data ends the image at the last good row (rows already
|
||||
/// decoded are kept); `None` only when no row decodes.
|
||||
pub fn decode(data: &[u8], p: &Params) -> Option<Vec<u8>> {
|
||||
/// decoded are kept); `None` when no row decodes or the output would exceed `max`.
|
||||
pub fn decode(data: &[u8], p: &Params, max: usize) -> Option<Vec<u8>> {
|
||||
let columns = p.columns.clamp(1, 1 << 16);
|
||||
let max_rows = if p.rows > 0 { p.rows.min(1 << 20) } else { 1 << 20 };
|
||||
let row_bytes = columns.checked_add(7)? / 8;
|
||||
// With `/Rows` the image is padded to that many rows (below), so its size is known up front.
|
||||
let padded_len = if p.rows > 0 { max_rows.checked_mul(row_bytes)? } else { 0 };
|
||||
if row_bytes > max || padded_len > max {
|
||||
return None;
|
||||
}
|
||||
let mut r = Reader { data, bit: 0 };
|
||||
let mut out = vec![];
|
||||
let mut refl: Vec<usize> = vec![];
|
||||
@@ -530,14 +536,17 @@ pub fn decode(data: &[u8], p: &Params) -> Option<Vec<u8>> {
|
||||
if two_d { row_2d(&mut r, &refl, columns).ok() } else { row_1d(&mut r, columns) }
|
||||
};
|
||||
let Some(line) = line else { break };
|
||||
// Unknown row counts are bounded as rows arrive, without returning a prefix.
|
||||
if out.len().checked_add(row_bytes)? > max {
|
||||
return None;
|
||||
}
|
||||
pack(&line, columns, p.black_is_1, &mut out);
|
||||
refl = line;
|
||||
rows += 1;
|
||||
}
|
||||
// Rows missing from damaged or short data stay white.
|
||||
if p.rows > 0 && rows > 0 && rows < p.rows {
|
||||
let row_bytes = columns.div_ceil(8);
|
||||
out.resize(p.rows.min(1 << 20) * row_bytes, if p.black_is_1 { 0 } else { 0xFF });
|
||||
out.resize(padded_len, if p.black_is_1 { 0 } else { 0xFF });
|
||||
}
|
||||
(rows > 0).then_some(out)
|
||||
}
|
||||
@@ -726,14 +735,14 @@ pub(crate) mod tests {
|
||||
for k in [-1, 0, 1, 3] {
|
||||
let data = encode(&img, k);
|
||||
let p = Params { k, columns: cols, rows, ..Params::default() };
|
||||
let out = decode(&data, &p).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows"));
|
||||
let out = decode(&data, &p, 1 << 20).unwrap_or_else(|| panic!("{cols}×{rows} K {k}: no rows"));
|
||||
assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}");
|
||||
// Rows unknown: stop at the end of the data / EOFB.
|
||||
let out = decode(&data, &Params { rows: 0, ..p.clone() }).unwrap();
|
||||
let out = decode(&data, &Params { rows: 0, ..p.clone() }, 1 << 20).unwrap();
|
||||
assert_eq!(unpack(&out, cols, rows), img, "{cols}×{rows} K {k}, no /Rows");
|
||||
// BlackIs1 inverts.
|
||||
let inv = decode(&data, &Params { black_is_1: true, ..p }).unwrap();
|
||||
assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }).unwrap()).all(|(a, b)| a ^ b == 0xFF));
|
||||
let inv = decode(&data, &Params { black_is_1: true, ..p }, 1 << 20).unwrap();
|
||||
assert!(inv.iter().zip(&decode(&data, &Params { k, columns: cols, rows, ..Params::default() }, 1 << 20).unwrap()).all(|(a, b)| a ^ b == 0xFF));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -769,9 +778,9 @@ pub(crate) mod tests {
|
||||
];
|
||||
for (i, (k, data)) in cases.iter().enumerate() {
|
||||
let p = Params { k: *k, columns: 61, rows: 23, black_is_1: true, end_of_line: *k >= 0, ..Params::default() };
|
||||
let out = decode(&hex(data), &p).unwrap();
|
||||
let out = decode(&hex(data), &p, 1 << 20).unwrap();
|
||||
assert_eq!(out, raw, "case {i} (K {k})");
|
||||
let out = decode(&hex(data), &Params { rows: 0, ..p }).unwrap();
|
||||
let out = decode(&hex(data), &Params { rows: 0, ..p }, 1 << 20).unwrap();
|
||||
assert_eq!(out, raw, "case {i} (K {k}), no /Rows");
|
||||
}
|
||||
}
|
||||
@@ -781,7 +790,7 @@ pub(crate) mod tests {
|
||||
let img = pattern(64, 20, 9);
|
||||
let data = encode(&img, -1);
|
||||
for cut in [0, 1, data.len() / 3, data.len() - 3] {
|
||||
let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() });
|
||||
let out = decode(&data[..cut], &Params { k: -1, columns: 64, rows: 20, ..Params::default() }, 1 << 20);
|
||||
if let Some(out) = out {
|
||||
assert_eq!(out.len(), 20 * 8);
|
||||
}
|
||||
@@ -791,8 +800,8 @@ pub(crate) mod tests {
|
||||
*b ^= (i * 37) as u8;
|
||||
}
|
||||
for k in [-1, 0, 2] {
|
||||
let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() });
|
||||
let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() });
|
||||
let _ = decode(&garbage, &Params { k, columns: 64, rows: 20, ..Params::default() }, 1 << 20);
|
||||
let _ = decode(&garbage, &Params { k, columns: 1, rows: 0, ..Params::default() }, 1 << 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
+302
-59
@@ -1,6 +1,9 @@
|
||||
//! Colour spaces (ISO 32000-1 §8.6) reduced to sRGB, and functions (§7.10: sampled,
|
||||
//! exponential, stitching and PostScript calculator functions).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::object::{Dict, File, Obj, decode_stream};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -79,9 +82,23 @@ fn lab_to_rgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
})
|
||||
}
|
||||
|
||||
/// How deep colour spaces nest (an Indexed base, the alternate of an ICCBased, Separation or
|
||||
/// DeviceN space) and, counted separately, functions (in arrays and stitching functions).
|
||||
/// References can make either a cycle, while real files nest two or three levels; anything deeper
|
||||
/// reads as unreadable (DeviceGray, an unsupported function).
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
/// Read a colour space (a name, or an array such as `[/ICCBased 5 0 R]`); named resources are
|
||||
/// looked up in `res` (`/ColorSpace`).
|
||||
pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
color_space_at(file, o, res, 0)
|
||||
}
|
||||
|
||||
/// [`color_space`] nested `depth` levels deep: DeviceGray past [`MAX_DEPTH`].
|
||||
fn color_space_at(file: &File, o: &Obj, res: Option<&Dict>, depth: usize) -> Cs {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Cs::Gray;
|
||||
}
|
||||
let o = file.resolve(o);
|
||||
match o {
|
||||
Obj::Name(n) => match n.as_str() {
|
||||
@@ -90,7 +107,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
"DeviceCMYK" | "CMYK" => Cs::Cmyk,
|
||||
"Pattern" => Cs::Pattern,
|
||||
other => match res.and_then(|r| file.get_dict(r, "ColorSpace")).and_then(|cs| file.get(cs, other)) {
|
||||
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space(file, x, None),
|
||||
Some(x) if !matches!(x, Obj::Name(m) if m == other) => color_space_at(file, x, None, depth + 1),
|
||||
_ => Cs::Gray,
|
||||
},
|
||||
},
|
||||
@@ -102,7 +119,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
// The profile's alternate space when it names one (profiles are not
|
||||
// evaluated), else the device space with the same number of components.
|
||||
if let Some(alt) = d.and_then(|d| file.get(d, "Alternate")) {
|
||||
return color_space(file, alt, res);
|
||||
return color_space_at(file, alt, res, depth + 1);
|
||||
}
|
||||
let n = d.and_then(|d| file.get_num(d, "N")).unwrap_or(3.0) as usize;
|
||||
match n {
|
||||
@@ -116,8 +133,10 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
"Lab" => Cs::Lab,
|
||||
"Pattern" => Cs::Pattern,
|
||||
"Indexed" | "I" => {
|
||||
let base = a.get(1).map(|b| color_space(file, b, res)).unwrap_or(Cs::Rgb);
|
||||
let hival = a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize;
|
||||
let base = a.get(1).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Rgb);
|
||||
// An indexed palette holds at most 256 entries, so hival (its largest index) is
|
||||
// 0 to 255; an image builds its palette from every entry up to hival.
|
||||
let hival = (a.get(2).and_then(|h| file.resolve(h).num()).unwrap_or(0.0) as usize).min(255);
|
||||
let lookup = match a.get(3).map(|l| file.resolve(l)) {
|
||||
Some(Obj::Str(s)) => s.clone(),
|
||||
Some(Obj::Stream(d, raw)) => decode_stream(file, d, raw).unwrap_or_default(),
|
||||
@@ -127,7 +146,7 @@ pub fn color_space(file: &File, o: &Obj, res: Option<&Dict>) -> Cs {
|
||||
}
|
||||
"Separation" | "DeviceN" => {
|
||||
let n = if head == "Separation" { 1 } else { a.get(1).map(|x| file.resolve(x)).and_then(Obj::array).map_or(1, <[Obj]>::len) };
|
||||
let alt = a.get(2).map(|b| color_space(file, b, res)).unwrap_or(Cs::Gray);
|
||||
let alt = a.get(2).map(|b| color_space_at(file, b, res, depth + 1)).unwrap_or(Cs::Gray);
|
||||
let func = a.get(3).map(|f| Func::read(file, f)).unwrap_or(Func::Unsupported);
|
||||
// An unreadable tint transform: tint 1 = black, 0 = white.
|
||||
if func == Func::Unsupported {
|
||||
@@ -158,14 +177,14 @@ pub enum Func {
|
||||
bounds: Vec<f64>,
|
||||
encode: Vec<f64>,
|
||||
},
|
||||
/// Type 0 with one input.
|
||||
/// Type 0 with one input. Copies share the samples (a graphics state is copied at every `q`).
|
||||
Sampled {
|
||||
domain: [f64; 2],
|
||||
size: usize,
|
||||
outputs: usize,
|
||||
encode: [f64; 2],
|
||||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
samples: Arc<[f64]>,
|
||||
},
|
||||
/// Type 0 with several inputs (function-based shadings): multilinear interpolation, the
|
||||
/// first input varying fastest in the sample table.
|
||||
@@ -175,7 +194,7 @@ pub enum Func {
|
||||
outputs: usize,
|
||||
encode: Vec<f64>,
|
||||
decode: Vec<f64>,
|
||||
samples: Vec<f64>,
|
||||
samples: Arc<[f64]>,
|
||||
},
|
||||
/// An array of one-output functions.
|
||||
Array(Vec<Func>),
|
||||
@@ -215,6 +234,17 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A calculator function's operand stack holds at most 100 values (the PDF specification's
|
||||
/// implementation limits); a real program uses a handful.
|
||||
const MAX_STACK: usize = 100;
|
||||
|
||||
/// Push onto a calculator's operand stack; a full stack takes nothing more.
|
||||
fn ps_push(st: &mut Vec<f64>, v: f64) {
|
||||
if st.len() < MAX_STACK {
|
||||
st.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
@@ -222,7 +252,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
let mut procs: Vec<&[PsOp]> = vec![];
|
||||
for op in code {
|
||||
match op {
|
||||
PsOp::Num(n) => st.push(*n),
|
||||
PsOp::Num(n) => ps_push(st, *n),
|
||||
PsOp::Proc(p) => procs.push(p),
|
||||
PsOp::Op(o) => {
|
||||
let pop = |st: &mut Vec<f64>| st.pop().unwrap_or(0.0);
|
||||
@@ -243,14 +273,14 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
ps_run(p, st, depth + 1);
|
||||
}
|
||||
}
|
||||
"true" => st.push(1.0),
|
||||
"false" => st.push(0.0),
|
||||
"true" => ps_push(st, 1.0),
|
||||
"false" => ps_push(st, 0.0),
|
||||
"pop" => {
|
||||
st.pop();
|
||||
}
|
||||
"dup" => {
|
||||
let v = st.last().copied().unwrap_or(0.0);
|
||||
st.push(v);
|
||||
ps_push(st, v);
|
||||
}
|
||||
"exch" => {
|
||||
let n = st.len();
|
||||
@@ -261,15 +291,16 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
"copy" => {
|
||||
let k = pop(st).max(0.0) as usize;
|
||||
let n = st.len();
|
||||
if k <= n {
|
||||
let tail = st[n - k..].to_vec();
|
||||
// A copy that would pass the cap copies nothing.
|
||||
if k <= n && n + k <= MAX_STACK {
|
||||
let tail = st.get(n - k..).unwrap_or_default().to_vec();
|
||||
st.extend(tail);
|
||||
}
|
||||
}
|
||||
"index" => {
|
||||
let k = pop(st).max(0.0) as usize;
|
||||
let v = st.len().checked_sub(k + 1).map(|i| st[i]).unwrap_or(0.0);
|
||||
st.push(v);
|
||||
let v = k.checked_add(1).and_then(|k| st.len().checked_sub(k)).and_then(|i| st.get(i)).copied().unwrap_or(0.0);
|
||||
ps_push(st, v);
|
||||
}
|
||||
"roll" => {
|
||||
let j = pop(st) as i64;
|
||||
@@ -309,7 +340,7 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
};
|
||||
if let Some(f) = unary {
|
||||
let x = pop(st);
|
||||
st.push(f(x));
|
||||
ps_push(st, f(x));
|
||||
continue;
|
||||
}
|
||||
let y = pop(st);
|
||||
@@ -325,20 +356,10 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
"idiv" => {
|
||||
if y as i64 != 0 {
|
||||
((x as i64) / (y as i64)) as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
"mod" => {
|
||||
if y as i64 != 0 {
|
||||
((x as i64) % (y as i64)) as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
// `checked_*` give 0 both for a zero divisor and for the one overflowing
|
||||
// case, `i64::MIN / -1` (`/` and `%` panic on it in every build).
|
||||
"idiv" => (x as i64).checked_div(y as i64).unwrap_or(0) as f64,
|
||||
"mod" => (x as i64).checked_rem(y as i64).unwrap_or(0) as f64,
|
||||
"exp" => x.powf(y),
|
||||
"atan" => {
|
||||
let a = x.atan2(y).to_degrees();
|
||||
@@ -358,11 +379,11 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
(if s >= 0 { v << s.min(62) } else { v >> (-s).min(62) }) as f64
|
||||
}
|
||||
_ => {
|
||||
st.push(x);
|
||||
ps_push(st, x);
|
||||
y
|
||||
}
|
||||
};
|
||||
st.push(v);
|
||||
ps_push(st, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +391,111 @@ fn ps_run(code: &[PsOp], st: &mut Vec<f64>, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most `f64`s one sampled table holds: 16 Mi (128 MiB), room for a 33 × 33 × 33 × 33 grid
|
||||
/// with four outputs (4_743_684 samples). One table this large is allowed; what stops a file from
|
||||
/// building many of them is [`Budget`], which caps the samples decoded across a whole
|
||||
/// [`Func::read`] at the same figure.
|
||||
const MAX_SAMPLES: usize = 1 << 24;
|
||||
|
||||
/// The most inputs or outputs of a function: no colour space has more than 32 components (the most
|
||||
/// a DeviceN space may have), so a sampled function needs no more. Also the most one-output
|
||||
/// functions a [`Func::Array`] holds (one per output). It is NOT a limit on the pieces of a
|
||||
/// stitching function — a repeating gradient lists one piece once per repeat, often many more.
|
||||
const MAX_IO: usize = 32;
|
||||
|
||||
/// The most `Func` nodes one top-level [`Func::read`] builds, across the whole structure of
|
||||
/// references. A real function is a handful of nodes; a cyclic or fan-out reference graph would
|
||||
/// build far more, so past this the whole function is unreadable. It is a soft limit, and the only
|
||||
/// one on the pieces of a stitching function: past it a colour space takes its fallback (a
|
||||
/// Separation tint reads as grey), nothing unsafe.
|
||||
const MAX_NODES: usize = 10_000;
|
||||
|
||||
/// Samples in a table of `total` entries × `outputs`; `None` when that overflows or passes
|
||||
/// [`MAX_SAMPLES`].
|
||||
fn sample_count(total: usize, outputs: usize) -> Option<usize> {
|
||||
total.checked_mul(outputs).filter(|n| *n <= MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// What one top-level [`Func::read`] may build before the whole function is treated as unreadable:
|
||||
/// a running cap on `Func` nodes and on sampled-table samples across the entire structure, so a
|
||||
/// reference graph that is cyclic or fans out (one object listed many times at each of several
|
||||
/// levels) cannot build an unbounded tree or decode unbounded memory. Samples of a table shared by
|
||||
/// repeat references (one `Arc`) are charged once; its nodes are charged per copy.
|
||||
struct Budget {
|
||||
nodes: usize,
|
||||
samples: usize,
|
||||
/// Set once either cap is passed; the top-level read then yields [`Func::Unsupported`].
|
||||
overrun: bool,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn new() -> Self {
|
||||
Budget { nodes: MAX_NODES, samples: MAX_SAMPLES, overrun: false }
|
||||
}
|
||||
|
||||
/// Charge `n` nodes; `false` (and the read is abandoned) once the cap is passed.
|
||||
fn take_nodes(&mut self, n: usize) -> bool {
|
||||
match self.nodes.checked_sub(n) {
|
||||
Some(left) => {
|
||||
self.nodes = left;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.overrun = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge `n` samples; `false` once the cap is passed.
|
||||
fn take_samples(&mut self, n: usize) -> bool {
|
||||
match self.samples.checked_sub(n) {
|
||||
Some(left) => {
|
||||
self.samples = left;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.overrun = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` limited to the range between `a` and `b`, in either order. Unlike [`f64::clamp`] this never
|
||||
/// panics, whether a file gives the bounds reversed (`/Domain [1 0]`) or a bound is NaN.
|
||||
fn clamp(v: f64, a: f64, b: f64) -> f64 {
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if v < lo {
|
||||
lo
|
||||
} else if v > hi {
|
||||
hi
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl Func {
|
||||
pub fn read(file: &File, o: &Obj) -> Func {
|
||||
let mut budget = Budget::new();
|
||||
let f = Func::read_at(file, o, 0, &mut budget);
|
||||
// Either cap passed anywhere in the structure: the whole function is unreadable.
|
||||
if budget.overrun { Func::Unsupported } else { f }
|
||||
}
|
||||
|
||||
/// [`Func::read`] nested `depth` levels deep, charging `budget`: unsupported past [`MAX_DEPTH`]
|
||||
/// or once the budget is spent.
|
||||
fn read_at(file: &File, o: &Obj, depth: usize, budget: &mut Budget) -> Func {
|
||||
if depth >= MAX_DEPTH || !budget.take_nodes(1) {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
let o = file.resolve(o);
|
||||
if let Obj::Array(a) = o {
|
||||
return Func::Array(a.iter().map(|f| Func::read(file, f)).collect());
|
||||
// An array of one-output functions: one per output, so at most `MAX_IO`.
|
||||
if a.len() > MAX_IO {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
return Func::read_all(file, a, depth + 1, budget).map_or(Func::Unsupported, Func::Array);
|
||||
}
|
||||
let Some(d) = o.dict() else { return Func::Unsupported };
|
||||
let dom = file.get(d, "Domain").map(|x| file.nums(x)).unwrap_or_default();
|
||||
@@ -386,12 +507,21 @@ impl Func {
|
||||
c1: file.get(d, "C1").map(|x| file.nums(x)).unwrap_or_else(|| vec![1.0]),
|
||||
n: file.get_num(d, "N").unwrap_or(1.0),
|
||||
},
|
||||
3 => Func::Stitch {
|
||||
domain,
|
||||
funcs: file.get(d, "Functions").and_then(Obj::array).map(|a| a.iter().map(|f| Func::read(file, f)).collect()).unwrap_or_default(),
|
||||
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
},
|
||||
3 => {
|
||||
// A stitching function's pieces: no element cap (a repeating gradient lists one
|
||||
// piece once per repeat); the budget bounds the total work.
|
||||
let funcs = match file.get(d, "Functions").and_then(Obj::array) {
|
||||
Some(a) => Func::read_all(file, a, depth + 1, budget),
|
||||
None => Some(vec![]),
|
||||
};
|
||||
let Some(funcs) = funcs else { return Func::Unsupported };
|
||||
Func::Stitch {
|
||||
domain,
|
||||
funcs,
|
||||
bounds: file.get(d, "Bounds").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
encode: file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
0 => {
|
||||
let Obj::Stream(sd, raw) = o else { return Func::Unsupported };
|
||||
let Some(data) = decode_stream(file, sd, raw) else { return Func::Unsupported };
|
||||
@@ -400,26 +530,31 @@ impl Func {
|
||||
let bps = file.get_num(d, "BitsPerSample").unwrap_or(8.0) as usize;
|
||||
let range = file.get(d, "Range").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let outputs = range.len() / 2;
|
||||
if size == 0 || outputs == 0 || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
|
||||
if size == 0 || outputs == 0 || outputs > MAX_IO || sizes.len() > MAX_IO || !matches!(bps, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
let total = sizes.iter().try_fold(1usize, |a, b| a.checked_mul(*b)).filter(|t| *t > 0 && *t <= 1 << 22);
|
||||
let Some(total) = total else { return Func::Unsupported };
|
||||
let Some(count) = sample_count(total, outputs) else { return Func::Unsupported };
|
||||
if !budget.take_samples(count) {
|
||||
return Func::Unsupported;
|
||||
}
|
||||
let enc = file.get(d, "Encode").map(|x| file.nums(x)).unwrap_or_default();
|
||||
let encode = [enc.first().copied().unwrap_or(0.0), enc.get(1).copied().unwrap_or(size as f64 - 1.0)];
|
||||
let decode = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= outputs * 2).unwrap_or_else(|| range.clone());
|
||||
let max = ((1u64 << bps) - 1) as f64;
|
||||
let mut samples = Vec::with_capacity(size * outputs);
|
||||
let mut bit = 0usize;
|
||||
for _ in 0..total * outputs {
|
||||
let mut v = 0u64;
|
||||
for _ in 0..bps {
|
||||
let byte = data.get(bit / 8).copied().unwrap_or(0);
|
||||
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
|
||||
bit += 1;
|
||||
}
|
||||
samples.push(v as f64 / max);
|
||||
}
|
||||
let samples: Arc<[f64]> = (0..count)
|
||||
.map(|_| {
|
||||
let mut v = 0u64;
|
||||
for _ in 0..bps {
|
||||
let byte = data.get(bit / 8).copied().unwrap_or(0);
|
||||
v = (v << 1) | ((byte >> (7 - bit % 8)) & 1) as u64;
|
||||
bit += 1;
|
||||
}
|
||||
v as f64 / max
|
||||
})
|
||||
.collect();
|
||||
if sizes.len() > 1 {
|
||||
let m = sizes.len();
|
||||
let encode =
|
||||
@@ -444,22 +579,59 @@ impl Func {
|
||||
}
|
||||
}
|
||||
|
||||
/// The functions of an array (of one-output functions, or a stitching function's
|
||||
/// `/Functions`) read at `depth`, charging `budget`; `None` once the budget is spent. An object
|
||||
/// the array refers to more than once is read once and copied: the copies share its samples
|
||||
/// (one `Arc`, charged once), but each copy is charged its nodes again — so a reference listed
|
||||
/// many times at each of several levels spends the node budget quickly rather than expanding.
|
||||
fn read_all(file: &File, a: &[Obj], depth: usize, budget: &mut Budget) -> Option<Vec<Func>> {
|
||||
let mut cache: HashMap<(u32, u16), (Func, usize)> = HashMap::new();
|
||||
let mut funcs = Vec::with_capacity(a.len());
|
||||
for o in a {
|
||||
if budget.overrun {
|
||||
return None;
|
||||
}
|
||||
let f = match o {
|
||||
Obj::Ref(n, g) => {
|
||||
if let Some((cached, cost)) = cache.get(&(*n, *g)) {
|
||||
let (cached, cost) = (cached.clone(), *cost);
|
||||
if !budget.take_nodes(cost) {
|
||||
return None;
|
||||
}
|
||||
cached
|
||||
} else {
|
||||
let before = budget.nodes;
|
||||
let f = Func::read_at(file, o, depth, budget);
|
||||
let cost = before.saturating_sub(budget.nodes);
|
||||
cache.insert((*n, *g), (f.clone(), cost));
|
||||
f
|
||||
}
|
||||
}
|
||||
_ => Func::read_at(file, o, depth, budget),
|
||||
};
|
||||
funcs.push(f);
|
||||
}
|
||||
Some(funcs)
|
||||
}
|
||||
|
||||
/// Evaluate at the first input (the functions used by shadings and tints take one input
|
||||
/// in practice; extra inputs of DeviceN tints are averaged in).
|
||||
pub fn eval(&self, input: &[f64]) -> Vec<f64> {
|
||||
if let Func::Calc { domain, range, code } = self {
|
||||
// The inputs start the operand stack, which holds at most `MAX_STACK` values.
|
||||
let mut st: Vec<f64> = input
|
||||
.iter()
|
||||
.take(MAX_STACK)
|
||||
.enumerate()
|
||||
.map(|(i, v)| match (domain.get(2 * i), domain.get(2 * i + 1)) {
|
||||
(Some(a), Some(b)) => v.clamp(a.min(*b), a.max(*b)),
|
||||
(Some(a), Some(b)) => clamp(*v, *a, *b),
|
||||
_ => *v,
|
||||
})
|
||||
.collect();
|
||||
ps_run(code, &mut st, 0);
|
||||
let n = range.len() / 2;
|
||||
let k = st.len().saturating_sub(n);
|
||||
return st[k..].iter().enumerate().map(|(i, v)| v.clamp(range[2 * i], range[2 * i + 1])).collect();
|
||||
return st.get(k..).unwrap_or_default().iter().zip(range.as_chunks::<2>().0).map(|(v, [a, b])| clamp(*v, *a, *b)).collect();
|
||||
}
|
||||
match self {
|
||||
Func::SampledN { domain, size, outputs, encode, decode, samples } => {
|
||||
@@ -470,9 +642,9 @@ impl Func {
|
||||
for i in 0..m {
|
||||
let x = input.get(i).copied().unwrap_or(0.0);
|
||||
let (d0, d1) = (domain[2 * i], domain[2 * i + 1]);
|
||||
let x = x.clamp(d0.min(d1), d0.max(d1));
|
||||
let x = clamp(x, d0, d1);
|
||||
let e = if d1 != d0 { encode[2 * i] + (x - d0) / (d1 - d0) * (encode[2 * i + 1] - encode[2 * i]) } else { encode[2 * i] };
|
||||
let e = if e.is_finite() { e.clamp(0.0, size[i] as f64 - 1.0) } else { 0.0 };
|
||||
let e = if e.is_finite() { clamp(e, 0.0, size[i] as f64 - 1.0) } else { 0.0 };
|
||||
base[i] = e.floor() as usize;
|
||||
frac[i] = e - e.floor();
|
||||
}
|
||||
@@ -510,7 +682,7 @@ impl Func {
|
||||
pub fn eval1(&self, t: f64) -> Vec<f64> {
|
||||
match self {
|
||||
Func::Exp { domain, c0, c1, n } => {
|
||||
let t = t.clamp(domain[0].min(domain[1]), domain[0].max(domain[1]));
|
||||
let t = clamp(t, domain[0], domain[1]);
|
||||
let x = if *n == 1.0 { t } else { t.max(0.0).powf(*n) };
|
||||
(0..c0.len().max(c1.len()))
|
||||
.map(|i| c0.get(i).copied().unwrap_or(0.0) + x * (c1.get(i).copied().unwrap_or(1.0) - c0.get(i).copied().unwrap_or(0.0)))
|
||||
@@ -520,7 +692,7 @@ impl Func {
|
||||
if funcs.is_empty() {
|
||||
return vec![0.0];
|
||||
}
|
||||
let t = t.clamp(domain[0], domain[1]);
|
||||
let t = clamp(t, domain[0], domain[1]);
|
||||
let k = bounds.iter().take_while(|b| t >= **b).count().min(funcs.len() - 1);
|
||||
let lo = if k == 0 { domain[0] } else { bounds[k - 1] };
|
||||
let hi = bounds.get(k).copied().unwrap_or(domain[1]);
|
||||
@@ -529,9 +701,9 @@ impl Func {
|
||||
funcs[k].eval1(u)
|
||||
}
|
||||
Func::Sampled { domain, size, outputs, encode, decode, samples } => {
|
||||
let t = t.clamp(domain[0], domain[1]);
|
||||
let t = clamp(t, domain[0], domain[1]);
|
||||
let e = if domain[1] > domain[0] { encode[0] + (t - domain[0]) / (domain[1] - domain[0]) * (encode[1] - encode[0]) } else { encode[0] };
|
||||
let e = e.clamp(0.0, *size as f64 - 1.0);
|
||||
let e = clamp(e, 0.0, *size as f64 - 1.0);
|
||||
let (i0, f) = (e.floor() as usize, e.fract());
|
||||
let i1 = (i0 + 1).min(size - 1);
|
||||
(0..*outputs)
|
||||
@@ -573,6 +745,19 @@ pub fn text_string(b: &[u8]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sample_count_at_and_over_the_cap() {
|
||||
// 4 Mi entries × 4 outputs fill the cap exactly; a fifth output passes it.
|
||||
assert_eq!(sample_count(1 << 22, 4), Some(MAX_SAMPLES));
|
||||
assert_eq!(sample_count(1 << 22, 5), None);
|
||||
// A 33 × 33 × 33 × 33 grid with four outputs fits (4_743_684 samples, under the cap, so
|
||||
// `Some`).
|
||||
assert_eq!(sample_count(33 * 33 * 33 * 33, 4), Some(4_743_684));
|
||||
// A product that overflows is refused (on 64-bit targets that takes a `/Range` of at least
|
||||
// 2^41 numbers, too big to build in a test).
|
||||
assert_eq!(sample_count(1 << 22, usize::MAX / (1 << 22) + 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_functions() {
|
||||
// Two inputs → three outputs: { 2 copy mul 3 1 roll add 2 div dup 0.5 gt { pop 1 } if 0 }
|
||||
@@ -583,4 +768,62 @@ mod tests {
|
||||
assert_eq!(f.eval(&[0.5, 0.4]), vec![0.2, 0.1125, 0.0]);
|
||||
assert_eq!(f.eval(&[1.0, 0.8]), vec![0.8, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Run the calculator program `{ src }` on the stack `st`.
|
||||
fn run(src: &str, mut st: Vec<f64>) -> Vec<f64> {
|
||||
let src = format!("{{ {src} }}");
|
||||
let mut lx = crate::object::Lexer::new(src.as_bytes(), 0);
|
||||
let Some(PsOp::Proc(code)) = ps_parse(&mut lx, 0).into_iter().next() else { panic!() };
|
||||
ps_run(&code, &mut st, 0);
|
||||
st
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_stack_holds_at_most_100_values() {
|
||||
// `1 copy 2 copy 4 copy …`: 40 doublings asked for 2^40 values (8 TiB). The copies stop at
|
||||
// 64 values, since the next would pass 100.
|
||||
let doublings: String = (0..40).map(|i| format!("{} copy ", 1u64 << i)).collect();
|
||||
assert_eq!(run(&doublings, vec![0.0]).len(), 64);
|
||||
// A copy up to exactly 100 values is made; after it nothing grows the stack.
|
||||
let fifty: Vec<f64> = (0..50).map(f64::from).collect();
|
||||
let full = run("50 copy", fifty.clone());
|
||||
assert_eq!(full.len(), 100);
|
||||
assert_eq!(full[50..], fifty[..]);
|
||||
for op in ["7", "true", "false", "dup", "0 index", "1 copy", "neg", "add", "nonsense"] {
|
||||
assert!(run(op, full.clone()).len() <= 100, "{op}");
|
||||
}
|
||||
// Nor does a long program without loops: one push per operator, up to the cap.
|
||||
for op in ["1 ", "true ", "dup ", "0 index "] {
|
||||
assert_eq!(run(&op.repeat(1000), vec![1.0]).len(), 100, "{op}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_index_past_the_stack_pushes_zero() {
|
||||
// 10^20 is past usize::MAX: `k + 1` overflowed (a panic), or wrapped to read past the stack.
|
||||
assert_eq!(run("99999999999999999999 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]);
|
||||
assert_eq!(run("2 index", vec![1.0, 2.0]), vec![1.0, 2.0, 0.0]);
|
||||
assert_eq!(run("1 index", vec![1.0, 2.0]), vec![1.0, 2.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_copy_index_and_roll_below_the_cap() {
|
||||
assert_eq!(run("3 copy 1 index 4 1 roll", vec![1.0, 2.0, 3.0]), vec![1.0, 2.0, 3.0, 2.0, 1.0, 2.0, 3.0]);
|
||||
assert_eq!(run("3 -1 roll dup", vec![1.0, 2.0, 3.0]), vec![2.0, 3.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_idiv_and_mod_of_i64_min_by_minus_one() {
|
||||
// `i64::MIN / -1` and `i64::MIN % -1` overflow and panic (in debug and release); `idiv` and
|
||||
// `mod` must fall back like a zero divisor does. -2^63 is below any f64 the program sees,
|
||||
// but the cast saturates to i64::MIN, which is enough to trigger it.
|
||||
let min = -(2f64.powi(63));
|
||||
assert_eq!(run("idiv", vec![min, -1.0]), vec![0.0]);
|
||||
assert_eq!(run("mod", vec![min, -1.0]), vec![0.0]);
|
||||
// Divide by zero still gives 0, ordinary division still works.
|
||||
assert_eq!(run("idiv", vec![7.0, 0.0]), vec![0.0]);
|
||||
assert_eq!(run("mod", vec![7.0, 0.0]), vec![0.0]);
|
||||
assert_eq!(run("idiv", vec![7.0, 2.0]), vec![3.0]);
|
||||
assert_eq!(run("mod", vec![7.0, 2.0]), vec![1.0]);
|
||||
}
|
||||
}
|
||||
+133
-40
@@ -304,9 +304,29 @@ pub fn parse_num(w: &[u8]) -> Option<f64> {
|
||||
|
||||
// ------------------------------------------------------------------ filters
|
||||
|
||||
/// The most bytes one stream filter may decode to. Deflate expands up to about 1000:1, so
|
||||
/// unbounded, a few MB of PDF ask for gigabytes and a failed allocation aborts the process. The
|
||||
/// largest image `image::decode` accepts (64 Mi pixels) of four 16-bit components is 512 MiB;
|
||||
/// 1 GiB leaves room for more components and is PdfCraft's ceiling for one stream. 32-bit
|
||||
/// targets (the web build) get a quarter: their whole address space is 4 GiB, and the inflater's
|
||||
/// growing buffer can reserve about twice the limit.
|
||||
const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 };
|
||||
|
||||
/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them,
|
||||
/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and
|
||||
/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and
|
||||
/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above.
|
||||
const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||
|
||||
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
|
||||
/// JBIG2) give `None`.
|
||||
pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
|
||||
decode_stream_within(file, d, raw, MAX_INFLATED)
|
||||
}
|
||||
|
||||
/// [`decode_stream`] with every filter's output bounded by `max` bytes. Exceeding the
|
||||
/// bound gives `None`, never a truncated prefix; predictors share the same bound.
|
||||
pub fn decode_stream_within(file: &File, d: &Dict, raw: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let filters: Vec<String> = match d.get("Filter").map(|f| file.resolve(f)) {
|
||||
None => vec![],
|
||||
Some(Obj::Name(n)) => vec![n.clone()],
|
||||
@@ -321,20 +341,23 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut data = raw.to_vec();
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
data = match f.as_str() {
|
||||
"FlateDecode" | "Fl" => inflate(&data)?,
|
||||
"ASCIIHexDecode" | "AHx" => ascii_hex(&data),
|
||||
"ASCII85Decode" | "A85" => ascii85(&data),
|
||||
"FlateDecode" | "Fl" => inflate(&data, max)?,
|
||||
"ASCIIHexDecode" | "AHx" => ascii_hex(&data, max)?,
|
||||
"ASCII85Decode" | "A85" => ascii85_within(&data, max)?,
|
||||
"LZWDecode" | "LZW" => {
|
||||
lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0)
|
||||
lzw(&data, parms.get(i).and_then(|p| p.as_ref()).and_then(|p| p.get("EarlyChange")).and_then(Obj::num).unwrap_or(1.0) != 0.0, max)?
|
||||
}
|
||||
"RunLengthDecode" | "RL" => run_length(&data),
|
||||
"CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())))?,
|
||||
"RunLengthDecode" | "RL" => run_length(&data, max)?,
|
||||
"CCITTFaxDecode" | "CCF" => crate::ccitt::decode(&data, &ccitt_params(file, parms.get(i).and_then(|p| p.as_ref())), max)?,
|
||||
_ => return None,
|
||||
};
|
||||
if data.len() > max {
|
||||
return None;
|
||||
}
|
||||
if let Some(Some(p)) = parms.get(i)
|
||||
&& matches!(f.as_str(), "FlateDecode" | "Fl" | "LZWDecode" | "LZW")
|
||||
{
|
||||
data = predict(&data, p);
|
||||
data = predict(&data, p, max)?;
|
||||
}
|
||||
}
|
||||
Some(data)
|
||||
@@ -358,25 +381,60 @@ fn ccitt_params(file: &File, d: Option<&Dict>) -> crate::ccitt::Params {
|
||||
p
|
||||
}
|
||||
|
||||
pub fn inflate(data: &[u8]) -> Option<Vec<u8>> {
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| {
|
||||
// Truncated streams: keep what inflates.
|
||||
let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib);
|
||||
let mut out = vec![0u8; data.len() * 8 + 1024];
|
||||
let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish);
|
||||
(r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec())
|
||||
})
|
||||
/// Inflate zlib data (or raw deflate) to at most `max` bytes. A stream that inflates to more is
|
||||
/// undecodable (`None`), not salvaged as a truncated one.
|
||||
pub fn inflate(data: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
use miniz_oxide::inflate::{TINFLStatus, decompress_to_vec_with_limit, decompress_to_vec_zlib_with_limit};
|
||||
for attempt in [decompress_to_vec_zlib_with_limit, decompress_to_vec_with_limit] {
|
||||
match attempt(data, max) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) if e.status == TINFLStatus::HasMoreOutput => return None,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// Truncated streams: keep what inflates.
|
||||
let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib);
|
||||
let mut out = vec![0u8; salvage_len(data.len(), max)];
|
||||
let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish);
|
||||
(r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec())
|
||||
}
|
||||
|
||||
fn ascii_hex(data: &[u8]) -> Vec<u8> {
|
||||
let mut hex: Vec<u8> = data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit).collect();
|
||||
if hex.len() % 2 == 1 {
|
||||
hex.push(b'0');
|
||||
/// The buffer [`inflate`] salvages a truncated stream of `data_len` bytes into: eight times that
|
||||
/// and 1 KiB, at most `max`. The zlib attempt already stopped within `max`, so the bound loses
|
||||
/// nothing, while without it a Flate filter after another could ask for eight times `max`.
|
||||
pub fn salvage_len(data_len: usize, max: usize) -> usize {
|
||||
data_len.saturating_mul(8).saturating_add(1024).min(max)
|
||||
}
|
||||
|
||||
fn ascii_hex(data: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let mut high = None;
|
||||
for c in data.iter().copied().take_while(|c| *c != b'>').filter(u8::is_ascii_hexdigit) {
|
||||
if let Some(h) = high.take() {
|
||||
if out.len() >= max {
|
||||
return None;
|
||||
}
|
||||
out.push((h << 4) | hexv(c));
|
||||
} else {
|
||||
high = Some(hexv(c));
|
||||
}
|
||||
}
|
||||
hex.chunks(2).map(|p| (hexv(p[0]) << 4) | hexv(p[1])).collect()
|
||||
if let Some(h) = high {
|
||||
if out.len() >= max {
|
||||
return None;
|
||||
}
|
||||
out.push(h << 4);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn ascii85(data: &[u8]) -> Vec<u8> {
|
||||
// The PostScript reader uses this unbounded entry point. No Vec can hold more
|
||||
// than usize::MAX bytes, so its output cannot exceed this bound.
|
||||
ascii85_within(data, usize::MAX).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ascii85_within(data: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let mut group = [0u32; 5];
|
||||
let mut n = 0;
|
||||
@@ -389,11 +447,19 @@ pub fn ascii85(data: &[u8]) -> Vec<u8> {
|
||||
i += 1;
|
||||
match c {
|
||||
b'~' => break,
|
||||
b'z' if n == 0 => out.extend_from_slice(&[0; 4]),
|
||||
b'z' if n == 0 => {
|
||||
if out.len().checked_add(4)? > max {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
}
|
||||
b'!'..=b'u' => {
|
||||
group[n] = (c - b'!') as u32;
|
||||
n += 1;
|
||||
if n == 5 {
|
||||
if out.len().checked_add(4)? > max {
|
||||
return None;
|
||||
}
|
||||
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
n = 0;
|
||||
@@ -403,16 +469,19 @@ pub fn ascii85(data: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
if n > 1 {
|
||||
if out.len().checked_add(n - 1)? > max {
|
||||
return None;
|
||||
}
|
||||
for g in group.iter_mut().skip(n) {
|
||||
*g = 84;
|
||||
}
|
||||
let v = group.iter().fold(0u32, |a, &d| a.wrapping_mul(85).wrapping_add(d));
|
||||
out.extend_from_slice(&v.to_be_bytes()[..n - 1]);
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn lzw(data: &[u8], early: bool) -> Vec<u8> {
|
||||
fn lzw(data: &[u8], early: bool, max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let mut table: Vec<Vec<u8>> = (0..=255u16).map(|b| vec![b as u8]).collect();
|
||||
table.push(vec![]);
|
||||
@@ -432,7 +501,7 @@ fn lzw(data: &[u8], early: bool) -> Vec<u8> {
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
257 => return out,
|
||||
257 => return Some(out),
|
||||
_ => {}
|
||||
}
|
||||
let entry = if code < table.len() {
|
||||
@@ -442,10 +511,16 @@ fn lzw(data: &[u8], early: bool) -> Vec<u8> {
|
||||
e.push(p[0]);
|
||||
e
|
||||
} else {
|
||||
return out;
|
||||
return Some(out);
|
||||
};
|
||||
if out.len().checked_add(entry.len())? > max {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&entry);
|
||||
if let Some(p) = prev {
|
||||
// PDF codes have at most 12 bits. Freeze a full table until a clear code.
|
||||
if let Some(p) = prev
|
||||
&& table.len() < 4096
|
||||
{
|
||||
let mut e = p;
|
||||
e.push(entry[0]);
|
||||
table.push(e);
|
||||
@@ -457,43 +532,61 @@ fn lzw(data: &[u8], early: bool) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn run_length(data: &[u8]) -> Vec<u8> {
|
||||
fn run_length(data: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
let l = data[i] as usize;
|
||||
i += 1;
|
||||
if l < 128 {
|
||||
out.extend_from_slice(&data[i.min(data.len())..(i + l + 1).min(data.len())]);
|
||||
i += l + 1;
|
||||
let end = i.saturating_add(l + 1).min(data.len());
|
||||
let literal = data.get(i..end)?;
|
||||
if out.len().checked_add(literal.len())? > max {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(literal);
|
||||
i = end;
|
||||
} else if l > 128 {
|
||||
if let Some(&b) = data.get(i) {
|
||||
if out.len().checked_add(257 - l)? > max {
|
||||
return None;
|
||||
}
|
||||
out.extend(std::iter::repeat_n(b, 257 - l));
|
||||
}
|
||||
i += 1;
|
||||
i = i.saturating_add(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PNG / TIFF predictors (`/Predictor`, `/Colors`, `/BitsPerComponent`, `/Columns`).
|
||||
fn predict(data: &[u8], p: &Dict) -> Vec<u8> {
|
||||
fn predict(data: &[u8], p: &Dict, max: usize) -> Option<Vec<u8>> {
|
||||
if data.len() > max {
|
||||
return None;
|
||||
}
|
||||
let g = |k: &str, d: f64| p.get(k).and_then(Obj::num).unwrap_or(d) as usize;
|
||||
let pred = g("Predictor", 1.0);
|
||||
if pred < 10 {
|
||||
return data.to_vec();
|
||||
return Some(data.to_vec());
|
||||
}
|
||||
let bpp = (g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8).max(1);
|
||||
let row = (g("Columns", 1.0) * g("Colors", 1.0) * g("BitsPerComponent", 8.0)).div_ceil(8);
|
||||
// A row that is zero bytes wide, overflows, or is not shorter than the data leaves no
|
||||
// complete row: nothing comes out, as it always did for the first and last, and the row
|
||||
// buffer (sized from `/Columns`) is not allocated.
|
||||
let Some(pixel_bits) = g("Colors", 1.0).checked_mul(g("BitsPerComponent", 8.0)) else { return Some(vec![]) };
|
||||
let Some(bpp) = pixel_bits.checked_add(7).map(|n| (n / 8).max(1)) else { return Some(vec![]) };
|
||||
let Some(row) = g("Columns", 1.0).checked_mul(pixel_bits).and_then(|n| n.checked_add(7)).map(|n| n / 8).filter(|&n| n > 0 && n < data.len()) else {
|
||||
return Some(vec![]);
|
||||
};
|
||||
let stride = row.checked_add(1)?;
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut prev = vec![0u8; row];
|
||||
for chunk in data.chunks(row + 1) {
|
||||
if chunk.len() < row + 1 {
|
||||
for chunk in data.chunks(stride) {
|
||||
if chunk.len() < stride {
|
||||
break;
|
||||
}
|
||||
let ft = chunk[0];
|
||||
@@ -524,7 +617,7 @@ fn predict(data: &[u8], p: &Dict) -> Vec<u8> {
|
||||
out.extend_from_slice(&cur);
|
||||
prev = cur;
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the file
|
||||
@@ -655,7 +748,7 @@ impl File {
|
||||
})
|
||||
.collect();
|
||||
for (d, raw) in streams {
|
||||
let Some(dec) = decode_stream(&f, &d, &raw) else { continue };
|
||||
let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue };
|
||||
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
|
||||
let mut lx = Lexer::new(&dec, 0);
|
||||
|
||||
+513
-1
@@ -147,7 +147,182 @@ fn eps_postscript_subset() {
|
||||
fn filters_decode() {
|
||||
assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec());
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6);
|
||||
assert_eq!(object::inflate(&z).unwrap(), b"abc");
|
||||
assert_eq!(object::inflate(&z, 1 << 20).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flate_bomb_is_bounded() {
|
||||
// A decompression bomb in miniature: 16 MiB of zeros deflate to about 16 KB.
|
||||
let zeros = vec![0u8; 16 << 20];
|
||||
for bomb in [miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 6), miniz_oxide::deflate::compress_to_vec(&zeros, 6)] {
|
||||
assert!(bomb.len() < 64 << 10);
|
||||
// Past the limit, as zlib and as raw deflate, it is undecodable: not inflated in full
|
||||
// and not salvaged as a truncated stream.
|
||||
assert_eq!(object::inflate(&bomb, 1 << 20).map(|v| v.len()), None);
|
||||
assert_eq!(object::inflate(&bomb, 32 << 20).map(|v| v.len()), Some(16 << 20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_flate_keeps_what_inflates() {
|
||||
let mut x = 1u32;
|
||||
let data: Vec<u8> = (0..20_000)
|
||||
.map(|_| {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
(x >> 16) as u8
|
||||
})
|
||||
.collect();
|
||||
let z = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6);
|
||||
let part = object::inflate(&z[..z.len() / 2], 1 << 20).unwrap();
|
||||
assert!(!part.is_empty() && part.len() < data.len());
|
||||
assert_eq!(part, data[..part.len()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salvage_buffer_is_bounded_by_the_limit() {
|
||||
// Small inputs keep eight times their size and 1 KiB.
|
||||
assert_eq!(object::salvage_len(0, 1 << 20), 1024);
|
||||
assert_eq!(object::salvage_len(10_000, 1 << 20), 81_024);
|
||||
// Large ones stop at the limit (`1 << 28` fits a 32-bit `usize` too), without overflowing.
|
||||
assert_eq!(object::salvage_len(1 << 28, 1 << 20), 1 << 20);
|
||||
assert_eq!(object::salvage_len(usize::MAX, 1 << 20), 1 << 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_bomb_in_the_second_flate_filter_is_refused() {
|
||||
// `/Filter [/FlateDecode /FlateDecode]`: the first filter inflates, within the limit, to a zlib
|
||||
// bomb (padded to just under the limit) that the second would inflate to 16 MiB. The second
|
||||
// stops at the limit, so the stream is undecodable.
|
||||
const MAX: usize = 1 << 20;
|
||||
let flate = object::Obj::Name("FlateDecode".into());
|
||||
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
|
||||
let file = object::File::parse(b"");
|
||||
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
|
||||
nested.resize(MAX - 1, 0);
|
||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
|
||||
assert_eq!(object::inflate(&raw, MAX).map(|v| v.len()), Some(MAX - 1));
|
||||
assert_eq!(object::decode_stream_within(&file, &chain, &raw, MAX).map(|v| v.len()), None);
|
||||
}
|
||||
|
||||
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
|
||||
object::decode_stream_within(&object::File::parse(b""), &d, raw, max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictor_huge_columns_decode_to_nothing() {
|
||||
// A row of a petabyte used to be allocated before looking at the data (an allocation
|
||||
// failure aborts the process, so this test took the whole test binary down).
|
||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6);
|
||||
let dict = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 1000000000000000 >> >>";
|
||||
assert_eq!(bounded_filter(dict, &raw, 16), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictor_overflow_and_empty_rows_decode_to_nothing() {
|
||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&[0, 42], 6);
|
||||
for parms in [
|
||||
format!("/Columns {} /Colors 2 /BitsPerComponent 8", usize::MAX),
|
||||
format!("/Columns 1 /Colors {} /BitsPerComponent 8", usize::MAX),
|
||||
"/Columns 0".into(),
|
||||
// A row is two bytes plus its tag byte: longer than the two bytes of data.
|
||||
"/Columns 2".into(),
|
||||
] {
|
||||
assert_eq!(bounded_filter(&format!("<< /Filter /FlateDecode /DecodeParms << /Predictor 12 {parms} >> >>"), &raw, 16), Some(vec![]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_length_chain_is_bounded() {
|
||||
// Each stage expands repetitions of 0x81, which is itself an RL repeat code.
|
||||
let raw = [0x81, 0x81, 128];
|
||||
let chain = "<< /Filter [/RunLengthDecode /RL /RunLengthDecode] >>";
|
||||
assert_eq!(bounded_filter(chain, &raw, 128).map(|v| v.len()), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /RL >>", &raw, 128), Some(vec![0x81; 128]));
|
||||
// An over-limit intermediate stage must fail even if the next stage shrinks it.
|
||||
assert_eq!(bounded_filter("<< /Filter [/RL /AHx] >>", &raw, 127), None);
|
||||
}
|
||||
|
||||
fn lzw_codes(codes: impl IntoIterator<Item = u16>, early: bool) -> Vec<u8> {
|
||||
let mut w = crate::ccitt::tests::W::default();
|
||||
let (mut bits, mut entries, mut prev) = (9, 258, false);
|
||||
for code in codes {
|
||||
w.put(code, bits);
|
||||
match code {
|
||||
256 => (bits, entries, prev) = (9, 258, false),
|
||||
257 => break,
|
||||
_ => {
|
||||
if prev && entries < 4096 {
|
||||
entries += 1;
|
||||
}
|
||||
prev = true;
|
||||
if entries + usize::from(early) >= (1 << bits) && bits < 12 {
|
||||
bits += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzw_long_run_is_bounded_and_full_table_decodes() {
|
||||
// Grow strings of A until the 12-bit table is full, then send literal A codes.
|
||||
// Both old and new implementations use well under 100 MB for this fixture.
|
||||
for early in [false, true] {
|
||||
let raw = lzw_codes([256, 65].into_iter().chain(258..=4095).chain(std::iter::repeat_n(65, 62_500)).chain([257]), early);
|
||||
assert!((95_000..105_000).contains(&raw.len()));
|
||||
let dict = format!("<< /Filter /LZWDecode /DecodeParms << /EarlyChange {} >> >>", usize::from(early));
|
||||
assert_eq!(bounded_filter(&dict, &raw, 1 << 20).map(|v| v.len()), None);
|
||||
let expected = 3839 * 3840 / 2 + 62_500;
|
||||
assert_eq!(bounded_filter(&dict, &raw, expected), Some(vec![b'A'; expected]));
|
||||
// Clear restores the initial dictionary and code width after saturation.
|
||||
let cleared = lzw_codes([256, 65].into_iter().chain(258..=4095).chain([256, 66, 257]), early);
|
||||
let mut expected = vec![b'A'; 3839 * 3840 / 2];
|
||||
expected.push(b'B');
|
||||
assert_eq!(bounded_filter(&dict, &cleared, expected.len()), Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccitt_declared_and_unknown_rows_are_bounded() {
|
||||
// Eight white Group 4 rows. The old decoder pads declared missing rows to white.
|
||||
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 /Rows 1024 >> >>", &[0xFF], 16), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 64 >> >>", &[0xFF], 16), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 8 >> >>", &[0xFF], 8), Some(vec![0xFF; 8]));
|
||||
// Padding below the limit remains unchanged.
|
||||
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 8 /Rows 16 >> >>", &[0xFF], 16), Some(vec![0xFF; 16]));
|
||||
// Without `/Rows` the size is never computed up front: 2^20 rows of 4096 bytes overflow a
|
||||
// 32-bit `usize`, but eight rows of this width decode on every target.
|
||||
assert_eq!(bounded_filter("<< /Filter /CCF /DecodeParms << /K -1 /Columns 32768 >> >>", &[0xFF], 32768), Some(vec![0xFF; 32768]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii85_and_hex_outputs_are_bounded() {
|
||||
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 7), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"zz", 8), Some(vec![0; 8]));
|
||||
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 12), Some(b"Hello World!".to_vec()));
|
||||
assert_eq!(bounded_filter("<< /Filter /A85 >>", b"87cURD]i,\"Ebo80", 11), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 3), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /AHx >>", b"4142434>", 4), Some(b"ABC@".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictors_and_normal_lzw_decode_within_limit() {
|
||||
for predictor in [1, 2, 12] {
|
||||
let (data, expected) = if predictor == 12 { (vec![0, 10, 20, 2, 1, 2], vec![10, 20, 11, 22]) } else { (vec![10, 20], vec![10, 20]) };
|
||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6);
|
||||
let dict = format!("<< /Filter /FlateDecode /DecodeParms << /Predictor {predictor} /Columns 2 >> >>");
|
||||
assert_eq!(bounded_filter(&dict, &raw, data.len()), Some(expected));
|
||||
}
|
||||
for early in [false, true] {
|
||||
let raw = lzw_codes([256, 65, 66, 258, 257], early);
|
||||
assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 4), Some(b"ABAB".to_vec()));
|
||||
assert_eq!(bounded_filter("<< /Filter /LZW >>", &raw, 3), None);
|
||||
}
|
||||
assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 254, b'd', 128], 6), Some(b"abcddd".to_vec()));
|
||||
assert_eq!(bounded_filter("<< /Filter /RL >>", &[2, b'a', b'b', b'c', 128], 2), None);
|
||||
assert_eq!(bounded_filter("<< /Filter /RL >>", &[128], 0), Some(vec![]));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ M13.6: text, images,
|
||||
@@ -485,6 +660,343 @@ fn tiling_patterns_coloured_and_uncoloured() {
|
||||
assert_eq!(at(115, 15)[3], 0.0);
|
||||
}
|
||||
|
||||
/// Object 1 of a file holding just `objs`, read as a function.
|
||||
fn read_function(objs: Objs) -> color::Func {
|
||||
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
|
||||
}
|
||||
|
||||
/// A sampled (type 0) function, read from a file holding just its stream.
|
||||
fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
|
||||
read_function(vec![(1, dict.into(), Some(data))])
|
||||
}
|
||||
|
||||
/// Object 1 of a file holding just `objs`, read as a colour space.
|
||||
fn read_color_space(objs: Objs) -> color::Cs {
|
||||
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_function_over_the_sample_cap_is_unsupported() {
|
||||
// 4 Mi entries, as many as `/Size` allows; every `/Range` pair adds an output, 4 Mi more samples.
|
||||
let dict = |outputs: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [4194304] /Range [{}] /BitsPerSample 1 >>", "0 1 ".repeat(outputs));
|
||||
// Five outputs ask for 20 Mi samples, past the per-table cap (16 Mi); refused by the sample
|
||||
// count before any decode, so this is cheap and no lost cap reaches the large table below.
|
||||
assert!(matches!(read_sampled_function(&dict(5), vec![]), color::Func::Unsupported));
|
||||
// 32 outputs of an empty stream ask for 128 Mi zeros; also past the cap. Through parse() the
|
||||
// refused tint transform takes the Separation fallback: tint 0.25 paints grey 0.75, not black.
|
||||
let bytes =
|
||||
page_pdf("/Tint cs 0.25 scn 0 0 200 100 re f", "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, dict(32), Some(vec![]))]);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert_eq!(px(&doc, 50, 50), [0.75, 0.75, 0.75, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_function_one_input_two_outputs() {
|
||||
let func =
|
||||
read_sampled_function("<< /FunctionType 0 /Domain [0 1] /Size [4] /Range [0 1 -1 1] /BitsPerSample 8 >>", vec![0, 255, 85, 170, 170, 85, 255, 0]);
|
||||
assert!(matches!(func, color::Func::Sampled { outputs: 2, .. }));
|
||||
// 0.5 → coordinate 1.5, halfway between samples 1 and 2; the second output decodes to [-1, 1].
|
||||
assert_eq!(func.eval(&[0.5]), vec![0.5, 0.0]);
|
||||
// Inputs outside the domain take the end samples.
|
||||
assert_eq!(func.eval(&[-1.0]), vec![0.0, 1.0]);
|
||||
assert_eq!(func.eval(&[2.0]), vec![1.0, -1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_function_two_inputs() {
|
||||
let func = read_sampled_function(
|
||||
"<< /FunctionType 0 /Domain [0 1 0 1] /Size [3 3] /Range [0 1 0 1] /BitsPerSample 8 >>",
|
||||
vec![0, 0, 85, 0, 170, 0, 0, 85, 85, 85, 170, 85, 0, 170, 85, 170, 170, 170],
|
||||
);
|
||||
assert!(matches!(func, color::Func::SampledN { outputs: 2, .. }));
|
||||
// Entry (i, j), the first input varying fastest, holds (i / 3, j / 3); (0.25, 0.75) → (0.5, 1.5).
|
||||
assert_eq!(func.eval(&[0.25, 0.75]), vec![1.0 / 6.0, 0.5]);
|
||||
assert_eq!(func.eval(&[0.0, 0.0]), vec![0.0, 0.0]);
|
||||
assert_eq!(func.eval(&[1.0, 1.0]), vec![2.0 / 3.0, 2.0 / 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_functions_have_at_most_32_inputs_and_outputs() {
|
||||
// Each evaluation allocates and loops over every output, and shadings evaluate millions of
|
||||
// times; no colour space has more than 32 components.
|
||||
let outputs = |n: usize| format!("<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [{}] /BitsPerSample 8 >>", "0 1 ".repeat(n));
|
||||
assert!(matches!(read_sampled_function(&outputs(32), vec![255; 64]), color::Func::Sampled { outputs: 32, .. }));
|
||||
assert_eq!(read_sampled_function(&outputs(33), vec![255; 66]), color::Func::Unsupported);
|
||||
let inputs = |m: usize| format!("<< /FunctionType 0 /Domain [{}] /Size [{}] /Range [0 1] /BitsPerSample 8 >>", "0 1 ".repeat(m), "1 ".repeat(m));
|
||||
let f = read_sampled_function(&inputs(32), vec![255]);
|
||||
assert!(matches!(f, color::Func::SampledN { .. }), "{f:?}");
|
||||
assert_eq!(f.eval(&[0.5; 32]), vec![1.0]);
|
||||
assert_eq!(read_sampled_function(&inputs(33), vec![255]), color::Func::Unsupported);
|
||||
}
|
||||
|
||||
/// Object 10: a sampled function with one input, one output and samples 0 and 1.
|
||||
fn ramp() -> (u32, String, Option<Vec<u8>>) {
|
||||
(10, "<< /FunctionType 0 /Domain [0 1] /Size [2] /Range [0 1] /BitsPerSample 8 >>".into(), Some(vec![0, 255]))
|
||||
}
|
||||
|
||||
/// A stitching function of `n` equal pieces, each function object 10.
|
||||
fn stitching_of_10(n: usize) -> String {
|
||||
let bounds: Vec<String> = (1..n).map(|i| (i as f64 / n as f64).to_string()).collect();
|
||||
format!("<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>", "10 0 R ".repeat(n), bounds.join(" "), "0 1 ".repeat(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_arrays_hold_at_most_32_functions() {
|
||||
// An array of one-output functions gives an output per function: 32 at most, as for a single
|
||||
// function. (A stitching function's pieces are NOT capped this way; see the 100-piece test.)
|
||||
let array = |n: usize| format!("[{}]", "10 0 R ".repeat(n));
|
||||
assert!(matches!(read_function(vec![(1, array(32), None), ramp()]), color::Func::Array(fs) if fs.len() == 32));
|
||||
assert_eq!(read_function(vec![(1, array(33), None), ramp()]), color::Func::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitching_function_with_100_pieces_builds_and_evaluates() {
|
||||
// A repeating gradient (Cairo, Inkscape) lists one sub-function once per repeat, often far
|
||||
// more than 32 times; such a stitching function is valid and must build. Here object 10 (a
|
||||
// 0 → 1 ramp) is listed 100 times, read once and shared.
|
||||
let f = read_function(vec![(1, stitching_of_10(100), None), ramp()]);
|
||||
let color::Func::Stitch { funcs, .. } = &f else { panic!("{f:?}") };
|
||||
assert_eq!(funcs.len(), 100);
|
||||
let table = |g: &color::Func| match g {
|
||||
color::Func::Sampled { samples, .. } => Some(samples.as_ptr()),
|
||||
_ => None,
|
||||
};
|
||||
assert!(table(&funcs[0]).is_some());
|
||||
assert!(funcs.iter().all(|g| g == &funcs[0] && table(g) == table(&funcs[0])));
|
||||
// The sawtooth's ends: piece 0 at t = 0 and piece 99 at t = 1 both map through the ramp.
|
||||
assert_eq!(f.eval1(0.0), vec![0.0]);
|
||||
assert_eq!(f.eval1(1.0), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_repeated_in_an_array_is_read_once() {
|
||||
// Read once per reference, 32 references to one 32 MiB table made 1 GiB. Now the object is
|
||||
// read once: the copies are equal and share its table.
|
||||
let table = |f: &color::Func| match f {
|
||||
color::Func::Sampled { samples, .. } => samples.as_ptr(),
|
||||
_ => std::ptr::null(),
|
||||
};
|
||||
let array = read_function(vec![(1, format!("[{}]", "10 0 R ".repeat(32)), None), ramp()]);
|
||||
let stitch = read_function(vec![(1, stitching_of_10(32), None), ramp()]);
|
||||
for f in [&array, &stitch] {
|
||||
let (color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. }) = f else { panic!("{f:?}") };
|
||||
assert_eq!(fs.len(), 32);
|
||||
assert!(!table(&fs[0]).is_null());
|
||||
assert!(fs.iter().all(|g| g == &fs[0] && table(g) == table(&fs[0])));
|
||||
}
|
||||
assert_eq!(array.eval1(0.5), vec![0.5; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_graphics_states_share_a_sampled_tint() {
|
||||
// `q` saves a copy of the graphics state, colour spaces included: the copies share the tint
|
||||
// transform's table.
|
||||
let tint = "<< /FunctionType 0 /Domain [0 1] /Size [4096] /Range [0 1 0 1 0 1] /BitsPerSample 8 >>";
|
||||
let data: Vec<u8> = (0..4096u32).flat_map(|i| [(i >> 4) as u8, 0, 255 - (i >> 4) as u8]).collect();
|
||||
let cs = read_color_space(vec![(1, "[/Separation /Ink /DeviceRGB 10 0 R]".into(), None), (10, tint.into(), Some(data.clone()))]);
|
||||
let table = |cs: &color::Cs| match cs {
|
||||
color::Cs::Tint { func: color::Func::Sampled { samples, .. }, .. } => samples.as_ptr(),
|
||||
_ => std::ptr::null(),
|
||||
};
|
||||
assert!(!table(&cs).is_null());
|
||||
assert_eq!(table(&cs.clone()), table(&cs));
|
||||
// A thousand nested `q` around a fill in that space: tint 1 is the last entry, red.
|
||||
let content = format!("/Tint cs 1 scn {}0 0 200 100 re f {}", "q ".repeat(1000), "Q ".repeat(1000));
|
||||
let doc = parse(&page_pdf(&content, "/ColorSpace << /Tint [/Separation /Ink /DeviceRGB 10 0 R] >>", vec![(10, tint.into(), Some(data))])).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert_eq!(px(&doc, 100, 50), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// A one-page PDF drawing image object 10 over the whole 200 × 100 page; `extra` holds more objects.
|
||||
fn image_page(dict: &str, data: Vec<u8>, extra: Objs) -> Vec<u8> {
|
||||
let mut objs: Objs = vec![(10, dict.into(), Some(data))];
|
||||
objs.extend(extra);
|
||||
page_pdf("q 200 0 0 100 0 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs)
|
||||
}
|
||||
|
||||
/// Whether two colours match within rendering error.
|
||||
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_space_reads_at_most_256_entries() {
|
||||
// hival 10^12 asked the image palette for 10^12 colours (24 TB); an indexed palette holds at
|
||||
// most 256 entries, so hival is 0 to 255.
|
||||
let im = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceRGB 1000000000000 <00>] /BitsPerComponent 8 >>";
|
||||
let doc = parse(&image_page(im, vec![200], vec![])).unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
// Entry 200 is past the one-byte lookup string: black.
|
||||
assert!(near(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 100, 50));
|
||||
let cs = read_color_space(vec![(1, "[/Indexed /DeviceRGB 1000000000000 <00>]".into(), None)]);
|
||||
assert!(matches!(cs, color::Cs::Indexed { hival: 255, .. }), "{cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_images_with_4_and_256_entries() {
|
||||
let im = |cs: &str, w: usize| format!("<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {cs} /BitsPerComponent 8 >>");
|
||||
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 3 <FF000000FF000000FFFFFFFF>]", 4), vec![0, 1, 2, 3], vec![])).unwrap();
|
||||
for (x, c) in [(25, [1.0, 0.0, 0.0, 1.0]), (75, [0.0, 1.0, 0.0, 1.0]), (125, [0.0, 0.0, 1.0, 1.0]), (175, [1.0; 4])] {
|
||||
assert!(near(px(&doc, x, 50), c), "x = {x}: {:?}", px(&doc, x, 50));
|
||||
}
|
||||
// 256 entries, the lookup table in a stream: entry i is (i, 0, 255 − i).
|
||||
let lookup: Vec<u8> = (0..=255u8).flat_map(|i| [i, 0, 255 - i]).collect();
|
||||
let doc = parse(&image_page(&im("[/Indexed /DeviceRGB 255 11 0 R]", 2), vec![255, 0], vec![(11, "<< >>".into(), Some(lookup))])).unwrap();
|
||||
assert!(near(px(&doc, 50, 50), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&doc, 50, 50));
|
||||
assert!(near(px(&doc, 150, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 150, 50));
|
||||
}
|
||||
|
||||
/// Functions nested down the first element of arrays and stitching functions, outermost first.
|
||||
fn nested_functions(f: &color::Func) -> Vec<&color::Func> {
|
||||
let mut out = vec![f];
|
||||
let mut f = f;
|
||||
while let color::Func::Array(fs) | color::Func::Stitch { funcs: fs, .. } = f
|
||||
&& let Some(first) = fs.first()
|
||||
{
|
||||
out.push(first);
|
||||
f = first;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Colour spaces nested down Indexed bases and Separation or DeviceN alternates, outermost first.
|
||||
fn nested_spaces(cs: &color::Cs) -> Vec<&color::Cs> {
|
||||
let mut out = vec![cs];
|
||||
let mut cs = cs;
|
||||
while let color::Cs::Indexed { base: next, .. } | color::Cs::Tint { alt: next, .. } = cs {
|
||||
cs = next;
|
||||
out.push(cs);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_array_containing_itself_stops_at_depth_8() {
|
||||
// `[1 0 R]` as object 1: the reader recursed until the stack overflowed (an abort).
|
||||
let f = read_function(vec![(1, "[1 0 R]".into(), None)]);
|
||||
let chain = nested_functions(&f);
|
||||
assert_eq!(chain.len(), 9);
|
||||
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Array(_))));
|
||||
assert_eq!(chain[8], &color::Func::Unsupported);
|
||||
// As a tint transform: the innermost function's 0.5, in DeviceGray.
|
||||
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
|
||||
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, "[10 0 R]".into(), None)])).unwrap();
|
||||
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitching_function_containing_itself_stops_at_depth_8() {
|
||||
let stitch = |me: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me} 0 R] /Bounds [] /Encode [0 1] >>");
|
||||
let f = read_function(vec![(1, stitch(1), None)]);
|
||||
let chain = nested_functions(&f);
|
||||
assert_eq!(chain.len(), 9);
|
||||
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
|
||||
assert_eq!(chain[8], &color::Func::Unsupported);
|
||||
let tint = "/ColorSpace << /Tint [/Separation /Ink /DeviceGray 10 0 R] >>";
|
||||
let doc = parse(&page_pdf("/Tint cs 1 scn 0 0 200 100 re f", tint, vec![(10, stitch(10), None)])).unwrap();
|
||||
assert_eq!(px(&doc, 100, 50), [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_spaces_based_on_themselves_stop_at_depth_8() {
|
||||
// Object 1 as its own Indexed base, Separation alternate or ICCBased alternate: the reader
|
||||
// recursed until the stack overflowed (an abort).
|
||||
let indexed = read_color_space(vec![(1, "[/Indexed 1 0 R 1 <00>]".into(), None)]);
|
||||
let separation = read_color_space(vec![(1, "[/Separation /Ink 1 0 R << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>]".into(), None)]);
|
||||
for cs in [&indexed, &separation] {
|
||||
let chain = nested_spaces(cs);
|
||||
assert_eq!(chain.len(), 9);
|
||||
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. } | color::Cs::Tint { .. })));
|
||||
assert_eq!(chain[8], &color::Cs::Gray);
|
||||
}
|
||||
// An ICCBased space reads as its alternate: DeviceGray once the depth runs out.
|
||||
let icc = read_color_space(vec![(1, "[/ICCBased 2 0 R]".into(), None), (2, "<< /N 3 /Alternate 1 0 R >>".into(), Some(vec![0; 16]))]);
|
||||
assert_eq!(icc, color::Cs::Gray);
|
||||
// A fill in the Indexed one paints black.
|
||||
let doc = parse(&page_pdf("/CS0 cs 0 scn 0 0 200 100 re f", "/ColorSpace << /CS0 10 0 R >>", vec![(10, "[/Indexed 10 0 R 1 <00>]".into(), None)])).unwrap();
|
||||
assert_eq!(px(&doc, 100, 50), [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_deeper_than_8_is_refused() {
|
||||
// Chains of 100 distinct objects, each nesting the next (real files nest two or three
|
||||
// levels): read down to depth 7, refused at depth 8.
|
||||
let functions = |n: u32| -> Objs {
|
||||
let stitch = |next: u32| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
|
||||
(1..n).map(|i| (i, stitch(i + 1), None)).chain([(n, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)]).collect()
|
||||
};
|
||||
let f = read_function(functions(100));
|
||||
let chain = nested_functions(&f);
|
||||
assert_eq!(chain.len(), 9);
|
||||
assert!(chain[..8].iter().all(|f| matches!(f, color::Func::Stitch { .. })));
|
||||
assert_eq!(chain[8], &color::Func::Unsupported);
|
||||
// Three levels are read to the end.
|
||||
let f = read_function(functions(3));
|
||||
assert_eq!(nested_functions(&f).len(), 3);
|
||||
assert_eq!(f.eval1(0.25), vec![0.25]);
|
||||
// Indexed bases.
|
||||
let spaces: Objs =
|
||||
(1..100).map(|i| (i, format!("[/Indexed {} 0 R 0 <00>]", i + 1), None)).chain([(100, "[/Indexed /DeviceRGB 0 <FF0000>]".into(), None)]).collect();
|
||||
let cs = read_color_space(spaces);
|
||||
let chain = nested_spaces(&cs);
|
||||
assert_eq!(chain.len(), 9);
|
||||
assert!(chain[..8].iter().all(|c| matches!(c, color::Cs::Indexed { .. })));
|
||||
assert_eq!(chain[8], &color::Cs::Gray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fanning_out_reference_graph_is_refused_within_the_budget() {
|
||||
// Eight objects, each an array of 32 references to the next, with a function at the bottom:
|
||||
// depth 8 alone would still expand 32^7 copies. Each object is read once per array but its
|
||||
// nodes are charged per copy, so the per-read node budget is spent after a few levels and the
|
||||
// whole function is refused — fast and with bounded memory, not 32^7 nodes.
|
||||
let objs: Objs = (1..=7u32)
|
||||
.map(|i| (i, format!("[{}]", format!("{} 0 R ", i + 1).repeat(32)), None))
|
||||
.chain([(8, "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".into(), None)])
|
||||
.collect();
|
||||
assert_eq!(read_function(objs), color::Func::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_space_chain_of_depth_3() {
|
||||
// Indexed → Separation → ICCBased → DeviceRGB: entry 0 is tint 1 (blue), entry 1 tint 0 (white).
|
||||
let doc = parse(&image_page(
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/Indexed 11 0 R 1 <FF00>] /BitsPerComponent 8 >>",
|
||||
vec![0, 1],
|
||||
vec![
|
||||
(11, "[/Separation /Ink 12 0 R 13 0 R]".into(), None),
|
||||
(12, "[/ICCBased 14 0 R]".into(), None),
|
||||
(13, "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>".into(), None),
|
||||
(14, "<< /N 3 /Alternate /DeviceRGB >>".into(), Some(vec![0; 16])),
|
||||
],
|
||||
))
|
||||
.unwrap();
|
||||
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||
assert!(near(px(&doc, 50, 50), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&doc, 50, 50));
|
||||
assert!(near(px(&doc, 150, 50), [1.0; 4]), "{:?}", px(&doc, 150, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitching_function_with_a_reversed_domain() {
|
||||
// `f64::clamp` panics on bounds in the wrong order, as `/Domain [1 0]` gives them.
|
||||
let exp = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>";
|
||||
let f = read_function(vec![(1, format!("<< /FunctionType 3 /Domain [1 0] /Functions [{exp}] /Bounds [] /Encode [0 1] >>"), None)]);
|
||||
assert_eq!(f.eval(&[0.25]), vec![0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_function_with_a_reversed_domain() {
|
||||
let f = read_sampled_function("<< /FunctionType 0 /Domain [1 0] /Size [2] /Range [0 1] /BitsPerSample 8 >>", vec![0, 255]);
|
||||
assert_eq!(f.eval(&[0.25]), vec![0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculator_function_with_a_reversed_range() {
|
||||
let f = read_function(vec![(1, "<< /FunctionType 4 /Domain [0 1] /Range [1 0] >>".into(), Some(b"{ 0.5 add }".to_vec()))]);
|
||||
assert_eq!(f.eval(&[0.25]), vec![0.75]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_pages_and_calculator_shadings() {
|
||||
let objs: Objs = vec![
|
||||
|
||||
@@ -85,7 +85,9 @@ fn units_ceil(t: Tick, rate: u32) -> i64 {
|
||||
}
|
||||
|
||||
fn sample_tick(i: i64, rate: u32) -> Tick {
|
||||
Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64)
|
||||
// Keep the requested sample when the rate does not divide the tick clock.
|
||||
let t = Tick::from_units(i, i64::from(rate));
|
||||
if t.to_units_floor(i64::from(rate)) < i { Tick(t.0.saturating_add(1)) } else { t }
|
||||
}
|
||||
|
||||
struct MixEnv<'a> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Layer render cache.
|
||||
//!
|
||||
//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the
|
||||
//! layer's own non-transform properties, its source item and the render options — not on its
|
||||
//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed
|
||||
//! layer's non-transform properties, its source item, selected-layer dependencies and the render
|
||||
//! options. Its own transform is normally applied later; referenced layers' transforms can
|
||||
//! affect their sampled pixels. [`LayerCache`] keeps those buffers keyed
|
||||
//! by a content hash of exactly those inputs, *evaluated at the frame time*, so:
|
||||
//!
|
||||
//! * a static layer (or one whose only animation is in Transform) renders once and is reused on
|
||||
@@ -25,6 +26,8 @@ use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
mod dependencies;
|
||||
|
||||
/// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the
|
||||
/// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and
|
||||
/// buffers that were slow to render are written there (keys salted per project, see
|
||||
@@ -534,6 +537,9 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
|
||||
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
|
||||
ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h);
|
||||
ctx.comp.enable_frame_blending.hash(&mut h);
|
||||
dependencies::hash(&mut h, ctx, layer, effects)?;
|
||||
match &layer.source {
|
||||
LayerSource::Solid { item } => {
|
||||
let it = ctx.project.item(*item)?;
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
//! Content selected by layer parameters participates in the owner's cache key. Keep the
|
||||
//! ordinary layer cache (including static self reads), and conservatively decline only
|
||||
//! cyclic/over-budget graphs or expressions sampled at unknown times.
|
||||
use super::{KeyHasher, hash_debug, hash_values};
|
||||
use crate::EvalCtx;
|
||||
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup};
|
||||
use std::hash::Hash;
|
||||
|
||||
pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option<usize>) -> Option<()> {
|
||||
let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) };
|
||||
deps.effects(ctx, layer, effects)
|
||||
}
|
||||
|
||||
struct Dependencies<'a> {
|
||||
h: &'a mut KeyHasher,
|
||||
path: Vec<(ItemId, LayerId)>,
|
||||
remaining: usize,
|
||||
other_times: bool,
|
||||
}
|
||||
|
||||
impl Dependencies<'_> {
|
||||
fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option<usize>) -> Option<()> {
|
||||
let previous = self.other_times;
|
||||
self.other_times |= super::reads_other_times(layer);
|
||||
if layer.switches.effects
|
||||
&& let Some(fx) = layer.effects()
|
||||
{
|
||||
for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) {
|
||||
self.group(ctx, layer, g, 0)?;
|
||||
}
|
||||
}
|
||||
self.other_times = previous;
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn group(&mut self, ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> Option<()> {
|
||||
if depth >= 32 {
|
||||
return None;
|
||||
}
|
||||
for node in &g.children {
|
||||
match node {
|
||||
Node::Prop(p) => {
|
||||
if let Some(id) = ctx.value(layer, p).as_layer() {
|
||||
p.uid.hash(self.h);
|
||||
id.hash(self.h);
|
||||
let other = ctx.layer(LayerId(id));
|
||||
other.is_some().hash(self.h);
|
||||
if let Some(other) = other {
|
||||
if other.id == layer.id {
|
||||
// Source/Masks never enter the same stack again. The owner
|
||||
// key already covers its source and mask properties.
|
||||
hash_debug(self.h, &ctx.world_matrix(other));
|
||||
} else {
|
||||
self.layer(ctx, other)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Group(sub) if sub.enabled => self.group(ctx, layer, sub, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> {
|
||||
let id = (ctx.comp_id, layer.id);
|
||||
if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) {
|
||||
return None;
|
||||
}
|
||||
self.remaining = self.remaining.checked_sub(1)?;
|
||||
if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {
|
||||
return None;
|
||||
}
|
||||
self.path.push(id);
|
||||
// Include transforms/parents as well as pixels: selected-layer anchors and
|
||||
// continuously rasterized sources can depend on them.
|
||||
hash_debug(self.h, layer);
|
||||
hash_values(self.h, ctx, layer, &layer.props);
|
||||
hash_debug(self.h, &ctx.world_matrix(layer));
|
||||
if super::time_dependent(layer) {
|
||||
layer.layer_time(ctx.time).0.hash(self.h);
|
||||
}
|
||||
match layer.source {
|
||||
LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => {
|
||||
let source = ctx.project.item(item);
|
||||
hash_debug(self.h, &source);
|
||||
if let Some(source) = source {
|
||||
match &source.kind {
|
||||
ItemKind::Footage(_) => ctx.source_time(layer).0.hash(self.h),
|
||||
ItemKind::Comp(comp) => {
|
||||
let time = ctx.source_time(layer);
|
||||
time.0.hash(self.h);
|
||||
let nested = EvalCtx::new(ctx.project, item, comp, time);
|
||||
for child in &comp.layers {
|
||||
self.layer(&nested, child)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.effects(ctx, layer, None)?;
|
||||
self.path.pop();
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -195,14 +195,22 @@ pub fn quantize(img: &mut Image, levels: f32) {
|
||||
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
|
||||
#[inline(always)]
|
||||
fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) {
|
||||
let a = (p[3].max(0.0).min(1.0) * levels + 0.5) as u32 as f32 * inv;
|
||||
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
|
||||
let hi = [a, a, a, a];
|
||||
for c in 0..3 {
|
||||
p[c] = (p[c].max(0.0).min(hi[c]) * levels + 0.5) as u32 as f32 * inv;
|
||||
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
|
||||
}
|
||||
p[3] = a;
|
||||
}
|
||||
|
||||
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
|
||||
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
|
||||
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
|
||||
#[inline(always)]
|
||||
fn round_half_up(v: f32) -> f32 {
|
||||
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
|
||||
}
|
||||
|
||||
/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel.
|
||||
fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
if r.x0 >= r.x1 || r.y0 >= r.y1 {
|
||||
@@ -268,3 +276,22 @@ pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode,
|
||||
*p = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
|
||||
/// 0..=levels, exact halves included.
|
||||
#[test]
|
||||
fn rounding_forms_agree() {
|
||||
for levels in [255.0f32, 32_768.0] {
|
||||
for i in 0..=(levels as u32 * 16) {
|
||||
let v = i as f32 / 16.0;
|
||||
assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}");
|
||||
}
|
||||
for i in 0..=100_000 {
|
||||
let v = (i as f32 / 100_000.0) * levels;
|
||||
assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-17
@@ -40,8 +40,16 @@ use rayon::prelude::*;
|
||||
pub trait FootageSource: Send + Sync {
|
||||
/// The frame of `item` at source time `t`, straight from the file (any size).
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
|
||||
/// [`FootageSource::frame`] for a render that shows it at `width` × `height`, at most half its
|
||||
/// size: either that frame or, when the source can make it more cheaply than the whole frame
|
||||
/// (movies, see `effectcraft-media`), the frame already resampled to that size as
|
||||
/// [`effectcraft_raster::resample`] would.
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option<Arc<Image>> {
|
||||
self.frame(item, footage, t)
|
||||
}
|
||||
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
|
||||
/// `rate` Hz (`None` when unavailable).
|
||||
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
|
||||
/// a sample boundary uses the earliest representable tick that selects that sample.
|
||||
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
@@ -61,6 +69,10 @@ pub trait FootageSource: Send + Sync {
|
||||
/// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the
|
||||
/// next reads decode again. Sources without a cache ignore it.
|
||||
fn purge(&self) {}
|
||||
/// The file at `path` changed on disk (Reload Footage): drop what it keeps of it (decoded
|
||||
/// frames, open decoders, parsed models), so the next reads see the new contents. Sources
|
||||
/// without a cache ignore it.
|
||||
fn forget(&self, _path: &str) {}
|
||||
/// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads
|
||||
/// (`None` = off); sources that don't decode audio ignore it.
|
||||
fn set_conform_folder(&self, _folder: Option<std::path::PathBuf>) {}
|
||||
@@ -92,6 +104,7 @@ struct FxHost<'r, 'a, 'c> {
|
||||
origin: [f64; 2],
|
||||
/// Index of the effect being rendered (bounds `self_at` to effects before it).
|
||||
index: std::sync::atomic::AtomicUsize,
|
||||
original: Option<&'c Buf>,
|
||||
}
|
||||
|
||||
impl FxHost<'_, '_, '_> {
|
||||
@@ -138,12 +151,16 @@ pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_eff
|
||||
const MAX_FX_DEPTH: usize = 8;
|
||||
|
||||
impl EffectHost for FxHost<'_, '_, '_> {
|
||||
fn original(&self) -> Option<&Buf> {
|
||||
self.original
|
||||
}
|
||||
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
self.r.active_accel().and_then(|a| a.particles())
|
||||
}
|
||||
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
if (other.id == self.layer.id && masks_and_effects) || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sub = self.r.nested();
|
||||
@@ -154,7 +171,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
|
||||
fn layer_masks(&self, id: u64) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
if self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sub = self.r.nested();
|
||||
@@ -219,14 +236,17 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
if (other.id == self.layer.id && masks_and_effects) || !comp_time.is_finite() || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
|
||||
let sub = self.r.nested();
|
||||
Some(if masks_and_effects {
|
||||
self.pixels(other, (*sub.content_buf(&ctx, other)?).clone(), true)
|
||||
} else if other.id == self.layer.id {
|
||||
self.pixels(other, sub.source(&ctx, other)?, true)
|
||||
} else {
|
||||
// Preserve other-layer reads: time effects historically see source + masks.
|
||||
self.pixels(other, (*sub.layer_input(&ctx, other, 0)?).clone(), false)
|
||||
})
|
||||
}
|
||||
@@ -953,7 +973,18 @@ impl<'a> Renderer<'a> {
|
||||
*q = [q[0] - origin[0], q[1] - origin[1]];
|
||||
}
|
||||
}
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: Default::default() };
|
||||
// Capture once, before preceding effects can expand or replace the input. An
|
||||
// adjustment stack's original is the comp below, not its solid source.
|
||||
let needs_original =
|
||||
fx.groups().take(limit).any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite"));
|
||||
let mut original = None;
|
||||
if needs_original {
|
||||
target.cpu(&mut |buf| {
|
||||
original = Some(buf.clone());
|
||||
buf
|
||||
});
|
||||
}
|
||||
let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() };
|
||||
let env = EffectEnv {
|
||||
masks: &mask_shapes,
|
||||
host: Some(&host),
|
||||
@@ -1104,8 +1135,10 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
// The proxy is decoded instead, at its own size, and fills the footage's frame.
|
||||
let pf = self.proxy_for(*item).unwrap_or(f);
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt)?;
|
||||
let proxy = self.proxy_for(*item);
|
||||
let pf = proxy.unwrap_or(f);
|
||||
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?;
|
||||
Some(self.footage_buf(&img, pf, f.width, f.height))
|
||||
}
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
@@ -1127,12 +1160,11 @@ impl<'a> Renderer<'a> {
|
||||
let s = self.opts.scale;
|
||||
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
let mut buf = if s < 0.75 && k <= 1.0 + 1e-9 {
|
||||
let rw = ((w as f64 * s).round() as u32).max(1);
|
||||
let rh = ((h as f64 * s).round() as u32).max(1);
|
||||
Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
let mut buf = match self.reduced_size(w, h) {
|
||||
// The source resampled it already (`FootageSource::frame_at_size`).
|
||||
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
|
||||
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
|
||||
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
@@ -1140,6 +1172,13 @@ impl<'a> Renderer<'a> {
|
||||
buf
|
||||
}
|
||||
|
||||
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
|
||||
/// is resampled to it (below 3/4 scale).
|
||||
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {
|
||||
let s = self.opts.scale;
|
||||
(s < 0.75).then(|| (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)))
|
||||
}
|
||||
|
||||
/// Authored colours into the working space (linear working spaces).
|
||||
fn authored(&self, mut buf: Buf) -> Buf {
|
||||
if let Some(c) = self.pipe.authored_in() {
|
||||
@@ -1151,7 +1190,8 @@ impl<'a> Renderer<'a> {
|
||||
/// A footage frame at source time `t`, frame-blended between the two nearest source frames
|
||||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
/// `size`: a plain frame may come resampled to it ([`FootageSource::frame_at_size`]).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||||
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
|
||||
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
|
||||
@@ -1162,11 +1202,13 @@ impl<'a> Renderer<'a> {
|
||||
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
|
||||
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
|
||||
}
|
||||
let img = self.footage_frame_raw(ctx, layer, item, f, t)?;
|
||||
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
|
||||
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
|
||||
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
|
||||
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
|
||||
}
|
||||
|
||||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let (i, w) = frame_position(t, f.frame_rate);
|
||||
@@ -1194,7 +1236,10 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.footage.frame(item, f, t)
|
||||
match size {
|
||||
Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h),
|
||||
None => self.footage.frame(item, f, t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Source → masks, clamped/quantised to the project depth.
|
||||
@@ -2152,6 +2197,8 @@ mod tests_collapse;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_footage_size;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_blur;
|
||||
|
||||
@@ -586,3 +586,186 @@ fn layer_parameters_see_a_whole_shape_layer() {
|
||||
assert!(f.get(x, y)[3] < 0.01, "outside at ({x}, {y}): {:?}", f.get(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_matte_self_source_and_masks_precede_effects() {
|
||||
for source in [0, 1] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let mut next = p.next_id;
|
||||
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
l.props.sub_mut("masks").unwrap().children.push(mask.into());
|
||||
let id = l.id.0;
|
||||
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[("channel", Value::Enum(effectcraft_effects::INVERT_ALPHA))]);
|
||||
add_effect_200(
|
||||
&mut p,
|
||||
&mut l,
|
||||
"ec.channel.setmatte",
|
||||
&[
|
||||
("takeMatteFromLayer", Value::Layer(Some(id))),
|
||||
("takeMatteFromLayerSource", Value::Enum(source)),
|
||||
("compositeMatteWithOriginal", Value::Bool(false)),
|
||||
],
|
||||
);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(img.get(50, 50)[3] > 0.99, "original alpha must survive preceding alpha inversion");
|
||||
assert!((img.get(150, 50)[3] - if source == 0 { 1.0 } else { 0.0 }).abs() < 0.01, "Source and Masks must remain distinct");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_composite_restores_stack_input_on_layers_and_adjustments() {
|
||||
for adjustment in [false, true] {
|
||||
for mode in [0, 1] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
l.switches.adjustment = adjustment;
|
||||
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]);
|
||||
add_effect_200(&mut p, &mut l, "ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(mode))]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
if adjustment {
|
||||
let bg = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
p.comp_mut(cid).unwrap().layers.push(bg);
|
||||
}
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let want = if mode == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] };
|
||||
assert_eq!(img.get(50, 50), want, "adjustment={adjustment} mode={mode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_inputs_share_source_semantics_across_effects_and_times() {
|
||||
use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer};
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let mut next = p.next_id;
|
||||
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
l.props.sub_mut("masks").unwrap().children.push(mask.into());
|
||||
p.comp_mut(cid).unwrap().layers.push(l.clone());
|
||||
let c = p.comp(cid).unwrap();
|
||||
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 };
|
||||
assert_eq!(host.layer(l.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0);
|
||||
assert_eq!(host.layer_masks(l.id.0).unwrap().buf.img.get(150, 50)[3], 0.0);
|
||||
assert_eq!(host.layer_at(l.id.0, 0.5, false).unwrap().buf.img.get(150, 50)[3], 1.0);
|
||||
assert!(host.layer(l.id.0, true).is_none(), "full-stack self references must not recurse");
|
||||
assert!(host.layer_at(l.id.0, 0.5, true).is_none());
|
||||
assert!(host.layer_at(l.id.0, f64::NAN, false).is_none());
|
||||
// A second built-in using the shared selector: Invert(red) then Blend(original red).
|
||||
l.props.sub_mut("masks").unwrap().children.clear();
|
||||
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]);
|
||||
let id = l.id.0;
|
||||
add_effect_200(
|
||||
&mut p,
|
||||
&mut l,
|
||||
"ec.channel.blend",
|
||||
&[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))],
|
||||
);
|
||||
p.comp_mut(cid).unwrap().layers = vec![l];
|
||||
let c = p.comp(cid).unwrap();
|
||||
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
|
||||
assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some());
|
||||
assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 2).is_some());
|
||||
assert_eq!(render_frame(&p, cid, Tick::ZERO, 1.0).get(50, 50), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_source_reads_preserve_other_layer_masks() {
|
||||
use effectcraft_effects::EffectHost;
|
||||
let (mut p, cid, comp) = setup();
|
||||
let owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let mut other = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100);
|
||||
let mut next = p.next_id;
|
||||
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
|
||||
p.next_id = next;
|
||||
other.props.sub_mut("masks").unwrap().children.push(mask.into());
|
||||
p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()];
|
||||
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
|
||||
let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
|
||||
let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None };
|
||||
// Current Source and timed Source had different contracts before this fix. Keep
|
||||
// the existing timed read (used by Clone/Time effects) masked for other layers.
|
||||
assert_eq!(host.layer(other.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0);
|
||||
let timed = host.layer_at(other.id.0, 0.5, false).unwrap();
|
||||
assert_eq!(timed.buf.img.get(50, 50)[3], 1.0);
|
||||
assert_eq!(timed.buf.img.get(150, 50)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
let mut source = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100);
|
||||
let third = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
|
||||
let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
|
||||
add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
|
||||
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
|
||||
p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated];
|
||||
let key = |p: &Project, time: f64, limit| {
|
||||
let comp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time));
|
||||
crate::cache::input_key(&ctx, &comp.layers[0], 1.0, false, false, limit).unwrap()
|
||||
};
|
||||
let initial = key(&p, 0.0, 1);
|
||||
let input = key(&p, 0.0, 0);
|
||||
assert_eq!(initial, key(&p, 0.5, 1), "static dependencies reuse across frames");
|
||||
let cache = crate::LayerCache::default();
|
||||
let before = render_cached(&p, cid, Tick::ZERO, Some(&cache));
|
||||
render_cached(&p, cid, Tick::ZERO, Some(&cache));
|
||||
assert!(cache.stats().hits > 0, "layer parameters must not disable caching");
|
||||
let LayerSource::Solid { item: unrelated } = p.comp(cid).unwrap().layers[3].source else { panic!() };
|
||||
let ItemKind::Solid(s) = &mut p.item_mut(unrelated).unwrap().kind else { panic!() };
|
||||
s.color = [0.9, 0.8, 0.7];
|
||||
assert_eq!(initial, key(&p, 0.0, 1), "unrelated edits preserve the owner key");
|
||||
let LayerSource::Solid { item: third } = p.comp(cid).unwrap().layers[2].source else { panic!() };
|
||||
let ItemKind::Solid(s) = &mut p.item_mut(third).unwrap().kind else { panic!() };
|
||||
s.color = [1.0, 0.0, 1.0];
|
||||
assert_ne!(initial, key(&p, 0.0, 1), "transitive source edits invalidate the owner");
|
||||
assert_eq!(input, key(&p, 0.0, 0), "effects after an input prefix do not affect its key");
|
||||
let cached = render_cached(&p, cid, Tick::ZERO, Some(&cache));
|
||||
let fresh = render_cached(&p, cid, Tick::ZERO, None);
|
||||
assert_same(&cached, &fresh, "transitive dependency edit");
|
||||
assert_ne!(before.get(50, 50), fresh.get(50, 50));
|
||||
let owner_id = p.comp(cid).unwrap().layers[0].id;
|
||||
p.comp_mut(cid).unwrap().layers[1].props.prop_mut("effects/#1/blendWithLayer").unwrap().value = Value::Layer(Some(owner_id.0));
|
||||
let comp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO);
|
||||
assert!(crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false).is_none(), "cycles decline caching without recursion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut child = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
|
||||
let LayerSource::Solid { item: source } = child.source else { panic!() };
|
||||
child.props.prop_mut("transform/opacity").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))];
|
||||
let mut nested = comp.clone();
|
||||
nested.layers = vec![child];
|
||||
let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into()));
|
||||
let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None);
|
||||
let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
|
||||
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
|
||||
p.comp_mut(cid).unwrap().layers = vec![owner, pre];
|
||||
let key = |p: &Project, time| {
|
||||
let c = p.comp(cid).unwrap();
|
||||
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap()
|
||||
};
|
||||
let cache = crate::LayerCache::default();
|
||||
let before = render_cached(&p, cid, Tick::ZERO, Some(&cache));
|
||||
let initial = key(&p, Tick::ZERO);
|
||||
let later = Tick::from_seconds_f64(0.5);
|
||||
assert_ne!(initial, key(&p, later));
|
||||
assert_same(&render_cached(&p, cid, later, Some(&cache)), &render_cached(&p, cid, later, None), "animated precomp dependency");
|
||||
let ItemKind::Solid(s) = &mut p.item_mut(source).unwrap().kind else { panic!() };
|
||||
s.color = [0.0, 1.0, 0.0];
|
||||
assert_ne!(initial, key(&p, Tick::ZERO), "items used inside the precomp invalidate the owner");
|
||||
let fresh = render_cached(&p, cid, Tick::ZERO, None);
|
||||
assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit");
|
||||
assert_ne!(before.get(50, 50), fresh.get(50, 50));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Footage at a reduced resolution: a source that resamples frames itself
|
||||
//! ([`FootageSource::frame_at_size`], movies in `effectcraft-media`) renders what resampling the
|
||||
//! full frame renders.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{FootageSource, Image, RenderOpts, Renderer};
|
||||
|
||||
/// Footage with a textured frame; `sizes` (when set) resamples it itself and records the sizes
|
||||
/// it was asked for.
|
||||
struct Clip {
|
||||
sizes: Option<Mutex<Vec<(u32, u32)>>>,
|
||||
}
|
||||
|
||||
impl FootageSource for Clip {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let mut img = Image::new(f.width, f.height);
|
||||
img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
let s = (0.5 + 0.3 * (x as f32 * 0.37 + y as f32 * 0.11).sin()) * 0.9;
|
||||
*p = [s, 1.0 - s, s * 0.5, 1.0];
|
||||
}
|
||||
});
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, w: u32, h: u32) -> Option<Arc<Image>> {
|
||||
let Some(sizes) = &self.sizes else { return self.frame(item, f, t) };
|
||||
sizes.lock().unwrap().push((w, h));
|
||||
Some(Arc::new(effectcraft_raster::resample(&*self.frame(item, f, t)?, w, h)))
|
||||
}
|
||||
}
|
||||
|
||||
fn project(depth: BitDepth, invert_alpha: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
let comp = Comp::new(96, 54, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let f = Footage {
|
||||
path: "clip.mp4".into(),
|
||||
kind: FootageKind::Video,
|
||||
width: 96,
|
||||
height: 54,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
duration: Tick::from_seconds_f64(1.0),
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Ignore,
|
||||
loop_count: 1,
|
||||
invert_alpha,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (96, 54), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_renders_take_footage_resampled_by_the_source() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let (p, cid) = project(depth, false);
|
||||
for (scale, asked) in [(1.0, None), (0.75, None), (0.5, Some((48, 27))), (1.0 / 3.0, Some((32, 18))), (0.25, Some((24, 14)))] {
|
||||
let opts = RenderOpts { scale, ..Default::default() };
|
||||
let plain = Renderer::new(&p, &Clip { sizes: None }, opts).comp_frame(cid, Tick::ZERO);
|
||||
let sized = Clip { sizes: Some(Mutex::default()) };
|
||||
let frame = Renderer::new(&p, &sized, opts).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!(frame, plain, "{depth:?} at {scale}");
|
||||
assert_eq!(sized.sizes.unwrap().into_inner().unwrap(), asked.into_iter().collect::<Vec<_>>(), "{depth:?} at {scale}");
|
||||
}
|
||||
}
|
||||
// Interpreted pixels (Invert Alpha) are interpreted at full size, then resampled.
|
||||
let (p, cid) = project(BitDepth::Bpc8, true);
|
||||
let sized = Clip { sizes: Some(Mutex::default()) };
|
||||
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
|
||||
}
|
||||
@@ -480,9 +480,16 @@ TextDocument.prototype = {
|
||||
get smallCaps() { return this.__style("smallCaps", false); },
|
||||
set smallCaps(v) { this.__set("smallCaps", !!v); },
|
||||
get superscript() { return this.__style("baseline", "normal") === "superscript"; },
|
||||
set superscript(v) { this.__set("superscript", !!v); },
|
||||
set superscript(v) {
|
||||
// The pending document reads the same canonical baseline that setValue will apply.
|
||||
if (v) this.__set("baseline", "superscript");
|
||||
else if (this.superscript) this.__set("baseline", "normal");
|
||||
},
|
||||
get subscript() { return this.__style("baseline", "normal") === "subscript"; },
|
||||
set subscript(v) { this.__set("subscript", !!v); },
|
||||
set subscript(v) {
|
||||
if (v) this.__set("baseline", "subscript");
|
||||
else if (this.subscript) this.__set("baseline", "normal");
|
||||
},
|
||||
get tsume() { return this.__style("tsume", 0); },
|
||||
set tsume(v) { this.__set("tsume", __num(v)); },
|
||||
get boxText() { return !!(this.__base && this.__base.box_size); },
|
||||
@@ -574,7 +581,9 @@ PropertyBase.prototype = {
|
||||
return false;
|
||||
},
|
||||
set selected(v) {
|
||||
if (v) __call("prop.select", { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid, add: true });
|
||||
var ref = { comp: this.__layer.__comp, layer: this.__layer.__id, prop: this.__uid };
|
||||
if (v) __call("prop.select", { comp: ref.comp, layer: ref.layer, prop: ref.prop, add: true });
|
||||
else __call("prop.deselect", ref);
|
||||
},
|
||||
propertyGroup: function (n) {
|
||||
var p = this;
|
||||
@@ -1228,6 +1237,7 @@ LayerCollection.prototype = {
|
||||
if (name !== undefined) p.name = String(name);
|
||||
if (width !== undefined) p.width = Math.round(__num(width, "width"));
|
||||
if (height !== undefined) p.height = Math.round(__num(height, "height"));
|
||||
if (pixelAspect !== undefined) p.pixelAspect = __num(pixelAspect, "pixelAspect");
|
||||
return this.__dur(this.__new(__call("layer.newSolid", p)), duration);
|
||||
},
|
||||
addNull: function (duration) { return this.__dur(this.__new(__call("layer.newNull", { comp: this.__comp })), duration); },
|
||||
@@ -1335,7 +1345,18 @@ Item.prototype = {
|
||||
get parentFolder() { return __item(this.__info().parent); },
|
||||
set parentFolder(f) { __call("project.move", { items: [this.__id], folder: f && f.__id ? f.__id : null }); },
|
||||
get selected() { return this.__info().selected; },
|
||||
set selected(v) { if (v) __call("project.select", { items: [this.__id], add: true }); },
|
||||
set selected(v) {
|
||||
var selection = __get("project").selection;
|
||||
var selected = selection.indexOf(this.__id) !== -1;
|
||||
if (!!v === selected) return;
|
||||
if (v) {
|
||||
__call("project.select", { items: [this.__id], add: true });
|
||||
} else {
|
||||
var id = this.__id;
|
||||
// Replace just this item, preserving the other Project panel selections and their order.
|
||||
__call("project.select", { items: selection.filter(function (item) { return item !== id; }) });
|
||||
}
|
||||
},
|
||||
get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); },
|
||||
remove: function () { __call("project.delete", { items: [this.__id] }); },
|
||||
};
|
||||
@@ -1703,7 +1724,10 @@ Project.prototype = {
|
||||
importFile: function (opts) {
|
||||
var f = opts instanceof ImportOptions ? opts.file : opts;
|
||||
if (!f) throw __err("importFile(): no file");
|
||||
var r = __call("file.import", { paths: [__str(f)] });
|
||||
var args = { paths: [__str(f)] };
|
||||
// After Effects imports just the picked file unless ImportOptions.sequence is set.
|
||||
if (opts instanceof ImportOptions) args.sequence = !!opts.sequence;
|
||||
var r = __call("file.import", args);
|
||||
if (r.errors && r.errors.length) throw __err(r.errors.join("; "));
|
||||
return __item(r.items[0]);
|
||||
},
|
||||
|
||||
@@ -524,3 +524,168 @@ fn comp_preserves_nested_frame_rate_and_resolution() {
|
||||
let (_, c) = comp_named(&s, "Nested");
|
||||
assert!(c.preserve_frame_rate && c.preserve_resolution);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_document_baseline_flags_reflect_pending_edits_and_applied_value() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var comp = app.project.items.addComp("Baseline", 64, 64, 1, 1, 24);
|
||||
var src = comp.layers.addText("AB").text.sourceText;
|
||||
var doc = src.value;
|
||||
doc.superscript = true;
|
||||
var pendingSuper = [doc.superscript, doc.subscript];
|
||||
doc.text = doc.superscript ? "super" : "normal";
|
||||
src.setValue(doc);
|
||||
var appliedSuper = [src.value.text, src.value.superscript, src.value.subscript];
|
||||
doc = src.value;
|
||||
doc.subscript = true;
|
||||
var pendingSub = [doc.superscript, doc.subscript];
|
||||
src.setValue(doc);
|
||||
var appliedSub = [src.value.superscript, src.value.subscript];
|
||||
doc = src.value;
|
||||
doc.subscript = false;
|
||||
var cleared = [doc.superscript, doc.subscript];
|
||||
src.setValue(doc);
|
||||
var appliedClear = [src.value.superscript, src.value.subscript];
|
||||
doc = src.value;
|
||||
doc.superscript = true;
|
||||
doc.superscript = false;
|
||||
var repeatClear = [doc.superscript, doc.subscript];
|
||||
[pendingSuper, appliedSuper, pendingSub, appliedSub, cleared, appliedClear, repeatClear];
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([[true, false], ["super", true, false], [false, true], [false, true], [false, false], [false, false], [false, false]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_item_selected_false_preserves_other_project_items() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var target = app.project.items.addFolder("Target");
|
||||
var keep = app.project.items.addFolder("Keep");
|
||||
app.run("project.select", {items: []});
|
||||
keep.selected = true;
|
||||
target.selected = true;
|
||||
var states = [[target.selected, keep.selected]];
|
||||
target.selected = false;
|
||||
states.push([target.selected, keep.selected]);
|
||||
target.selected = false; // idempotent: never remove Keep
|
||||
states.push([target.selected, keep.selected]);
|
||||
target.selected = true;
|
||||
states.push([target.selected, keep.selected]);
|
||||
app.project.selection.map(function (item) { return item.name; }).join(",")
|
||||
+ "|" + JSON.stringify(states);
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!("Keep,Target|[[true,true],[false,true],[false,true],[true,true]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_solid_keeps_the_requested_pixel_aspect() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("PA", 64, 32, 1, 1, 10);
|
||||
var square = c.layers.addSolid([1, 0, 0], "Square", 16, 16, 1);
|
||||
var wide = c.layers.addSolid([0, 1, 0], "Wide", 16, 16, 2);
|
||||
var inherited = c.layers.addSolid([0, 0, 1], "Inherited", 16, 16);
|
||||
[c.pixelAspect, square.source.pixelAspect, wide.source.pixelAspect, inherited.source.pixelAspect,
|
||||
wide.source.width, wide.source.height]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([1, 1, 2, 1, 16, 16]));
|
||||
let bad = run_code(&mut s, r#"app.project.items.addComp("Q", 8, 8, 1, 1, 10).layers.addSolid([1, 1, 1], "Bad", 8, 8, 0)"#, "bad.jsx");
|
||||
assert!(bad.error.is_some_and(|e| e.message.contains("pixelAspect")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_selected_false_preserves_other_property_and_key_selections() {
|
||||
let mut s = session();
|
||||
let out = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Selection", 64, 64, 1, 1, 10);
|
||||
c.openInViewer();
|
||||
var layer = c.layers.addSolid([1, 0, 0], "Layer", 8, 8, 1);
|
||||
var opacity = layer.transform.opacity;
|
||||
var rotation = layer.transform.rotation;
|
||||
rotation.setValueAtTime(0, 0);
|
||||
rotation.setValueAtTime(0.5, 45);
|
||||
app.run("edit.deselectAll", {});
|
||||
rotation.selected = true;
|
||||
opacity.selected = true;
|
||||
var states = [[opacity.selected, rotation.selected]];
|
||||
opacity.selected = false;
|
||||
states.push([opacity.selected, rotation.selected]);
|
||||
opacity.selected = false;
|
||||
states.push([opacity.selected, rotation.selected]);
|
||||
opacity.selected = true;
|
||||
states.push([opacity.selected, rotation.selected]);
|
||||
[states, rotation.selectedKeys.length, c.selectedProperties.length];
|
||||
"#,
|
||||
);
|
||||
assert_eq!(out.result, json!([[[true, true], [false, true], [false, true], [true, true]], 2, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selected_false_deselects_masks_and_effects() {
|
||||
let mut s = session();
|
||||
let out = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("Groups", 64, 64, 1, 1, 10);
|
||||
c.openInViewer();
|
||||
var l = c.layers.addSolid([1, 0, 0], "Layer", 64, 64, 1);
|
||||
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
|
||||
app.run("edit.deselectAll", {});
|
||||
m.selected = true;
|
||||
fx.selected = true;
|
||||
var states = [[m.selected, fx.selected]];
|
||||
m.selected = false;
|
||||
states.push([m.selected, fx.selected]);
|
||||
fx.selected = false;
|
||||
states.push([m.selected, fx.selected]);
|
||||
states;
|
||||
"#,
|
||||
);
|
||||
assert_eq!(out.result, json!([[true, true], [false, true], [false, false]]));
|
||||
}
|
||||
|
||||
/// Probes every file as a 64x32 still (the media layer's single-file probe).
|
||||
struct Stills;
|
||||
impl effectcraft_engine::Importer for Stills {
|
||||
fn probe(&self, path: &str) -> Result<effectcraft_engine::project::Footage, String> {
|
||||
use effectcraft_engine::project::{Footage, FootageKind};
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// `importFile(new ImportOptions(file))` imports just the picked file unless `sequence` is set (#475).
|
||||
#[test]
|
||||
fn import_options_sequence_picks_one_still_or_the_run() {
|
||||
use effectcraft_engine::project::{FootageKind, ItemKind};
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-seq-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for n in ["shot_0001.png", "shot_0002.png", "shot_0003.png"] {
|
||||
std::fs::write(dir.join(n), b"").unwrap();
|
||||
}
|
||||
let pick = dir.join("shot_0002.png").to_string_lossy().replace('\\', "/");
|
||||
let mut s = session();
|
||||
s.importer = Some(Arc::new(Stills));
|
||||
let kind_of = |s: &Session, name: &str| {
|
||||
let it = s.project.items.values().find(|i| i.name == name).unwrap_or_else(|| panic!("no item {name}"));
|
||||
let ItemKind::Footage(f) = &it.kind else { panic!("footage") };
|
||||
f.kind
|
||||
};
|
||||
ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); app.project.importFile(io);"));
|
||||
assert_eq!(kind_of(&s, "shot_0002.png"), FootageKind::Still);
|
||||
ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); io.sequence = true; app.project.importFile(io);"));
|
||||
assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+137
-8
@@ -186,8 +186,9 @@ impl FrameRate {
|
||||
];
|
||||
|
||||
pub fn new(num: i64, den: i64) -> Self {
|
||||
let g = gcd(num.abs(), den.abs()).max(1);
|
||||
FrameRate { num: num / g, den: den / g }
|
||||
// Magnitudes can be 2^63 for i64::MIN; keep them unsigned and divide in i128.
|
||||
let g = gcd(num.unsigned_abs(), den.unsigned_abs()).max(1) as i128;
|
||||
FrameRate { num: (num as i128 / g) as i64, den: (den as i128 / g) as i64 }
|
||||
}
|
||||
|
||||
/// Closest standard rate for a float (e.g. from a container's average rate).
|
||||
@@ -214,10 +215,16 @@ impl FrameRate {
|
||||
clamp128((t.0 as i128 * self.num as i128).div_euclid(TICKS_PER_SECOND as i128 * nonzero(self.den)))
|
||||
}
|
||||
|
||||
/// Start tick of frame `f`.
|
||||
/// First tick belonging to frame `f` (ceiling at a fractional tick boundary).
|
||||
/// Common broadcast rates have exact integer boundaries. For other rates, flooring would
|
||||
/// put the returned tick in frame `f - 1`, since [`Self::frame_at`] floors its frame index.
|
||||
pub fn tick_of(self, f: i64) -> Tick {
|
||||
let n = f as i128 * TICKS_PER_SECOND as i128 * self.den as i128;
|
||||
Tick(clamp128(n.div_euclid(nonzero(self.num))))
|
||||
let n = (f as i128).saturating_mul(TICKS_PER_SECOND as i128).saturating_mul(self.den as i128);
|
||||
let d = nonzero(self.num);
|
||||
// The only signed division overflow (MIN / -1) has a positive result beyond i128.
|
||||
let q = n.checked_div_euclid(d).unwrap_or(i128::MAX);
|
||||
let r = n.checked_rem_euclid(d).unwrap_or(0);
|
||||
Tick(clamp128(if d > 0 && r != 0 { q.saturating_add(1) } else { q }))
|
||||
}
|
||||
|
||||
/// Snap `t` down to a frame boundary.
|
||||
@@ -234,7 +241,10 @@ impl FrameRate {
|
||||
|
||||
/// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976.
|
||||
pub fn timecode_base(self) -> i64 {
|
||||
(self.num.saturating_add(self.den - 1) / nonzero(self.den) as i64).max(1)
|
||||
if self.num <= 0 || self.den <= 0 {
|
||||
return 1;
|
||||
}
|
||||
clamp128((i128::from(self.num) + i128::from(self.den) - 1) / i128::from(self.den)).max(1)
|
||||
}
|
||||
|
||||
/// NTSC (x/1001) rates can use drop-frame timecode.
|
||||
@@ -265,7 +275,7 @@ impl fmt::Display for FrameRate {
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: i64, mut b: i64) -> i64 {
|
||||
fn gcd(mut a: u64, mut b: u64) -> u64 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
@@ -457,7 +467,8 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i
|
||||
.ok_or_else(too_large)?
|
||||
};
|
||||
Ok(match rel {
|
||||
Some(sign) => current.saturating_add(sign * frames),
|
||||
Some(-1) => current.saturating_sub(frames),
|
||||
Some(_) => current.saturating_add(frames),
|
||||
None => frames,
|
||||
})
|
||||
}
|
||||
@@ -520,6 +531,19 @@ pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_rate_constructors_accept_extreme_numbers() {
|
||||
assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 });
|
||||
assert_eq!(FrameRate::new(0, i64::MIN), FrameRate { num: 0, den: -1 });
|
||||
assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 });
|
||||
assert_eq!(FrameRate::new(0, 0), FrameRate { num: 0, den: 0 });
|
||||
assert_eq!(FrameRate::from_f64(f64::NAN), FrameRate { num: 0, den: 1 });
|
||||
for fps in [f64::NEG_INFINITY, -1e308, -9.2e18, f64::INFINITY, 1e308] {
|
||||
assert_eq!(FrameRate::from_f64(fps), FrameRate::new((fps * 1000.0).round() as i64, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_never_overflow() {
|
||||
// `layer.timing {"delta": -1e308}` over the control channel overflowed `snap_nearest`;
|
||||
@@ -549,6 +573,42 @@ mod tests {
|
||||
let huge = FrameRate { num: i64::MAX, den: 1 };
|
||||
let _ = format_timecode_frames(i64::MAX, huge, false);
|
||||
}
|
||||
#[test]
|
||||
fn hostile_rates_format_and_parse_without_panicking() {
|
||||
for rate in [
|
||||
FrameRate::new(i64::MIN, -1),
|
||||
FrameRate { num: 1, den: i64::MIN },
|
||||
FrameRate::new(i64::MIN, i64::MIN),
|
||||
FrameRate { num: 0, den: 0 },
|
||||
FrameRate { num: -30, den: 1 },
|
||||
] {
|
||||
assert_eq!(rate.timecode_base(), 1);
|
||||
assert_eq!(format_timecode_frames(0, rate, false), "00:00:00:00");
|
||||
assert_eq!(parse_timecode("0:0:0:0", rate, false, 0).unwrap(), 0);
|
||||
}
|
||||
for rate in [FrameRate { num: i64::MAX, den: 1 }, FrameRate { num: i64::MAX, den: 1001 }] {
|
||||
let expected = ((i128::from(rate.num) + i128::from(rate.den) - 1) / i128::from(rate.den)) as i64;
|
||||
assert_eq!(rate.timecode_base(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_relative_min_field_saturates_in_the_requested_direction() {
|
||||
assert_eq!(parse_timecode("-0:0:0:-9223372036854775808", FrameRate::FPS_30, false, 0).unwrap(), i64::MAX);
|
||||
assert_eq!(parse_timecode("+0:0:0:-9223372036854775808", FrameRate::FPS_30, false, 0).unwrap(), i64::MIN);
|
||||
assert_eq!(parse_timecode("-1", FrameRate::FPS_30, false, i64::MIN).unwrap(), i64::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_rate_timecode_roundtrips_keep_their_existing_behavior() {
|
||||
for rate in FrameRate::COMMON {
|
||||
for frame in [-10_000, -1, 0, 1, 10_000] {
|
||||
let text = format_timecode_frames(frame, rate, false);
|
||||
assert_eq!(parse_timecode(&text, rate, false, 0).unwrap(), frame, "{rate}: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
@@ -562,6 +622,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_tick_frame_boundaries_belong_to_the_requested_frame() {
|
||||
let rate = FrameRate::from_f64(12.345);
|
||||
assert_eq!(rate, FrameRate::new(2469, 200));
|
||||
assert_eq!(rate.tick_of(1), Tick(20_576_427_704));
|
||||
assert_eq!(rate.tick_of(-1), Tick(-20_576_427_703));
|
||||
for frame in [-10_000, -2, -1, 0, 1, 2, 10_000] {
|
||||
let start = rate.tick_of(frame);
|
||||
assert_eq!(rate.frame_at(start), frame);
|
||||
assert_eq!(rate.frame_at(start - Tick(1)), frame - 1);
|
||||
assert_eq!(rate.snap(start), start);
|
||||
assert_eq!(rate.snap_nearest(start), start);
|
||||
let next = rate.tick_of(frame + 1);
|
||||
assert_eq!(rate.frame_at(next - Tick(1)), frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_rate_boundaries_keep_their_exact_ticks() {
|
||||
for rate in FrameRate::COMMON {
|
||||
for frame in [-10_000, -1, 0, 1, 10_000] {
|
||||
let expected = frame as i128 * TICKS_PER_SECOND as i128 * rate.den as i128 / rate.num as i128;
|
||||
assert_eq!(rate.tick_of(frame), Tick(expected as i64), "{rate}, frame {frame}");
|
||||
assert_eq!(rate.frame_at(rate.tick_of(frame) - Tick(1)), frame - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_frame_boundaries_saturate_without_overflowing() {
|
||||
for rate in [
|
||||
FrameRate { num: 1, den: i64::MAX },
|
||||
FrameRate { num: -1, den: i64::MAX },
|
||||
FrameRate { num: i64::MAX, den: i64::MAX },
|
||||
FrameRate { num: 0, den: i64::MAX },
|
||||
] {
|
||||
for frame in [i64::MIN, i64::MAX] {
|
||||
let _ = rate.tick_of(frame);
|
||||
}
|
||||
}
|
||||
assert_eq!(FrameRate { num: 1, den: i64::MAX }.tick_of(i64::MAX), Tick(i64::MAX));
|
||||
assert_eq!(FrameRate { num: 1, den: i64::MAX }.tick_of(i64::MIN), Tick(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_known_values() {
|
||||
let r = FrameRate::FPS_29_97;
|
||||
@@ -608,6 +712,13 @@ mod tests {
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn frame_rate_normalization_preserves_exact_ratio(num in any::<i64>(), den in any::<i64>()) {
|
||||
let rate = FrameRate::new(num, den);
|
||||
prop_assert_eq!(num as i128 * rate.den as i128, den as i128 * rate.num as i128);
|
||||
prop_assert_eq!(FrameRate::new(rate.num, rate.den), rate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) {
|
||||
let r = FrameRate::COMMON[ri];
|
||||
@@ -615,6 +726,24 @@ mod tests {
|
||||
prop_assert_eq!(r.frame_at(r.tick_of(f) + r.frame_duration() - Tick(1)), f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_frame_boundaries_roundtrip_and_interior_ticks_stay_in_their_frame(
|
||||
frame in -1_000_000i64..1_000_000,
|
||||
milli_fps in 1_000i64..1_000_000,
|
||||
phase in 1i64..1_000,
|
||||
) {
|
||||
let rate = FrameRate::new(milli_fps, 1000);
|
||||
let start = rate.tick_of(frame);
|
||||
let next = rate.tick_of(frame + 1);
|
||||
prop_assert_eq!(rate.frame_at(start), frame);
|
||||
prop_assert_eq!(rate.frame_at(start - Tick(1)), frame - 1);
|
||||
prop_assert_eq!(rate.frame_at(next - Tick(1)), frame);
|
||||
let interior = start + (next - start).mul_ratio(phase, 1000);
|
||||
prop_assert!(interior > start && interior < next);
|
||||
prop_assert_eq!(rate.frame_at(interior), frame);
|
||||
prop_assert_eq!(rate.snap(interior), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_bijection(f in 0i64..(24 * 107_892)) {
|
||||
let r = FrameRate::FPS_29_97;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use effectcraft_time::{FrameRate, parse_timecode};
|
||||
|
||||
const RELATIVE_NEGATIVE_MIN_FIELD: &str = "-0:0:0:-9223372036854775808";
|
||||
|
||||
#[test]
|
||||
fn normal_relative_and_current_zero_controls_keep_existing_results() {
|
||||
let rate = FrameRate::FPS_30;
|
||||
assert_eq!(parse_timecode("+15", rate, false, 100).unwrap(), 115);
|
||||
assert_eq!(parse_timecode("-1.00", rate, false, 100).unwrap(), 70);
|
||||
assert_eq!(parse_timecode("-1", rate, false, i64::MIN).unwrap(), i64::MIN);
|
||||
assert_eq!(parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, rate, false, 0).unwrap(), i64::MAX);
|
||||
assert_eq!(parse_timecode("+0:0:0:-9223372036854775808", rate, false, 0).unwrap(), i64::MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_negative_min_field_preserves_cancellation_from_negative_one() {
|
||||
let actual = parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, FrameRate::FPS_30, false, -1).unwrap();
|
||||
eprintln!("relative MIN field: current=-1 actual={actual} expected={}", i64::MAX);
|
||||
// The relative offset is positive 2^63; minus one is exactly i64::MAX.
|
||||
assert_eq!(actual, i64::MAX, "saturation must preserve cancellation with the current frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_negative_min_field_cancels_the_minimum_current_frame() {
|
||||
let actual = parse_timecode(RELATIVE_NEGATIVE_MIN_FIELD, FrameRate::FPS_30, false, i64::MIN).unwrap();
|
||||
eprintln!("relative MIN field: current=MIN actual={actual} expected=0");
|
||||
// The starting frame is negative 2^63; the relative offset is positive 2^63.
|
||||
assert_eq!(actual, 0, "opposite extreme relative offsets must cancel before saturation");
|
||||
}
|
||||
@@ -467,8 +467,9 @@ impl DockNode {
|
||||
*self = (**a).clone();
|
||||
}
|
||||
} else if let DockNode::Tabs { panels, active } = self {
|
||||
let shown = panels.get(*active).copied();
|
||||
panels.retain(|x| *x != p);
|
||||
*active = (*active).min(panels.len().saturating_sub(1));
|
||||
*active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1)));
|
||||
} else if let DockNode::Stack { entries } = self {
|
||||
entries.retain(|e| e.panel != p);
|
||||
}
|
||||
@@ -692,8 +693,9 @@ impl Layout {
|
||||
let mut found = false;
|
||||
for f in &mut self.floating {
|
||||
if let Some(i) = f.panels.iter().position(|x| *x == p) {
|
||||
let shown = f.panels.get(f.active).copied();
|
||||
f.panels.remove(i);
|
||||
f.active = f.active.min(f.panels.len().saturating_sub(1));
|
||||
f.active = shown.and_then(|shown| f.panels.iter().position(|x| *x == shown)).unwrap_or(f.active.min(f.panels.len().saturating_sub(1)));
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
@@ -1416,4 +1418,73 @@ mod tests {
|
||||
assert_eq!(back, d);
|
||||
assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_earlier_inactive_tab_keeps_the_shown_panel() {
|
||||
use PanelKind::*;
|
||||
let mut d = tabs(&[Properties, Align, EffectsPresets], 1);
|
||||
d.close(Properties);
|
||||
assert!(d.is_visible(Align), "closing an inactive tab keeps the shown panel");
|
||||
assert!(!d.is_visible(EffectsPresets));
|
||||
assert_eq!(d.panel_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_active_tab_keeps_existing_neighbor_choice() {
|
||||
use PanelKind::*;
|
||||
for (active, closed, shown) in [(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align)] {
|
||||
let mut d = tabs(&[Properties, Align, EffectsPresets], active);
|
||||
d.close(closed);
|
||||
assert!(d.is_visible(shown), "active {active}: {closed:?} -> {shown:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_normalizes_invalid_active_and_collapses_empty_splits() {
|
||||
use PanelKind::*;
|
||||
let mut d = tabs(&[Properties, Align, EffectsPresets], usize::MAX);
|
||||
d.close(History);
|
||||
assert!(d.is_visible(EffectsPresets));
|
||||
d.close(Properties);
|
||||
assert!(d.is_visible(EffectsPresets));
|
||||
d.close(EffectsPresets);
|
||||
assert!(d.is_visible(Align));
|
||||
d.close(Align);
|
||||
assert_eq!(d, tabs(&[], 0));
|
||||
let mut d = DockNode::Split { vertical: true, size: SplitSize::Ratio(0.5), a: Box::new(tabs(&[Properties], 0)), b: Box::new(tabs(&[Align], 0)) };
|
||||
d.close(Properties);
|
||||
assert_eq!(d, tabs(&[Align], 0));
|
||||
d.close(Align);
|
||||
assert_eq!(d, tabs(&[], 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_earlier_inactive_floating_tab_keeps_the_shown_panel() {
|
||||
use PanelKind::*;
|
||||
let mut layout =
|
||||
Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active: 1, rect: [0.0; 4] }] };
|
||||
assert!(layout.unfloat(Properties));
|
||||
let f = &layout.floating[0];
|
||||
assert_eq!(f.panels.get(f.active), Some(&Align));
|
||||
assert!(layout.root.is_visible(Composition));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_removal_keeps_active_neighbor_and_handles_invalid_index() {
|
||||
use PanelKind::*;
|
||||
for (active, closed, shown) in
|
||||
[(0, Properties, Align), (1, Align, EffectsPresets), (2, EffectsPresets, Align), (usize::MAX, Properties, EffectsPresets)]
|
||||
{
|
||||
let mut layout =
|
||||
Layout { root: tabs(&[Composition], 0), floating: vec![Floating { panels: vec![Properties, Align, EffectsPresets], active, rect: [0.0; 4] }] };
|
||||
assert!(layout.unfloat(closed));
|
||||
let f = &layout.floating[0];
|
||||
assert_eq!(f.panels.get(f.active), Some(&shown));
|
||||
for p in [Properties, Align, EffectsPresets] {
|
||||
layout.unfloat(p);
|
||||
}
|
||||
assert!(layout.floating.is_empty());
|
||||
assert!(!layout.unfloat(History));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,42 @@ impl Frames {
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// Prefer the exact requested frame. While an edit renders, advance to the newest completed
|
||||
/// revision of the same comp, time, scale, view and render options instead of freezing on the
|
||||
/// image from before the drag. Input may arrive before the viewer draws on every UI frame,
|
||||
/// so even a fast worker cannot always finish the *latest* revision in time.
|
||||
///
|
||||
/// Never move backwards when workers finish out of order, and never substitute another time
|
||||
/// or view while scrubbing or switching comps. The exact content lookup above also means an
|
||||
/// undo can immediately reuse an old cache entry, regardless of its original revision.
|
||||
pub fn get_for_viewer(&self, wanted: &FrameKey, shown: Option<&FrameKey>) -> Option<(FrameKey, FrameImage)> {
|
||||
if let Some(img) = self.get(wanted) {
|
||||
return Some((*wanted, img));
|
||||
}
|
||||
let shown = shown?;
|
||||
if shown.comp != wanted.comp || shown.frame != wanted.frame || shown.view != wanted.view || shown.revision >= wanted.revision {
|
||||
return None;
|
||||
}
|
||||
let mut c = self.cache.lock().ok()?;
|
||||
let newest = c
|
||||
.map
|
||||
.keys()
|
||||
.filter(|k| {
|
||||
k.revision > shown.revision
|
||||
&& k.revision < wanted.revision
|
||||
&& k.comp == wanted.comp
|
||||
&& k.frame == wanted.frame
|
||||
&& k.scale == wanted.scale
|
||||
&& k.view == wanted.view
|
||||
&& k.opts == wanted.opts
|
||||
})
|
||||
.max_by_key(|k| k.revision)
|
||||
.copied()?;
|
||||
let img = c.map.get(&newest).cloned()?;
|
||||
c.touch(&newest);
|
||||
Some((newest, img))
|
||||
}
|
||||
|
||||
/// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per
|
||||
/// revision). The project is kept while frames of that identity are cached or rendering, so
|
||||
/// the comp addresses it hashes can't be reused by other comps meanwhile.
|
||||
@@ -665,6 +701,12 @@ impl Frames {
|
||||
c.bytes = 0;
|
||||
c.gpu_bytes = 0;
|
||||
}
|
||||
// Disk keys hash the footage files' size and time, but are remembered per revision; a
|
||||
// file reloaded from disk without a new revision (a re-rendered image sequence) must be
|
||||
// keyed again, or the disk cache serves the frames from before.
|
||||
if let Ok(mut k) = self.content_keys.lock() {
|
||||
k.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a prefetch frame (no-op if cached or already queued/rendering).
|
||||
@@ -698,8 +740,9 @@ impl Frames {
|
||||
// Only the newest viewer frame is urgent. The comp's earlier viewer frames that
|
||||
// haven't started are dropped (scrubbing: rendering every frame the pointer passed
|
||||
// would hold the workers the frame under it needs); frames already rendering finish
|
||||
// into the cache, and the viewer shows its last frame until the new one is in
|
||||
// (#284). Other comps' viewer frames become prefetch.
|
||||
// into the cache. During edits the viewer advances through compatible completed
|
||||
// revisions; scrubbing holds its last frame until the requested time is in (#284).
|
||||
// Other comps' viewer frames become prefetch.
|
||||
q.jobs.retain(|j| {
|
||||
let superseded = j.urgent && j.key != key && j.key.comp == key.comp;
|
||||
if superseded {
|
||||
@@ -1267,6 +1310,53 @@ mod tests {
|
||||
assert!(!f.is_cached(&key(3, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_edits_present_the_newest_completed_revision_without_going_backwards() {
|
||||
let f = Frames::default();
|
||||
let shown = key(1, 0, 0);
|
||||
let newest = key(3, 0, 0);
|
||||
let wanted = key(5, 0, 0);
|
||||
insert(&f, shown);
|
||||
insert(&f, newest);
|
||||
insert(&f, key(2, 0, 0)); // Older worker finishes after the newer one.
|
||||
let displayed = f.get_for_viewer(&wanted, Some(&shown)).unwrap().0;
|
||||
assert_eq!(displayed, newest);
|
||||
assert!(f.get_for_viewer(&wanted, Some(&displayed)).is_none(), "never replay an older completion");
|
||||
insert(&f, wanted);
|
||||
assert_eq!(f.get_for_viewer(&wanted, Some(&displayed)).unwrap().0, wanted, "settle on the exact final edit");
|
||||
// Undo reuses the content of an earlier revision immediately.
|
||||
let undo = FrameKey { revision: 6, ..shown };
|
||||
let reused = f.get_for_viewer(&undo, Some(&wanted)).unwrap().0;
|
||||
assert_eq!(reused.content, shown.content);
|
||||
assert_eq!(reused.revision, undo.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_edits_do_not_substitute_other_times_views_or_render_settings() {
|
||||
let f = Frames::default();
|
||||
let shown = key(1, 0, 0);
|
||||
let wanted = key(3, 0, 0);
|
||||
let done = key(2, 0, 0);
|
||||
for mismatch in [
|
||||
FrameKey { comp: 9, ..done },
|
||||
FrameKey { frame: 1, ..done },
|
||||
FrameKey { scale: 500, ..done },
|
||||
FrameKey { view: 1, ..done },
|
||||
FrameKey { opts: 1, ..done },
|
||||
FrameKey { revision: 4, ..done },
|
||||
] {
|
||||
f.clear();
|
||||
insert(&f, mismatch);
|
||||
assert!(f.get_for_viewer(&wanted, Some(&shown)).is_none(), "wrong candidate: {mismatch:?}");
|
||||
}
|
||||
f.clear();
|
||||
insert(&f, done);
|
||||
assert!(f.get_for_viewer(&wanted, None).is_none(), "no stale content on opening a viewer");
|
||||
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { comp: 9, ..shown })).is_none());
|
||||
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { frame: 1, ..shown })).is_none());
|
||||
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { view: 1, ..shown })).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community
|
||||
//! buttons (Discord is always one click away).
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -198,6 +198,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
|
||||
rx = r.min.x - 4.0;
|
||||
}
|
||||
// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next.
|
||||
let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() {
|
||||
"auto" => (Icon::Monitor, tr("Sync with System")),
|
||||
"light" => (Icon::Sun, tr("Light")),
|
||||
_ => (Icon::Moon, tr("Dark")),
|
||||
};
|
||||
let tip = tr_args("Appearance Mode: {}", &[&mode]);
|
||||
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
|
||||
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() {
|
||||
app.cycle_appearance(ui.ctx());
|
||||
}
|
||||
app.auto.add("header.appearance", r, &tip);
|
||||
rx = r.min.x - 4.0;
|
||||
rx -= 10.0;
|
||||
p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator));
|
||||
rx -= 10.0;
|
||||
|
||||
+1426
-5
File diff suppressed because it is too large.
Load diff
+1079
-7
File diff suppressed because it is too large.
Load diff
@@ -112,6 +112,13 @@ pub enum Icon {
|
||||
Globe,
|
||||
Code,
|
||||
Sparkle,
|
||||
// appearance modes
|
||||
/// Sync with System: a display on a stand.
|
||||
Monitor,
|
||||
/// Light: a disc with eight rays.
|
||||
Sun,
|
||||
/// Dark: a crescent.
|
||||
Moon,
|
||||
}
|
||||
|
||||
pub struct Pen16<'a> {
|
||||
@@ -616,6 +623,24 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
Sparkle => {
|
||||
pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]);
|
||||
}
|
||||
Monitor => {
|
||||
pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2);
|
||||
pen.line(&[(8.0, 11.0), (8.0, 13.8)]);
|
||||
pen.line(&[(5.0, 13.8), (11.0, 13.8)]);
|
||||
}
|
||||
Sun => {
|
||||
pen.circle(8.0, 8.0, 2.8);
|
||||
for i in 0..8 {
|
||||
let a = std::f32::consts::FRAC_PI_4 * i as f32;
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.4 * c, 8.0 + 6.4 * s)]);
|
||||
}
|
||||
}
|
||||
Moon => {
|
||||
// The outer edge of a disc, and the inner edge of the disc that eclipses it.
|
||||
pen.arc(8.0, 8.0, 5.8, -20.0, 250.0);
|
||||
pen.arc(10.6, 2.4, 4.6, 52.0, 178.0);
|
||||
}
|
||||
PenAdd | PenDelete => {
|
||||
// A small pen nib with a plus or minus beside it.
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
|
||||
+147
-10
@@ -92,9 +92,16 @@ pub enum Dialog {
|
||||
DeleteItems,
|
||||
}
|
||||
|
||||
/// 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<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
#[derive(Default)]
|
||||
pub struct Hooks {
|
||||
/// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads
|
||||
/// the Linux desktop portal); without it, what egui reports.
|
||||
pub system_theme: Option<SystemThemeFn>,
|
||||
pub pick_files: Option<Box<dyn Fn(&[&str]) -> Vec<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
@@ -269,7 +276,8 @@ pub struct EffectcraftApp {
|
||||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
/// Settings revision applied to the theme, tooltips and caches.
|
||||
applied_prefs: Option<u64>,
|
||||
/// The settings revision and system appearance (light?) last applied.
|
||||
applied_prefs: Option<(u64, Option<bool>)>,
|
||||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||||
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
|
||||
@@ -367,13 +375,16 @@ impl EffectcraftApp {
|
||||
|
||||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_prefs == Some(self.session.prefs_revision) {
|
||||
// Sync with System re-applies when the system appearance changes (the host wakes the UI;
|
||||
// nothing polls).
|
||||
let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None };
|
||||
if self.applied_prefs == Some((self.session.prefs_revision, system)) {
|
||||
return;
|
||||
}
|
||||
self.applied_prefs = Some(self.session.prefs_revision);
|
||||
self.applied_prefs = Some((self.session.prefs_revision, system));
|
||||
let p = &self.session.prefs;
|
||||
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p);
|
||||
self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p, self.ui.theme);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let tips = p.general.show_tool_tips;
|
||||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||||
@@ -399,13 +410,32 @@ impl EffectcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that
|
||||
/// family's theme.
|
||||
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
||||
let name = match k {
|
||||
theme::ThemeKind::Dark => "dark",
|
||||
theme::ThemeKind::Darker => "darker",
|
||||
theme::ThemeKind::Light => "light",
|
||||
if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
/// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux
|
||||
/// desktop portal) wins over egui's, which Wayland compositors often leave empty.
|
||||
pub fn system_light(&self, ctx: &egui::Context) -> Option<bool> {
|
||||
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light)
|
||||
}
|
||||
|
||||
/// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then
|
||||
/// Dark, then Sync with System again. The light and dark theme choices are kept.
|
||||
pub fn cycle_appearance(&mut self, ctx: &egui::Context) {
|
||||
let next = match self.session.prefs.appearance.appearance_mode.as_str() {
|
||||
"auto" => "light",
|
||||
"light" => "dark",
|
||||
_ => "auto",
|
||||
};
|
||||
let _ = self.set_pref("appearance.theme", json!(name));
|
||||
if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
@@ -510,6 +540,12 @@ impl EffectcraftApp {
|
||||
&& self.ui.viewer.extended.is_none()
|
||||
}
|
||||
|
||||
/// The UI draws its panels and handles input such as dropped files. Until the fonts are
|
||||
/// installed (the first frames) it draws nothing, and that input goes unhandled.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.fonts_ready
|
||||
}
|
||||
|
||||
/// Provide the executable/embedding host's device notifications. The host installs the
|
||||
/// handlers once; this frontend never replaces handlers on a borrowed device.
|
||||
pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) {
|
||||
@@ -1472,6 +1508,8 @@ impl EffectcraftApp {
|
||||
header::show(self, ui, header);
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||||
self.dock_area(ui, body);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prefs_live::dropped_files(self, ui, full);
|
||||
panels::precomp::mini_flowchart(self, &ctx);
|
||||
panels::home::capture_thumbnail(self);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
@@ -1544,6 +1582,34 @@ impl EffectcraftApp {
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(10));
|
||||
}
|
||||
self.tick_footage_reload(ctx, now);
|
||||
}
|
||||
|
||||
/// Settings ▸ Import ▸ Reload Footage Changed on Disk: every two seconds, and as soon as the
|
||||
/// window comes back to the front (after saving in another app), stamp the footage files on a
|
||||
/// worker thread and reload the ones that changed.
|
||||
fn tick_footage_reload(&mut self, ctx: &egui::Context, now: f64) {
|
||||
use effectcraft_engine::footage_reload;
|
||||
if let Some(Err(e)) = footage_reload::poll_scan(&mut self.session) {
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
let (last_key, focus_key) = (egui::Id::new("footageReload.lastScan"), egui::Id::new("footageReload.focused"));
|
||||
let focused = ctx.input(|i| i.focused);
|
||||
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
|
||||
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
|
||||
if self.session.prefs.import.auto_reload_footage == "off" || self.session.project.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(0.0);
|
||||
if now - last >= 2.0 || (focused && !was_focused) {
|
||||
ctx.data_mut(|d| d.insert_temp(last_key, now));
|
||||
footage_reload::start_scan(&mut self.session);
|
||||
}
|
||||
if self.session.footage_scan.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
|
||||
@@ -1713,3 +1779,74 @@ mod gpu_failure_tests {
|
||||
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod appearance_tests {
|
||||
use super::*;
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
EffectcraftApp::new(effectcraft_engine::Session::default())
|
||||
}
|
||||
|
||||
/// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System
|
||||
/// again, showing the saved theme of each family without losing either choice.
|
||||
#[test]
|
||||
fn appearance_button_cycles_modes_without_losing_theme_choices() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
|
||||
app.set_pref("appearance.darkTheme", json!("darker")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||||
for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] {
|
||||
menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
|
||||
assert_eq!(app.ui.theme, shown, "{mode}");
|
||||
assert_eq!(app.tokens.kind, shown);
|
||||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(app.session.prefs.appearance.light_theme, "light");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync with System follows the host's answer (the Linux portal) when it changes, and falls
|
||||
/// back to Dark when nothing reports a system appearance.
|
||||
#[test]
|
||||
fn auto_follows_a_stubbed_system_theme() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let system = Arc::new(Mutex::new(Some(egui::Theme::Light)));
|
||||
let read = Arc::clone(&system);
|
||||
app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner)));
|
||||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||||
*system.lock().unwrap() = Some(egui::Theme::Dark);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change");
|
||||
*system.lock().unwrap() = None;
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||||
// A fixed mode ignores the system.
|
||||
*system.lock().unwrap() = Some(egui::Theme::Light);
|
||||
app.set_pref("appearance.appearanceMode", json!("dark")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||||
}
|
||||
|
||||
/// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family.
|
||||
#[test]
|
||||
fn picking_a_theme_fixes_its_mode() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||||
menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, "light");
|
||||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||||
menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, "dark");
|
||||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||||
}
|
||||
}
|
||||
+76
-14
@@ -46,6 +46,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
uic!("view.safeMargins", "Title/Action Safe", [], None),
|
||||
uic!("view.transparencyGrid", "Transparency Grid", [], None),
|
||||
uic!("view.fastPreviews", "Fast Previews", [], None),
|
||||
uic!("view.theme.toggle", "Next Appearance Mode", [], None),
|
||||
uic!("view.theme.dark", "Theme: Dark", [], None),
|
||||
uic!("view.theme.darker", "Theme: Darker", [], None),
|
||||
uic!("view.theme.light", "Theme: Light", [], None),
|
||||
@@ -300,6 +301,14 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
None => Err("select a Render Queue item first".into()),
|
||||
};
|
||||
}
|
||||
// Shift+Delete deletes the Project panel's selection without asking; it does nothing in the
|
||||
// other panels.
|
||||
if id == "project.deleteWithoutConfirmation" {
|
||||
if app.ui.focused != PanelKind::Project {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
return crate::panels::delete_items::delete_confirmed(app, ctx, json!({}));
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Closing a modified project asks to save it first; the command runs once answered.
|
||||
if crate::panels::unsaved::guard(app, id, ¶ms) {
|
||||
@@ -463,6 +472,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
app.ui.viewer.res = Resolution::Auto;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if id == "view.theme.toggle" {
|
||||
app.cycle_appearance(ctx);
|
||||
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
|
||||
}
|
||||
if let Some(th) = id.strip_prefix("view.theme.") {
|
||||
let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?;
|
||||
app.set_theme(ctx, k);
|
||||
@@ -668,7 +681,7 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
@@ -713,6 +726,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
}
|
||||
// View ▸ New Viewer.
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
|
||||
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
@@ -1125,6 +1140,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
/// Commands that need a file or folder path: ask the host's file dialog when `params` lacks one.
|
||||
fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
enum Ask {
|
||||
Folder,
|
||||
Import,
|
||||
OpenProject,
|
||||
Save(&'static str),
|
||||
@@ -1151,6 +1167,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
"file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
|
||||
"effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.relinkFootage" => ("folder", Ask::Folder),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files")),
|
||||
"file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")),
|
||||
"keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])),
|
||||
@@ -1169,6 +1186,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
}
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
let picked: Option<Value> = match ask {
|
||||
Ask::Folder => {
|
||||
let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) };
|
||||
f().map(Value::from)
|
||||
}
|
||||
Ask::Import => {
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
let paths = f(&[
|
||||
@@ -1455,8 +1476,27 @@ fn space_tap_id() -> egui::Id {
|
||||
egui::Id::new("spacebar-tap")
|
||||
}
|
||||
|
||||
/// The modifiers held when this frame's input began: the state the last pass ended with, if it
|
||||
/// ran this (egui keeps only the state after the frame's events). Records this pass's state.
|
||||
fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers> {
|
||||
let id = egui::Id::new("shortcut-modifiers");
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let now = ctx.input(|i| i.modifiers);
|
||||
ctx.data_mut(|d| {
|
||||
let last = d.get_temp::<(u64, egui::Modifiers)>(id);
|
||||
d.insert_temp(id, (pass, now));
|
||||
last.and_then(|(p, m)| (p.checked_add(1) == Some(pass)).then_some(m))
|
||||
})
|
||||
}
|
||||
|
||||
/// Only Shift held (Shift+Delete, not Ctrl/Alt+Shift+Delete).
|
||||
fn only_shift(m: egui::Modifiers) -> bool {
|
||||
m.shift && !m.alt && !m.ctrl && !m.command && !m.mac_cmd
|
||||
}
|
||||
|
||||
/// Dispatch keyboard shortcuts (skipped while typing in a text field).
|
||||
pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let start = modifiers_at_start(ctx);
|
||||
// Dialog cancellation owns Escape even when a text field has keyboard focus.
|
||||
if app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
|
||||
@@ -1465,6 +1505,8 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
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::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
} else {
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
@@ -1491,14 +1533,23 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let project = app.ui.focused == PanelKind::Project;
|
||||
let events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
// Paste and their variants (Ctrl+Alt+C…) run.
|
||||
let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND };
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
// Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and
|
||||
// `i.modifiers` is the state after all of the frame's events, so each is read with the
|
||||
// modifiers where it is in the stream: walk the events from the frame's starting state.
|
||||
// `None` when that isn't known.
|
||||
let changed = i.events.iter().any(|e| matches!(e, egui::Event::ModifiersChanged(_) | egui::Event::WindowFocused(false)));
|
||||
let mut at = if changed { start } else { Some(i.modifiers) };
|
||||
let held = |at: Option<egui::Modifiers>| at.filter(|m| m.command).unwrap_or(egui::Modifiers::COMMAND);
|
||||
let mut out = Vec::new();
|
||||
for e in &i.events {
|
||||
match e {
|
||||
egui::Event::ModifiersChanged(m) => at = Some(*m),
|
||||
// egui forgets the modifiers held when the window loses focus.
|
||||
egui::Event::WindowFocused(false) => at = Some(egui::Modifiers::NONE),
|
||||
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
|
||||
if !*repeat
|
||||
|| matches!(
|
||||
@@ -1506,15 +1557,22 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
|
||||
) =>
|
||||
{
|
||||
Some((*key, *modifiers, true))
|
||||
out.push((*key, *modifiers, true));
|
||||
}
|
||||
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)),
|
||||
egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)),
|
||||
egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)),
|
||||
egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => out.push((egui::Key::Space, egui::Modifiers::NONE, false)),
|
||||
egui::Event::Copy if clipboard => out.push((egui::Key::C, held(at), true)),
|
||||
// On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project
|
||||
// panel has no clipboard: there a Cut with only Shift held is Shift+Delete (Delete
|
||||
// Project Items Without Confirmation). Never when the modifiers aren't known.
|
||||
egui::Event::Cut if clipboard && project && at.is_some_and(only_shift) => {
|
||||
out.push((egui::Key::Delete, egui::Modifiers::SHIFT, true));
|
||||
}
|
||||
egui::Event::Cut if clipboard => out.push((egui::Key::X, held(at), true)),
|
||||
egui::Event::Paste(_) if clipboard => out.push((egui::Key::V, held(at), true)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
|
||||
// comes as text.
|
||||
@@ -1550,6 +1608,10 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if crate::panels::shortcut_editor::recording(app) {
|
||||
continue;
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
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()
|
||||
|
||||
@@ -192,6 +192,10 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
|
||||
"服務"
|
||||
} else if crate::i18n::ukrainian(app) {
|
||||
"Служби"
|
||||
} else if crate::i18n::spanish(app) {
|
||||
"Servicios"
|
||||
} else if crate::i18n::brazilian_portuguese(app) {
|
||||
"Serviços"
|
||||
} else {
|
||||
"Services"
|
||||
}
|
||||
|
||||
@@ -340,11 +340,13 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr
|
||||
Tab::Renderer => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Renderer:"));
|
||||
let r =
|
||||
egui::ComboBox::from_id_salt("comp-renderer").selected_text(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" }).show_ui(ui, |ui| {
|
||||
let r = egui::ComboBox::from_id_salt("comp-renderer").selected_text(tr(if d.advanced_3d { "Advanced 3D" } else { "Classic 3D" })).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.selectable_value(&mut d.advanced_3d, false, tr("Classic 3D"));
|
||||
ui.selectable_value(&mut d.advanced_3d, true, tr("Advanced 3D"));
|
||||
});
|
||||
},
|
||||
);
|
||||
auto.add("dialog.comp.renderer", r.response.rect, "Renderer");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Deleting Project items that compositions use asks first, as in After Effects: the Delete key
|
||||
//! and the Project panel's trash button (`project.delete` through the UI) show how many layers in
|
||||
//! how many compositions go with the items (`project.usage`). Items nothing uses are deleted at
|
||||
//! once. Agents running `project.delete` with `engine.execute` are never asked.
|
||||
//! once. Agents running `project.delete` with `engine.execute` are never asked, and neither is
|
||||
//! Delete Project Items Without Confirmation (Shift+Delete in the Project panel).
|
||||
//!
|
||||
//! Automation ids: `dialog.deleteItems.delete`, `dialog.deleteItems.cancel`.
|
||||
|
||||
@@ -16,8 +17,6 @@ use crate::{Dialog, EffectcraftApp};
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Pending {
|
||||
pub command: Option<(Value, Value)>,
|
||||
/// The next `project.delete` runs without asking (the user answered Delete).
|
||||
pub answered: bool,
|
||||
}
|
||||
|
||||
fn plural(n: u64, one: &str, many: &str) -> String {
|
||||
@@ -27,7 +26,7 @@ fn plural(n: u64, one: &str, many: &str) -> String {
|
||||
/// Whether `id` needs the prompt first. When it does, the prompt opens holding the command and
|
||||
/// the caller must not run it (it runs once the user answers Delete).
|
||||
pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> bool {
|
||||
if id != "project.delete" || std::mem::take(&mut app.dialog_state.delete_items.answered) {
|
||||
if id != "project.delete" {
|
||||
return false;
|
||||
}
|
||||
let Ok(usage) = app.session.execute("project.usage", params.clone()) else { return false };
|
||||
@@ -79,9 +78,14 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
app.dialog = None;
|
||||
app.dialog_state.delete_items.command = None;
|
||||
if delete {
|
||||
app.dialog_state.delete_items.answered = true;
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "project.delete", params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
// An error is already in the status bar.
|
||||
let _ = delete_confirmed(app, ctx, params);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `project.delete` with `params` without the prompt: this request is already confirmed (the
|
||||
/// prompt's Delete, or Delete Project Items Without Confirmation). The confirmation goes with the
|
||||
/// request, so it can't carry over to a later deletion.
|
||||
pub fn delete_confirmed(app: &mut EffectcraftApp, ctx: &egui::Context, params: Value) -> Result<Value, String> {
|
||||
crate::menus::run_engine(app, ctx, "project.delete", params)
|
||||
}
|
||||
@@ -158,7 +158,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(size.x - 36.0);
|
||||
ui.set_min_height(size.y - 36.0);
|
||||
ui.label(egui::RichText::new(title).font(Tokens::semibold(15.0)).color(t.tab_text_active));
|
||||
// The id stays keyed on the untranslated source, so switching language cannot move it.
|
||||
ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
|
||||
ui.add_space(10.0);
|
||||
egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body);
|
||||
});
|
||||
@@ -291,7 +292,7 @@ fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut
|
||||
);
|
||||
let foreground = if discord { Color32::WHITE } else { t.text };
|
||||
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, foreground);
|
||||
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), foreground);
|
||||
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), foreground);
|
||||
p.text(
|
||||
pos2(r.max.x - 12.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
@@ -418,8 +419,8 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if editing.is_some() {
|
||||
ui.add_space(8.0);
|
||||
let r = ui
|
||||
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, "Affect all layers that use this solid"))
|
||||
.on_disabled_hover_text("Only this layer uses this solid.");
|
||||
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
|
||||
.on_disabled_hover_text(tr("Only this layer uses this solid."));
|
||||
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
|
||||
if shared && !affect_all {
|
||||
ui.label(egui::RichText::new(tr("This change will create a new solid for this layer.")).color(t.text_dim).size(11.0));
|
||||
|
||||
@@ -37,6 +37,21 @@ const DIAL_ROW: f32 = 64.0;
|
||||
const PARAM_NAME_X: f32 = 58.0;
|
||||
const TREE_INDENT: f32 = 14.0;
|
||||
|
||||
/// Selecting any descendant activates its owning effect without replacing the property
|
||||
/// selection used by keyframe editing, copy and delete.
|
||||
fn effect_active(g: &PropGroup, layer: effectcraft_engine::project::LayerId, selected: &[(effectcraft_engine::project::LayerId, u64)]) -> bool {
|
||||
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
|
||||
if g.uid == uid {
|
||||
return true;
|
||||
}
|
||||
if depth >= 16 {
|
||||
return false;
|
||||
}
|
||||
g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(child) if contains(child, uid, depth + 1)))
|
||||
}
|
||||
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
|
||||
}
|
||||
|
||||
fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
|
||||
let comp = app.session.active_comp()?;
|
||||
let id = app.session.state.selected_layers.first()?;
|
||||
@@ -165,6 +180,10 @@ fn prop_row(
|
||||
let mut dims = vec![];
|
||||
let cy = r.center().y;
|
||||
let uid = prop.uid;
|
||||
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(r) {
|
||||
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": add})));
|
||||
}
|
||||
let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0));
|
||||
if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) {
|
||||
let resp = ui.interact(swr, egui::Id::new(("ec-sw", uid)), Sense::click());
|
||||
@@ -211,6 +230,8 @@ 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());
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||
let merge = format!("ec-{uid}");
|
||||
let set =
|
||||
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
|
||||
@@ -756,7 +777,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
.min(4);
|
||||
let x0 = r.min.x + 30.0;
|
||||
let cy = r.min.y + 17.0;
|
||||
let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect();
|
||||
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
|
||||
match chan_prop {
|
||||
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
|
||||
@@ -845,7 +866,7 @@ fn levels_editor(
|
||||
g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4)
|
||||
};
|
||||
if effect == "ec.color.levelsic" {
|
||||
let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect();
|
||||
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
|
||||
{
|
||||
app.ui.fx_levels_channel.insert(euid, n);
|
||||
@@ -972,7 +993,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Clicking empty space deselects the effects.
|
||||
let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click());
|
||||
if bg.clicked() {
|
||||
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| g.uid == *u)));
|
||||
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)]))));
|
||||
}
|
||||
let has_clip = !app.session.state.effect_clipboard.is_empty();
|
||||
let drag_id = egui::Id::new("ec-drag-effect");
|
||||
@@ -986,7 +1007,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
y += ROW + 2.0;
|
||||
let open = !app.ui.fx_closed.contains(&g.uid);
|
||||
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
|
||||
bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
|
||||
let active = effect_active(g, layer.id, &app.session.state.selected_props);
|
||||
bp.rect_filled(r, 0.0, if active { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
@@ -1028,6 +1050,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
egui::Id::new(("ec-hdr", g.uid)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name));
|
||||
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
|
||||
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
|
||||
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
@@ -1077,6 +1100,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
|
||||
y += eh;
|
||||
if er.max.y >= body.min.y && er.min.y <= body.max.y {
|
||||
// Curves, Levels and other custom editors do not use prop_row.
|
||||
// Clicking their controls must activate the owner as well.
|
||||
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) {
|
||||
actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid})));
|
||||
}
|
||||
match effect.as_str() {
|
||||
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
|
||||
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
@@ -1153,11 +1181,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Viewer hook
|
||||
|
||||
/// Collect point controls through visible parameter groups. Hidden optional slots do not
|
||||
/// leave stray viewport handles. The depth cap also bounds malformed project trees.
|
||||
fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx: &EvalCtx, depth: usize, out: &mut Vec<&'a Property>) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for node in &g.children {
|
||||
match node {
|
||||
Node::Prop(p) if matches!(p.ui, ParamUi::Point) && scope.shown(layer, ectx, &p.match_id) => out.push(p),
|
||||
Node::Group(child) if child.enabled && scope.shown(layer, ectx, &child.match_id) => {
|
||||
visible_points(child, &scope.sub(&child.match_id), layer, ectx, depth + 1, out)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
|
||||
/// interaction (so these controls sit on top):
|
||||
///
|
||||
/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point
|
||||
/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels;
|
||||
/// - while a crosshair pick is armed, its handle previews the cursor position without editing
|
||||
/// the property; the next click sets the point in layer space, and Esc cancels;
|
||||
/// - an eyedropper pick samples the frame's colour on click;
|
||||
/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls.
|
||||
///
|
||||
/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view).
|
||||
@@ -1172,6 +1218,7 @@ pub fn viewer_hook(
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut actions: Actions = vec![];
|
||||
let mut point_preview = None;
|
||||
if let Some(pick) = app.ui.fx_pick.clone() {
|
||||
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
|
||||
app.ui.fx_pick = None;
|
||||
@@ -1197,6 +1244,9 @@ pub fn viewer_hook(
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
let c = map.to_comp(hp);
|
||||
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
|
||||
if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() {
|
||||
point_preview = Some(hp);
|
||||
}
|
||||
if pick.kind == "color"
|
||||
&& keyer.is_none()
|
||||
&& let Some(img) = &app.viewer_image
|
||||
@@ -1238,23 +1288,51 @@ pub fn viewer_hook(
|
||||
app.ui.fx_pick = None;
|
||||
app.ui.status.clear();
|
||||
}
|
||||
} else if app.ui.viewer.show_layer_controls {
|
||||
}
|
||||
let picking = app.ui.fx_pick.is_some();
|
||||
let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point");
|
||||
if app.ui.viewer.show_layer_controls || point_pick.is_some() {
|
||||
// ⊕ controls for the point parameters of the selected effects.
|
||||
let sel = app.session.state.selected_props.clone();
|
||||
let mut sel = app.session.state.selected_props.clone();
|
||||
if let Some(pick) = &point_pick {
|
||||
sel.push((effectcraft_engine::project::LayerId(pick.layer), pick.prop));
|
||||
}
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) {
|
||||
for layer in ectx
|
||||
.comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| (selected.contains(&l.id) || point_pick.as_ref().is_some_and(|p| p.layer == l.id.0)) && l.is_active_at(ectx.time))
|
||||
{
|
||||
let Some(fx) = layer.effects() else { continue };
|
||||
let m = l2c(ectx, layer);
|
||||
// Masks are in layer space, effect points in effect space.
|
||||
let fm = super::viewer::from_effect_space(ectx, layer, m);
|
||||
for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) {
|
||||
if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
|
||||
let picked_prop = point_pick.as_ref().filter(|p| p.layer == layer.id.0).map(|p| p.prop);
|
||||
for g in fx.groups().filter(|g| g.enabled && effect_active(g, layer.id, &sel)) {
|
||||
// Puppet owns its pin handles and hit testing. Generic crosshairs would
|
||||
// overlap the pins and steal their selection/rotation/scale gestures.
|
||||
if matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_engine::effects::puppet::ID) {
|
||||
continue;
|
||||
}
|
||||
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
|
||||
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
|
||||
}
|
||||
let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect();
|
||||
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
|
||||
let mut pts = Vec::new();
|
||||
visible_points(g, &scope, layer, ectx, 0, &mut pts);
|
||||
// An explicit pick stays visible even when View > Show Layer Controls is off.
|
||||
if !app.ui.viewer.show_layer_controls {
|
||||
pts.retain(|pr| picked_prop == Some(pr.uid));
|
||||
}
|
||||
let screen: Vec<egui::Pos2> = pts
|
||||
.iter()
|
||||
.map(|pr| {
|
||||
if picked_prop == Some(pr.uid)
|
||||
&& let Some(pos) = point_preview
|
||||
{
|
||||
return pos;
|
||||
}
|
||||
let v = ectx.value(layer, pr).as_vec2();
|
||||
map.to_screen(fw::layer_to_comp(&fm, v))
|
||||
})
|
||||
@@ -1266,11 +1344,13 @@ pub fn viewer_hook(
|
||||
for (pr, s) in pts.iter().zip(screen) {
|
||||
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
|
||||
let id = egui::Id::new(("viewer-fx-pt", pr.uid));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
|
||||
// The picker owns the click. Preview handles must not steal it or start a drag.
|
||||
let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag()));
|
||||
let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE };
|
||||
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
|
||||
fw::draw_crosshair(painter, s, 5.0, col);
|
||||
app.auto.add(&format!("viewer.effectPoint.{}", pr.uid), hr, &pr.name);
|
||||
let Some(resp) = resp else { continue };
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Move);
|
||||
resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Keyboard Shortcuts: `shortcut_editor`). Every form runs an engine command with the collected parameters, so whatever a
|
||||
//! dialog does an agent can do with one `engine.execute`.
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -487,9 +487,11 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
}
|
||||
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
let st = &s.project.settings;
|
||||
// The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
|
||||
// tr_args: wrapping the assembled string in tr() would look up the backend's name.
|
||||
let gpu_label = match &s.accel {
|
||||
Some(a) => format!("Mercury GPU Acceleration ({})", a.name()),
|
||||
None => "Mercury GPU Acceleration (no GPU: software)".to_string(),
|
||||
Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
|
||||
None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
|
||||
};
|
||||
let depth = match st.bit_depth.label() {
|
||||
l if l.starts_with("16") => 1,
|
||||
@@ -765,7 +767,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| {
|
||||
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
for (i, fl) in f.fields.iter_mut().enumerate() {
|
||||
ui.label(&fl.label);
|
||||
ui.label(tr(&fl.label));
|
||||
let r = match &mut fl.kind {
|
||||
FieldKind::Number { value, speed } => {
|
||||
let dec = if *speed < 0.01 { 5 } else { 3 };
|
||||
@@ -801,7 +803,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
FieldKind::Note(text) => {
|
||||
ui.scope(|ui| {
|
||||
ui.set_width(220.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text.as_str()).small()).wrap())
|
||||
ui.add(egui::Label::new(egui::RichText::new(tr(text.as_str())).small()).wrap())
|
||||
})
|
||||
.inner
|
||||
.rect
|
||||
@@ -810,11 +812,11 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
egui::ComboBox::from_id_salt(("form", fl.key.as_str()))
|
||||
.selected_text(cur)
|
||||
.selected_text(tr(&cur))
|
||||
.width(240.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, (l, _)) in options.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, l);
|
||||
ui.selectable_value(sel, i, tr(l));
|
||||
}
|
||||
})
|
||||
.response
|
||||
@@ -879,11 +881,16 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the information dialog without abandoning a pending Settings transaction.
|
||||
pub(crate) fn close_info(app: &mut EffectcraftApp) {
|
||||
app.dialog = app.dialog_state.prefs_snapshot.is_some().then_some(Dialog::Settings);
|
||||
}
|
||||
|
||||
pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let (title, body) = app.dialog_state.info.clone();
|
||||
let mut close = false;
|
||||
super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| {
|
||||
ui.label(body);
|
||||
ui.label(tr(&body));
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
@@ -892,7 +899,7 @@ pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
});
|
||||
});
|
||||
if close {
|
||||
app.dialog = None;
|
||||
close_info(app);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,9 +190,10 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(opts[*sel]).show_ui(ui, |ui| {
|
||||
// The options are the English source strings; the automation id and label stay English.
|
||||
let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(tr(opts[*sel])).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, *o);
|
||||
ui.selectable_value(sel, i, tr(o));
|
||||
}
|
||||
});
|
||||
app.auto.add(id, r.response.rect, id);
|
||||
|
||||
@@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dock::PanelKind;
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
@@ -120,7 +121,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let per_char = layer.props.prop("text/perChar3d").is_some_and(|q| q.value.as_bool());
|
||||
let entries = animator_entries(per_char);
|
||||
let labels: Vec<String> = entries.iter().map(|(l, _)| l.clone()).collect();
|
||||
// The entries are the English source strings; the menu draws them translated.
|
||||
let labels: Vec<String> = entries.iter().map(|(l, _)| tr(l).to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) {
|
||||
match entries[i].1 {
|
||||
"" => {}
|
||||
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user