Merge pull request #598 from storytold/claude/community-pr-review-lwtrlr

Land 49 community PRs, with follow-ups
This commit is contained in:
BFlat authored and GitHub committed 2026-10-10 22:46:18 +07:00
commit 1446e27d85
134 files changed
+12299 -599

No files matched your search

+2 -2
View File
@@ -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
+17 -3
View File
@@ -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
+24 -24
View File
@@ -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
+5 -5
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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"
+17 -1
View File
@@ -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 |
+2
View 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.
+29 -6
View File
@@ -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"),
+138
View File
@@ -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(())
}
+36 -2
View File
@@ -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);
}
+540 -1
View File
@@ -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", &params, &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", &params, "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", &params, "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);
}
+10
View File
@@ -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"
+154
View File
@@ -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);
}
}
+80
View File
@@ -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())
}
}
+66 -2
View File
@@ -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() {
+13
View File
@@ -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
+21 -19
View File
@@ -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),
+121 -4
View File
@@ -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);
+14 -2
View File
@@ -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"]}
+54 -4
View File
@@ -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: &params,
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]);
}
}
}
+57
View File
@@ -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");
}
}
+8 -1
View File
@@ -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.
+1 -1
View File
@@ -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;
}
+11
View File
@@ -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 {
+23 -11
View File
@@ -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")),
+10 -35
View File
@@ -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...",
+10
View 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
),
]
}
+154 -8
View File
@@ -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
),
+2
View File
@@ -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());
+281 -66
View File
@@ -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
),
+414
View File
@@ -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");
}
}
+22 -1
View File
@@ -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,
+202 -1
View File
@@ -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(&notes, "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);
}
}
+11 -3
View File
@@ -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"));
+272
View File
@@ -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))
}
+20
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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",
+40
View File
@@ -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");
}
+35
View File
@@ -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));
}
+149
View File
@@ -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());
}
+17
View File
@@ -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();
+63
View File
@@ -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();
+16
View File
@@ -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));
}
+16
View File
@@ -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");
}
+3 -2
View File
@@ -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 {
+3 -1
View File
@@ -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);
}
+4 -2
View File
@@ -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),
+12
View File
@@ -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");
}
+7 -1
View File
@@ -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)
+3 -1
View File
@@ -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 {
+16
View File
@@ -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);
}
}
}
}
+196
View File
@@ -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));
}
+1
View File
@@ -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 }
+107
View File
@@ -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);
}
}
}
+56 -6
View File
@@ -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::*;
+209
View File
@@ -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());
}
}
+1
View File
@@ -17,6 +17,7 @@
mod convert;
pub mod exr_channels;
mod gif_anim;
mod layered;
pub use layered::vector_doc;
mod pool;
+84 -23
View File
@@ -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);
+6
View File
@@ -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());
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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![
+3 -1
View File
@@ -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> {
+8 -2
View File
@@ -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
View File
@@ -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(())
}
}
+29 -2
View File
@@ -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
View File
@@ -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;
+183
View File
@@ -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));
}
+83
View File
@@ -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());
}
+29 -5
View File
@@ -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]);
},
+165
View File
@@ -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
View File
@@ -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");
}
+73 -2
View File
@@ -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));
}
}
}
+92 -2
View File
@@ -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;
+14 -1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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, &params) {
@@ -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()
+4
View File
@@ -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"
}
+5 -3
View File
@@ -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");
});
}
+12 -8
View File
@@ -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)
}
+5 -4
View File
@@ -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));
+94 -14
View File
@@ -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));
+16 -9
View File
@@ -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);
}
}
+3 -2
View File
@@ -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);
+3 -1
View File
@@ -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