mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
#1388: integrate raster clipping and preserve whole-layer consumers
This commit is contained in:
commit
38e7b5e16b
1420 files changed
+248977
-60542
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -0,0 +1,28 @@
|
||||
name: CI (macOS)
|
||||
|
||||
# The macOS leg of the gate: `cargo xtask ci` on Apple silicon, through the suite's shared gate
|
||||
# workflow. The Linux gate (ci.yml) has no real GPU and runs on x86, so GPU tests on Metal and
|
||||
# results that depend on the platform's math library first ran on a contributor's Mac.
|
||||
#
|
||||
# On pushes to main, not on every pull request: macOS runners are scarce. A newer push cancels the
|
||||
# older run, so main is checked at its latest commit. Pull requests that change this file run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths: [.github/workflows/ci-macos.yml]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-macos-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
os: '["macos-15"]'
|
||||
timeout-minutes: 120
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev libasound2-dev
|
||||
@@ -2,12 +2,10 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
@@ -15,6 +13,8 @@ on:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -22,3 +22,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
@@ -10,21 +10,29 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-title: EffectCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -40,3 +48,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Track shared CI improvements on main.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
timeout-minutes: 120
|
||||
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
secrets: inherit
|
||||
@@ -33,3 +33,5 @@ jobs:
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
install-relative-path: Programs/ArtCraft/EffectCraft
|
||||
install-scope: perUser
|
||||
@@ -5,3 +5,7 @@ target/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
__pycache__/
|
||||
|
||||
# After Effects test corpus fetched by `cargo xtask aep-corpus`
|
||||
/corpus/
|
||||
@@ -1,3 +1,56 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# EffectCraft — rules for agents and contributors
|
||||
|
||||
These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this
|
||||
@@ -9,7 +62,7 @@ People trust EffectCraft with hours of work. A malformed project, PSD, SVG, Lott
|
||||
expression or script, a broken plug-in, a bad control-channel or MCP argument, a corrupt preferences file
|
||||
or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work.
|
||||
**This rule outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before
|
||||
building on top of it. Full standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
building on top of it.
|
||||
|
||||
- **Fail with `Result<T, E>`** through the crate's error type and propagate with `?`; add context
|
||||
(`map_err`, an error variant) rather than discarding it. Lenient readers fall back and record the repair.
|
||||
@@ -102,12 +155,23 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
|
||||
## 3. Engineering rules
|
||||
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
|
||||
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are layer time.
|
||||
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
|
||||
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
|
||||
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
|
||||
- `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`). For UI work, look at the result, including the headless `snapshot` example.
|
||||
- `plan/` is an optional gitignored notebook (`plan/aftereffects/` may hold reference notes and captures). Do not ask for it, do not stop when it is absent, and never commit those captures.
|
||||
- Read [`docs/gaps.md`](docs/gaps.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md) before choosing work. Fidelity, reliability and stability (G1–G3) come before new features.
|
||||
- Some workflow files still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task.
|
||||
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus).
|
||||
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
|
||||
as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
@@ -116,7 +180,7 @@ as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
- [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next
|
||||
- [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work
|
||||
- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md)
|
||||
- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting)
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things
|
||||
- [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline
|
||||
- [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks
|
||||
@@ -134,11 +198,13 @@ as PhotoCraft does with `cargo xtask corpus`.
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
+26
-2
@@ -1,9 +1,28 @@
|
||||
# Attribution
|
||||
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has
|
||||
a `<file>.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe
|
||||
iconography, images or other Adobe assets; open licences only; every asset attributed.
|
||||
`cargo xtask assets` checks that this index and the sidecars cover every asset in the repository.
|
||||
The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded
|
||||
by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the
|
||||
`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts.
|
||||
|
||||
## Bundled dependency fonts
|
||||
|
||||
These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at
|
||||
`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository.
|
||||
egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and
|
||||
`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices
|
||||
and licence texts are in [`NOTICE`](NOTICE).
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) |
|
||||
|
||||
## Asset files
|
||||
|
||||
@@ -15,6 +34,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
|
||||
| `assets/fonts/NotoSansSymbols2-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/symbols | OFL-1.1 (`assets/fonts/OFL-NotoSansSymbols2.txt`) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
|
||||
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
@@ -44,6 +64,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
|
||||
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
|
||||
|
||||
Two small After Effects test projects of `crates/aep` are in this repository: `crates/aep/tests/fixtures/synthetic.aep` (original work, made with After Effects 2026 from own data, working space None, the embedded colour profile removed; MIT OR Apache-2.0, with its `.attribution` sidecar) and `crates/aep/tests/fixtures/ai-mask-3099.aep` (original work, made with After Effects 2026 from four hand-written one-page PDFs named `.ai`, footage paths and host name neutralised, the embedded colour profiles replaced by stubs made with `crates/aep/examples/stubicc.rs`; MIT OR Apache-2.0, with its `.attribution` sidecar). The other After Effects test projects of `crates/aep` are not in this repository. They are original work of an EffectCraft contributor, generated with After Effects 2026 as own test data (MIT OR Apache-2.0, each file with its `.attribution` sidecar); they are in [storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus) with their sidecars, fetched by `cargo xtask aep-corpus` (commit-pinned, SHA-256-verified), and the tests read them only from the folder named by `EFFECTCRAFT_AEP_FIXTURES` (opt-in). Their embedded colour profiles are replaced by stubs made with `crates/aep/examples/stubicc.rs` (a header and a description, no colour data), so no vendor profile data is in them.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Screenshots of EffectCraft itself, rendered headlessly, showing only procedurally generated demo content.
|
||||
@@ -69,13 +91,15 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids
|
||||
and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`).
|
||||
- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker):
|
||||
`crates/engine/presets/behaviors.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
(sidecars `.attribution`).
|
||||
- Bundled extension (ScriptUI panel) with its twelve built-in ease presets:
|
||||
`extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`).
|
||||
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
|
||||
- Example effect plug-in: `examples/plugins/posterize-bands` (WebAssembly ABI 1).
|
||||
|
||||
## Trained models (optional downloads, not bundled)
|
||||
|
||||
|
||||
@@ -1,30 +1 @@
|
||||
# EffectCraft — instructions for agents
|
||||
|
||||
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
|
||||
1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`.
|
||||
3. Read [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked, and the prioritised workstreams; the numbers are in [`docs/target-app-parity.md`](docs/target-app-parity.md). Fidelity, reliability and stability (G1–G3) come before new features.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
|
||||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
|
||||
- **Never crash** (AGENTS.md "Never crash"; outranks feature work: a crash loses people's work). Non-test code has no `unwrap()`/`expect()`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` and no `unsafe`: return `Result` and use `?`, `ok_or`, `let … else`. Input-derived indices, slices and arithmetic use `get()` and checked/saturating math; recursion is depth-limited; locks handle poisoning. The app's last-resort guard turns an escaped panic into an error and keeps the document. Every crash fix gets a regression test. Standard: [craftrules `standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **Clean-room.** Behaviour and public docs only. No GPL/LGPL/AGPL code. ffmpeg only as an external test oracle.
|
||||
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
|
||||
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
|
||||
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**.
|
||||
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
|
||||
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
|
||||
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen).
|
||||
- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l <windowid>`); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
|
||||
@AGENTS.md
|
||||
@@ -7,12 +7,75 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
|
||||
|
||||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
|
||||
On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA
|
||||
pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime
|
||||
library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile
|
||||
ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking
|
||||
error; `cargo xtask ci` probes pkg-config up front and names the package:
|
||||
|
||||
```sh
|
||||
sudo dnf install alsa-lib-devel # Fedora
|
||||
sudo apt install libasound2-dev # Debian / Ubuntu
|
||||
sudo pacman -S alsa-lib # Arch
|
||||
```
|
||||
|
||||
If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc`
|
||||
whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs
|
||||
that variable set.
|
||||
|
||||
```sh
|
||||
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
|
||||
cargo test --workspace
|
||||
cargo xtask ci # what every commit must pass
|
||||
```
|
||||
|
||||
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
|
||||
still name the file and line, but a debugger cannot show local variables. For an interactive
|
||||
debugger session use the `dev-full` profile, which is the same build with full debug info:
|
||||
|
||||
```sh
|
||||
cargo run --profile dev-full -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
The MSVC toolchain, rustup's default on Windows, is what the releases use: with the Visual Studio
|
||||
C++ build tools installed, the commands above work as they are.
|
||||
|
||||
The GNU toolchain (`x86_64-pc-windows-gnu`) needs a current mingw-w64 GCC and binutils first on
|
||||
`PATH`. They provide the linker and the Windows import libraries, such as `libshlwapi.a` for
|
||||
opening links from the app. With MSYS2:
|
||||
|
||||
```powershell
|
||||
C:\msys64\usr\bin\pacman.exe -S --needed mingw-w64-x86_64-gcc
|
||||
$env:PATH = "C:\msys64\mingw64\bin;$env:PATH"
|
||||
cargo +stable-x86_64-pc-windows-gnu run -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
An older MinGW earlier on `PATH` can break the build: CodeBlocks' GCC 8.1 fails to link
|
||||
`boa_engine` ([#1882](https://github.com/storytold/effectcraft/issues/1882)).
|
||||
|
||||
Rust's bundled LLD and self-contained libraries don't include every import library EffectCraft
|
||||
needs, so on their own they stop with `unable to find library` for `-lshlwapi`, `-lktmw32`,
|
||||
`-lpropsys` and `-lversion` ([#1886](https://github.com/storytold/effectcraft/issues/1886)). To
|
||||
link that way, copy those four from mingw-w64 into a folder of its own and add that folder to the
|
||||
search path in your own Cargo config (`%USERPROFILE%\.cargo\config.toml`). Don't add all of
|
||||
mingw-w64's `lib` folder: its C runtime clashes with Rust's bundled one
|
||||
(`undefined symbol: DllEntryPoint`).
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force C:\gnu-import-libs
|
||||
foreach ($lib in "shlwapi", "ktmw32", "propsys", "version") {
|
||||
Copy-Item "C:\msys64\mingw64\lib\lib$lib.a" C:\gnu-import-libs\
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/gnu-import-libs"]
|
||||
```
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
|
||||
|
||||
Generated
+1516
-373
File diff suppressed because it is too large.
Load diff
+76
-29
@@ -4,15 +4,19 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/effectcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-ecproj-format = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "ca1b5e2edb8568335c6c14f390307bd5beae8238" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -25,6 +29,7 @@ effectcraft-text = { path = "crates/text" }
|
||||
effectcraft-effects = { path = "crates/effects" }
|
||||
effectcraft-track = { path = "crates/track" }
|
||||
effectcraft-segment = { path = "crates/segment" }
|
||||
effectcraft-sam2 = { path = "crates/sam2" }
|
||||
effectcraft-model = { path = "crates/model" }
|
||||
effectcraft-render = { path = "crates/render" }
|
||||
effectcraft-gpu = { path = "crates/gpu" }
|
||||
@@ -35,6 +40,7 @@ effectcraft-lottie = { path = "crates/lottie" }
|
||||
effectcraft-interchange = { path = "crates/interchange" }
|
||||
effectcraft-pdf = { path = "crates/pdf" }
|
||||
effectcraft-psd = { path = "crates/psd" }
|
||||
effectcraft-aep = { path = "crates/aep" }
|
||||
effectcraft-vp9enc = { path = "crates/vp9enc" }
|
||||
effectcraft-svg = { path = "crates/svg" }
|
||||
effectcraft-engine = { path = "crates/engine" }
|
||||
@@ -46,12 +52,15 @@ effectcraft-script = { path = "crates/script" }
|
||||
effectcraft-hevcenc = { path = "crates/hevcenc" }
|
||||
effectcraft-av1enc = { path = "crates/av1enc" }
|
||||
effectcraft-plugin = { path = "crates/plugin" }
|
||||
effectcraft-helper-host = { path = "crates/helper-host" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
# std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there).
|
||||
web-time = "1"
|
||||
@@ -70,44 +79,52 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
exr = "1.74"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
same-file = "1.0"
|
||||
tempfile = "3.27"
|
||||
|
||||
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
|
||||
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# encoders + muxer (crates/export)
|
||||
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# container headers read for AAC channel layouts (crates/media)
|
||||
filmcraft-matroska = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-mpegts = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency)
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only)
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
# timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only)
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" }
|
||||
# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned
|
||||
# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4ba1ee2f1cd13cb86a4239aee2730a3d9f5" }
|
||||
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
bytemuck = "1"
|
||||
pollster = "0.4"
|
||||
pollster = "1"
|
||||
half = "2.7"
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
# Shared UI and the complete egui family select reviewed immutable commits below.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
|
||||
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
cpal = "0.16"
|
||||
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
|
||||
@@ -124,22 +141,36 @@ sys-locale = "0.3"
|
||||
# filmcraft-prores = { path = "../filmcraft/crates/prores" }
|
||||
# filmcraft-aac = { path = "../filmcraft/crates/aac" }
|
||||
# filmcraft-isobmff = { path = "../filmcraft/crates/isobmff" }
|
||||
# filmcraft-matroska = { path = "../filmcraft/crates/matroska" }
|
||||
# filmcraft-mpegts = { path = "../filmcraft/crates/mpegts" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
needless_range_loop = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Same settings as `dev`, but with full debug info for interactive debugger sessions: local
|
||||
# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging;
|
||||
# everything else stays on the fast default. See CONTRIBUTING.md, "Setup".
|
||||
[profile.dev-full]
|
||||
inherits = "dev"
|
||||
debug = true
|
||||
|
||||
# Pixel hot loops: keep debug builds interactive.
|
||||
[profile.dev.package.effectcraft-raster]
|
||||
@@ -211,4 +242,20 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
@@ -12,11 +12,311 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Bundled fonts
|
||||
|
||||
EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from
|
||||
assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision
|
||||
6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack,
|
||||
Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts`
|
||||
and `monochrome_emoji_fonts` features; no font file from that dependency is added to
|
||||
this repository. The complete copyright notices and licence texts required for all of
|
||||
these fonts follow.
|
||||
|
||||
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2
|
||||
(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf).
|
||||
Copyright notices:
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
Copyright 2013 Google Inc. All Rights Reserved.
|
||||
|
||||
The fonts in assets/fonts/ also keep their upstream licence files at
|
||||
assets/fonts/OFL-<family>.txt. The complete licence text follows.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu Font Licence 1.0
|
||||
|
||||
Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice:
|
||||
|
||||
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Hack: MIT License and Bitstream Vera License
|
||||
|
||||
Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
MIT License (egui-icons and emoji-icon-font)
|
||||
|
||||
Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent
|
||||
emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -8,15 +8,19 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png" width="128" height="128" alt="EffectCraft icon: an engraved unicorn on a pink tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">EffectCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.</b>
|
||||
<b>Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A free, open-source compositor in the spirit of After Effects: compositions, layers,
|
||||
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
A free, open-source compositor: compositions, layers,
|
||||
keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
Windows and Linux, and in the browser. Young, moving fast, and already usable.
|
||||
</p>
|
||||
|
||||
@@ -24,6 +28,9 @@
|
||||
<img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-e0368f?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_effectcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/effectcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -64,27 +71,29 @@
|
||||
## What EffectCraft is
|
||||
|
||||
EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing
|
||||
people reach for After Effects to do. You build a composition out of layers (solids, shapes,
|
||||
motion designers and VFX artists need. You build a composition out of layers (solids, shapes,
|
||||
text, footage, other compositions), animate their properties with keyframes, stack effects on
|
||||
them and render the result.
|
||||
|
||||
The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project,
|
||||
Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and
|
||||
then to go further in a few places where it matters to us:
|
||||
The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline,
|
||||
Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph,
|
||||
Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in
|
||||
a few places where it matters to us:
|
||||
|
||||
- **Lottie import and export built in**, so animations can go straight to the web and apps
|
||||
without a plugin.
|
||||
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
|
||||
in version control.
|
||||
- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
|
||||
command registry, so the same actions are reachable from a command line, a JSON control
|
||||
channel and an MCP server for agents.
|
||||
- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as
|
||||
commands. Engine commands are available through the CLI, JSON control channel and MCP;
|
||||
UI-only commands use the frontend dispatcher and can be invoked through the control channel
|
||||
or an attached MCP bridge.
|
||||
- **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and
|
||||
EffectCraft's own VP9, AV1, HEVC and Opus encoders.
|
||||
|
||||
## Animate
|
||||
|
||||
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
|
||||
The panels, menus and shortcuts follow standard motion design conventions:
|
||||
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
|
||||
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
|
||||
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
|
||||
@@ -102,7 +111,7 @@ or into the Timeline, between layers and at the time you point to.
|
||||
feather, expansion and vertex editing in the viewer.
|
||||
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
|
||||
Character and Paragraph panels, and text animators with range selectors.
|
||||
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
|
||||
- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`,
|
||||
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
|
||||
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
|
||||
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
|
||||
@@ -118,23 +127,19 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
## Effects
|
||||
|
||||
306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
|
||||
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, Perspective Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**simulation** (Particle Emitter 3D, Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, Glass Relief…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
**audio** (Reverb, Parametric EQ, Delay, Stereo Mixer…), text, transitions, utility and expression
|
||||
controls. All nine **Layer Styles** (Drop Shadow, Inner/Outer Glow, Bevel and Emboss, Satin,
|
||||
overlays, Stroke) with Global Light. Preview plays audio in sync, with meters and waveforms.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/effectcraft-effects.png" alt="A CC Particle World burst with Glow, and the Effect Controls panel listing the particle system's parameters" width="100%">
|
||||
</p>
|
||||
|
||||
## 3D
|
||||
|
||||
Classic 3D the way After Effects does it: 3D layers with orientation and material options,
|
||||
Classic 3D compositing: 3D layers with orientation and material options,
|
||||
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
|
||||
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
|
||||
tools, and Front, Top, Left and Custom views.
|
||||
@@ -145,9 +150,9 @@ tools, and Front, Top, Left and Custom views.
|
||||
|
||||
## Export
|
||||
|
||||
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
|
||||
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
|
||||
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
|
||||
overflow and render logs. The same queue runs from the command line. Every encoder is pure
|
||||
@@ -165,8 +170,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
|
||||
|
||||
## Built for agents
|
||||
|
||||
Everything you can do from a menu is a command with an id, and agents can reach every one of
|
||||
them:
|
||||
Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands
|
||||
need the running app's frontend dispatcher: complete menu access requires the app's control
|
||||
channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands,
|
||||
but not UI-only commands or bridge-only `ui_*` tools.
|
||||
|
||||
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
|
||||
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
|
||||
@@ -219,25 +226,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
|
||||
exists, including all of its effects, but existing is not the same as behaving exactly like After
|
||||
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
|
||||
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
|
||||
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
|
||||
exists, including 308 effects, but breadth is not the same as full maturity across every edge
|
||||
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
|
||||
for production client work. In particular:
|
||||
|
||||
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
|
||||
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
|
||||
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
|
||||
After Effects automatically, so please report differences.
|
||||
- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
|
||||
project formats and After Effects SDK plug-ins are not supported.
|
||||
- **Fidelity is still being refined.** Automated rendering tests currently focus on small
|
||||
compositions, so please report visual discrepancies or unexpected behaviour.
|
||||
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
|
||||
bugs, which we're fixing first.
|
||||
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
|
||||
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
|
||||
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
|
||||
3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning.
|
||||
|
||||
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
|
||||
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
|
||||
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
|
||||
[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
EffectCraft is in **alpha**: about 91% of the features tracked in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the
|
||||
way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md)
|
||||
every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
|
||||
send us right now.
|
||||
|
||||
@@ -245,8 +253,8 @@ send us right now.
|
||||
|
||||
- **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on
|
||||
top and nothing underneath depends on it.
|
||||
- **Clean room.** We work from After Effects' public documentation and how it behaves, never
|
||||
from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules
|
||||
- **Clean room.** We work from public documentation and observable behavior, never
|
||||
from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules
|
||||
are in [AGENTS.md](AGENTS.md).
|
||||
- **Private by default.** No telemetry, and no network access unless you ask for it.
|
||||
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
|
||||
@@ -266,9 +274,13 @@ 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. The installer asks where to install EffectCraft
|
||||
(Program Files by default), and later versions install into the same folder. To install silently
|
||||
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
|
||||
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
|
||||
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). 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"`.
|
||||
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
|
||||
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -328,8 +340,17 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
@@ -368,8 +389,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+40
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-11-05 · **Change:** minor (ProRes alpha final-run conformance; previously: features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This page is the one-page summary; the evidence is in
|
||||
@@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and
|
||||
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
|
||||
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
|
||||
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
|
||||
@@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b
|
||||
|
||||
## Languages
|
||||
|
||||
UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
|
||||
| Language | Status | % |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial (menus, part of the panels) | 29% |
|
||||
| Spanish | menus only | 16% |
|
||||
| Simplified Chinese | partial (menus, most of the panels) | 46% |
|
||||
| Spanish | menus only | 29% |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | menus only | 16% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 26% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 34% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | partial (menus, part of the panels) | 27% |
|
||||
| Japanese | partial (menus, most of the panels) | 46% |
|
||||
| German | menus only | 16% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed), Russian (menus only, 15%) and Swedish (menus, panels and effect names; not yet measured). No other language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -117,10 +117,37 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-05: ProRes 4444 and XQ exports write the required run after the final alpha
|
||||
difference. The FilmCraft encoder pin fixes alpha movies rejected by native macOS
|
||||
decoding; an export regression checks the stored bytes for both profiles, alpha modes,
|
||||
and motion-blur settings (#293). Resolve itself still needs confirmation.
|
||||
- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel,
|
||||
Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items
|
||||
drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one
|
||||
animation preset can hold several (#2552). The Composition panel pans while a mask vertex is
|
||||
held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget
|
||||
(#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick
|
||||
between frames (#2593). ScriptUI drags keep their control past the window edge with
|
||||
`MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585).
|
||||
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
|
||||
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
|
||||
starts off, so existing shapes behave as before (#2489).
|
||||
- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
|
||||
`layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
|
||||
rotation of the paths above it by random amounts that change over time, up to the Transform
|
||||
values it holds. The copies of a Repeater above it wiggle independently as far as its
|
||||
Correlation allows; with the Repeater below it, they wiggle together.
|
||||
- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified
|
||||
Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus,
|
||||
tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774:
|
||||
Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese
|
||||
panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is
|
||||
46% with its 307 effect names; the overall share is ≈ 24%.
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
@@ -132,7 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
design — alongside its 618/618 menus.
|
||||
- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5
|
||||
release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs
|
||||
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
restructured: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
|
||||
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
|
||||
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
|
||||
@@ -154,11 +181,13 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size |
|
||||
| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
|
||||
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | major | Progress docs restructured: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||
@@ -20,6 +20,9 @@ effectcraft-ui-egui = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-gpu = { workspace = true }
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
effectcraft-segment = { workspace = true }
|
||||
effectcraft-sam2 = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
cpu-time = "1"
|
||||
@@ -6,15 +6,18 @@
|
||||
//! effectcraft-cli exec <command-id> [--params JSON] run one command
|
||||
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
|
||||
//! effectcraft-cli run --args-file F the same, one argument per line of F
|
||||
//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin
|
||||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! [--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]
|
||||
//! [--exr-compression none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab] [--exr-depth half|float]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
|
||||
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
|
||||
@@ -25,13 +28,15 @@
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings
|
||||
//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel)
|
||||
//! effectcraft-cli bench --roto [--frames N] [--sam2 model.safetensors] [--mobilesam mobile_sam.pt] [--fixes] [--clip FRAMES --truth MASKS] [--json] Roto Brush trackers on synthetic clips with known mattes
|
||||
//! (classical engine, MobileSAM and SAM 2.1 (any installed size) when installed; IoU, flicker, re-appearance, seconds per frame)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--autosave | --bridge PORT] MCP server on stdio
|
||||
//!
|
||||
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
|
||||
//! Saving: --save (back to --project) | --save-as F.ecproj
|
||||
//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Mercury Software Only)
|
||||
//! GPU: --gpu renders on the GPU compositor (GPU Acceleration) when an adapter exists;
|
||||
//! the default is the CPU (Software Rendering)
|
||||
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
|
||||
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
|
||||
//! ```
|
||||
@@ -43,6 +48,7 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
mod project_lock;
|
||||
mod template;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
@@ -96,6 +102,11 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
|
||||
run <id> <json> [<id> <json> ...] run several commands in order
|
||||
run --args-file F the same, one argument per line of F (no command-line length limit)
|
||||
run --commands FILE|- steps from FILE, or standard input for -: a JSON array of
|
||||
{\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one
|
||||
such object per line (JSON Lines); at most 64 MiB and 100000 steps.
|
||||
Repeatable; run after the <id> <json> pairs and --args-file
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
@@ -104,9 +115,13 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
[--exr-compression C] [--exr-depth half|float] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus;
|
||||
--exr-compression none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab
|
||||
(default zip; pxr24 on float, b44/b44a on half and dwaa/dwab are lossy),
|
||||
--exr-depth (default float): OpenEXR)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
|
||||
@@ -117,6 +132,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
bench --ops [--small] [--layers N] [--comps N] [--footage N]
|
||||
everyday operations on a large generated project: startup, open,
|
||||
save, auto-save, edits + undo/redo, timeline, Project panel
|
||||
bench --roto [--frames N] [--sam2 FILE] [--mobilesam FILE] [--fixes] [--clip FRAMES_DIR --truth MASKS_DIR] [--json]
|
||||
Roto Brush trackers on synthetic clips with known mattes: movement,
|
||||
lighting change, occlusion, leaving and re-entering the frame
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
(app.project, comps, layers, properties…); prints writeLn
|
||||
output and the result; errors exit 1 with file:line:col
|
||||
@@ -130,6 +148,11 @@ options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge
|
||||
/// Options that take a value.
|
||||
const VALUED: &[&str] = &[
|
||||
"--n",
|
||||
"--clip",
|
||||
"--truth",
|
||||
"--frames",
|
||||
"--sam2",
|
||||
"--mobilesam",
|
||||
"--play",
|
||||
"--layers",
|
||||
"--comps",
|
||||
@@ -148,6 +171,8 @@ const VALUED: &[&str] = &[
|
||||
"--expression",
|
||||
"--depth",
|
||||
"--eval",
|
||||
"--args-file",
|
||||
"--commands",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
@@ -170,6 +195,8 @@ const VALUED: &[&str] = &[
|
||||
"--webm-codec",
|
||||
"--audio-bitrate",
|
||||
"--opus-app",
|
||||
"--exr-compression",
|
||||
"--exr-depth",
|
||||
// mcp
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
@@ -178,6 +205,7 @@ const VALUED: &[&str] = &[
|
||||
/// Options without a value. Any other `--option` is a usage error.
|
||||
const FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--fixes",
|
||||
"--save",
|
||||
"--demo",
|
||||
"--empty",
|
||||
@@ -190,6 +218,7 @@ const FLAGS: &[&str] = &[
|
||||
"--work-area",
|
||||
"--queue",
|
||||
"--ops",
|
||||
"--roto",
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
@@ -276,6 +305,9 @@ fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<St
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Warnings and errors to standard error (`RUST_LOG` overrides), so a GPU that declines
|
||||
// frames or runs out of memory says so instead of silently rendering on the CPU.
|
||||
effectcraft_engine::logging::install_with("warn");
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
@@ -331,6 +363,87 @@ impl From<String> for Failure {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Most steps one `run --commands` takes.
|
||||
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||
|
||||
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||
/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from
|
||||
/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum.
|
||||
fn read_commands(source: &str) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
let read = if source == "-" {
|
||||
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||
} else {
|
||||
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||
};
|
||||
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||
}
|
||||
|
||||
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||
/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank
|
||||
/// lines skipped). `first` is the run's index of the first of them, which an error names with the
|
||||
/// entry's place in the text.
|
||||
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<(String, Value)>, String> {
|
||||
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||
// Each entry with where it is in the text: its line for JSON Lines.
|
||||
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||
if text.trim_start().starts_with('[') {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||
if items.len() > MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||
} else {
|
||||
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
if entries.len() == MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||
entries.push((Some(n + 1), v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, (line, v))| {
|
||||
command_step(v).map_err(|e| {
|
||||
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||
fn command_step(v: Value) -> Result<(String, Value), String> {
|
||||
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) };
|
||||
let id = match (o.remove("command"), o.remove("id")) {
|
||||
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||
(None, None) => return Err("no `command`".into()),
|
||||
_ => return Err("`command` must be a string, a command id".into()),
|
||||
};
|
||||
let params = match o.remove("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p,
|
||||
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||
};
|
||||
if let Some(key) = o.keys().next() {
|
||||
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||
}
|
||||
Ok((id, params))
|
||||
}
|
||||
|
||||
fn usage_err<T>(m: &str) -> Result<T, Failure> {
|
||||
Err(Failure::Usage(m.to_string()))
|
||||
}
|
||||
@@ -353,7 +466,7 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
|
||||
}
|
||||
|
||||
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
|
||||
/// renderer setting, Mercury GPU Acceleration by default).
|
||||
/// renderer setting, GPU Acceleration by default).
|
||||
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
|
||||
let mut s = effectcraft_host::session();
|
||||
if args.flag("--autosave") {
|
||||
@@ -403,6 +516,7 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
|
||||
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, false)?;
|
||||
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
|
||||
let saved = maybe_save(&mut b, args)?;
|
||||
@@ -506,6 +620,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
(Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?,
|
||||
(None, None) => json!({}),
|
||||
};
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let r = b.exec(&id, params)?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
@@ -515,15 +630,20 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
"run" => {
|
||||
if args.pos.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs");
|
||||
let mut pairs = args.pos.clone();
|
||||
// Windows caps a command line at 32 767 characters: long batches come from a file.
|
||||
if let Some(f) = args.opt("--args-file") {
|
||||
let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
|
||||
pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
|
||||
}
|
||||
let pairs = args.pos.clone();
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
let sources = args.opts("--commands");
|
||||
if pairs.is_empty() && sources.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs or --commands FILE|-");
|
||||
}
|
||||
let mut steps = vec![];
|
||||
let mut i = 0;
|
||||
while i < pairs.len() {
|
||||
let id = &pairs[i];
|
||||
let id = pairs[i].clone();
|
||||
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
|
||||
Some(p) => {
|
||||
i += 1;
|
||||
@@ -532,9 +652,27 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
None => json!({}),
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if let Some(e) = failed_render(id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e}")));
|
||||
steps.push((id, params));
|
||||
}
|
||||
if sources.iter().filter(|s| **s == "-").count() > 1 {
|
||||
return usage_err("--commands - is given twice: standard input is read once");
|
||||
}
|
||||
for &source in &sources {
|
||||
let text = read_commands(source).map_err(Failure::Usage)?;
|
||||
steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?);
|
||||
}
|
||||
if steps.is_empty() {
|
||||
return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
|
||||
}
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
// The step's index closes the message: a generated list repeats ids, and callers match
|
||||
// the leading `<id>: `.
|
||||
for (n, (id, params)) in steps.into_iter().enumerate() {
|
||||
let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e} (step {n})")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
@@ -563,7 +701,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), value_text(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,6 +719,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone());
|
||||
let value = args.pos.get(3).map(|v| value_arg(v));
|
||||
let _lock = project_lock::for_save(args)?;
|
||||
let mut b = backend(args, true)?;
|
||||
let a =
|
||||
json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr});
|
||||
@@ -612,9 +751,8 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let out = args.opt("--out").unwrap_or("frame.png").to_string();
|
||||
let t0 = std::time::Instant::now();
|
||||
let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
|
||||
let f = b.render_to_file(comp.as_ref(), time, max_side, args.flag("--transparent"), &out)?;
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
|
||||
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
|
||||
if json_out {
|
||||
emit(&info, true);
|
||||
@@ -750,6 +888,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
("--rate-control", "rateControl"),
|
||||
("--webm-codec", "webmCodec"),
|
||||
("--opus-app", "opusApplication"),
|
||||
("--exr-compression", "exrCompression"),
|
||||
("--exr-depth", "exrBitDepth"),
|
||||
] {
|
||||
if let Some(v) = args.opt(flag) {
|
||||
p[key] = json!(v);
|
||||
@@ -775,6 +915,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
r["outputModuleSummary"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
let attempted: std::collections::HashSet<u64> = s.project.render_queue.iter().filter(|i| i.is_queued()).map(|i| i.id).collect();
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
@@ -813,7 +954,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let mut results = vec![];
|
||||
let mut failed = vec![];
|
||||
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
|
||||
for it in s.project.render_queue.iter().filter(|i| attempted.contains(&i.id)) {
|
||||
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
|
||||
match &it.status {
|
||||
RenderStatus::Done => {
|
||||
@@ -860,6 +1001,9 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
if args.flag("--ops") {
|
||||
return bench_ops(args);
|
||||
}
|
||||
if args.flag("--roto") {
|
||||
return bench_roto(args);
|
||||
}
|
||||
let mut s = effectcraft_host::session();
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})),
|
||||
@@ -882,6 +1026,117 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The model a bench option asks for: the file named on the command line, where failing to read or
|
||||
/// build it is an error saying why (not "not installed"); else the installed file, where a file that
|
||||
/// cannot be used just leaves the model out.
|
||||
fn bench_weights<T>(
|
||||
flag: &str,
|
||||
named: Option<&str>,
|
||||
installed: Option<std::path::PathBuf>,
|
||||
build: impl Fn(&[u8]) -> Result<T, String>,
|
||||
) -> Result<Option<T>, Failure> {
|
||||
match named {
|
||||
Some(path) => {
|
||||
let bytes = std::fs::read(path).map_err(|e| Failure::Error(format!("{flag} {path}: {e}")))?;
|
||||
build(&bytes).map(Some).map_err(|e| Failure::Error(format!("{flag} {path}: {e}")))
|
||||
}
|
||||
None => Ok(installed.and_then(|p| std::fs::read(p).ok()).and_then(|bytes| build(&bytes).ok())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `bench --roto`: the Roto Brush's trackers on synthetic clips whose true mattes are known.
|
||||
fn bench_roto(args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_segment::{Loaded, MOBILE_SAM, MaskModel};
|
||||
use effectcraft_track::roto::bench;
|
||||
let frames = args.num("--frames")?.unwrap_or(24.0).clamp(1.0, 240.0) as usize;
|
||||
let session = effectcraft_host::session();
|
||||
// `--mobilesam FILE` uses the weights at FILE (a file that cannot be used is an error);
|
||||
// otherwise the installed ones.
|
||||
let mobilesam: Option<std::sync::Arc<dyn MaskModel>> = bench_weights("--mobilesam", args.opt("--mobilesam"), session.model_path(&MOBILE_SAM), |bytes| {
|
||||
match effectcraft_segment::load(MOBILE_SAM.id, bytes)? {
|
||||
Loaded::Mask(m) => Ok(m),
|
||||
_ => Err("not a mask model".to_string()),
|
||||
}
|
||||
})?;
|
||||
if mobilesam.is_none() {
|
||||
note!("bench --roto: MobileSAM is not installed; comparing the classical engine only");
|
||||
}
|
||||
// `--sam2 FILE` uses the weights at FILE (a file that cannot be used is an error); otherwise
|
||||
// the installed ones.
|
||||
let installed_sam2 = effectcraft_sam2::mask::SIZES.iter().find_map(|m| session.model_path(m));
|
||||
let sam2: Option<std::sync::Arc<dyn MaskModel>> =
|
||||
bench_weights("--sam2", args.opt("--sam2"), installed_sam2, |bytes| effectcraft_sam2::mask::load(bytes).map(|m| m as std::sync::Arc<dyn MaskModel>))?;
|
||||
if sam2.is_none() {
|
||||
note!("bench --roto: no SAM 2.1 size is installed; no SAM 2 column");
|
||||
}
|
||||
// `--fixes`: count correction rounds before every frame reaches IoU 0.9 (up to 6 rounds, each up to two strokes).
|
||||
let fixes = args.flag("--fixes");
|
||||
let measure = |sc: &bench::Scenario, run: &dyn Fn(&bench::Scenario) -> Option<bench::Run>| if fixes { bench::with_fixes(sc, 0.9, 6, run) } else { run(sc) };
|
||||
let mut runs = vec![];
|
||||
// User clips pair unique filename stems, allowing JPEG frames with PNG truth masks.
|
||||
let scenarios = if let Some(dir) = args.opt("--clip") {
|
||||
let truth = args.opt("--truth").ok_or_else(|| Failure::Error("--clip needs --truth MASKS_DIR".into()))?;
|
||||
let name = std::path::Path::new(dir).file_name().map_or_else(|| "clip".to_string(), |n| n.to_string_lossy().chars().take(10).collect());
|
||||
vec![load_bench_clip(&name, dir, truth, frames).map_err(Failure::Error)?]
|
||||
} else {
|
||||
bench::scenarios(frames)
|
||||
};
|
||||
for sc in scenarios {
|
||||
runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "classical", None))));
|
||||
if let Some(m) = &mobilesam {
|
||||
runs.extend(measure(&sc, &|s| Some(bench::run_flow(s, "mobilesam", Some(m.as_ref())))));
|
||||
}
|
||||
if let Some(m) = &sam2 {
|
||||
let label = m.info().id.replacen("sam2.1-hiera-", "sam2-", 1);
|
||||
runs.extend(measure(&sc, &|s| bench::run_tracker(s, &label, m.as_ref())));
|
||||
}
|
||||
}
|
||||
if args.flag("--json") {
|
||||
say!("{}", serde_json::to_string_pretty(&runs).map_err(|e| Failure::Error(e.to_string()))?);
|
||||
} else {
|
||||
say!("{}", bench::table(&runs));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The image files of `dir` (png, jpg, jpeg), ordered by stem then full file name.
|
||||
fn image_files(dir: &str) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("cannot read {dir}: {e}"))?
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["png", "jpg", "jpeg"].contains(&e.to_ascii_lowercase().as_str())))
|
||||
.collect();
|
||||
files.sort_by(|a, b| a.file_stem().cmp(&b.file_stem()).then_with(|| a.file_name().cmp(&b.file_name())));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// A benchmark scenario from the user's frames and truth masks (at most `limit` frames, frames at most 480 px
|
||||
/// wide; a mask pixel above 127 is the object). Nothing is copied or kept.
|
||||
fn load_bench_clip(name: &str, frames_dir: &str, truth_dir: &str, limit: usize) -> Result<effectcraft_track::roto::bench::Scenario, String> {
|
||||
let (frames, masks) = (image_files(frames_dir)?, image_files(truth_dir)?);
|
||||
if frames.is_empty() || frames.len() != masks.len() {
|
||||
return Err(format!("{} frames in {frames_dir} but {} masks in {truth_dir}: they must match one to one", frames.len(), masks.len()));
|
||||
}
|
||||
if frames.iter().map(|p| p.file_stem()).ne(masks.iter().map(|p| p.file_stem()))
|
||||
|| frames.windows(2).chain(masks.windows(2)).any(|pair| pair.first().and_then(|p| p.file_stem()) == pair.get(1).and_then(|p| p.file_stem()))
|
||||
{
|
||||
return Err("frame and truth file names must match one to one, with unique stems (extensions may differ)".into());
|
||||
}
|
||||
let n = frames.len().min(limit.clamp(1, 240));
|
||||
let (mut imgs, mut truth) = (vec![], vec![]);
|
||||
for (f, m) in frames.iter().zip(&masks).take(n) {
|
||||
let rgba = image::open(f).map_err(|e| format!("{}: {e}", f.display()))?.to_rgba8();
|
||||
let scale = (480.0 / f64::from(rgba.width().max(1))).min(1.0);
|
||||
let (w, h) = (((f64::from(rgba.width()) * scale).round() as u32).max(1), ((f64::from(rgba.height()) * scale).round() as u32).max(1));
|
||||
let rgba = if (w, h) == rgba.dimensions() { rgba } else { image::imageops::resize(&rgba, w, h, image::imageops::FilterType::Triangle) };
|
||||
let mask = image::open(m).map_err(|e| format!("{}: {e}", m.display()))?.to_luma8();
|
||||
let mask = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Nearest);
|
||||
imgs.push(effectcraft_render::Image::from_rgba8(w, h, rgba.as_raw()));
|
||||
truth.push(mask.as_raw().iter().map(|v| u8::from(*v > 127)).collect());
|
||||
}
|
||||
effectcraft_track::roto::bench::scenario_from(name, imgs, truth)
|
||||
}
|
||||
|
||||
/// `bench --ops`: everyday operations on a large generated project (200 comps, 5,000 layers in
|
||||
/// the main comp, 300 footage items, 20-deep precomp nesting, expressions): startup to first
|
||||
/// frame, open, save, auto-save, small and large edits with undo/redo, first frame, timeline
|
||||
@@ -1181,14 +1436,14 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
/// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold"
|
||||
/// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of
|
||||
/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback;
|
||||
/// "auto" is Backend::Auto (Mercury GPU Acceleration) choosing per comp from measured times,
|
||||
/// "auto" is Backend::Auto (GPU Acceleration) choosing per comp from measured times,
|
||||
/// warm; "up/dn MB" are the GPU's uploads / readbacks per warm GPU frame.
|
||||
fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_render::Backend;
|
||||
@@ -1298,6 +1553,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flat property's value for the text listing: an enum's number with the label `options` gives it
|
||||
/// (#2928), since the numbers are 0-based where After Effects scripting counts from 1.
|
||||
fn value_text(p: &Value) -> String {
|
||||
let label = p["options"].as_array().and_then(|o| o.iter().find(|o| o["value"] == p["value"])).and_then(|o| o["label"].as_str());
|
||||
match label {
|
||||
Some(l) => format!("{} ({l})", p["value"]),
|
||||
None => p["value"].to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! One saving run at a time per project file.
|
||||
//!
|
||||
//! A `--save` run reads the project, edits it and writes the whole file back, so two at once would
|
||||
//! silently drop the edits of whichever saved first (#2851). Each run holds an OS lock from before
|
||||
//! its read until it exits; the next one waits, then reads what the first one saved. This is how
|
||||
//! filmcraft-cli does it (storytold/filmcraft#3926).
|
||||
//!
|
||||
//! The lock is on a file next to the project, not on the project: a save replaces the project's
|
||||
//! inode, and on Windows a locked file can't be read by other processes. The OS drops the lock when
|
||||
//! the process ends, however it ends, so a crash leaves nothing stale. The file itself stays, since
|
||||
//! removing it would let one run wait on the old file while another locks a new one.
|
||||
|
||||
use crate::{Args, Failure};
|
||||
use std::fs::{File, TryLockError};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Most bytes of the project's file name kept in the lock's name; the rest of the 255-byte limit
|
||||
/// is left for the leading dot and `.lock`.
|
||||
const MAX_NAME_BYTES: usize = 200;
|
||||
|
||||
fn lock_path(project: &Path) -> PathBuf {
|
||||
let name = project.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
project.with_file_name(format!(".{}.lock", &name[..name.floor_char_boundary(MAX_NAME_BYTES)]))
|
||||
}
|
||||
|
||||
/// Do two paths name one existing file?
|
||||
fn same_file(a: &str, b: &str) -> bool {
|
||||
a == b || matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
|
||||
}
|
||||
|
||||
/// The path this run saves over, when that is the file it opens. `--save-as` somewhere else replaces
|
||||
/// that file whatever it held, so there is no edit of its to lose.
|
||||
///
|
||||
/// The lock goes by this path as written, not by where a symlink points: runs that name the
|
||||
/// project the same way share one lock.
|
||||
///
|
||||
/// A missing project is an error here, before anything else, the same one opening it would give.
|
||||
/// If the run went on, another one could create the project before it is read, and this run would
|
||||
/// edit and save it with no lock. Checking first also means the paths compared below exist, so
|
||||
/// `canonicalize` tells exactly whether they are one file, however they are spelt. A project that
|
||||
/// exists but isn't a file gets no lock file beside it, and opening it reports why.
|
||||
fn saved_in_place(a: &Args) -> Result<Option<&Path>, String> {
|
||||
// `--bridge` with a project is a usage error, which `backend` reports
|
||||
let Some(project) = a.project.as_deref().filter(|_| a.opt("--bridge").is_none()) else { return Ok(None) };
|
||||
match Path::new(project).metadata() {
|
||||
Ok(meta) if meta.is_file() => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("cannot read {project}: {e}")),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
match a.opt("--save-as") {
|
||||
Some(target) if same_file(target, project) => Ok(Some(Path::new(target))),
|
||||
None if a.flag("--save") => Ok(Some(Path::new(project))),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock `project`, calling `waiting` first if another run holds the lock.
|
||||
fn lock(project: &Path, waiting: impl FnOnce()) -> std::io::Result<File> {
|
||||
let file = File::options().create(true).truncate(false).read(true).write(true).open(lock_path(project))?;
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
waiting();
|
||||
file.lock()?;
|
||||
}
|
||||
Err(TryLockError::Error(e)) => return Err(e),
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Take the project's lock if this run saves over the file it opens, or fail if the project is
|
||||
/// missing. Call it before opening the project and keep the result until the save is done. Blocks
|
||||
/// while another run holds the lock, which is fine here: nothing else is running yet. A lock that
|
||||
/// can't be had (a read-only folder, a file system without locks) is a warning and not an error, so
|
||||
/// that saving works where it did.
|
||||
pub(crate) fn for_save(a: &Args) -> Result<Option<File>, Failure> {
|
||||
let Some(project) = saved_in_place(a).map_err(Failure::Error)? else { return Ok(None) };
|
||||
let shown = project.display();
|
||||
let waiting = || {
|
||||
let _ = writeln!(std::io::stderr(), "effectcraft-cli: waiting for another run saving {shown}");
|
||||
};
|
||||
match lock(project, waiting) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "effectcraft-cli: warning: can't lock {shown} ({e}); another run saving it now could overwrite these edits");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(list: &[&str]) -> Args {
|
||||
Args::parse(list.iter().map(|s| s.to_string()).collect()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lock_sits_next_to_the_project() {
|
||||
assert_eq!(lock_path(Path::new("/a/b/p.ecproj")), Path::new("/a/b/.p.ecproj.lock"));
|
||||
assert_eq!(lock_path(Path::new("p.ecproj")), Path::new(".p.ecproj.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_project_name_still_gets_a_lock_name_the_file_system_takes() {
|
||||
for name in ["a".repeat(255), "é".repeat(127)] {
|
||||
let lock = lock_path(Path::new(&name));
|
||||
let lock = lock.to_str().unwrap();
|
||||
assert!(lock.len() <= 255, "{} bytes", lock.len());
|
||||
assert!(lock.ends_with(".lock"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The moment between this check and opening the project can't be hit on demand, so this pins
|
||||
/// the decision that closes it: a run never goes on to open a project that wasn't there, however
|
||||
/// its paths are spelt and whatever it saves.
|
||||
#[test]
|
||||
fn a_missing_project_stops_the_run_before_anything_opens_it() {
|
||||
let name = format!("ec-cli-gone-{}.ecproj", std::process::id());
|
||||
let gone = std::env::temp_dir().join(&name);
|
||||
let gone = gone.to_str().unwrap();
|
||||
let alias = std::env::temp_dir().join(".").join(&name);
|
||||
let alias = alias.to_str().unwrap();
|
||||
let dotted = format!("./{name}");
|
||||
// in a folder that doesn't exist either, so no part of either spelling can be resolved
|
||||
let new_dir = std::env::temp_dir().join(format!("ec-cli-new-dir-{}", std::process::id()));
|
||||
let in_new_dir = new_dir.join("p.ecproj");
|
||||
let in_new_dir = in_new_dir.to_str().unwrap();
|
||||
let in_new_dir_alias = new_dir.join(".").join("p.ecproj");
|
||||
let in_new_dir_alias = in_new_dir_alias.to_str().unwrap();
|
||||
for (project, list) in [
|
||||
(gone, &["exec", "--project", gone, "--save"][..]),
|
||||
(gone, &["exec", gone, "--save"]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", gone]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", alias]),
|
||||
(name.as_str(), &["exec", "--project", &name, "--save-as", &dotted]),
|
||||
(in_new_dir, &["exec", "--project", in_new_dir, "--save-as", in_new_dir_alias]),
|
||||
(gone, &["exec", "--project", gone, "--save-as", "other.ecproj"]),
|
||||
(gone, &["exec", "--project", gone]),
|
||||
] {
|
||||
let error = saved_in_place(&args(list)).unwrap_err();
|
||||
assert_eq!(error.split_once(": ").map(|(head, _)| head), Some(format!("cannot read {project}").as_str()), "{list:?}: {error}");
|
||||
}
|
||||
// a project with --bridge is a usage error, which `backend` reports; no project, no lock
|
||||
assert_eq!(saved_in_place(&args(&["exec", "--bridge", "9877", "--project", gone, "--save"])), Ok(None));
|
||||
assert_eq!(saved_in_place(&args(&["exec", "--demo", "--save-as", gone])), Ok(None));
|
||||
}
|
||||
}
|
||||
@@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import
|
||||
}
|
||||
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
|
||||
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
|
||||
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
|
||||
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the
|
||||
// wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs.
|
||||
s.edit("Template Duration", None, |project, _| {
|
||||
let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone();
|
||||
project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name;
|
||||
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
|
||||
let duration = comp.duration;
|
||||
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
Command::new(crate::binary())
|
||||
}
|
||||
|
||||
/// Run with `--json`; returns (exit code, parsed stdout).
|
||||
@@ -110,6 +110,299 @@ fn exec_set_get_with_saved_project() {
|
||||
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
|
||||
}
|
||||
|
||||
/// #2928: the text listing gives an enum's label next to its number (0-based, unlike After
|
||||
/// Effects scripting), the way `props --json` lists its `options`.
|
||||
#[test]
|
||||
fn props_lists_enum_labels() {
|
||||
let proj = tmp("enum-labels.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let anim = r#"{"layer":"t","properties":["opacity"]}"#;
|
||||
ok_json(&[
|
||||
"run",
|
||||
"comp.new",
|
||||
r#"{"name":"T","width":64,"height":36}"#,
|
||||
"layer.newText",
|
||||
r#"{"text":"AB","name":"t"}"#,
|
||||
"layer.addTextAnimator",
|
||||
anim,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
ok_json(&["set", "T", "t", "text/animators/animator/selectors/rangeSelector/advanced/shape", "1", p, "--save"]);
|
||||
let out = bin().args(["props", "T", "t", p]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let shape = text.lines().find(|l| l.contains("advanced/shape")).unwrap_or_else(|| panic!("no shape line in:\n{text}"));
|
||||
assert!(shape.trim_end().ends_with("enum 1 (Ramp Up)"), "{shape}");
|
||||
let units = text.lines().find(|l| l.contains("advanced/units")).unwrap_or_default();
|
||||
assert!(units.trim_end().ends_with("0 (Percentage)"), "{units}");
|
||||
let size = text.lines().find(|l| l.contains("transform/opacity")).unwrap_or_default();
|
||||
assert!(size.trim_end().ends_with("100.0"), "a number without options stays bare: {size}");
|
||||
}
|
||||
|
||||
/// A project in a new folder of its own, for the save lock tests (#2851).
|
||||
fn lock_test_project(tag: &str) -> (PathBuf, String) {
|
||||
let dir = tmp(&format!("lock-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proj = dir.join("p.ecproj").to_str().unwrap().to_string();
|
||||
ok_json(&["exec", "project.newFolder", r#"{"name":"start"}"#, "--empty", "--save-as", &proj]);
|
||||
(dir, proj)
|
||||
}
|
||||
|
||||
/// The lock of the project file `name`, held by this process.
|
||||
fn hold_lock(dir: &std::path::Path, name: &str) -> std::fs::File {
|
||||
let lock = std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(format!(".{name}.lock"))).unwrap();
|
||||
lock.lock().unwrap();
|
||||
lock
|
||||
}
|
||||
|
||||
/// Start a run that has to wait for a lock this process holds; returns once it says it is waiting.
|
||||
fn start_waiting_run(args: &[&str]) -> std::process::Child {
|
||||
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut stderr = BufReader::new(child.stderr.take().unwrap());
|
||||
let mut line = String::new();
|
||||
while !line.contains("waiting for another run saving") {
|
||||
line.clear();
|
||||
assert_ne!(stderr.read_line(&mut line).unwrap(), 0, "{args:?} ended without waiting");
|
||||
}
|
||||
assert!(child.try_wait().unwrap().is_none(), "{args:?} did not wait");
|
||||
child
|
||||
}
|
||||
|
||||
/// Folder names in a saved project.
|
||||
fn has_folder(proj: &str, name: &str) -> bool {
|
||||
std::fs::read_to_string(proj).unwrap().contains(&format!("\"{name}\""))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_saves_keep_every_edit() {
|
||||
let (dir, proj) = lock_test_project("parallel");
|
||||
let names = |writer: usize| (0..300).map(move |i| format!("edit-{writer}-{i}"));
|
||||
let mut writers: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
bin()
|
||||
.args(["run", "--project", &proj, "--commands", "-", "--save"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
// Each writer reads its whole script before it opens the project, so closing the stdins together
|
||||
// starts all the edits at once, and long scripts keep each read-to-save span over the others.
|
||||
for (n, writer) in writers.iter_mut().enumerate() {
|
||||
let script: String = names(n).map(|name| format!("{}\n", json!({"command": "project.newFolder", "params": {"name": name}}))).collect();
|
||||
writer.stdin.take().unwrap().write_all(script.as_bytes()).unwrap();
|
||||
}
|
||||
for mut writer in writers {
|
||||
assert!(writer.wait().unwrap().success());
|
||||
}
|
||||
let lost: Vec<String> = (0..4).flat_map(names).filter(|name| !has_folder(&proj, name)).collect();
|
||||
assert!(lost.is_empty(), "lost {} edits: {lost:?}", lost.len());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_in_place_waits_for_the_lock_then_edits_the_latest_project() {
|
||||
let (dir, proj) = lock_test_project("wait");
|
||||
for save in [&["--save"][..], &["--save-as", proj.as_str()]] {
|
||||
let held = hold_lock(&dir, "p.ecproj");
|
||||
let before = std::fs::read(&proj).unwrap();
|
||||
let params = json!({"name": format!("waited-{}", save.len())}).to_string();
|
||||
let args = [&["exec", "project.newFolder", ¶ms, "--project", &proj][..], save].concat();
|
||||
let mut child = start_waiting_run(&args);
|
||||
assert_eq!(std::fs::read(&proj).unwrap(), before, "{save:?} wrote while another run held the lock");
|
||||
// an edit made meanwhile by the holder must survive: the waiter reads only after the lock is free
|
||||
let held_params = json!({"name": format!("held-{}", save.len())}).to_string();
|
||||
ok_json(&["exec", "project.newFolder", &held_params, "--project", &proj, "--save-as", &format!("{proj}.tmp")]);
|
||||
std::fs::rename(format!("{proj}.tmp"), &proj).unwrap();
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{save:?}");
|
||||
for want in [format!("waited-{}", save.len()), format!("held-{}", save.len())] {
|
||||
assert!(has_folder(&proj, &want), "{save:?} lost {want}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Runs through a symlink take the same lock before and after a save, or a run still waiting on
|
||||
/// the old one could overwrite a later run's save.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlinked_project_keeps_its_lock_after_a_save() {
|
||||
let (dir, proj) = lock_test_project("symlink");
|
||||
let link = dir.join("link.ecproj");
|
||||
std::os::unix::fs::symlink(&proj, &link).unwrap();
|
||||
for name in [r#"{"name":"first"}"#, r#"{"name":"second"}"#] {
|
||||
let held = hold_lock(&dir, "link.ecproj");
|
||||
let mut child = start_waiting_run(&["exec", "project.newFolder", name, "--project", link.to_str().unwrap(), "--save"]);
|
||||
drop(held);
|
||||
assert!(child.wait().unwrap().success(), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_that_do_not_save_in_place_ignore_the_lock() {
|
||||
let (dir, proj) = lock_test_project("ignore");
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
let _held = hold_lock(&dir, "p.ecproj");
|
||||
let params = r#"{"name":"copy-only"}"#;
|
||||
let cases: [&[&str]; 3] = [
|
||||
&["info", "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj],
|
||||
&["exec", "project.newFolder", params, "--project", &proj, "--save-as", &other],
|
||||
];
|
||||
for args in cases {
|
||||
let mut child = bin().args(args).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("{args:?} is waiting for a lock it doesn't need");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
};
|
||||
assert!(status.success(), "{args:?}");
|
||||
}
|
||||
assert!(!has_folder(&proj, "copy-only"));
|
||||
assert!(has_folder(&other, "copy-only"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_project_gets_no_lock_file_and_keeps_its_error() {
|
||||
let (dir, _) = lock_test_project("missing");
|
||||
let gone = dir.join("gone.ecproj").to_str().unwrap().to_string();
|
||||
let other = dir.join("other.ecproj").to_str().unwrap().to_string();
|
||||
// what opening it says, after the `effectcraft-cli <subcommand>: ` prefix
|
||||
let error = |args: &[&str]| {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?}");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
stderr.split_once(": ").map(|(_, e)| e.to_string()).unwrap_or(stderr)
|
||||
};
|
||||
let plain = error(&["info", "--project", &gone]);
|
||||
assert!(plain.contains("gone.ecproj"), "{plain}");
|
||||
// saving in place, saving elsewhere, or not saving: the error is the one opening gives
|
||||
for save in [&["--save"][..], &["--save-as", gone.as_str()], &["--save-as", other.as_str()], &[]] {
|
||||
let args = [&["exec", "project.newFolder", r#"{"name":"copy-only"}"#, "--project", &gone][..], save].concat();
|
||||
assert_eq!(error(&args), plain, "{save:?}");
|
||||
}
|
||||
assert!(!dir.join(".gone.ecproj.lock").exists());
|
||||
assert!(!dir.join("other.ecproj").exists());
|
||||
// a project with --bridge stays a usage error
|
||||
assert_eq!(bin().args(["exec", "project.newFolder", "--bridge", "9877", "--project", &gone, "--save"]).output().unwrap().status.code(), Some(2));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer
|
||||
/// than a Windows command line (32 767 characters, os error 206) still runs.
|
||||
#[test]
|
||||
fn run_reads_arguments_from_a_file() {
|
||||
let proj = tmp("args-file.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()];
|
||||
for i in 0..400 {
|
||||
lines.push("layer.newSolid".into());
|
||||
lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > 32_767);
|
||||
let file = tmp("args-file.txt");
|
||||
std::fs::write(&file, text).unwrap();
|
||||
let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]);
|
||||
assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}");
|
||||
let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
|
||||
}
|
||||
|
||||
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
|
||||
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// The child may exit before it has read all of standard input (the usage-error cases below
|
||||
// exit with code 2 without draining stdin), so a broken pipe while writing is that early
|
||||
// exit, not a failure of this helper; the assertions on the returned output still hold.
|
||||
let mut stdin_pipe = child.stdin.take().unwrap();
|
||||
if let Err(err) = stdin_pipe.write_all(stdin.as_bytes()) {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe, "writing the child's standard input: {err}");
|
||||
}
|
||||
drop(stdin_pipe);
|
||||
let out = child.wait_with_output().unwrap();
|
||||
(out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
|
||||
/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON
|
||||
/// Lines, from a file or standard input, and runs them as the same positional pairs would: after
|
||||
/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are
|
||||
/// over Linux's 128 KiB per-argument limit goes through.
|
||||
#[test]
|
||||
fn run_reads_commands_from_a_file_or_stdin() {
|
||||
let steps = [
|
||||
("comp.new", json!({"name": "A", "width": 64, "height": 64})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})),
|
||||
("project.summary", json!({})),
|
||||
];
|
||||
let pairs: Vec<String> = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect();
|
||||
let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect();
|
||||
let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat());
|
||||
let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64();
|
||||
assert_eq!(layers(&expected), Some(2), "{expected}");
|
||||
|
||||
let objects: Vec<Value> = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let array = tmp("commands.json");
|
||||
std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap();
|
||||
assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected);
|
||||
|
||||
let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect();
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&out).unwrap(), expected);
|
||||
|
||||
// Positional pairs first, then the file.
|
||||
let rest = tmp("commands-rest.jsonl");
|
||||
std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::<String>()).unwrap();
|
||||
assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected);
|
||||
|
||||
let name = "n".repeat(140 * 1024);
|
||||
let big = tmp("commands-big.jsonl");
|
||||
std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap();
|
||||
let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]);
|
||||
assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len()));
|
||||
|
||||
for (input, args, error) in [
|
||||
("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"),
|
||||
(
|
||||
"{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n",
|
||||
&["run", "project.summary", "{}", "--commands", "-", "--empty"],
|
||||
"step 2 (line 2): unknown key `param`",
|
||||
),
|
||||
("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"),
|
||||
("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"),
|
||||
] {
|
||||
let (code, out, err) = run_with_stdin(args, input);
|
||||
assert_eq!(code, 2, "{input:?}: {err}");
|
||||
assert!(err.contains(error), "{input:?}: {err}");
|
||||
assert!(out.is_empty(), "{input:?}: nothing ran");
|
||||
}
|
||||
// An empty list after positional pairs is no error: there is still something to run.
|
||||
let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], "");
|
||||
assert_eq!(code, 0, "{err}");
|
||||
// A step that fails when it runs is named by its index too.
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n");
|
||||
assert_eq!(code, 1, "{err}");
|
||||
let e = serde_json::from_str::<Value>(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default();
|
||||
assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_writes_png() {
|
||||
let out = tmp("f.png");
|
||||
@@ -324,6 +617,67 @@ fn render_out_is_relative_to_the_working_directory() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #1743: `render-frame --out` writes a PNG to the path it is given, and never over footage or
|
||||
/// the project file.
|
||||
#[test]
|
||||
fn render_frame_never_writes_over_footage_or_the_project() {
|
||||
let root = tmp("render-frame-overwrite");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let (clip, project) = (root.join("clip.png"), root.join("p.ecproj"));
|
||||
let (c, p) = (clip.to_str().unwrap(), project.to_str().unwrap());
|
||||
// A real PNG to import: a frame of the demo project.
|
||||
ok_json(&["render-frame", "--max-side", "64", "--out", c]);
|
||||
let imported = json!({"paths": [c]}).to_string();
|
||||
ok_json(&["run", "file.import", &imported, "comp.new", r#"{"name":"T","width":64,"height":36,"duration":1}"#, "--empty", "--save-as", p]);
|
||||
let (footage, saved) = (std::fs::read(&clip).unwrap(), std::fs::read(&project).unwrap());
|
||||
for (out, what) in [(c, "footage"), (p, "project")] {
|
||||
let (code, v) = run_json(&["render-frame", "--project", p, "--out", out]);
|
||||
assert_eq!(code, 1, "{what}: {v}");
|
||||
let error = v["error"].as_str().unwrap();
|
||||
assert!(error.contains(out) && error.contains(what), "{what}: {v}");
|
||||
}
|
||||
assert_eq!(std::fs::read(&clip).unwrap(), footage, "the footage is as it was");
|
||||
assert_eq!(std::fs::read(&project).unwrap(), saved, "the project file is as it was");
|
||||
// Anywhere else it writes.
|
||||
let free = root.join("frame.png");
|
||||
ok_json(&["render-frame", "--project", p, "--out", free.to_str().unwrap()]);
|
||||
assert_eq!(&std::fs::read(&free).unwrap()[..8], b"\x89PNG\r\n\x1a\n");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Two queue items on one file are refused by the render before anything is written, with the
|
||||
/// file as it was.
|
||||
#[test]
|
||||
fn render_refuses_two_items_on_one_file() {
|
||||
let root = tmp("render-clash");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let out = root.join("clash.mp4");
|
||||
std::fs::write(&out, b"EARLIER").unwrap();
|
||||
let project = root.join("p.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
let add = json!({"output": out.to_str().unwrap()}).to_string();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"comp.new",
|
||||
r#"{"name":"T","width":64,"height":36,"duration":1}"#,
|
||||
"renderQueue.add",
|
||||
&add,
|
||||
"renderQueue.add",
|
||||
&add,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
let (code, v) = run_json(&["render", "--queue", "--project", p]);
|
||||
assert_eq!(code, 1, "{v}");
|
||||
let error = v["error"].as_str().unwrap();
|
||||
assert!(error.contains("#1") && error.contains("#2") && error.contains("clash.mp4"), "{v}");
|
||||
assert_eq!(std::fs::read(&out).unwrap(), b"EARLIER");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A real stdio client: each edit's reply is read before disconnecting or killing the server.
|
||||
struct McpChild {
|
||||
child: std::process::Child,
|
||||
@@ -510,6 +864,49 @@ fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// A source refusal happens before encoding and has no started timestamp. It must still
|
||||
/// fail the CLI, while the valid queue item finishes and the imported frame stays intact.
|
||||
#[test]
|
||||
fn a_partly_protected_queue_exits_1_under_render_run_and_exec() {
|
||||
let root = tmp("protected-mixed-queue");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = root.join("source_00000.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&source).unwrap();
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let project = root.join("q.ecproj");
|
||||
let good = root.join("good.gif");
|
||||
let import = json!({"paths": [source]}).to_string();
|
||||
let blocked = json!({"format": "png", "output": root.join("source_[#####].png")}).to_string();
|
||||
let valid = json!({"format": "gif", "output": good}).to_string();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"--empty",
|
||||
"--save-as",
|
||||
project.to_str().unwrap(),
|
||||
"comp.new",
|
||||
r#"{"name":"Main","width":16,"height":16,"frameRate":10,"duration":0.1}"#,
|
||||
"layer.newSolid",
|
||||
"{}",
|
||||
"file.import",
|
||||
&import,
|
||||
"renderQueue.add",
|
||||
&blocked,
|
||||
"renderQueue.add",
|
||||
&valid,
|
||||
]);
|
||||
let p = project.to_str().unwrap();
|
||||
for args in [vec!["render", "--queue", "--project", p], vec!["run", p, "renderQueue.render", "{}"], vec!["exec", "renderQueue.render", "--project", p]] {
|
||||
std::fs::write(&good, b"previous output").unwrap();
|
||||
let (code, result) = run_json(&args);
|
||||
assert_eq!(code, 1, "{args:?}: {result}");
|
||||
assert!(result["error"].as_str().is_some_and(|e| e.contains("footage") && e.contains("source_00000.png")), "{result}");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), original);
|
||||
assert!(std::fs::read(&good).unwrap().starts_with(b"GIF8"), "the valid item still rendered");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
@@ -540,6 +937,113 @@ fn render_bitrate_applies_to_vp9_webm() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `bench --roto --clip FRAMES --truth MASKS` runs the trackers on the user's own image sequences: here a bright disc
|
||||
/// drifting over a dark textured frame, with its true masks. Mismatched sequences are refused with a message.
|
||||
#[test]
|
||||
fn bench_roto_runs_on_a_users_clip() {
|
||||
let (frames, masks) = (tmp("clip-frames"), tmp("clip-masks"));
|
||||
let config = frames.join("config");
|
||||
for d in [&frames, &masks] {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let (w, h) = (96u32, 72u32);
|
||||
for f in 0..14u32 {
|
||||
let (cx, cy) = (30.0 + 4.0 * f as f32, 36.0f32);
|
||||
let (mut img, mut mask) = (image::RgbImage::new(w, h), image::GrayImage::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < 14.0;
|
||||
let tex = 40 + ((x * 7 + y * 13) % 30) as u8;
|
||||
img.put_pixel(x, y, if inside { image::Rgb([230, 120, 40 + (x % 20) as u8]) } else { image::Rgb([tex / 2, tex, tex + 20]) });
|
||||
mask.put_pixel(x, y, image::Luma([if inside { 255 } else { 0 }]));
|
||||
}
|
||||
}
|
||||
let extension = if f == 0 { "jpg" } else { "png" };
|
||||
let stem = match f {
|
||||
0 => "a".into(),
|
||||
1 => "a.jpg".into(),
|
||||
_ => format!("f{f:03}"),
|
||||
};
|
||||
img.save(frames.join(format!("{stem}.{extension}"))).unwrap();
|
||||
mask.save(masks.join(format!("{stem}.png"))).unwrap();
|
||||
}
|
||||
let (f, m) = (frames.to_str().unwrap(), masks.to_str().unwrap());
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m, "--frames", "6", "--json"]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
assert!(out.status.success(), "{text}\n{}", String::from_utf8_lossy(&out.stderr));
|
||||
let rows: Value = serde_json::from_str(&text).unwrap();
|
||||
let row = rows.as_array().unwrap().iter().find(|row| row["tracker"] == "classical").unwrap();
|
||||
assert_eq!(row["per_frame"].as_array().unwrap().len(), 6, "a user clip respects the requested frame limit");
|
||||
let mean = row["mean_iou"].as_f64().unwrap();
|
||||
assert!(mean > 0.8, "classical mean IoU on the user's clip: {mean}\n{text}");
|
||||
// one mask short: refused
|
||||
std::fs::remove_file(masks.join("f005.png")).unwrap();
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f, "--truth", m]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("must match"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
// --clip without --truth
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", &config).args(["bench", "--roto", "--clip", f]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_roto_rejects_mismatched_names_before_loading_images() {
|
||||
let root = tmp("bench-clip-names");
|
||||
let (frames, masks) = (root.join("frames"), root.join("masks"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for dir in [&frames, &masks] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("f000.png"), []).unwrap();
|
||||
}
|
||||
std::fs::write(frames.join("f001.png"), []).unwrap();
|
||||
std::fs::write(masks.join("f002.png"), []).unwrap();
|
||||
let out = bin()
|
||||
.env("EFFECTCRAFT_CONFIG_DIR", root.join("config"))
|
||||
.args(["bench", "--roto", "--frames", "1", "--clip"])
|
||||
.arg(&frames)
|
||||
.arg("--truth")
|
||||
.arg(&masks)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success());
|
||||
assert!(error.contains("names must match"), "validate the entire name set before decoding: {error}");
|
||||
std::fs::remove_file(frames.join("f001.png")).unwrap();
|
||||
std::fs::remove_file(masks.join("f002.png")).unwrap();
|
||||
for dir in [&frames, &masks] {
|
||||
std::fs::write(dir.join("f000.jpg"), []).unwrap();
|
||||
}
|
||||
let out =
|
||||
bin().env("EFFECTCRAFT_CONFIG_DIR", root.join("config")).args(["bench", "--roto", "--clip"]).arg(&frames).arg("--truth").arg(&masks).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unique stems"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `bench --roto --sam2 FILE` / `--mobilesam FILE` say why a file named on the command line cannot be
|
||||
/// used (a missing file, one that is not the published weights), instead of "not installed".
|
||||
#[test]
|
||||
fn bench_roto_reports_why_a_named_model_file_failed() {
|
||||
let dir = tmp("bench-weights");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bogus = dir.join("bogus.bin");
|
||||
std::fs::write(&bogus, b"not the weights").unwrap();
|
||||
for flag in ["--sam2", "--mobilesam"] {
|
||||
let missing = dir.join("missing.bin");
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, missing.to_str().unwrap()]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(!out.status.success(), "{flag}: {err}");
|
||||
assert!(err.contains(flag) && err.contains("missing.bin"), "{flag} names the file that could not be read: {err}");
|
||||
let out = bin().env("EFFECTCRAFT_CONFIG_DIR", dir.join("config")).args(["bench", "--roto", flag, bogus.to_str().unwrap()]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(!out.status.success(), "{flag}: {err}");
|
||||
assert!(err.contains("does not match"), "{flag} says the file is not the published weights: {err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Render uses the same empty/project/demo selection as the other CLI commands.
|
||||
#[test]
|
||||
fn render_empty_does_not_load_the_demo() {
|
||||
@@ -667,3 +1171,55 @@ fn render_without_progress_writes_no_progress_lines() {
|
||||
let stderr = render_demo_stderr("no-progress.gif", &[]);
|
||||
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
|
||||
}
|
||||
|
||||
/// `render --exr-compression C --exr-depth half` writes OpenEXR frames with that compression and
|
||||
/// half-float channels (#2911); an unknown value is an error naming the parameter.
|
||||
#[test]
|
||||
fn render_exr_compression_and_depth() {
|
||||
let root = tmp("render-exr-options");
|
||||
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":64,"height":36,"duration":1}"#,
|
||||
"layer.newSolid",
|
||||
r#"{"width":64,"height":36}"#,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
let out = root.join("f_[#####].exr");
|
||||
let reply = ok_json(&[
|
||||
"render",
|
||||
"--project",
|
||||
p,
|
||||
"--start",
|
||||
"0",
|
||||
"--end",
|
||||
"0.01",
|
||||
"--exr-compression",
|
||||
"piz",
|
||||
"--exr-depth",
|
||||
"half",
|
||||
"--out",
|
||||
out.to_str().unwrap(),
|
||||
]);
|
||||
assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}");
|
||||
let bytes = std::fs::read(root.join("f_00000.exr")).unwrap();
|
||||
// The `compression` attribute: name, type name, a 1-byte size, then PIZ (4).
|
||||
let attr = b"compression\0compression\0\x01\0\0\0";
|
||||
let at = bytes.windows(attr.len()).position(|w| w == attr).expect("compression attribute");
|
||||
assert_eq!(bytes[at + attr.len()], 4, "PIZ");
|
||||
// Each channel in `chlist`: its name, then the pixel type (1 = half).
|
||||
for name in [&b"R\0"[..], b"G\0", b"B\0", b"A\0"] {
|
||||
let half = [name, &[1, 0, 0, 0]].concat();
|
||||
assert!(bytes.windows(half.len()).any(|w| w == half.as_slice()), "{:?} is half float", std::str::from_utf8(name));
|
||||
}
|
||||
let (code, reply) = run_json(&["render", "--project", p, "--exr-depth", "8", "--out", out.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod cli;
|
||||
mod qa_template;
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into())
|
||||
}
|
||||
+67
-17
@@ -9,7 +9,7 @@ use std::process::Command;
|
||||
use serde_json::Value;
|
||||
|
||||
fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap();
|
||||
let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr)));
|
||||
(out.status.code().unwrap_or(-1), v)
|
||||
@@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
|
||||
|
||||
invalid_template_values_fail_cleanly(&dir);
|
||||
template_usage_errors(&dir);
|
||||
rational_template_keeps_exact_rate(&dir, &original_template);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn invalid_template_values_fail_cleanly(dir: &PathBuf) {
|
||||
// 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 [
|
||||
@@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
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()]);
|
||||
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"]);
|
||||
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"]);
|
||||
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"]);
|
||||
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());
|
||||
}
|
||||
|
||||
fn template_usage_errors(dir: &PathBuf) {
|
||||
for args in [
|
||||
vec!["render-frame", "--values", "values.json"],
|
||||
vec!["info", "--template", "lt.ectemplate"],
|
||||
@@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
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();
|
||||
let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) {
|
||||
// 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();
|
||||
@@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
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"]);
|
||||
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"]);
|
||||
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"]);
|
||||
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)
|
||||
@@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
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);
|
||||
@@ -251,7 +262,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
|
||||
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"))
|
||||
let result = Command::new(crate::binary())
|
||||
.current_dir(dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
|
||||
.output()
|
||||
@@ -527,7 +538,7 @@ fn template_unknown_control_does_not_replace_extracted_media() {
|
||||
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"))
|
||||
let result = Command::new(crate::binary())
|
||||
.current_dir(&dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
|
||||
.output()
|
||||
@@ -623,11 +634,8 @@ fn template_missing_output_does_not_replace_extracted_media() {
|
||||
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 result =
|
||||
Command::new(crate::binary()).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();
|
||||
@@ -767,3 +775,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() {
|
||||
assert!(!dir.join("missing-out.png").exists(), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Renders with `--template` name their outputs after the template's composition (#1937), not
|
||||
/// the numbered name a duplicate would get. The name ends in a number and differs from the
|
||||
/// archive's file name; an expression that looks the composition up by that name still finds it.
|
||||
#[test]
|
||||
fn template_render_outputs_keep_the_template_composition_name() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace(
|
||||
"var fill =",
|
||||
"bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =",
|
||||
);
|
||||
std::fs::write(dir.join("build.jsx"), build).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]);
|
||||
let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap();
|
||||
std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap();
|
||||
|
||||
for (case, values) in [("defaults", None), ("values", Some("values.json"))] {
|
||||
let out = dir.join(case).join("[compName]_[#####].png");
|
||||
let mut args =
|
||||
vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"];
|
||||
if let Some(values) = values {
|
||||
args.extend(["--values", values]);
|
||||
}
|
||||
let rendered = ok(&dir, &args);
|
||||
let rendered = rendered["rendered"].as_array().unwrap();
|
||||
assert_eq!(rendered.len(), 1, "{case}: {rendered:?}");
|
||||
assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}");
|
||||
let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(frames.len(), 1, "{case}: {frames:?}");
|
||||
assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}");
|
||||
// The template's own two layers place the bar; the one-layer wrapper would move it left.
|
||||
let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8();
|
||||
let bar = frame.get_pixel(300, 75).0;
|
||||
assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}");
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
"CssStyleDeclaration",
|
||||
"Document",
|
||||
"Element",
|
||||
@@ -50,6 +52,13 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"ReadableStream",
|
||||
"ReadableStreamDefaultReader",
|
||||
"Request",
|
||||
"RequestCache",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
//
|
||||
// Plain functions only: the Rust side owns the logic.
|
||||
|
||||
// ------------------------------------------------------------------ shortcuts
|
||||
|
||||
/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks.
|
||||
/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because
|
||||
/// eframe falls back to the key code there; it does not on Dvorak, where it types E.
|
||||
/// eframe still receives the key; other chords and HTML inputs keep their browser behavior.
|
||||
export function installCanvasShortcuts(canvas) {
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
if (event.target === canvas && (event.ctrlKey || event.metaKey)
|
||||
&& !event.shiftKey && !event.altKey && !event.isComposing
|
||||
&& (event.key.toLowerCase() === "d"
|
||||
|| (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ storage
|
||||
|
||||
const DIR = "effectcraft";
|
||||
@@ -19,7 +36,7 @@ const enc = (k) => encodeURIComponent(k);
|
||||
const dec = (n) => decodeURIComponent(n);
|
||||
|
||||
async function opfs() {
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
|
||||
if (!navigator.storage?.getDirectory) throw new Error("no OPFS");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle(DIR, { create: true });
|
||||
// Probe a write: some browsers expose OPFS but only write it from workers.
|
||||
@@ -116,7 +133,7 @@ export async function storeOpen(prefer) {
|
||||
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
|
||||
}
|
||||
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
|
||||
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
|
||||
try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {}
|
||||
return backend.name;
|
||||
}
|
||||
|
||||
@@ -128,7 +145,7 @@ export async function storeDelete(key) { return backend.del(key); }
|
||||
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
|
||||
export async function storePersist() {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
|
||||
if (navigator.storage?.persist) return await navigator.storage.persist();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
@@ -227,7 +244,7 @@ export async function audioStart(workletUrl) {
|
||||
}
|
||||
if (gen !== generation) return;
|
||||
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
|
||||
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
|
||||
node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; };
|
||||
kind = "worklet";
|
||||
} catch (e) {
|
||||
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
|
||||
@@ -318,11 +335,11 @@ function spawn(base) {
|
||||
const w = { worker, files: new Map(), ready: null, gpu: null };
|
||||
w.ready = new Promise((res, rej) => {
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data && e.data.type === "gpu") {
|
||||
if (e.data?.type === "gpu") {
|
||||
w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" };
|
||||
jobGpu = w.gpu;
|
||||
}
|
||||
if (e.data && e.data.type === "ready") res();
|
||||
if (e.data?.type === "ready") res();
|
||||
};
|
||||
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
|
||||
});
|
||||
@@ -341,7 +358,7 @@ export function workerRun(id, json, files, base, onMessage) {
|
||||
} catch (e) {
|
||||
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
|
||||
// fails like one whose worker didn't start.
|
||||
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
|
||||
setTimeout(() => onMessage("error", String(e?.message || e), null, null));
|
||||
return;
|
||||
}
|
||||
running.set(id, w);
|
||||
@@ -416,12 +433,12 @@ export function frameWorkerStart(base, onMessage, gpu) {
|
||||
// slot stays, and the failure is reported like a worker that died, after the caller has
|
||||
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
|
||||
frameWorkers.push(null);
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed")));
|
||||
return index;
|
||||
}
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
|
||||
@@ -447,7 +464,7 @@ const cacheDirHandles = {};
|
||||
|
||||
async function cacheDir(kind = "frames") {
|
||||
if (cacheDirHandles[kind]) return cacheDirHandles[kind];
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
|
||||
if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System");
|
||||
let d = await navigator.storage.getDirectory();
|
||||
for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true });
|
||||
cacheDirHandles[kind] = d;
|
||||
@@ -494,8 +511,12 @@ export async function cacheRead(name) {
|
||||
/// resolves with its size.
|
||||
export async function cacheWrite(fullName, bytes) {
|
||||
const [dir, name] = await entryDir(fullName);
|
||||
const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
|
||||
const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
|
||||
const canMove =
|
||||
typeof FileSystemHandle !== "undefined" &&
|
||||
"move" in FileSystemHandle.prototype &&
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function";
|
||||
const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
|
||||
const fh = await dir.getFileHandle(tmp, { create: true });
|
||||
if (fh.createSyncAccessHandle) {
|
||||
const h = await fh.createSyncAccessHandle();
|
||||
@@ -540,7 +561,7 @@ export async function cacheClear() {
|
||||
|
||||
/// Whether this thread has WebGPU (`navigator.gpu`).
|
||||
export function hasWebGpu() {
|
||||
return !!(self.navigator && self.navigator.gpu);
|
||||
return !!self.navigator?.gpu;
|
||||
}
|
||||
|
||||
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
|
||||
@@ -648,7 +669,7 @@ export async function browseForget(name) {
|
||||
export async function browseRead(path) {
|
||||
const rel = path.replace(/^\/Folders\//, "");
|
||||
const folder = folders.get(rel.split("/")[0]);
|
||||
const h = folder && folder.files.get(rel);
|
||||
const h = folder?.files.get(rel);
|
||||
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
|
||||
return new Uint8Array(await (await h.getFile()).arrayBuffer());
|
||||
}
|
||||
@@ -702,7 +723,7 @@ export function showDeviceLost(message, kept) {
|
||||
self.effectcraft
|
||||
.execute("file.save", {})
|
||||
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
|
||||
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
|
||||
.catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`));
|
||||
const reload = document.createElement("button");
|
||||
reload.textContent = "Reload";
|
||||
reload.onclick = async () => {
|
||||
@@ -710,7 +731,7 @@ export function showDeviceLost(message, kept) {
|
||||
try {
|
||||
await self.effectcraft.flush();
|
||||
} catch (e) {
|
||||
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
reload.textContent = "Reload Anyway";
|
||||
reload.disabled = false;
|
||||
reload.onclick = () => location.reload();
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
|
||||
use crate::startup::Startup;
|
||||
use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*;
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = showDeviceLost)]
|
||||
fn show_device_lost(message: &str, kept: bool);
|
||||
#[wasm_bindgen(js_name = installCanvasShortcuts)]
|
||||
fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement);
|
||||
}
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
@@ -81,32 +84,60 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
/// Keep the session snapshot (off with `?empty`).
|
||||
/// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until
|
||||
/// the user's own work begins, see `demo_start`).
|
||||
pub persist: bool,
|
||||
/// Started on the demo by the config: the snapshot stays off, so the demo never replaces the
|
||||
/// last visit's project, until the user opens a project from a file or edits the demo.
|
||||
pub demo_start: bool,
|
||||
/// The last title written to `document.title`.
|
||||
pub document_title: String,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
/// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously
|
||||
/// through `open_or_import`, then run the shared app's input hook.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// Files dropped on the page are input: read them here, where the browser's drop event is
|
||||
// still the current pass's, not from a later (possibly logic-only) tick.
|
||||
//
|
||||
// The list is *taken* out of the raw input: a hidden tab runs `logic` and eframe appends
|
||||
// the same prepared raw input back for the next pass, so leaving the files there would
|
||||
// import them again on every hidden logic tick.
|
||||
let dropped_files = std::mem::take(&mut raw_input.dropped_files);
|
||||
let dropped: Vec<web_sys::File> = dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect();
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
/// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render
|
||||
/// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close
|
||||
/// the IME pass.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
self.app.tick(ctx, frame);
|
||||
// eframe on the web ignores `ViewportCommand::Title`: the tab title follows the open document here.
|
||||
if self.app.window_title() != self.document_title {
|
||||
self.document_title = self.app.window_title().to_owned();
|
||||
set_document_title(&self.document_title);
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// The user's own work after a demo start is worth keeping too: turn the snapshot on.
|
||||
if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) {
|
||||
self.demo_start = false;
|
||||
self.persist = true;
|
||||
}
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
|
||||
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
|
||||
persist::snapshot(&self.app.session, self.app.now(), 1.0);
|
||||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
api::set_info("frameWorkers", frames::stats());
|
||||
@@ -114,6 +145,7 @@ impl eframe::App for WebApp {
|
||||
let d = &self.app.session.prefs.disk;
|
||||
diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota());
|
||||
api::set_info("diskCache", diskcache::stats());
|
||||
self.app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -121,6 +153,13 @@ impl eframe::App for WebApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser tab's title.
|
||||
fn set_document_title(title: &str) {
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
doc.set_title(title);
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> Option<web_sys::UrlSearchParams> {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok())
|
||||
}
|
||||
@@ -174,8 +213,24 @@ fn install_hooks(app: &mut EffectcraftApp) {
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs. A project (Save As,
|
||||
// Save a Copy) asks for the name, starting from the open project's (#2788): without asking,
|
||||
// Save As would replace the open project's file; Cancel cancels. A name that is already in
|
||||
// browser storage asks before replacing it, as the desktop's save dialogs do.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
let name = if name.is_empty() { "Untitled Project.ecproj" } else { name };
|
||||
if !std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("ecproj") || e.eq_ignore_ascii_case("ecprojx")) {
|
||||
return Some(files::path_for(name));
|
||||
}
|
||||
let window = web_sys::window()?;
|
||||
let typed = window.prompt_with_message_and_default("Save project as:", name).ok().flatten();
|
||||
let typed = crate::store::typed_save_name(name, typed)?;
|
||||
let path = files::path_for(&typed);
|
||||
if files::get(&path).is_some() && !window.confirm_with_message(&format!("{typed} already exists. Replace it?")).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
Some(path)
|
||||
}));
|
||||
app.hooks.audio_device = Some(Box::new(audio::open));
|
||||
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
|
||||
}
|
||||
@@ -184,13 +239,21 @@ fn install_hooks(app: &mut EffectcraftApp) {
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// Match System follows `?lang=` in the address, then the browser's languages; a language
|
||||
// chosen in Settings still wins.
|
||||
let navigator = web_sys::window().map(|w| w.navigator());
|
||||
let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::<Vec<_>>());
|
||||
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
install_canvas_shortcuts(&canvas);
|
||||
let empty = query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
// the last session come back.
|
||||
let t0 = js_sys::Date::now();
|
||||
// Monotonic (performance.now) rather than the wall clock: a system-time jump must not turn
|
||||
// the storage-load report negative or huge.
|
||||
let t0 = web_time::Instant::now();
|
||||
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -198,8 +261,13 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
0
|
||||
}
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
|
||||
audio::install_unlock();
|
||||
// `./effectcraft-config.json`: what to open on load.
|
||||
// `?empty` and `?demo` decide by themselves, so they skip the request.
|
||||
let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await };
|
||||
// An explicit `"demo"` start leaves the saved session alone until the user opens or edits something.
|
||||
let keep_session = !empty && startup.keeps_session();
|
||||
browse::restore();
|
||||
diskcache::init();
|
||||
storage::refresh();
|
||||
@@ -209,6 +277,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
// The page owns this shared presentation device; worker devices retain their
|
||||
// separate owned-device handlers. No browser error-scope future is blocked on.
|
||||
@@ -224,7 +293,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
// The canvas can't present any more (#225): the page says so over it and
|
||||
// offers a reload, which brings the session back when browser storage keeps it.
|
||||
let lost = gpu_failures.clone();
|
||||
let kept = !empty && persist::backend() != "memory";
|
||||
let kept = keep_session && persist::backend() != "memory";
|
||||
rs.device.set_device_lost_callback(move |reason, message| {
|
||||
let message = format!("GPU device lost ({reason:?}): {message}");
|
||||
lost.report(&message, true);
|
||||
@@ -233,11 +302,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
// Scripts in Scripts/Startup run before a project opens.
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
|
||||
// project (the demo on a first visit).
|
||||
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
|
||||
if !restored && !empty {
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
|
||||
// `startup` says (default: the last visit's project, the demo on a first visit).
|
||||
let demo = query_flag("demo");
|
||||
let restored = !empty && !demo && startup.restores() && persist::restore(&mut session);
|
||||
if !restored && !empty && (demo || startup.demo_when_nothing_restored()) {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
@@ -257,12 +329,13 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
|
||||
}
|
||||
app.frames.set_virtual_id(Arc::new(|path| files::STORE.file_identity(path)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app, persist: !empty }))
|
||||
Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session, document_title: String::new() }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -52,7 +52,7 @@ struct State {
|
||||
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The web app's Media Browser.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::startup::{self, Startup};
|
||||
|
||||
/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up.
|
||||
const TIMEOUT_MS: i32 = 5_000;
|
||||
|
||||
thread_local! {
|
||||
/// `None`: not fetched yet; `Some(None)`: no usable file.
|
||||
static FILE: RefCell<Option<Option<Vec<u8>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request
|
||||
/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read).
|
||||
async fn fetch() -> Option<Vec<u8>> {
|
||||
let win = web_sys::window()?;
|
||||
let ctl = web_sys::AbortController::new().ok()?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
// Always ask the server: the file is the operator's switch, never a cached copy.
|
||||
init.set_cache(web_sys::RequestCache::NoCache);
|
||||
init.set_signal(Some(&ctl.signal()));
|
||||
let on_timeout = Closure::once(move || ctl.abort());
|
||||
let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?;
|
||||
let out = read(&win, &init).await;
|
||||
win.clear_timeout_with_handle(timer);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`.
|
||||
async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option<Vec<u8>> {
|
||||
let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?;
|
||||
let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?;
|
||||
if resp.status() != 200 {
|
||||
return None;
|
||||
}
|
||||
let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into();
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let chunk = JsFuture::from(reader.read()).await.ok()?;
|
||||
if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) {
|
||||
return Some(out);
|
||||
}
|
||||
let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?;
|
||||
if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX {
|
||||
// Stop the download: the limit is enforced while reading, not after the whole body arrived.
|
||||
let _ = reader.cancel();
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&bytes.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// The config file's bytes (`None` when there is none), fetched on the first call.
|
||||
pub async fn bytes() -> Option<Vec<u8>> {
|
||||
if let Some(done) = FILE.with(|f| f.borrow().clone()) {
|
||||
return done;
|
||||
}
|
||||
let got = fetch().await;
|
||||
FILE.with(|f| *f.borrow_mut() = Some(got.clone()));
|
||||
got
|
||||
}
|
||||
|
||||
/// The configured startup behaviour.
|
||||
pub async fn startup() -> Startup {
|
||||
let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default();
|
||||
log::info!("startup: {s:?}");
|
||||
s
|
||||
}
|
||||
@@ -4,23 +4,28 @@
|
||||
//!
|
||||
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
|
||||
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
|
||||
//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
|
||||
//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
|
||||
//! `FrameReply::Stored`.
|
||||
//! the sealed entry (`disk_cache::frame_entry`: the source files the frame read, LZ4,
|
||||
//! checksum) with a synchronous access handle into a temporary file moved into place
|
||||
//! (`js/host.js` `cacheWrite`), and reports `FrameReply::Stored`. A frame whose files can't
|
||||
//! all be named is not stored.
|
||||
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
|
||||
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
|
||||
//! `Stored` replies and by reads; eviction deletes the least recently used files.
|
||||
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
|
||||
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
|
||||
//! of rendering it (`crate::frames`), if the files its entry lists still have the size and
|
||||
//! content hash it was made of (the file table's identities); a damaged or missing entry, or
|
||||
//! one whose files changed, is a miss and is deleted.
|
||||
//!
|
||||
//! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a
|
||||
//! synchronous render, where the browser's file system can't be read, so the frame workers
|
||||
//! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache
|
||||
//! misses; before the next request the page names the recent misses that are on disk
|
||||
//! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries
|
||||
//! into memory before rendering, so the lookups find them. Slow buffers are written by the
|
||||
//! worker after its frame and indexed here like frames (one least-recently-used order for
|
||||
//! both kinds).
|
||||
//! into memory before rendering, so the lookups find them; an entry is used only while the
|
||||
//! files it lists (the file table's size and content hash) are unchanged, and a buffer whose
|
||||
//! files can't all be named is not written (#3184). Slow buffers are written by the worker
|
||||
//! after its frame and indexed here like frames (one least-recently-used order for both
|
||||
//! kinds).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -187,11 +192,12 @@ pub fn note_miss() {
|
||||
}
|
||||
|
||||
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
|
||||
/// damaged.
|
||||
/// damaged, or the source files it was made of changed since.
|
||||
pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
|
||||
let name = disk_cache::entry_name(key);
|
||||
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()).map(|b| b.to_vec());
|
||||
let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
|
||||
let identity = |path: &str| crate::files::STORE.file_identity(path);
|
||||
let frame = bytes.as_deref().and_then(|b| disk_cache::read_frame_entry(b, Some(&identity)));
|
||||
ST.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if frame.is_some() {
|
||||
|
||||
@@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> {
|
||||
|
||||
/// The next write of `path` goes to browser storage only (no download).
|
||||
pub fn save_quietly(path: &str) {
|
||||
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
|
||||
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
|
||||
/// downloaded at the next [`flush_downloads`].
|
||||
pub fn add_output(path: &str, data: Arc<[u8]>) {
|
||||
STORE.put_file(path, data, false);
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
@@ -120,7 +120,7 @@ impl Services for WebServices {
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
let quiet = {
|
||||
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
@@ -151,6 +151,10 @@ impl Services for WebServices {
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// The table's keys are the paths themselves.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
@@ -184,7 +188,7 @@ pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
pub fn flush_downloads() {
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64);
|
||||
|
||||
thread_local! {
|
||||
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, PendingFrameCheck>> = RefCell::new(Default::default());
|
||||
}
|
||||
|
||||
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
|
||||
@@ -416,6 +417,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
|
||||
fn new_server() -> FrameServer {
|
||||
let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)));
|
||||
s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default())));
|
||||
s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path))));
|
||||
s
|
||||
}
|
||||
|
||||
@@ -471,10 +473,13 @@ pub async fn worker_frame(json: String) {
|
||||
g.settled().await;
|
||||
replies = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| s.resume()).unwrap_or_default());
|
||||
}
|
||||
// The source files the frame read, kept with its entry (checked by the page before use); a
|
||||
// frame whose files can't all be named is not stored (#3184).
|
||||
let (files, virtual_id) = SERVER.with(|c| c.borrow_mut().as_mut().map(|s| (s.take_files(), s.virtual_id().cloned())).unwrap_or_default());
|
||||
for (r, px) in replies {
|
||||
post_reply(&r, px.as_deref());
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key)) = (&r, &px, &disk) {
|
||||
let entry = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px);
|
||||
if let (FrameReply::Frame { width, height, .. }, Some(px), Some(key), Some(files)) = (&r, &px, &disk, &files) {
|
||||
let Some(entry) = effectcraft_engine::render::disk_cache::frame_entry(*width, *height, px, files, virtual_id.as_deref()) else { continue };
|
||||
let name = format!("{key}.ecc");
|
||||
match JsFuture::from(crate::diskcache::cache_write(&name, js_sys::Uint8Array::from(&entry[..]))).await {
|
||||
Ok(n) => post_reply(&FrameReply::Stored { key: key.clone(), bytes: n.as_f64().unwrap_or(entry.len() as f64) as u64, layer: false }, None),
|
||||
|
||||
@@ -6,22 +6,23 @@
|
||||
//!
|
||||
//! | Desktop | Web |
|
||||
//! |---|---|
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) |
|
||||
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
|
||||
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) |
|
||||
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers |
|
||||
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) |
|
||||
//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers |
|
||||
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) |
|
||||
//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
|
||||
//!
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod startup;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -33,6 +34,8 @@ pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod browse;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod config_file;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod diskcache;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load.
|
||||
//!
|
||||
//! | Value | On load |
|
||||
//! |---|---|
|
||||
//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit |
|
||||
//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo |
|
||||
//! | `"demo"` | the demo project, whatever the last visit left |
|
||||
//!
|
||||
//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an
|
||||
//! unknown value, a config that is not JSON) is the default, `"last"`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the page opens on load.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
#[default]
|
||||
Last,
|
||||
Empty,
|
||||
Demo,
|
||||
}
|
||||
|
||||
/// The largest config file read (bytes).
|
||||
pub const CONFIG_MAX: usize = 64 * 1024;
|
||||
|
||||
/// The `startup` key of a config file's bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Startup {
|
||||
if bytes.len() > CONFIG_MAX {
|
||||
return Startup::default();
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(bytes) else { return Startup::default() };
|
||||
match v.get("startup").and_then(Value::as_str) {
|
||||
Some("empty") => Startup::Empty,
|
||||
Some("demo") => Startup::Demo,
|
||||
_ => Startup::Last,
|
||||
}
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
/// Whether the last visit's project is restored (off for the demo).
|
||||
pub fn restores(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the
|
||||
/// project the last visit left).
|
||||
pub fn keeps_session(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the demo project opens when nothing was restored (off for a blank start).
|
||||
pub fn demo_when_nothing_restored(self) -> bool {
|
||||
self != Startup::Empty
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ pub struct Entry {
|
||||
/// Which write this is: new with every write of the key, so a file replaced by another of
|
||||
/// the same size still reads as changed (workers are sent it again, #218).
|
||||
pub version: u64,
|
||||
/// FNV-1a of `data`, filled the first time a disk-cache key asks for it.
|
||||
hash: Option<u64>,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
@@ -69,13 +71,13 @@ impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
@@ -149,8 +151,35 @@ pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The project file name typed in the web's Save As prompt (#2788): `None` when it was cancelled or
|
||||
/// left empty; folders are dropped (the file table is flat) and a name without the default's
|
||||
/// extension gets it (`Intro copy` → `Intro copy.ecproj`, `Intro.v2` → `Intro.v2.ecproj`), so the
|
||||
/// browser still opens and keeps it as a project.
|
||||
pub fn typed_save_name(default: &str, typed: Option<String>) -> Option<String> {
|
||||
let typed = typed?;
|
||||
let name = typed.trim().rsplit(['/', '\\']).next().unwrap_or("").trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ext = Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if ext.is_empty() || Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case(&ext)) {
|
||||
Some(name.to_string())
|
||||
} else {
|
||||
Some(format!("{name}.{ext}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
|
||||
fn content_hash(data: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in data {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
@@ -159,9 +188,16 @@ pub struct Store {
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
/// An empty store with no mirror contents, change callback or clock.
|
||||
fn default() -> Self {
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
@@ -179,12 +215,30 @@ impl Store {
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
let key = file_key(path);
|
||||
self.lock().put(&key, data, now, persist);
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
|
||||
/// later key does not walk the bytes again.
|
||||
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
|
||||
let key = file_key(path);
|
||||
let mut mirror = self.lock();
|
||||
let entry = mirror.entries.get(&key)?;
|
||||
if let Some(hash) = entry.hash {
|
||||
return Some((entry.data.len() as u64, hash));
|
||||
}
|
||||
let hash = content_hash(&entry.data);
|
||||
let len = entry.data.len() as u64;
|
||||
if let Some(entry) = mirror.entries.get_mut(&key) {
|
||||
entry.hash = Some(hash);
|
||||
}
|
||||
Some((len, hash))
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
@@ -318,6 +372,21 @@ impl ConfigStore for WebConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
|
||||
let mut v: Vec<String> = self
|
||||
.store
|
||||
.lock()
|
||||
.list(&pre)
|
||||
.into_iter()
|
||||
.filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
@@ -373,6 +442,20 @@ pub struct SnapshotMeta {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typed_save_names() {
|
||||
let t = |s: &str| Some(s.to_string());
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro copy")).as_deref(), Some("Intro copy.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t(" Other.ecproj ")).as_deref(), Some("Other.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("a/b\\Shot.ecproj")).as_deref(), Some("Shot.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.v2")).as_deref(), Some("Intro.v2.ecproj"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("Intro.ECPROJ")).as_deref(), Some("Intro.ECPROJ"));
|
||||
assert_eq!(typed_save_name("Intro.ecprojx", t("Intro")).as_deref(), Some("Intro.ecprojx"));
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t(" ")), None);
|
||||
assert_eq!(typed_save_name("Intro.ecproj", t("folder/")), None);
|
||||
assert_eq!(typed_save_name("Intro.ecproj", None), None);
|
||||
}
|
||||
|
||||
fn store() -> Arc<Store> {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(|| 1_000.0);
|
||||
@@ -446,6 +529,9 @@ mod tests {
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(&data[..], b"blue");
|
||||
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
|
||||
let id = s.file_identity("/collision.png").expect("identity");
|
||||
assert_eq!(id, (4, content_hash(b"blue")));
|
||||
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
|
||||
}
|
||||
|
||||
/// A write that failed is flushed again with the key's latest state (#215).
|
||||
@@ -480,6 +566,22 @@ mod tests {
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
/// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
|
||||
#[test]
|
||||
fn config_store_lists_folders() {
|
||||
let c = WebConfig::new(store());
|
||||
for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
|
||||
c.write(name, "x").unwrap();
|
||||
}
|
||||
assert_eq!(c.list("Scripts"), ["Top.jsx"]);
|
||||
assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
|
||||
assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
|
||||
let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
|
||||
let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
|
||||
let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
|
||||
assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mod startup_tests;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The `startup` key of the config file.
|
||||
|
||||
use effectcraft_web::startup::{CONFIG_MAX, Startup, parse};
|
||||
|
||||
/// The three accepted values parse.
|
||||
#[test]
|
||||
fn the_three_values() {
|
||||
assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo);
|
||||
assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last);
|
||||
}
|
||||
|
||||
/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`.
|
||||
#[test]
|
||||
fn absent_or_invalid_is_the_default() {
|
||||
for bytes in
|
||||
[&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#]
|
||||
{
|
||||
assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/// Other keys do not hide `startup`.
|
||||
#[test]
|
||||
fn a_config_with_other_keys_still_has_its_startup() {
|
||||
assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo);
|
||||
}
|
||||
|
||||
/// A file over `CONFIG_MAX` is ignored.
|
||||
#[test]
|
||||
fn an_oversized_file_is_the_default() {
|
||||
let mut big = br#"{"startup":"empty","pad":""#.to_vec();
|
||||
big.extend(std::iter::repeat_n(b'x', CONFIG_MAX));
|
||||
big.extend_from_slice(br#""}"#);
|
||||
assert_eq!(parse(&big), Startup::Last);
|
||||
}
|
||||
|
||||
/// Which project each value restores or opens.
|
||||
#[test]
|
||||
fn what_each_value_does_on_load() {
|
||||
assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored());
|
||||
assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored());
|
||||
assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored());
|
||||
}
|
||||
|
||||
/// Only the explicit demo start stops saving the session.
|
||||
#[test]
|
||||
fn only_the_demo_start_leaves_the_saved_session_alone() {
|
||||
assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session());
|
||||
assert!(!Startup::Demo.keeps_session());
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
|
||||
// the reload writes pending changes to browser storage first, and a failed write is reported
|
||||
// instead of reloading (#225).
|
||||
// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering
|
||||
// the key to the app; other chords and HTML inputs keep their browser behavior (#2495).
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ };
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
|
||||
// #2495: prevent the browser default without swallowing the app's Duplicate key.
|
||||
const canvas = new EventTarget();
|
||||
const input = new EventTarget();
|
||||
host.installCanvasShortcuts(canvas);
|
||||
let delivered = 0;
|
||||
canvas.addEventListener("keydown", () => delivered++);
|
||||
const key = (target, properties) => {
|
||||
const event = new Event("keydown", { cancelable: true });
|
||||
Object.assign(event, { key: "d", ...properties });
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
assert.equal(key(canvas, { ctrlKey: true }), true);
|
||||
assert.equal(key(canvas, { metaKey: true, key: "D" }), true);
|
||||
for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) {
|
||||
assert.equal(key(canvas, properties), false);
|
||||
}
|
||||
// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true);
|
||||
// Dvorak: the physical D key types "e", which is not Duplicate.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false);
|
||||
assert.equal(delivered, 9, "all keys still reach the app");
|
||||
assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut");
|
||||
|
||||
// #226: one readable and one unreadable file.
|
||||
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
|
||||
const picked = host.browsePickFiles(false);
|
||||
|
||||
@@ -162,7 +162,7 @@ try {
|
||||
const info = await js("effectcraft.info()");
|
||||
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
|
||||
if (info.webgpu && info.backend === "BrowserWebGpu") {
|
||||
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu?.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ try {
|
||||
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
|
||||
report.steps.storage = st;
|
||||
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
|
||||
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
|
||||
check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
|
||||
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
|
||||
await sleep(800);
|
||||
report.steps.storage.screenshot = await shot("01c-storage-manager");
|
||||
@@ -378,14 +378,14 @@ try {
|
||||
const jobs = gj.workers.jobs || [];
|
||||
const rj = jobs.filter((j) => j.kind === "Render").pop();
|
||||
const wj = jobs.filter((j) => j.kind === "Warp").pop();
|
||||
check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
|
||||
check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
|
||||
check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`);
|
||||
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
|
||||
check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
|
||||
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
|
||||
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
|
||||
check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
|
||||
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
|
||||
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
|
||||
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
|
||||
check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
|
||||
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
|
||||
check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`);
|
||||
} else {
|
||||
@@ -418,7 +418,7 @@ try {
|
||||
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
|
||||
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
|
||||
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
|
||||
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
||||
check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
await js(`effectcraft.execute("time.set", {time: 0})`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
||||
@@ -579,7 +579,7 @@ try {
|
||||
report.steps.final2 = { screenshot: await shot("03-offline") };
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
report.error = String(e && e.stack || e);
|
||||
report.error = String(e?.stack || e);
|
||||
try { report.errorShot = await shot("error"); } catch {}
|
||||
}
|
||||
report.console = logs.slice(-40);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
|
||||
// cache named after the version, served cache-first (responses keep the server's headers, so
|
||||
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time.
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time,
|
||||
// except the config file, which must stay live.
|
||||
const VERSION = "__EC_VERSION__";
|
||||
const FILES = __EC_FILES__;
|
||||
const CACHE = `effectcraft-${VERSION}`;
|
||||
@@ -30,15 +31,18 @@ self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
if (url.pathname.endsWith("/effectcraft-config.json")) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
|
||||
if (hit) return hit;
|
||||
const cache = await caches.open(CACHE).catch(() => null);
|
||||
if (cache) {
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const res = await fetch(req);
|
||||
if (res.ok && res.type === "basic") cache.put(req, res.clone());
|
||||
if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ effectcraft-host = { workspace = true }
|
||||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
|
||||
@@ -27,6 +27,9 @@ cpal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Native GNOME/Wayland controls without a bundled title font.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
|
||||
# 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"
|
||||
@@ -48,3 +51,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
|
||||
# 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"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
+148
-26
@@ -8,9 +8,11 @@
|
||||
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
|
||||
//!
|
||||
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `effectcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
//! see `effectcraft_ui_egui::control` for the methods. An app started for an agent opens without
|
||||
//! taking keyboard focus and skips crash recovery, the Home screen (unless `--home`) and the
|
||||
//! startup update check.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
mod appearance;
|
||||
@@ -50,7 +52,7 @@ fn app_icon() -> egui::IconData {
|
||||
fn is_project_file(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
|
||||
}
|
||||
|
||||
@@ -112,6 +114,13 @@ fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, s
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Whether the app looks for a newer release when it starts. Not when started for an agent
|
||||
/// (`--control`): the "update available" prompt is modal and would block the run until a person
|
||||
/// dismisses it. Help ▸ Check for Updates still works.
|
||||
fn startup_update_check(control_port: Option<u16>) -> bool {
|
||||
control_port.is_none()
|
||||
}
|
||||
|
||||
/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
|
||||
/// the flag or variable it came from.
|
||||
fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
@@ -128,17 +137,54 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
/// answer without pumping events, so the window stops answering the compositor and the desktop
|
||||
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
|
||||
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
|
||||
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the
|
||||
/// main window's handle is at hand.
|
||||
fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>> {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
let ctx = ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<sheet_dialogs::Asked>();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| {
|
||||
if tx.send((dialog, done)).is_err() {
|
||||
log::error!("file dialog: the window is gone");
|
||||
}
|
||||
}));
|
||||
Some(rx)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
let _ = (app, ctx);
|
||||
{
|
||||
let _ = app;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// External effect helpers are native programs, so they start at launch only when the user opts
|
||||
/// in with `EFFECTCRAFT_HELPERS=1`: an environment variable, outside the settings folder, so
|
||||
/// writing there alone can't run code at launch. Otherwise Effect ▸ Scan for Effect Helpers
|
||||
/// starts them.
|
||||
fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) {
|
||||
if session.helpers.is_none() || std::env::var_os("EFFECTCRAFT_HELPERS").is_none_or(|v| v != "1") {
|
||||
return;
|
||||
}
|
||||
match session.execute("effect.helpers.scan", json!({})) {
|
||||
Ok(r) => {
|
||||
for e in r["errors"].as_array().into_iter().flatten() {
|
||||
log::warn!("effect helpers: {}", e.as_str().unwrap_or("error"));
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("effect helpers: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the
|
||||
/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window.
|
||||
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
|
||||
/// reports startup failure through `startup_failed`. Returns the event loop's result.
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
// feed the compatibility report.
|
||||
@@ -203,6 +249,7 @@ fn main() -> eframe::Result {
|
||||
"EffectCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
match &cc.wgpu_render_state {
|
||||
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
|
||||
None => log::info!("graphics: no wgpu device"),
|
||||
@@ -233,16 +280,25 @@ fn main() -> eframe::Result {
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
|
||||
let plugins = dir.join("Plug-ins");
|
||||
let plugins = dir.join(effectcraft_engine::commands::PLUGINS_DIR);
|
||||
if plugins.is_dir() {
|
||||
let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
|
||||
// Packages installed from the Plug-in Library (<config>/Plug-ins/Library).
|
||||
// Each package that fails to load is logged by the library itself.
|
||||
if let Err(e) = session.execute("library.loadInstalled", json!({})) {
|
||||
log::warn!("plug-in library: {e}");
|
||||
}
|
||||
}
|
||||
start_helpers_if_opted_in(&mut session);
|
||||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
|
||||
// a toast).
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
@@ -263,6 +319,7 @@ fn main() -> eframe::Result {
|
||||
// reported in a toast and forgotten).
|
||||
let _ = session.execute("window.restoreScriptPanels", json!({}));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_startup_update_check(startup_update_check(control_port));
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = show_home;
|
||||
if let Some(n) = notice {
|
||||
@@ -319,7 +376,7 @@ fn main() -> eframe::Result {
|
||||
}));
|
||||
// The commands' file dialogs don't block the frame where the platform's backend
|
||||
// would (#511, #1275); see [`install_file_dialogs`].
|
||||
install_file_dialogs(&mut app, &cc.egui_ctx);
|
||||
let file_dialogs = install_file_dialogs(&mut app);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.hooks.app_action = Some(Box::new(native_menu::app_action));
|
||||
@@ -337,20 +394,18 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
app,
|
||||
file_dialogs,
|
||||
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
|
||||
launch,
|
||||
drawn: false,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
match &result {
|
||||
Ok(()) => log::info!("the window closed"),
|
||||
Err(e) => {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
if let Err(e) = &result {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -359,9 +414,15 @@ fn main() -> eframe::Result {
|
||||
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
|
||||
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
|
||||
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
|
||||
///
|
||||
/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one
|
||||
/// frame in flight, so a drag or a scrub shows one frame sooner. The bare
|
||||
/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three
|
||||
/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms
|
||||
/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window.
|
||||
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::WgpuSetup;
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
|
||||
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
if let Some(b) = backends {
|
||||
setup.instance_descriptor.backends = b;
|
||||
@@ -371,15 +432,22 @@ fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfigu
|
||||
// Iris Pro: the device is lost and the window never opens.
|
||||
// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
|
||||
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
|
||||
label: Some("EffectCraft device"),
|
||||
required_limits: device_limits(adapter.limits()),
|
||||
..Default::default()
|
||||
});
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
|
||||
/// so memory the compositor frees on the shared device goes back to the driver.
|
||||
fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
|
||||
eframe::wgpu::DeviceDescriptor {
|
||||
label: Some("EffectCraft device"),
|
||||
required_limits: device_limits(adapter),
|
||||
memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
|
||||
/// capped at 16384 px like the headless renderer's device.
|
||||
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
|
||||
@@ -411,6 +479,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
|
||||
/// The desktop app: the egui app plus, on macOS, the system menu bar.
|
||||
struct Desktop {
|
||||
app: EffectcraftApp,
|
||||
/// The commands' file dialogs, shown at the end of the frame that asked for them.
|
||||
file_dialogs: Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: native_menu::NativeBar,
|
||||
/// Documents opened from Finder, the Dock or `open` (macOS).
|
||||
@@ -424,14 +494,24 @@ struct Desktop {
|
||||
}
|
||||
|
||||
impl eframe::App for Desktop {
|
||||
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the
|
||||
/// file dialogs asked for and, last, hand a close to the platform quit.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.menu.update(&mut self.app, ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.tick(ctx, frame);
|
||||
self.app.finish_ime_logic(ctx);
|
||||
if let Some(asked) = &self.file_dialogs {
|
||||
sheet_dialogs::show_asked(asked, frame, ctx);
|
||||
}
|
||||
effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
if let Some(asked) = &self.file_dialogs {
|
||||
sheet_dialogs::show_asked(asked, frame, ui.ctx());
|
||||
}
|
||||
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
@@ -445,9 +525,15 @@ impl eframe::App for Desktop {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
|
||||
// before `eframe::run_native` returns (#341).
|
||||
log::info!("the window is closing");
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
effectcraft_host::stop_helpers();
|
||||
// Scripts in Scripts/Shutdown run with the project closed.
|
||||
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
@@ -526,6 +612,19 @@ mod tests {
|
||||
assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
/// The window's surface keeps one frame in flight, like eframe's own default, not the two
|
||||
/// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag).
|
||||
#[test]
|
||||
fn the_window_surface_is_low_latency() {
|
||||
for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] {
|
||||
let surface = super::wgpu_options(backends).surface;
|
||||
assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}");
|
||||
assert_eq!(surface.desired_maximum_frame_latency, Some(1));
|
||||
assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync);
|
||||
}
|
||||
assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface");
|
||||
}
|
||||
|
||||
/// The window's device never asks for more than the adapter offers (#198).
|
||||
#[test]
|
||||
fn device_limits_fit_the_adapter() {
|
||||
@@ -539,6 +638,17 @@ mod tests {
|
||||
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
|
||||
}
|
||||
|
||||
/// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
|
||||
#[test]
|
||||
fn the_window_device_favours_memory_usage() {
|
||||
use eframe::wgpu::Limits;
|
||||
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
|
||||
let d = super::device_descriptor(big);
|
||||
assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
|
||||
assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
|
||||
assert_eq!(d.label, Some("EffectCraft device"));
|
||||
}
|
||||
|
||||
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
|
||||
#[test]
|
||||
fn launch_classifies_xml_and_json_projects_as_projects() {
|
||||
@@ -565,6 +675,18 @@ mod tests {
|
||||
assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
|
||||
}
|
||||
|
||||
/// An app started for an agent (`--control` or `EFFECTCRAFT_CONTROL_PORT`) doesn't check for
|
||||
/// updates at start, so no modal update prompt blocks the run; a normal launch still does.
|
||||
#[test]
|
||||
fn agent_runs_skip_the_startup_update_check() {
|
||||
let port = |args: &[&str], env| parse_env(args, env).unwrap().unwrap_or_default().control_port;
|
||||
assert!(super::startup_update_check(port(&[], Err(VarError::NotPresent))));
|
||||
assert!(super::startup_update_check(port(&[], Ok(String::new()))));
|
||||
assert!(!super::startup_update_check(port(&["--control", "9877"], Err(VarError::NotPresent))));
|
||||
assert!(!super::startup_update_check(port(&["--control=9877"], Err(VarError::NotPresent))));
|
||||
assert!(!super::startup_update_check(port(&[], Ok("9877".into()))));
|
||||
}
|
||||
|
||||
/// A missing, non-numeric or zero `--control` value stops the launch instead of starting
|
||||
/// without a control server, and the argument after `--control` isn't reinterpreted (#997).
|
||||
#[test]
|
||||
@@ -620,10 +742,10 @@ mod tests {
|
||||
#[test]
|
||||
fn commands_file_dialogs_do_not_block_the_frame() {
|
||||
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
super::install_file_dialogs(&mut app, &egui::Context::default());
|
||||
let asked = super::install_file_dialogs(&mut app);
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
|
||||
assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
|
||||
assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
//! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree.
|
||||
//!
|
||||
//! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window
|
||||
//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels,
|
||||
//! enabled and check state into the existing items; a structural change (Open Recent, a new
|
||||
//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS
|
||||
//! swaps back to the in-window bar.
|
||||
//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it
|
||||
//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a
|
||||
//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸
|
||||
//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
@@ -24,7 +24,7 @@ pub struct NativeBar {
|
||||
items: HashMap<String, CheckMenuItem>,
|
||||
spec: NativeMenu,
|
||||
structure: u64,
|
||||
state: Option<u64>,
|
||||
state: Option<(u64, bool)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
/// The in-window-bar setting last applied (None: not yet).
|
||||
in_window: Option<bool>,
|
||||
@@ -67,12 +67,13 @@ impl NativeBar {
|
||||
if in_window {
|
||||
return;
|
||||
}
|
||||
let key = native_menu::state_key(app);
|
||||
// While a text field has the keyboard, the Edit items it takes stay enabled.
|
||||
let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some());
|
||||
if self.state == Some(key) && self.menu.is_some() {
|
||||
return;
|
||||
}
|
||||
self.state = Some(key);
|
||||
let spec = native_menu::build(app);
|
||||
let spec = native_menu::build_now(app, ctx);
|
||||
let structure = spec.structure();
|
||||
if self.menu.is_none() || structure != self.structure {
|
||||
self.install(&spec);
|
||||
|
||||
@@ -17,10 +17,24 @@ use std::pin::Pin;
|
||||
|
||||
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
|
||||
/// closes, then wake the UI.
|
||||
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
|
||||
let answer = open(dialog);
|
||||
/// A command's dialog waiting for [`show_asked`].
|
||||
pub type Asked = (FileDialog, DialogDone);
|
||||
|
||||
/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens
|
||||
/// here) and hand each one's paths to its `done` once it closes, then wake the UI.
|
||||
///
|
||||
/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on
|
||||
/// AppKit's main window, which is a floating panel's OS window after a click in one; that
|
||||
/// window hides while the app has no focused window, and a sheet whose window goes away ends
|
||||
/// without answering, with later sheets queued behind it on the same window.
|
||||
pub fn show_asked(asked: &std::sync::mpsc::Receiver<Asked>, frame: &eframe::Frame, ctx: &egui::Context) {
|
||||
while let Ok((dialog, done)) = asked.try_recv() {
|
||||
show(dialog, done, frame, ctx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) {
|
||||
let answer = open(dialog, frame);
|
||||
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
|
||||
done(pollster::block_on(answer));
|
||||
ctx.request_repaint();
|
||||
@@ -30,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the
|
||||
/// window for the portal from the dialog's worker thread there.
|
||||
fn parented(frame: &eframe::Frame) -> AsyncFileDialog {
|
||||
let d = AsyncFileDialog::new();
|
||||
if cfg!(target_os = "macos") { d.set_parent(frame) } else { d }
|
||||
}
|
||||
|
||||
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
|
||||
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
|
||||
}
|
||||
@@ -39,10 +60,10 @@ fn path(file: Option<FileHandle>) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// The dialog, as the blocking hooks in `main.rs` show it.
|
||||
fn open(dialog: FileDialog) -> Answer {
|
||||
fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer {
|
||||
match dialog {
|
||||
FileDialog::Open { exts, many } => {
|
||||
let d = AsyncFileDialog::new().add_filter("Media", &exts);
|
||||
let d = parented(frame).add_filter("Media", &exts);
|
||||
if many {
|
||||
let f = d.pick_files();
|
||||
Box::pin(async move { paths(f.await) })
|
||||
@@ -52,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer {
|
||||
}
|
||||
}
|
||||
FileDialog::OpenProject => {
|
||||
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
|
||||
let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Folder => {
|
||||
let f = AsyncFileDialog::new().pick_folder();
|
||||
let f = parented(frame).pick_folder();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Save { default, ext, project } => {
|
||||
let at = std::path::Path::new(&default);
|
||||
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut d = if project {
|
||||
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
|
||||
parented(frame).add_filter("EffectCraft Project", &["ecproj"])
|
||||
} else if ext.is_empty() {
|
||||
AsyncFileDialog::new()
|
||||
parented(frame)
|
||||
} else {
|
||||
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
|
||||
parented(frame).add_filter(&ext, &[ext.as_str()])
|
||||
};
|
||||
// A default with a folder that exists opens the dialog there.
|
||||
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! The desktop binary rejects a bad command line before it starts anything (#997).
|
||||
// Integration tests: unwrapping and panicking on failure is fine here.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into())
|
||||
}
|
||||
|
||||
/// A settings folder of its own, so a launch that got past the arguments would show up as
|
||||
/// preferences (Recent Footage) written there instead of in the user's settings.
|
||||
fn config_dir(name: &str) -> PathBuf {
|
||||
@@ -19,7 +22,7 @@ fn config_dir(name: &str) -> PathBuf {
|
||||
/// regression fails instead of leaving a window open: the process is killed after 5 s.
|
||||
fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
let config = config_dir(name);
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft"));
|
||||
let mut cmd = Command::new(binary());
|
||||
cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config);
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
@@ -67,7 +70,7 @@ fn a_bad_control_port_stops_the_launch() {
|
||||
|
||||
#[test]
|
||||
fn version_still_prints_with_a_bad_port_variable() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
@@ -15,9 +15,8 @@ full-bleed colour field, tight head-and-shoulders crop, the body running off the
|
||||
| Paper | `#efe9dc` | the figure |
|
||||
| Ink | `#0b0b0c` | line work and contour |
|
||||
|
||||
**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image, kept in
|
||||
craftrules at `assets/app-icons/effectcraft/source.png`). It was vectorised with craftrules
|
||||
`assets/logo-options/_tools/vectorize_tile.py`, which keys out the field and traces the ink with potrace.
|
||||
**Provenance:** the project owner drew it in ArtCraft (a 2880 px engraving-style image). It was
|
||||
vectorised with potrace, which keys out the field and traces the ink.
|
||||
Licence: see [LICENSE.txt](LICENSE.txt) (MIT OR Apache-2.0, like the repo).
|
||||
|
||||
## Files
|
||||
@@ -37,6 +36,5 @@ App ID: `ai.storyteller.effectcraft` (Wayland app ID, `packaging/linux/ai.storyt
|
||||
|
||||
## Regenerate
|
||||
|
||||
Copy a new `icon-master.svg` / `icon.svg` from craftrules `assets/app-icons/effectcraft/` over
|
||||
`effectcraft.svg` / `effectcraft-small.svg`, then run `packaging/icons.sh` (needs `resvg`; `iconutil`
|
||||
After replacing `effectcraft.svg` / `effectcraft-small.svg`, run `packaging/icons.sh` (needs `resvg`; `iconutil`
|
||||
on macOS for the `.icns`). Copy `effectcraft-small.svg` to `apps/effectcraft-web/web/favicon.svg` too.
|
||||
@@ -5,4 +5,4 @@ source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
license-file: assets/app-icon/LICENSE.txt
|
||||
added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon)
|
||||
notes: The same drawing vectorised at 1024 px with craftrules vectorize_tile.py, about 350 KB, for places where size matters.
|
||||
notes: The same drawing vectorised at 1024 px, about 350 KB, for places where size matters.
|
||||
@@ -5,4 +5,4 @@ source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
license-file: assets/app-icon/LICENSE.txt
|
||||
added: 2026-10-02 by EffectCraft contributors (Claude agent, app icon)
|
||||
notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px with craftrules assets/logo-options/_tools/vectorize_tile.py (potrace; three colours). Canonical artwork; the other icon files derive from it.
|
||||
notes: The owner's ArtCraft drawing of an engraved unicorn bust, vectorised at 2048 px (potrace; three colours). Canonical artwork; the other icon files derive from it.
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
asset: NotoSansSymbols2-Regular.ttf
|
||||
title: Noto Sans Symbols 2 Regular (chrome symbols such as ✔ and ▣)
|
||||
author: The Noto Project Authors
|
||||
source: https://github.com/notofonts/symbols (Google Fonts commit 47d54ca1ebb14fd864695f1d39dc734f39017c33, file ofl/notosanssymbols2/NotoSansSymbols2-Regular.ttf)
|
||||
license: OFL-1.1
|
||||
license-file: assets/fonts/OFL-NotoSansSymbols2.txt
|
||||
added: 2026-10-24 by @skylartaylor
|
||||
notes: Unmodified font file, sha256 7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21. Embedded with include_bytes! in crates/ui-egui/src/theme.rs as the last UI fallback so every family can draw the chrome symbols (U+2714 ✔, U+25A3 ▣) that Inter, Inter Medium, Inter SemiBold and JetBrains Mono lack.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -17,11 +14,19 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.Krueels]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
@@ -57,7 +62,8 @@ sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
@@ -95,3 +101,37 @@ sync_github_name = true
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ki2pixel]
|
||||
display_name = "ki2pixel"
|
||||
|
||||
[people.iseemhlyfta]
|
||||
display_name = "iseemhlyfta"
|
||||
sync_github_name = false
|
||||
|
||||
[people.becauseno7]
|
||||
real_name = "Shuvra Basak"
|
||||
display_name = "Shuvra Basak"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.adikshan11]
|
||||
real_name = "Adithya Shankaran"
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
real_name = "Arshiya Rahgozar"
|
||||
display_name = "Arshiya"
|
||||
sync_github_name = false
|
||||
|
||||
[people.JonRC]
|
||||
real_name = "Jonathan Cardoso"
|
||||
display_name = "Jonathan Cardoso"
|
||||
|
||||
[people.nazar-gavrylyk]
|
||||
sync_github_name = true
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "effectcraft-aep"
|
||||
description = "After Effects projects: the .aep (RIFX 'Egg!') / .aepx container as a lossless chunk tree with a typed view, import into an EffectCraft project and export back to .aep with every chunk built in code; each reports everything it does not carry over"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
effectcraft-time = { workspace = true }
|
||||
effectcraft-color = { workspace = true }
|
||||
effectcraft-keyframe = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-effects = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# The tool's own tests run with the crate's (examples are only built by default).
|
||||
[[example]]
|
||||
name = "stubicc"
|
||||
test = true
|
||||
@@ -0,0 +1,99 @@
|
||||
# effectcraft-aep
|
||||
|
||||
After Effects projects (`.aep`, `.aepx`) in one crate, the way `effectcraft-lottie` handles Lottie:
|
||||
|
||||
- `container`: reads and writes the container as a lossless chunk tree, with a typed, read-only
|
||||
view (below; its API is re-exported at the crate root);
|
||||
- `import`: maps the typed view into an EffectCraft project and reports everything it does not
|
||||
import ([`docs/aep-import.md`](../../docs/aep-import.md));
|
||||
- `export`: writes an EffectCraft project as an `.aep` with every chunk built in code, and reports everything it does not export
|
||||
([`docs/aep-export.md`](../../docs/aep-export.md)).
|
||||
|
||||
No file access: callers pass bytes, so the crate builds for wasm32.
|
||||
|
||||
## Container
|
||||
|
||||
An `.aep` is a `RIFX` container (big-endian RIFF) with form type `Egg!`: chunks of a 4-byte id, a
|
||||
big-endian `u32` size and a body padded to an even length; `LIST` chunks start with a 4-byte list
|
||||
type followed by chunks. `parse` builds the tree, `write` serialises it, and for every input that
|
||||
parses, `write(parse(x)) == x` byte for byte (container and chunk sizes are recomputed from the
|
||||
tree; pad bytes, odd bodies and the bytes after the container, an XMP packet, are kept).
|
||||
|
||||
The XML form of a project (`.aepx`) is read into the same tree by `read_aepx` (a small bounded
|
||||
XML tokenizer, no dependency); `parse_any` picks the reader by content, so the typed view and the
|
||||
importer take either form. The mapping is in `docs/aep-format.md`, section 1.
|
||||
|
||||
Bodies that look like lists but are not chunk sequences (for example `LIST btdk`, raw text) stay
|
||||
opaque rather than failing the parse. Input is untrusted: sizes are checked against the remaining
|
||||
input, nesting and chunk count are bounded (`Limits`), nothing is allocated from a declared size,
|
||||
and truncated or damaged files return `Err` (or, inside the container, an opaque body).
|
||||
|
||||
The container layer decodes no chunk body. On top of it, a typed view (`Project`, `Item`, `Comp`, `Layer`,
|
||||
`Property`, ...) reads the fields whose layout was verified against the values the test files were made
|
||||
with: layer and composition headers (switches, exact times and time stretch, size, frame rate, work area, shutter phase, display start, renderer),
|
||||
static transform values in After Effects' units with the "absent = default" rule, keyframe records
|
||||
(interpolation sides, ease per component, plain and spatial templates), Time Remapping, expressions with their enable switch, effect
|
||||
instances and parameter values, masks and bezier paths with tangents, shape layer groups, text documents
|
||||
(paragraph and style runs, box, justification, tracking, faux styles, caps, leading), markers with
|
||||
duration, strings and cue data, cameras and lights with their options, footage (stills, audio, image
|
||||
sequences), solids and precomp links. Every accessor returns `Option`/an empty list on damaged data and never
|
||||
panics. Which fields are verified, which are not decoded, and how it was found is in
|
||||
[`docs/aep-format.md`](../../docs/aep-format.md).
|
||||
|
||||
## Specifications
|
||||
|
||||
Public specifications used for parts the format embeds:
|
||||
|
||||
- ICC.1:2010: the profile description of a working-space profile (a v2 `desc` or a v4 `mluc`
|
||||
tag), from which the working space's name is read (`container/settings.rs`).
|
||||
- RFC 4648 (base64): the profile data in the project's colour settings JSON (read, and written
|
||||
by the exporter).
|
||||
- The exporter's own colour profiles (`export/icc.rs`): ICC.1:2010 version 4.3 display profiles
|
||||
with ITU-R BT.709 primaries, D65 white, a D50 PCS and a Bradford adaptation, the IEC 61966-2-1
|
||||
sRGB curve or a 2.4 power curve; the profile ID is the MD5 of RFC 1321.
|
||||
|
||||
## Clean room
|
||||
|
||||
Everything here comes from our own observations: project files that we generated ourselves with
|
||||
After Effects, one change per file, compared byte by byte. No source code or format
|
||||
documentation of another `.aep` reader or writer was used, and no Adobe asset is used. The test fixtures are such generated files
|
||||
(own test data): a few combined projects, one composition per case (`tests/fixtures/CASES.md`;
|
||||
the generator is in `tests/fixtures/tools/`). They are not in this repository but in
|
||||
[storytold/effectcraft-corpus](https://github.com/storytold/effectcraft-corpus), with their
|
||||
attribution, the generation notes and the inspection of their embedded content.
|
||||
`cargo xtask aep-corpus` fetches them at the commit pinned in `tests/fixtures/corpus.lock`, checks
|
||||
every file against the SHA-256 listed there and only then installs them in `corpus/aep` (ignored by
|
||||
git). The tests read them from the folder named by the `EFFECTCRAFT_AEP_FIXTURES` environment
|
||||
variable (opt-in; set `EFFECTCRAFT_AEP_FIXTURES_REQUIRED=1` too, so that a missing project fails). The
|
||||
tests reach them only through `tests/it/common/mod.rs`, which hands a test its case as a project
|
||||
of its own cut from the combined file; a test whose fixture is missing (or with the variable not
|
||||
set) prints `SKIPPED: fixture <name> missing` and passes.
|
||||
|
||||
`tests/fixtures/synthetic.aep` (115 KB) is one of the two projects in the repository: made in After Effects 2026
|
||||
with the working space set to None (one composition, an image sequence of three 8x8 PNGs, a
|
||||
solid, a folder, a Levels and a Glow effect with expressions). Every After Effects project embeds
|
||||
the working space's ICC profile (HP sRGB IEC61966-2.1 for the default) in a JSON settings chunk;
|
||||
the one `Utf8` chunk of this file that held a profile was replaced by `{}`, and nothing else was
|
||||
changed. The tests in `tests/it/import/synthetic.rs` damage copies of it and need no other file.
|
||||
|
||||
`tests/fixtures/ai-mask-3099.aep` (131 KB, #3099) is the second: four hand-written one-page PDFs
|
||||
named `.ai` with distinct page boxes, placed in a composition, one of them with two masks under a
|
||||
Null parented to a Null (generated by `tests/fixtures/tools/ai-mask-3099.py` and
|
||||
`ai-mask-3099.jsx`, scrubbed with `scrub.py` and `stubicc`). `tests/it/import/ai_mask.rs` checks
|
||||
the import against After Effects' read-back, and the host's `vector_import` tests render it.
|
||||
|
||||
## Tools
|
||||
|
||||
- `tests/fixtures/tools/`: the generator of the test projects (regenerates the corpus with After
|
||||
Effects; its README says how).
|
||||
- `examples/aepdump.rs`: prints the chunk tree of a file (ids, sizes, short data, match names):
|
||||
`cargo run -p effectcraft-aep --example aepdump -- file.aep`.
|
||||
- `examples/stubicc.rs`: replaces every embedded ICC profile of a saved project by a stub that keeps
|
||||
only the profile's description: the JSON `colorProfileData` of the working space (any white space
|
||||
around the colon, `\/` escapes, quotes as `"`) in an `.aep` chunk or the `.aepx` text, and a raw
|
||||
profile of an imported image (an `.aep` chunk, or the `bdata` hex of an `.aepx`):
|
||||
`cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep`. A profile it cannot replace
|
||||
stops it before anything is written. The importer reads the working space's name from the
|
||||
description only, so the project imports the same; no vendor colour data stays in the test
|
||||
project. Check the result independently, not by the tool's count (the corpus repository has
|
||||
`tools/aep/inspect-embedded.py`, which scans the raw bytes, base64 and hex).
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Developer tool: prints the chunk tree of an .aep (ids, sizes, short data), match names inline.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
use effectcraft_aep::{Body, Chunk};
|
||||
|
||||
/// Ends this developer tool with a message instead of a panic.
|
||||
trait OrExit<T> {
|
||||
fn or_exit(self, what: &str) -> T;
|
||||
}
|
||||
|
||||
impl<T> OrExit<T> for Option<T> {
|
||||
fn or_exit(self, what: &str) -> T {
|
||||
match self {
|
||||
Some(v) => v,
|
||||
None => exit(what),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Debug> OrExit<T> for Result<T, E> {
|
||||
fn or_exit(self, what: &str) -> T {
|
||||
match self {
|
||||
Ok(v) => v,
|
||||
Err(e) => exit(&format!("{what}: {e:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exit(msg: &str) -> ! {
|
||||
eprintln!("{msg}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
fn show(c: &Chunk<'_>, depth: usize, max: usize, hex: usize) {
|
||||
let ind = " ".repeat(depth);
|
||||
let id = String::from_utf8_lossy(&c.id).to_string();
|
||||
match &c.body {
|
||||
Body::Data(d) if &c.id == b"tdmn" => {
|
||||
let n: String = d.iter().take_while(|b| **b != 0).map(|b| *b as char).collect();
|
||||
println!("{ind}tdmn {n}");
|
||||
}
|
||||
Body::Data(d) => {
|
||||
let h: String = d.iter().take(hex).map(|b| format!("{b:02x}")).collect();
|
||||
let a: String = d.iter().take(hex).map(|b| if (32..127).contains(b) { *b as char } else { '.' }).collect();
|
||||
println!("{ind}{id} [{}] {h} |{a}|", d.len());
|
||||
}
|
||||
Body::Chunks(k) => {
|
||||
println!("{ind}{id} (nested)");
|
||||
if depth < max {
|
||||
for x in k {
|
||||
show(x, depth + 1, max, hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Body::List { list_type, children } => {
|
||||
println!("{ind}LIST {} ({} children)", String::from_utf8_lossy(list_type), children.len());
|
||||
if depth < max {
|
||||
for x in children {
|
||||
show(x, depth + 1, max, hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Body::OpaqueList { list_type, data } => println!("{ind}LIST {} opaque [{}]", String::from_utf8_lossy(list_type), data.len()),
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(a.get(1).or_exit("usage: see the comment at the top of this file")).or_exit("read");
|
||||
let max: usize = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(4);
|
||||
let hex: usize = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(32);
|
||||
let f = effectcraft_aep::parse(&bytes).or_exit("parse");
|
||||
for c in &f.chunks {
|
||||
show(c, 0, max, hex);
|
||||
}
|
||||
println!("trailer {} bytes", f.trailer.len());
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Developer tool: replaces every embedded ICC profile of an .aep / .aepx by a stub that keeps only the
|
||||
//! profile's description. Fixture hygiene: a saved project carries the vendor's working-space profile
|
||||
//! (a few KB of colour data); the importer reads only the profile's name, the `desc` tag, so the stub
|
||||
//! (a 128-byte header, one `desc` tag, no colour data, made here) imports identically.
|
||||
//!
|
||||
//! `cargo run -p effectcraft-aep --example stubicc -- in.aep out.aep`
|
||||
//!
|
||||
//! It handles:
|
||||
//! - the JSON value `"colorProfileData": "<base64>"` in any chunk of an .aep and anywhere in the text
|
||||
//! of an .aepx (white space around the colon, `\/` escapes and quotes written as `"` allowed);
|
||||
//! - a raw ICC profile stored as a chunk of its own (the profile of an imported image), in an .aep
|
||||
//! as the chunk's bytes and in an .aepx as its `bdata="<hex>"` attribute.
|
||||
//!
|
||||
//! A profile it cannot replace (no readable description, data that does not decode) stops the tool
|
||||
//! before anything is written. Check the result independently (the corpus has
|
||||
//! `tools/aep/inspect-embedded.py`), not by this tool's own count.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
use effectcraft_aep::{Body, Chunk};
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Ends this developer tool with a message instead of a panic.
|
||||
trait OrExit<T> {
|
||||
fn or_exit(self, what: &str) -> T;
|
||||
}
|
||||
|
||||
impl<T> OrExit<T> for Option<T> {
|
||||
fn or_exit(self, what: &str) -> T {
|
||||
match self {
|
||||
Some(v) => v,
|
||||
None => exit(what),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Debug> OrExit<T> for Result<T, E> {
|
||||
fn or_exit(self, what: &str) -> T {
|
||||
match self {
|
||||
Ok(v) => v,
|
||||
Err(e) => exit(&format!("{what}: {e:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exit(msg: &str) -> ! {
|
||||
eprintln!("{msg}");
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
fn b64_decode(text: &[u8]) -> Option<Vec<u8>> {
|
||||
let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
|
||||
for &c in text.iter().filter(|c| **c != b'=') {
|
||||
let v = u32::try_from(B64.iter().position(|b| *b == c)?).ok()?;
|
||||
acc = (acc << 6 | v) & 0x00ff_ffff;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn b64_encode(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for ch in data.chunks(3) {
|
||||
let at = |i: usize| u32::from(ch.get(i).copied().unwrap_or(0));
|
||||
let n = (at(0) << 16) | (at(1) << 8) | at(2);
|
||||
for i in 0..4usize {
|
||||
if i <= ch.len() {
|
||||
out.push(B64.get(((n >> (18 - 6 * i)) & 63) as usize).copied().unwrap_or(b'A'));
|
||||
} else {
|
||||
out.push(b'=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn be32(d: &[u8], at: usize) -> Option<usize> {
|
||||
let b: [u8; 4] = d.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_be_bytes(b)).ok()
|
||||
}
|
||||
|
||||
/// The profile description (ICC.1 `desc`: v2 ASCII or v4 `mluc`, first record).
|
||||
fn description(icc: &[u8]) -> Option<String> {
|
||||
let count = be32(icc, 128)?;
|
||||
let tag = (0..count.min(1024)).find_map(|i| {
|
||||
let at = 132usize.checked_add(i.checked_mul(12)?)?;
|
||||
(icc.get(at..at.checked_add(4)?)? == b"desc").then_some(at)
|
||||
})?;
|
||||
let off = be32(icc, tag.checked_add(4)?)?;
|
||||
let body = icc.get(off..off.checked_add(be32(icc, tag.checked_add(8)?)?)?)?;
|
||||
let text = match body.get(..4)? {
|
||||
b"desc" => {
|
||||
let n = be32(body, 8)?;
|
||||
String::from_utf8_lossy(body.get(12..12usize.checked_add(n)?)?.split(|b| *b == 0).next()?).into_owned()
|
||||
}
|
||||
b"mluc" => {
|
||||
let (n, at) = (be32(body, 20)?, be32(body, 24)?);
|
||||
let units: Vec<u16> = body.get(at..at.checked_add(n)?)?.chunks(2).filter_map(|p| Some(u16::from_be_bytes([*p.first()?, *p.get(1)?]))).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(text.trim_end_matches('\0').trim().to_owned())
|
||||
}
|
||||
|
||||
/// A profile with a header, one `desc` tag and no colour data.
|
||||
fn stub(desc: &str) -> Option<Vec<u8>> {
|
||||
let mut body = b"desc\0\0\0\0".to_vec();
|
||||
body.extend_from_slice(&u32::try_from(desc.len().checked_add(1)?).ok()?.to_be_bytes());
|
||||
body.extend_from_slice(desc.as_bytes());
|
||||
body.push(0);
|
||||
body.extend_from_slice(&[0; 4 + 4 + 2 + 1 + 67]);
|
||||
let mut p = vec![0u8; 128];
|
||||
for (at, v) in [(8, &0x0240_0000u32.to_be_bytes()[..]), (12, b"mntr"), (16, b"RGB "), (20, b"XYZ "), (36, b"acsp")] {
|
||||
p.get_mut(at..at + v.len())?.copy_from_slice(v);
|
||||
}
|
||||
p.extend_from_slice(&1u32.to_be_bytes());
|
||||
p.extend_from_slice(b"desc");
|
||||
p.extend_from_slice(&144u32.to_be_bytes());
|
||||
p.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes());
|
||||
p.extend_from_slice(&body);
|
||||
let size = u32::try_from(p.len()).ok()?.to_be_bytes();
|
||||
p.get_mut(..4)?.copy_from_slice(&size);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// The JSON key, without its quotes.
|
||||
const KEY: &[u8] = b"colorProfileData";
|
||||
/// How a JSON quote is written: as is (a `Utf8` chunk, the `<string>` text of an .aepx) and as an
|
||||
/// XML entity (JSON inside an attribute of an .aepx).
|
||||
const QUOTES: [&[u8]; 2] = [b"\"", b"""];
|
||||
|
||||
/// What the tool did, counted.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
stubbed: usize,
|
||||
/// Values too short to be a profile (no ICC header), kept as they are.
|
||||
kept_short: usize,
|
||||
/// Profiles that could not be replaced (no readable description, undecodable data): the tool
|
||||
/// fails, so no profile is ever left behind silently.
|
||||
failed: Vec<String>,
|
||||
}
|
||||
|
||||
fn is_profile(d: &[u8]) -> bool {
|
||||
d.len() > 132 && d.get(36..40) == Some(b"acsp")
|
||||
}
|
||||
|
||||
/// The stub for the bytes `d` of a value, or `None` (counted) when it holds no profile or the
|
||||
/// profile has no readable description.
|
||||
fn stub_of(d: &[u8], at: usize, tally: &mut Tally) -> Option<Vec<u8>> {
|
||||
if !is_profile(d) {
|
||||
tally.kept_short += 1;
|
||||
return None;
|
||||
}
|
||||
let s = description(d).and_then(|t| stub(&t));
|
||||
if s.is_none() {
|
||||
tally.failed.push(format!("profile at byte {at}: no readable description"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// At the start of `t`: the key in quotes, optional white space, a colon, optional white space and
|
||||
/// the opening quote of the value. Returns the length up to and including that quote, and the quote.
|
||||
fn value_start(t: &[u8]) -> Option<(usize, &'static [u8])> {
|
||||
let ws = |mut i: usize| {
|
||||
while t.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
};
|
||||
QUOTES.into_iter().find_map(|q| {
|
||||
let key_end = q.len().checked_mul(2)?.checked_add(KEY.len())?;
|
||||
let quoted = t.get(..key_end)?;
|
||||
if !(quoted.starts_with(q) && quoted.get(q.len()..q.len() + KEY.len())? == KEY && quoted.ends_with(q)) {
|
||||
return None;
|
||||
}
|
||||
let colon = ws(key_end);
|
||||
if t.get(colon) != Some(&b':') {
|
||||
return None;
|
||||
}
|
||||
let open = ws(colon + 1);
|
||||
t.get(open..)?.starts_with(q).then_some((open + q.len(), q))
|
||||
})
|
||||
}
|
||||
|
||||
fn hex_decode(h: &[u8]) -> Option<Vec<u8>> {
|
||||
if !h.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
h.chunks(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).ok()?, 16).ok()).collect()
|
||||
}
|
||||
|
||||
fn hex_encode(d: &[u8], upper: bool) -> Vec<u8> {
|
||||
d.iter().flat_map(|b| if upper { format!("{b:02X}") } else { format!("{b:02x}") }.into_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Replaces, in `text`, every `colorProfileData` JSON value (base64; `\/` escapes, white space
|
||||
/// around the colon and quotes written as `"` allowed) and every .aepx `bdata="<hex>"`
|
||||
/// attribute that holds a raw profile.
|
||||
fn stub_text(text: &[u8], tally: &mut Tally) -> Vec<u8> {
|
||||
const BDATA: &[u8] = b"bdata=\"";
|
||||
let (mut out, mut i) = (Vec::with_capacity(text.len()), 0);
|
||||
while let Some(&b) = text.get(i) {
|
||||
let rest = text.get(i..).unwrap_or_default();
|
||||
if let Some((open, q)) = value_start(rest)
|
||||
&& let Some(len) = rest.get(open..).and_then(|v| v.windows(q.len()).position(|w| w == q))
|
||||
{
|
||||
let raw = rest.get(open..open + len).unwrap_or_default();
|
||||
let unescaped: Vec<u8> = raw.iter().copied().filter(|c| *c != b'\\').collect();
|
||||
let replaced = match b64_decode(&unescaped) {
|
||||
Some(d) => stub_of(&d, i, tally).map(|s| b64_encode(&s)),
|
||||
None => {
|
||||
tally.failed.push(format!("colorProfileData at byte {i}: not base64"));
|
||||
None
|
||||
}
|
||||
};
|
||||
out.extend_from_slice(rest.get(..open).unwrap_or_default());
|
||||
match replaced {
|
||||
Some(enc) => {
|
||||
out.extend_from_slice(&enc);
|
||||
tally.stubbed += 1;
|
||||
}
|
||||
None => out.extend_from_slice(raw),
|
||||
}
|
||||
i += open + len;
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with(BDATA)
|
||||
&& let Some(len) = rest.get(BDATA.len()..).and_then(|v| v.iter().position(|c| *c == b'"'))
|
||||
{
|
||||
let hex = rest.get(BDATA.len()..BDATA.len() + len).unwrap_or_default();
|
||||
let replaced = hex_decode(hex).filter(|d| is_profile(d)).and_then(|d| stub_of(&d, i, tally));
|
||||
out.extend_from_slice(BDATA);
|
||||
match replaced {
|
||||
Some(s) => {
|
||||
out.extend_from_slice(&hex_encode(&s, hex.iter().any(u8::is_ascii_uppercase)));
|
||||
tally.stubbed += 1;
|
||||
}
|
||||
None => out.extend_from_slice(hex),
|
||||
}
|
||||
i += BDATA.len() + len;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(chunks: &mut [Chunk<'_>], tally: &mut Tally) {
|
||||
for c in chunks.iter_mut() {
|
||||
// Only a data chunk is rewritten as one: an opaque `LIST` also has bytes, but replacing it by
|
||||
// a data chunk would drop its list type.
|
||||
let data = if let Body::Data(d) = &c.body { Some(d.to_vec()) } else { None };
|
||||
match data {
|
||||
Some(d) if d.windows(KEY.len()).any(|w| w == KEY) => {
|
||||
let new = stub_text(&d, tally);
|
||||
*c = Chunk::data(c.id, new);
|
||||
}
|
||||
Some(d) if is_profile(&d) => {
|
||||
if let Some(s) = stub_of(&d, 0, tally) {
|
||||
*c = Chunk::data(c.id, s);
|
||||
tally.stubbed += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Body::List { children, .. } | Body::Chunks(children) = &mut c.body {
|
||||
walk(children, tally);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (Some(input), Some(output)) = (args.get(1), args.get(2)) else { exit("usage: stubicc <in.aep|in.aepx> <out>") };
|
||||
let bytes = std::fs::read(input).or_exit("read the input");
|
||||
let mut tally = Tally::default();
|
||||
let out = if input.ends_with(".aepx") {
|
||||
stub_text(&bytes, &mut tally)
|
||||
} else {
|
||||
let mut file = effectcraft_aep::parse(&bytes).or_exit("parse the input");
|
||||
walk(&mut file.chunks, &mut tally);
|
||||
effectcraft_aep::write(&file).or_exit("write the output")
|
||||
};
|
||||
if !tally.failed.is_empty() {
|
||||
exit(&format!("nothing written: {} profile(s) could not be replaced:\n{}", tally.failed.len(), tally.failed.join("\n")));
|
||||
}
|
||||
std::fs::write(output, out).or_exit("write the output");
|
||||
println!("stubbed {} profile(s); kept {} short value(s) that hold no profile", tally.stubbed, tally.kept_short);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(desc: &str) -> Vec<u8> {
|
||||
// A profile with colour data after the description (like a real one): the stub of `desc`
|
||||
// plus 3000 bytes, its size field updated.
|
||||
let mut p = stub(desc).unwrap();
|
||||
p.extend(std::iter::repeat_n(0x5a, 3000));
|
||||
let size = u32::try_from(p.len()).unwrap().to_be_bytes();
|
||||
p[..4].copy_from_slice(&size);
|
||||
p
|
||||
}
|
||||
|
||||
fn stubbed(text: &[u8]) -> (Vec<u8>, Tally) {
|
||||
let mut tally = Tally::default();
|
||||
(stub_text(text, &mut tally), tally)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_values_in_every_spelling_are_replaced() {
|
||||
let b64 = String::from_utf8(b64_encode(&profile("sRGB IEC61966-2.1"))).unwrap();
|
||||
let want = String::from_utf8(b64_encode(&stub("sRGB IEC61966-2.1").unwrap())).unwrap();
|
||||
for (before, after) in [
|
||||
(format!("{{\"colorProfileData\":\"{b64}\"}}"), format!("{{\"colorProfileData\":\"{want}\"}}")),
|
||||
(format!("{{\"colorProfileData\" : \n\t\"{b64}\"}}"), format!("{{\"colorProfileData\" : \n\t\"{want}\"}}")),
|
||||
(format!("{{\"colorProfileData\":\"{}\"}}", b64.replace('/', "\\/")), format!("{{\"colorProfileData\":\"{want}\"}}")),
|
||||
(format!("{{"colorProfileData": "{b64}"}}"), format!("{{"colorProfileData": "{want}"}}")),
|
||||
] {
|
||||
let (out, tally) = stubbed(before.as_bytes());
|
||||
assert_eq!(String::from_utf8(out).unwrap(), after);
|
||||
assert_eq!((tally.stubbed, tally.failed.len()), (1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_profile_in_aepx_hex_is_replaced_and_other_hex_kept() {
|
||||
let hex = String::from_utf8(hex_encode(&profile("Rec.709 Gamma 2.4"), false)).unwrap();
|
||||
let want = String::from_utf8(hex_encode(&stub("Rec.709 Gamma 2.4").unwrap(), false)).unwrap();
|
||||
let text = format!("<icc bdata=\"{hex}\"/><PwCs bdata=\"01\"/><cdta bdata=\"{}\"/>", "ab".repeat(200));
|
||||
let (out, tally) = stubbed(text.as_bytes());
|
||||
assert_eq!(String::from_utf8(out).unwrap(), format!("<icc bdata=\"{want}\"/><PwCs bdata=\"01\"/><cdta bdata=\"{}\"/>", "ab".repeat(200)));
|
||||
assert_eq!((tally.stubbed, tally.failed.len()), (1, 0));
|
||||
// Upper-case hex stays upper case.
|
||||
let (out, _) = stubbed(format!("bdata=\"{}\"", hex.to_uppercase()).as_bytes());
|
||||
assert_eq!(String::from_utf8(out).unwrap(), format!("bdata=\"{}\"", want.to_uppercase()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_that_cannot_be_replaced_is_a_failure_not_a_silent_copy() {
|
||||
// An ICC header whose tag table has no `desc`.
|
||||
let mut p = profile("x");
|
||||
p[132..136].copy_from_slice(b"wtpt");
|
||||
let (_, tally) = stubbed(format!("\"colorProfileData\":\"{}\"", String::from_utf8(b64_encode(&p)).unwrap()).as_bytes());
|
||||
assert_eq!((tally.stubbed, tally.failed.len()), (0, 1));
|
||||
let (_, tally) = stubbed(b"\"colorProfileData\":\"not*base64\"");
|
||||
assert_eq!(tally.failed.len(), 1);
|
||||
// A short value holds no profile: kept and counted, not a failure.
|
||||
let (out, tally) = stubbed(b"\"colorProfileData\":\"AAAA\"");
|
||||
assert_eq!((out.as_slice(), tally.kept_short, tally.failed.len()), (&b"\"colorProfileData\":\"AAAA\""[..], 1, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! After Effects project (`.aep`) container reader and writer.
|
||||
//!
|
||||
//! An `.aep` is a `RIFX` container (big-endian RIFF) with form type `Egg!`. This crate reads it
|
||||
//! into a lossless chunk tree ([`AepFile`]) and writes it back byte for byte. The XML form of a
|
||||
//! project (`.aepx`) is read into the same tree by [`read_aepx`]; [`parse_any`] picks the reader
|
||||
//! by content. See
|
||||
//! `docs/aep-format.md` for the observed format and how it was established (clean room: only
|
||||
//! files we generated ourselves, one change per file).
|
||||
//!
|
||||
//! [`Project::new`] opens a typed, read-only view over the tree: items, compositions
|
||||
//! ([`CompHeader`]), layers ([`LayerHeader`], [`Transform`], effects, masks, text documents,
|
||||
//! markers), properties, keyframes and footage. Only fields whose layout was verified against the
|
||||
//! values the test files were made with are decoded; every accessor returns `None` (or an empty
|
||||
//! list) on damaged data instead of failing.
|
||||
|
||||
mod aepx;
|
||||
mod arbitrary;
|
||||
mod bytes;
|
||||
mod camera;
|
||||
mod chunk;
|
||||
pub mod effect_map;
|
||||
mod effects;
|
||||
mod error;
|
||||
mod footage;
|
||||
mod header;
|
||||
mod keys;
|
||||
mod masks;
|
||||
mod matte;
|
||||
mod read;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
pub mod style_map;
|
||||
mod textdoc;
|
||||
mod typed;
|
||||
mod values;
|
||||
mod version;
|
||||
mod write;
|
||||
|
||||
pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits};
|
||||
pub use arbitrary::{
|
||||
CURVE_IDENTITY, CryptomatteData, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type,
|
||||
};
|
||||
pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM};
|
||||
pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk};
|
||||
pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue};
|
||||
pub use error::AepError;
|
||||
pub use footage::{Footage, SequenceInfo};
|
||||
pub use header::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Quality, Rational, Renderer};
|
||||
pub use keys::{ExpressionFlags, Interpolation, KeyEase, KeyframeKind, SpatialKey};
|
||||
pub use masks::{Mask, MaskAtom, MaskMode, PathVertex, ShapePath};
|
||||
pub use matte::{MatteMode, TrackMatte};
|
||||
pub use read::{Limits, parse, parse_with_limits};
|
||||
pub use settings::{ProjectSettings, TimeDisplay, WorkingSpace};
|
||||
pub use shapes::{GradientType, PolystarType, ShapeDetail, ShapeGroup, ShapeItem, Stroke, StrokeCap, StrokeJoin};
|
||||
pub use textdoc::{
|
||||
AutoKern, Caps, CueType, FontBaseline, Justification, KernRun, Marker, ParagraphRun, StyleRun, TextBox, TextDocument, TextValue, parse_text_dict,
|
||||
};
|
||||
pub use typed::{Comp, Item, ItemKind, KeyLayout, KeyList, Keyframe, Layer, ListHeader, Project, PropEntry, PropGroup, PropNode, Property, TimeBase};
|
||||
pub use values::{Orientation, OrientationKey, TimeRemap, TimeRemapKey, Tracked, Transform, ValueSource};
|
||||
pub use version::AeVersion;
|
||||
pub use write::{write, write_to};
|
||||
@@ -0,0 +1,640 @@
|
||||
//! Reading the XML form of a project (`.aepx`) into the same chunk tree as an `.aep`.
|
||||
//!
|
||||
//! An `.aepx` is the `RIFX` chunk tree written as XML: one element per chunk, named by the chunk
|
||||
//! id (or the list type of a `LIST`), with the body as hex in a `bdata` attribute. A few chunks
|
||||
//! are written as text instead (`Utf8` as `<string>`, the numbers `numS` and `ppSn`, the footage
|
||||
//! alias `alas` as `<fileReference>`), and the XMP packet that follows the container of an `.aep`
|
||||
//! is the element `<ProjectXMPMetadata>`. The mapping, with the cases it was found on, is in
|
||||
//! `docs/aep-format.md` (section 1.1).
|
||||
//!
|
||||
//! The reader is a small XML tokenizer for exactly that subset: elements, attributes, text, the
|
||||
//! five predefined entities and character references, CDATA, comments and processing
|
||||
//! instructions. A document type declaration is refused (no entity expansion). It runs without
|
||||
//! recursion; the nesting depth and the element count are bounded by [`Limits`], and everything
|
||||
//! it allocates is bounded by the size of the input.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
|
||||
use crate::container::error::AepError;
|
||||
use crate::container::read::{Limits, parse};
|
||||
|
||||
/// The root element of an `.aepx`.
|
||||
const ROOT: &[u8] = b"AfterEffectsProject";
|
||||
/// The element that holds the XMP packet (the trailer of an `.aep`).
|
||||
const XMP: &[u8] = b"ProjectXMPMetadata";
|
||||
/// The element of a `Utf8` chunk.
|
||||
const STRING: &[u8] = b"string";
|
||||
/// The element of an `alas` chunk (footage alias), written as attributes instead of JSON.
|
||||
const FILE_REFERENCE: &[u8] = b"fileReference";
|
||||
|
||||
/// List types whose body is not a chunk sequence: the `.aepx` writes them like data chunks (with
|
||||
/// `bdata`), the `.aep` as an opaque `LIST`.
|
||||
const OPAQUE_LIST_TYPES: [[u8; 4]; 1] = [*b"btdk"];
|
||||
|
||||
/// True when `data` looks like an `.aepx`: optional UTF-8 byte order mark and white space, then
|
||||
/// an XML declaration or the `AfterEffectsProject` root element.
|
||||
pub fn is_aepx(data: &[u8]) -> bool {
|
||||
let rest = data.strip_prefix(&[0xef, 0xbb, 0xbf][..]).unwrap_or(data);
|
||||
let start = rest.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(rest.len());
|
||||
let rest = rest.get(start..).unwrap_or(&[]);
|
||||
rest.starts_with(b"<?xml") || rest.starts_with(b"<AfterEffectsProject")
|
||||
}
|
||||
|
||||
/// Reads an `.aep` or an `.aepx`, chosen by content: a `RIFX` file goes to [`parse`], an XML file
|
||||
/// (see [`is_aepx`]) to [`read_aepx`].
|
||||
///
|
||||
/// # Errors
|
||||
/// The errors of [`parse`] or [`read_aepx`].
|
||||
pub fn parse_any(data: &[u8]) -> Result<AepFile<'_>, AepError> {
|
||||
if is_aepx(data) { read_aepx(data) } else { parse(data) }
|
||||
}
|
||||
|
||||
/// Reads an `.aepx` with the default [`Limits`] into the chunk tree an `.aep` of the same project
|
||||
/// gives.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AepError::Xml`] for input that is not well-formed XML of the `.aepx` subset (bad UTF-8, an
|
||||
/// unknown entity, an unclosed or mismatched tag, invalid or odd-length hex, a number that does
|
||||
/// not parse, a document type declaration, a root element other than `AfterEffectsProject`);
|
||||
/// [`AepError::TooDeep`] / [`AepError::TooManyChunks`] beyond the limits.
|
||||
pub fn read_aepx(data: &[u8]) -> Result<AepFile<'static>, AepError> {
|
||||
read_aepx_with_limits(data, Limits::default())
|
||||
}
|
||||
|
||||
/// Like [`read_aepx`] with explicit [`Limits`]: `max_depth` bounds the element nesting (the root
|
||||
/// element's children are depth 1), `max_chunks` the number of elements.
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`read_aepx`].
|
||||
pub fn read_aepx_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'static>, AepError> {
|
||||
let text = match std::str::from_utf8(data) {
|
||||
Ok(t) => t.as_bytes(),
|
||||
Err(e) => return Err(xml(e.valid_up_to(), "the file is not valid UTF-8")),
|
||||
};
|
||||
Reader { data: text, pos: 0, limits, count: 0, stack: Vec::new(), top: None, trailer: None }.run()
|
||||
}
|
||||
|
||||
fn xml(offset: usize, what: &'static str) -> AepError {
|
||||
AepError::Xml { offset, what }
|
||||
}
|
||||
|
||||
/// What an open element becomes when it closes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
/// The root element: its children are the top-level chunks.
|
||||
Root,
|
||||
/// A chunk with this id (or a `LIST` with this list type).
|
||||
Chunk([u8; 4]),
|
||||
/// A `Utf8` chunk.
|
||||
Utf8,
|
||||
/// An `alas` chunk built from the attributes.
|
||||
Alias,
|
||||
/// The XMP packet (only directly below the root).
|
||||
Xmp,
|
||||
/// An element without a chunk counterpart (a name that is no chunk id); skipped with its content.
|
||||
Skip,
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
kind: Kind,
|
||||
/// Offset of the start tag (for errors).
|
||||
at: usize,
|
||||
/// The name as written, to match the end tag.
|
||||
name: (usize, usize),
|
||||
/// The decoded `bdata` attribute.
|
||||
bdata: Option<Vec<u8>>,
|
||||
/// The attributes of a `fileReference`, in document order.
|
||||
attrs: Vec<(String, String)>,
|
||||
/// Collected text (only for `string` and the XMP packet).
|
||||
text: Vec<u8>,
|
||||
children: Vec<Chunk<'static>>,
|
||||
}
|
||||
|
||||
struct Reader<'d> {
|
||||
data: &'d [u8],
|
||||
pos: usize,
|
||||
limits: Limits,
|
||||
count: usize,
|
||||
stack: Vec<Frame>,
|
||||
/// The top-level chunks once the root element has closed.
|
||||
top: Option<Vec<Chunk<'static>>>,
|
||||
trailer: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn run(mut self) -> Result<AepFile<'static>, AepError> {
|
||||
if self.data.starts_with(&[0xef, 0xbb, 0xbf]) {
|
||||
self.pos = 3;
|
||||
}
|
||||
while self.pos < self.data.len() {
|
||||
if self.rest().starts_with(b"<") {
|
||||
self.markup()?;
|
||||
} else {
|
||||
self.text()?;
|
||||
}
|
||||
}
|
||||
if let Some(open) = self.stack.last() {
|
||||
return Err(xml(open.at, "an element is not closed before the end of the file"));
|
||||
}
|
||||
let chunks = self.top.ok_or_else(|| xml(self.data.len(), "no AfterEffectsProject root element"))?;
|
||||
Ok(AepFile { chunks, trailer: Cow::Owned(self.trailer.unwrap_or_default()) })
|
||||
}
|
||||
|
||||
fn rest(&self) -> &[u8] {
|
||||
self.data.get(self.pos..).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The offset of `needle` at or after `from`.
|
||||
fn find(&self, from: usize, needle: &[u8]) -> Option<usize> {
|
||||
let hay = self.data.get(from..)?;
|
||||
if needle.is_empty() || hay.len() < needle.len() {
|
||||
return None;
|
||||
}
|
||||
let first = *needle.first()?;
|
||||
let mut i = 0;
|
||||
while let Some(p) = hay.get(i..)?.iter().position(|b| *b == first) {
|
||||
let at = i.checked_add(p)?;
|
||||
if hay.get(at..)?.starts_with(needle) {
|
||||
return from.checked_add(at);
|
||||
}
|
||||
i = at.checked_add(1)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Skips to just after `close`, which must follow.
|
||||
fn skip_past(&mut self, close: &[u8], what: &'static str) -> Result<usize, AepError> {
|
||||
let at = self.find(self.pos, close).ok_or(xml(self.pos, what))?;
|
||||
self.pos = at.saturating_add(close.len());
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
fn markup(&mut self) -> Result<(), AepError> {
|
||||
let rest = self.rest();
|
||||
if rest.starts_with(b"<?") {
|
||||
self.skip_past(b"?>", "a processing instruction is not closed")?;
|
||||
} else if rest.starts_with(b"<!--") {
|
||||
self.skip_past(b"-->", "a comment is not closed")?;
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
let start = self.pos.saturating_add(9);
|
||||
self.pos = start;
|
||||
let end = self.skip_past(b"]]>", "a CDATA section is not closed")?;
|
||||
let content = self.data.get(start..end).unwrap_or(&[]);
|
||||
let at = start;
|
||||
match self.stack.last_mut() {
|
||||
Some(frame) => {
|
||||
if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) {
|
||||
frame.text.extend_from_slice(content);
|
||||
}
|
||||
}
|
||||
None => return Err(xml(at, "CDATA outside the root element")),
|
||||
}
|
||||
} else if rest.starts_with(b"<!") {
|
||||
return Err(xml(self.pos, "document type declarations are not supported"));
|
||||
} else if rest.starts_with(b"</") {
|
||||
self.end_tag()?;
|
||||
} else {
|
||||
self.start_tag()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_name_byte(b: u8) -> bool {
|
||||
!(b.is_ascii_whitespace() || matches!(b, b'<' | b'>' | b'/' | b'=' | b'"' | b'\'' | b'&'))
|
||||
}
|
||||
|
||||
fn name(&mut self) -> Result<(usize, usize), AepError> {
|
||||
let start = self.pos;
|
||||
let len = self.rest().iter().position(|b| !Self::is_name_byte(*b)).unwrap_or(self.rest().len());
|
||||
if len == 0 {
|
||||
return Err(xml(start, "a tag or attribute has no name"));
|
||||
}
|
||||
self.pos = start.saturating_add(len);
|
||||
Ok((start, self.pos))
|
||||
}
|
||||
|
||||
fn skip_ws(&mut self) {
|
||||
let n = self.rest().iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(self.rest().len());
|
||||
self.pos = self.pos.saturating_add(n);
|
||||
}
|
||||
|
||||
fn slice(&self, (a, b): (usize, usize)) -> &[u8] {
|
||||
self.data.get(a..b).unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn start_tag(&mut self) -> Result<(), AepError> {
|
||||
let at = self.pos;
|
||||
self.pos = self.pos.saturating_add(1);
|
||||
let name = self.name()?;
|
||||
let mut attrs: Vec<((usize, usize), Vec<u8>)> = Vec::new();
|
||||
let self_closing = loop {
|
||||
self.skip_ws();
|
||||
let rest = self.rest();
|
||||
if rest.starts_with(b"/>") {
|
||||
self.pos = self.pos.saturating_add(2);
|
||||
break true;
|
||||
}
|
||||
if rest.starts_with(b">") {
|
||||
self.pos = self.pos.saturating_add(1);
|
||||
break false;
|
||||
}
|
||||
if rest.is_empty() {
|
||||
return Err(xml(at, "a start tag is not closed"));
|
||||
}
|
||||
let key = self.name()?;
|
||||
self.skip_ws();
|
||||
if !self.rest().starts_with(b"=") {
|
||||
return Err(xml(self.pos, "an attribute has no value"));
|
||||
}
|
||||
self.pos = self.pos.saturating_add(1);
|
||||
self.skip_ws();
|
||||
let quote = *self.rest().first().ok_or(xml(self.pos, "a start tag is not closed"))?;
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
return Err(xml(self.pos, "an attribute value is not quoted"));
|
||||
}
|
||||
let vstart = self.pos.saturating_add(1);
|
||||
let vend = self.find(vstart, &[quote]).ok_or(xml(vstart, "an attribute value is not closed"))?;
|
||||
let raw = self.data.get(vstart..vend).unwrap_or(&[]);
|
||||
if raw.contains(&b'<') {
|
||||
return Err(xml(vstart, "`<` inside an attribute value"));
|
||||
}
|
||||
let mut value = Vec::new();
|
||||
decode_entities(raw, vstart, &mut value)?;
|
||||
self.pos = vend.saturating_add(1);
|
||||
attrs.push((key, value));
|
||||
};
|
||||
let kind = self.kind_of(name, at)?;
|
||||
if kind != Kind::Root {
|
||||
self.count = self.count.saturating_add(1);
|
||||
if self.count > self.limits.max_chunks {
|
||||
return Err(AepError::TooManyChunks);
|
||||
}
|
||||
// The root's children are depth 1, like the top-level chunks of an `.aep`.
|
||||
if self.stack.len() > self.limits.max_depth {
|
||||
return Err(AepError::TooDeep);
|
||||
}
|
||||
}
|
||||
let mut frame = Frame { kind, at, name, bdata: None, attrs: Vec::new(), text: Vec::new(), children: Vec::new() };
|
||||
if matches!(kind, Kind::Chunk(_)) {
|
||||
if let Some((key, value)) = attrs.iter().find(|(k, _)| self.slice(*k) == b"bdata") {
|
||||
frame.bdata = Some(decode_hex(value, key.1)?);
|
||||
}
|
||||
} else if kind == Kind::Alias {
|
||||
frame.attrs = attrs.iter().map(|(k, v)| (String::from_utf8_lossy(self.slice(*k)).into_owned(), String::from_utf8_lossy(v).into_owned())).collect();
|
||||
}
|
||||
self.stack.push(frame);
|
||||
if self_closing {
|
||||
self.close()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classifies an element by its name and where it is.
|
||||
fn kind_of(&self, name: (usize, usize), at: usize) -> Result<Kind, AepError> {
|
||||
let n = self.slice(name);
|
||||
let parent = match self.stack.last() {
|
||||
None => {
|
||||
if self.top.is_some() {
|
||||
return Err(xml(at, "a second root element"));
|
||||
}
|
||||
if n != ROOT {
|
||||
return Err(xml(at, "the root element is not AfterEffectsProject"));
|
||||
}
|
||||
return Ok(Kind::Root);
|
||||
}
|
||||
Some(p) => p.kind,
|
||||
};
|
||||
Ok(match parent {
|
||||
Kind::Skip | Kind::Utf8 | Kind::Xmp | Kind::Alias => Kind::Skip,
|
||||
Kind::Root | Kind::Chunk(_) => {
|
||||
if n == STRING {
|
||||
Kind::Utf8
|
||||
} else if n == FILE_REFERENCE {
|
||||
Kind::Alias
|
||||
} else if n == XMP && parent == Kind::Root {
|
||||
Kind::Xmp
|
||||
} else if let Some(id) = chunk_id(n) {
|
||||
Kind::Chunk(id)
|
||||
} else {
|
||||
Kind::Skip
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn end_tag(&mut self) -> Result<(), AepError> {
|
||||
let at = self.pos;
|
||||
self.pos = self.pos.saturating_add(2);
|
||||
let name = self.name()?;
|
||||
self.skip_ws();
|
||||
if !self.rest().starts_with(b">") {
|
||||
return Err(xml(at, "an end tag is not closed"));
|
||||
}
|
||||
self.pos = self.pos.saturating_add(1);
|
||||
let open = self.stack.last().ok_or(xml(at, "an end tag without a start tag"))?;
|
||||
if self.slice(open.name) != self.slice(name) {
|
||||
return Err(xml(at, "an end tag does not match its start tag"));
|
||||
}
|
||||
self.close()
|
||||
}
|
||||
|
||||
/// Closes the innermost element and hands its chunk to the parent.
|
||||
fn close(&mut self) -> Result<(), AepError> {
|
||||
let frame = self.stack.pop().ok_or(xml(self.pos, "an end tag without a start tag"))?;
|
||||
let chunk = match frame.kind {
|
||||
Kind::Root => {
|
||||
self.top = Some(frame.children);
|
||||
return Ok(());
|
||||
}
|
||||
Kind::Skip => return Ok(()),
|
||||
Kind::Xmp => {
|
||||
if self.trailer.is_none() {
|
||||
self.trailer = Some(frame.text);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Kind::Utf8 => Chunk::data(*b"Utf8", frame.text),
|
||||
Kind::Alias => Chunk::data(*b"alas", alias_json(&frame.attrs).into_bytes()),
|
||||
Kind::Chunk(id) => chunk_of(id, frame)?,
|
||||
};
|
||||
if let Some(parent) = self.stack.last_mut() {
|
||||
parent.children.push(chunk);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn text(&mut self) -> Result<(), AepError> {
|
||||
let start = self.pos;
|
||||
let len = self.rest().iter().position(|b| *b == b'<').unwrap_or(self.rest().len());
|
||||
let end = start.saturating_add(len);
|
||||
self.pos = end;
|
||||
let raw = self.data.get(start..end).unwrap_or(&[]);
|
||||
match self.stack.last_mut() {
|
||||
Some(frame) if matches!(frame.kind, Kind::Utf8 | Kind::Xmp) => decode_entities(raw, start, &mut frame.text),
|
||||
Some(_) => Ok(()),
|
||||
None if raw.iter().all(u8::is_ascii_whitespace) => Ok(()),
|
||||
None => Err(xml(start, "text outside the root element")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk id of an element name: 1 to 4 printable ASCII bytes, padded with spaces (`Pin` is
|
||||
/// the list type `Pin `).
|
||||
fn chunk_id(name: &[u8]) -> Option<[u8; 4]> {
|
||||
if name.is_empty() || name.len() > 4 || !name.iter().all(|b| b.is_ascii_graphic()) {
|
||||
return None;
|
||||
}
|
||||
let mut id = [b' '; 4];
|
||||
for (d, s) in id.iter_mut().zip(name) {
|
||||
*d = *s;
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The chunk of a closed element named by a chunk id.
|
||||
fn chunk_of(id: [u8; 4], frame: Frame) -> Result<Chunk<'static>, AepError> {
|
||||
if let Some(bytes) = frame.bdata {
|
||||
if OPAQUE_LIST_TYPES.contains(&id) {
|
||||
let pad = if bytes.len() % 2 == 1 { Some(0) } else { None };
|
||||
return Ok(Chunk { id: LIST, body: Body::OpaqueList { list_type: id, data: Cow::Owned(bytes) }, pad });
|
||||
}
|
||||
return Ok(Chunk::data(id, bytes));
|
||||
}
|
||||
// Numbers written as text: `<numS><string>0</string></numS>` and `<ppSn><string>150.000000000</string></ppSn>`.
|
||||
if &id == b"numS" || &id == b"ppSn" {
|
||||
let text = match frame.children.as_slice() {
|
||||
[only] if only.is(b"Utf8") => only.bytes().unwrap_or(&[]),
|
||||
_ => return Err(xml(frame.at, "a numS or ppSn element without its number")),
|
||||
};
|
||||
let text = std::str::from_utf8(text).map_err(|_| xml(frame.at, "a number is not text"))?.trim();
|
||||
let body = if &id == b"numS" {
|
||||
// Only `0` is sampled, so whether the field is signed is open: accept both readings of 4 bytes.
|
||||
let n = text.parse::<i64>().ok().filter(|n| (i64::from(i32::MIN)..=i64::from(u32::MAX)).contains(n));
|
||||
let n = n.ok_or(xml(frame.at, "numS is not a 32-bit integer"))?;
|
||||
(n as u32).to_be_bytes().to_vec()
|
||||
} else {
|
||||
text.parse::<f64>().map_err(|_| xml(frame.at, "ppSn is not a number"))?.to_be_bytes().to_vec()
|
||||
};
|
||||
return Ok(Chunk::data(id, body));
|
||||
}
|
||||
if NESTED_IDS.contains(&id) {
|
||||
if frame.children.is_empty() {
|
||||
return Ok(Chunk::data(id, Vec::new()));
|
||||
}
|
||||
return Ok(Chunk { id, body: Body::Chunks(frame.children), pad: None });
|
||||
}
|
||||
Ok(Chunk::list(id, frame.children))
|
||||
}
|
||||
|
||||
/// Appends `raw` with the predefined entities and character references decoded.
|
||||
fn decode_entities(raw: &[u8], offset: usize, out: &mut Vec<u8>) -> Result<(), AepError> {
|
||||
let mut i = 0;
|
||||
while let Some(rest) = raw.get(i..) {
|
||||
let Some(amp) = rest.iter().position(|b| *b == b'&') else {
|
||||
out.extend_from_slice(rest);
|
||||
break;
|
||||
};
|
||||
out.extend_from_slice(rest.get(..amp).unwrap_or(&[]));
|
||||
let at = offset.saturating_add(i).saturating_add(amp);
|
||||
let after = rest.get(amp.saturating_add(1)..).unwrap_or(&[]);
|
||||
let semi = after.iter().take(12).position(|b| *b == b';').ok_or(xml(at, "an entity is not terminated"))?;
|
||||
let name = after.get(..semi).unwrap_or(&[]);
|
||||
let ch = match name {
|
||||
b"amp" => '&',
|
||||
b"lt" => '<',
|
||||
b"gt" => '>',
|
||||
b"quot" => '"',
|
||||
b"apos" => '\'',
|
||||
_ => {
|
||||
let code = if let Some(hex) = name.strip_prefix(b"#x").or_else(|| name.strip_prefix(b"#X")) {
|
||||
std::str::from_utf8(hex).ok().and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
} else if let Some(dec) = name.strip_prefix(b"#") {
|
||||
std::str::from_utf8(dec).ok().and_then(|d| d.parse::<u32>().ok())
|
||||
} else {
|
||||
return Err(xml(at, "an unknown entity"));
|
||||
};
|
||||
code.filter(|c| *c != 0).and_then(char::from_u32).ok_or(xml(at, "an invalid character reference"))?
|
||||
}
|
||||
};
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
||||
i = i.saturating_add(amp).saturating_add(semi).saturating_add(2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes a `bdata` value (hex digits, two per byte).
|
||||
fn decode_hex(text: &[u8], offset: usize) -> Result<Vec<u8>, AepError> {
|
||||
if text.len() % 2 == 1 {
|
||||
return Err(xml(offset, "bdata has an odd number of hex digits"));
|
||||
}
|
||||
let digit = |b: u8| match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
};
|
||||
let (pairs, _) = text.as_chunks::<2>();
|
||||
pairs
|
||||
.iter()
|
||||
.map(|[hi, lo]| digit(*hi).zip(digit(*lo)).map(|(h, l)| (h << 4) | l))
|
||||
.collect::<Option<Vec<u8>>>()
|
||||
.ok_or(xml(offset, "bdata holds a character that is not a hex digit"))
|
||||
}
|
||||
|
||||
/// The JSON body of an `alas` chunk from the attributes of a `fileReference`, keys in document
|
||||
/// order (alphabetical in every sample, as in the `.aep`): the two `ascendcount_*` values as
|
||||
/// numbers, `platform` `Win` as `1`, `target_is_folder` `0` / `1` as `false` / `true`, everything
|
||||
/// else as a string.
|
||||
fn alias_json(attrs: &[(String, String)]) -> String {
|
||||
let mut out = String::from("{");
|
||||
for (i, (key, value)) in attrs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
json_str(&mut out, key);
|
||||
out.push(':');
|
||||
let number = value.parse::<i64>().ok();
|
||||
match (key.as_str(), value.as_str()) {
|
||||
("ascendcount_base" | "ascendcount_target", _) if number.is_some() => out.push_str(value),
|
||||
("platform", "Win") => out.push('1'),
|
||||
("platform", _) if number.is_some() => out.push_str(value),
|
||||
("target_is_folder", "0" | "false") => out.push_str("false"),
|
||||
("target_is_folder", "1" | "true") => out.push_str("true"),
|
||||
_ => json_str(&mut out, value),
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
fn json_str(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc(body: &str) -> String {
|
||||
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<AfterEffectsProject xmlns=\"x\">{body}</AfterEffectsProject>\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_elements_to_chunks() {
|
||||
let x = doc(concat!(
|
||||
"<head bdata=\"00ff\"/><Pin><sspc bdata=\"01\"/></Pin><Pefl>\n</Pefl>",
|
||||
"<tdsn><string>A & BA</string></tdsn><numS><string>-3</string></numS>",
|
||||
"<ppSn><string>150.000000000</string></ppSn><btdk bdata=\"414243\"/><Comment>x<a bdata=\"00\"/></Comment>",
|
||||
"<ProjectXMPMetadata><![CDATA[<?xpacket begin?>]]></ProjectXMPMetadata>"
|
||||
));
|
||||
let f = read_aepx(x.as_bytes()).unwrap();
|
||||
assert_eq!(f.trailer.as_ref(), b"<?xpacket begin?>");
|
||||
let ids: Vec<_> = f.chunks.iter().map(|c| (c.id, c.list_type())).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
(*b"head", None),
|
||||
(LIST, Some(*b"Pin ")),
|
||||
(LIST, Some(*b"Pefl")),
|
||||
(*b"tdsn", None),
|
||||
(*b"numS", None),
|
||||
(*b"ppSn", None),
|
||||
(LIST, Some(*b"btdk"))
|
||||
]
|
||||
);
|
||||
assert_eq!(f.chunks[1].children()[0].bytes(), Some(&[1u8][..]));
|
||||
assert_eq!(f.chunks[1].children()[0].pad, Some(0));
|
||||
assert_eq!(f.chunks[3].children()[0].bytes(), Some(&b"A & BA"[..]));
|
||||
assert_eq!(f.chunks[4].bytes(), Some(&(-3i32).to_be_bytes()[..]));
|
||||
assert_eq!(f.chunks[5].bytes(), Some(&150f64.to_be_bytes()[..]));
|
||||
assert_eq!(f.chunks[6].bytes(), Some(&b"ABC"[..]));
|
||||
// The tree writes as a valid `.aep` that parses back to itself.
|
||||
let bin = crate::container::write(&f).unwrap();
|
||||
assert_eq!(parse(&bin).unwrap(), f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_reference_becomes_alias_json() {
|
||||
let x = doc(concat!(
|
||||
"<Als2><fileReference ascendcount_base=\"1\" ascendcount_target=\"1\" fullpath=\"C:\\a\\"b.png\" ",
|
||||
"platform=\"Win\" server_name=\"N\" server_volume_name=\"V\" target_is_folder=\"0\"/></Als2>"
|
||||
));
|
||||
let f = read_aepx(x.as_bytes()).unwrap();
|
||||
let alas = f.chunks[0].children()[0].bytes().unwrap();
|
||||
assert_eq!(
|
||||
std::str::from_utf8(alas).unwrap(),
|
||||
r#"{"ascendcount_base":1,"ascendcount_target":1,"fullpath":"C:\\a\\\"b.png","platform":1,"server_name":"N","server_volume_name":"V","target_is_folder":false}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nums_takes_signed_and_unsigned_32_bit_values() {
|
||||
for (text, bytes) in [("-2147483648", [0x80, 0, 0, 0]), ("4294967295", [0xff; 4]), (" 7 ", [0, 0, 0, 7])] {
|
||||
let f = read_aepx(doc(&format!("<numS><string>{text}</string></numS>")).as_bytes()).unwrap();
|
||||
assert_eq!(f.chunks[0].bytes(), Some(&bytes[..]), "{text}");
|
||||
}
|
||||
for text in ["-2147483649", "4294967296", "1.5"] {
|
||||
assert!(read_aepx(doc(&format!("<numS><string>{text}</string></numS>")).as_bytes()).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection() {
|
||||
assert!(is_aepx(b"\xef\xbb\xbf <?xml version=\"1.0\"?>"));
|
||||
assert!(is_aepx(b"<AfterEffectsProject>"));
|
||||
assert!(!is_aepx(b"RIFX\0\0\0\x04Egg!"));
|
||||
assert!(parse_any(b"RIFX\0\0\0\x04Egg!").is_ok());
|
||||
assert!(parse_any(doc("").as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_an_error() {
|
||||
let bad = [
|
||||
doc("<a bdata=\"0\"/>"),
|
||||
doc("<a bdata=\"zz\"/>"),
|
||||
doc("<string> </string>"),
|
||||
doc("<string>�</string>"),
|
||||
doc("<string>&</string>"),
|
||||
doc("<a>"),
|
||||
doc("<a></b>"),
|
||||
doc("<numS><string>x</string></numS>"),
|
||||
doc("<numS></numS>"),
|
||||
doc("<a b></a>"),
|
||||
doc("<a b=c></a>"),
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY a \"b\">]><AfterEffectsProject/>".into(),
|
||||
"<?xml version=\"1.0\"?><Other/>".into(),
|
||||
"<?xml version=\"1.0\"?>".into(),
|
||||
"<AfterEffectsProject/><AfterEffectsProject/>".into(),
|
||||
"<AfterEffectsProject/>junk".into(),
|
||||
"<AfterEffectsProject><!-- x".into(),
|
||||
];
|
||||
for b in &bad {
|
||||
assert!(matches!(read_aepx(b.as_bytes()), Err(AepError::Xml { .. })), "{b}");
|
||||
}
|
||||
assert!(matches!(read_aepx(b"<AfterEffectsProject>\xff</AfterEffectsProject>"), Err(AepError::Xml { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_hold() {
|
||||
let deep = format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<a>".repeat(100_000), "</a>".repeat(100_000));
|
||||
assert_eq!(read_aepx(deep.as_bytes()), Err(AepError::TooDeep));
|
||||
let ok = format!("<AfterEffectsProject>{}{}</AfterEffectsProject>", "<a>".repeat(64), "</a>".repeat(64));
|
||||
assert!(read_aepx(ok.as_bytes()).is_ok());
|
||||
let many = format!("<AfterEffectsProject>{}</AfterEffectsProject>", "<a/>".repeat(10));
|
||||
assert_eq!(read_aepx_with_limits(many.as_bytes(), Limits { max_depth: 64, max_chunks: 9 }), Err(AepError::TooManyChunks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Arbitrary-data effect parameters (type 11): the values of Hue/Saturation and Levels that are not
|
||||
//! ordinary parameters.
|
||||
//!
|
||||
//! In the effect's value group (`sspc/tdgp`) the parameter's `tdbs` (`tdsb` 1, a 4-byte `cdat`) is
|
||||
//! followed by a `LIST aRbs` holding one `aRbp`: the data. The `aRbp` in `parT` right after the
|
||||
//! parameter's `pard` holds the default (443, 511). Setting Master Hue, Saturation or Lightness of
|
||||
//! Hue/Saturation (444-446) or a Levels value (512-516) changes only bytes of this `aRbp` (and stale
|
||||
//! UI bytes of `pard`); the visible parameters of the same names keep no record.
|
||||
|
||||
use crate::container::Chunk;
|
||||
use crate::container::bytes::{be_f32, be_i32, be_u16, be_u32};
|
||||
use crate::container::effects::Effect;
|
||||
|
||||
impl<'f, 'a> Effect<'f, 'a> {
|
||||
/// The arbitrary data stored for the parameter with this match name (`<effect>-NNNN`): the
|
||||
/// `aRbp` of the `LIST aRbs` that follows the parameter's value record. `None` when there is
|
||||
/// none.
|
||||
pub fn arbitrary_data(&self, match_name: &str) -> Option<&'f [u8]> {
|
||||
let values = self.chunk().child_list(b"tdgp")?;
|
||||
let kids = values.children();
|
||||
let at = kids.iter().position(|c| c.is(b"tdmn") && c.bytes().is_some_and(|b| name_is(b, match_name)))?;
|
||||
// The value record, then the data list (no match name of its own).
|
||||
let rest = kids.get(at.checked_add(1)?..)?;
|
||||
let mut it = rest.iter().take(3);
|
||||
let _record = it.next().filter(|c| c.is_list(b"tdbs"))?;
|
||||
it.find(|c| c.is_list(b"aRbs")).and_then(|l| l.child(b"aRbp")).and_then(Chunk::bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn name_is(body: &[u8], name: &str) -> bool {
|
||||
let end = body.iter().position(|c| *c == 0).unwrap_or(body.len());
|
||||
body.get(..end) == Some(name.as_bytes())
|
||||
}
|
||||
|
||||
/// Hue/Saturation's data (180 bytes, `ADBE HUE SATURATION-0003`): the master hue, saturation and
|
||||
/// lightness as `i32` at bytes 0, 4 and 8 (444: hue 90 is `0x5a`; 445: saturation 50; 446:
|
||||
/// lightness -30), then six colour ranges (Reds, Yellows, Greens, Cyans, Blues, Magentas) of 28
|
||||
/// bytes each: four `i32` range points in degrees (Reds 315, 345, 15, 45 by default) and the
|
||||
/// range's hue, saturation and lightness. Only the master values are sampled; the ranges' layout is
|
||||
/// read from the default data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HueSaturationData {
|
||||
/// Master hue (degrees), saturation and lightness (-100..100).
|
||||
pub master: [i32; 3],
|
||||
/// Per colour range: the four range points (degrees) and its hue, saturation and lightness.
|
||||
pub ranges: [([i32; 4], [i32; 3]); 6],
|
||||
}
|
||||
|
||||
impl HueSaturationData {
|
||||
/// The default data (443): master 0, 0, 0 and every range at its default points.
|
||||
pub const DEFAULT_RANGES: [[i32; 4]; 6] =
|
||||
[[315, 345, 15, 45], [15, 45, 75, 105], [75, 105, 135, 165], [135, 165, 195, 225], [195, 225, 255, 285], [255, 285, 315, 345]];
|
||||
|
||||
/// Decodes the data; `None` when it is not 180 bytes.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != 180 {
|
||||
return None;
|
||||
}
|
||||
let i = |at: usize| be_i32(data, at);
|
||||
let master = [i(0)?, i(4)?, i(8)?];
|
||||
let mut ranges = [([0; 4], [0; 3]); 6];
|
||||
for (k, r) in ranges.iter_mut().enumerate() {
|
||||
let base = 12usize.checked_add(k.checked_mul(28)?)?;
|
||||
r.0 = [i(base)?, i(base + 4)?, i(base + 8)?, i(base + 12)?];
|
||||
r.1 = [i(base + 16)?, i(base + 20)?, i(base + 24)?];
|
||||
}
|
||||
Some(Self { master, ranges })
|
||||
}
|
||||
|
||||
/// Whether every colour range is at its default (points as in 443, no adjustment).
|
||||
pub fn ranges_are_default(&self) -> bool {
|
||||
self.ranges.iter().zip(Self::DEFAULT_RANGES).all(|((points, hsl), d)| *points == d && *hsl == [0, 0, 0])
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels' data (108 bytes, `ADBE Easy Levels2-0002`): a `u32` 0x000f10a7 and a `u32` 1 (511, in
|
||||
/// every sample), then five channels (RGB, Red, Green, Blue, Alpha) of five `f32`: input black,
|
||||
/// input white, gamma, output black, output white. The RGB values are sampled (512 input black
|
||||
/// 0.1, 513 input white 0.8, 514 gamma 2, 515 output black 0.2, 516 output white 0.9); the other
|
||||
/// channels' layout is read from the default data (0, 1, 1, 0, 1).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct LevelsData {
|
||||
pub header: [u32; 2],
|
||||
/// RGB, Red, Green, Blue, Alpha: input black, input white, gamma, output black, output white.
|
||||
pub channels: [[f32; 5]; 5],
|
||||
}
|
||||
|
||||
impl LevelsData {
|
||||
/// The values of a channel at its default.
|
||||
pub const DEFAULT_CHANNEL: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
|
||||
|
||||
/// Decodes the data; `None` when it is not 108 bytes.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != 108 {
|
||||
return None;
|
||||
}
|
||||
let header = [be_u32(data, 0)?, be_u32(data, 4)?];
|
||||
let mut channels = [[0.0; 5]; 5];
|
||||
for (k, c) in channels.iter_mut().enumerate() {
|
||||
let base = 8usize.checked_add(k.checked_mul(20)?)?;
|
||||
for (j, v) in c.iter_mut().enumerate() {
|
||||
*v = be_f32(data, base.checked_add(j.checked_mul(4)?)?)?;
|
||||
}
|
||||
}
|
||||
Some(Self { header, channels })
|
||||
}
|
||||
}
|
||||
|
||||
/// Curves' data (1644 bytes, `ADBE CurvesCustom-0001`; FORMAT S.1, 961-964): `u16` 1, `u16` 1,
|
||||
/// five 256-byte lookup tables (RGB, Red, Green, Blue, Alpha: output 0..255 per input 0..255),
|
||||
/// then five 72-byte point records: 16 slots of (`u16` input, `u16` output), both 0..255, the
|
||||
/// number of points as `u32` at 64 and an `i32` at 68 (-1 at the default; the point count - 2 in
|
||||
/// every edited sample, H: the active point). Every stored table is the natural cubic spline
|
||||
/// through its points ([`curve_lut`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CurvesData {
|
||||
/// RGB, Red, Green, Blue, Alpha: the points (input, output), as stored.
|
||||
pub curves: [Vec<(u8, u8)>; 5],
|
||||
}
|
||||
|
||||
/// The points of an untouched curve.
|
||||
pub const CURVE_IDENTITY: [(u8, u8); 2] = [(0, 0), (255, 255)];
|
||||
|
||||
impl CurvesData {
|
||||
pub const SIZE: usize = 1644;
|
||||
const POINTS: usize = 1284;
|
||||
|
||||
/// Decodes the data; `None` when it is not 1644 bytes or a point count is beyond 16.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::SIZE {
|
||||
return None;
|
||||
}
|
||||
let mut curves: [Vec<(u8, u8)>; 5] = Default::default();
|
||||
for (c, pts) in curves.iter_mut().enumerate() {
|
||||
let base = Self::POINTS.checked_add(c.checked_mul(72)?)?;
|
||||
let n = usize::try_from(be_u32(data, base.checked_add(64)?)?).ok()?;
|
||||
if n > 16 {
|
||||
return None;
|
||||
}
|
||||
for k in 0..n {
|
||||
let at = base.checked_add(k.checked_mul(4)?)?;
|
||||
let x = u8::try_from(be_u16(data, at)?).ok()?;
|
||||
let y = u8::try_from(be_u16(data, at.checked_add(2)?)?).ok()?;
|
||||
pts.push((x, y));
|
||||
}
|
||||
}
|
||||
Some(Self { curves })
|
||||
}
|
||||
|
||||
/// Whether every curve is the identity.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.curves.iter().all(|c| c.as_slice() == CURVE_IDENTITY)
|
||||
}
|
||||
|
||||
/// The data After Effects stores for these curves: the tables rebuilt from the points.
|
||||
/// `None` when a curve has fewer than 2 or more than 16 points or inputs that do not rise.
|
||||
pub fn to_bytes(&self) -> Option<Vec<u8>> {
|
||||
let mut out = vec![0u8; Self::SIZE];
|
||||
out.get_mut(0..4)?.copy_from_slice(&[0, 1, 0, 1]);
|
||||
for (c, pts) in self.curves.iter().enumerate() {
|
||||
if pts.len() > 16 {
|
||||
return None;
|
||||
}
|
||||
let lut = curve_lut(pts)?;
|
||||
out.get_mut(4 + 256 * c..4 + 256 * (c + 1))?.copy_from_slice(&lut);
|
||||
let base = Self::POINTS + 72 * c;
|
||||
for (k, (x, y)) in pts.iter().enumerate() {
|
||||
out.get_mut(base + 4 * k..base + 4 * k + 4)?.copy_from_slice(&[0, *x, 0, *y]);
|
||||
}
|
||||
let n = u32::try_from(pts.len()).ok()?;
|
||||
out.get_mut(base + 64..base + 68)?.copy_from_slice(&n.to_be_bytes());
|
||||
let active: i32 = if pts.as_slice() == CURVE_IDENTITY { -1 } else { i32::try_from(pts.len()).ok()? - 2 };
|
||||
out.get_mut(base + 68..base + 72)?.copy_from_slice(&active.to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn rising(points: &[(u8, u8)]) -> bool {
|
||||
points.windows(2).all(|w| matches!(w, [a, b] if a.0 < b.0))
|
||||
}
|
||||
|
||||
/// The lookup table After Effects stores for a curve: the natural cubic spline (second derivative
|
||||
/// 0 at both ends) through the points, at the integers 0..255, rounded to the nearest integer and
|
||||
/// clamped to 0..255; flat outside the first and last point (961-964: 0 of 1280 entries differ).
|
||||
/// `None` for fewer than 2 points or inputs that do not rise.
|
||||
pub fn curve_lut(points: &[(u8, u8)]) -> Option<[u8; 256]> {
|
||||
let n = points.len();
|
||||
if n < 2 || !rising(points) {
|
||||
return None;
|
||||
}
|
||||
let xs: Vec<f64> = points.iter().map(|p| f64::from(p.0)).collect();
|
||||
let ys: Vec<f64> = points.iter().map(|p| f64::from(p.1)).collect();
|
||||
let x = |i: usize| xs.get(i).copied().unwrap_or(0.0);
|
||||
let y = |i: usize| ys.get(i).copied().unwrap_or(0.0);
|
||||
let h = |i: usize| (x(i + 1) - x(i)).max(1.0);
|
||||
let slope = |i: usize| (y(i + 1) - y(i)) / h(i);
|
||||
// Second derivatives: the tridiagonal system of the natural spline (Thomas algorithm).
|
||||
let mut m = vec![0.0f64; n];
|
||||
let k = n - 2;
|
||||
if k > 0 {
|
||||
let mut diag: Vec<f64> = (0..k).map(|i| 2.0 * (h(i) + h(i + 1))).collect();
|
||||
let upper: Vec<f64> = (0..k).map(|i| h(i + 1)).collect();
|
||||
let mut rhs: Vec<f64> = (0..k).map(|i| 6.0 * (slope(i + 1) - slope(i))).collect();
|
||||
for i in 1..k {
|
||||
let (Some(&dp), Some(&up), Some(&rp)) = (diag.get(i - 1), upper.get(i - 1), rhs.get(i - 1)) else { return None };
|
||||
let w = h(i) / dp;
|
||||
if let (Some(d), Some(r)) = (diag.get_mut(i), rhs.get_mut(i)) {
|
||||
*d -= w * up;
|
||||
*r -= w * rp;
|
||||
}
|
||||
}
|
||||
for i in (0..k).rev() {
|
||||
let next = m.get(i + 2).copied().unwrap_or(0.0);
|
||||
let (Some(&r), Some(&u), Some(&d)) = (rhs.get(i), upper.get(i), diag.get(i)) else { return None };
|
||||
if let Some(slot) = m.get_mut(i + 1) {
|
||||
*slot = (r - u * next) / d;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut lut = [0u8; 256];
|
||||
for (xi, slot) in lut.iter_mut().enumerate() {
|
||||
let xf = xi as f64;
|
||||
let v = if xf <= x(0) {
|
||||
y(0)
|
||||
} else if xf >= x(n - 1) {
|
||||
y(n - 1)
|
||||
} else {
|
||||
let i = (0..n - 1).find(|&i| xf >= x(i) && xf <= x(i + 1)).unwrap_or(0);
|
||||
let hh = h(i);
|
||||
let (a, b) = ((x(i + 1) - xf) / hh, (xf - x(i)) / hh);
|
||||
let (mi, mj) = (m.get(i).copied().unwrap_or(0.0), m.get(i + 1).copied().unwrap_or(0.0));
|
||||
a * y(i) + b * y(i + 1) + ((a * a * a - a) * mi + (b * b * b - b) * mj) * hh * hh / 6.0
|
||||
};
|
||||
*slot = (v + 0.5).floor().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
Some(lut)
|
||||
}
|
||||
|
||||
/// Mesh Warp's data (`ADBE MESH WARP-0004`; FORMAT S.2, 965, 985): `u32` rows plus one, `u32`
|
||||
/// columns plus one (a 1 x 2-cell read-back test renders as made only in this order, `export-tests\mesh`;
|
||||
/// the vertices run row-major, columns plus one per row), 12 more bytes (zero, then
|
||||
/// `0d 0d`),
|
||||
/// then per vertex, row-major, ten `f32`: the position and the handles top, right, bottom and
|
||||
/// left, all as fractions 0..1 of the layer; a handle pointing out of the mesh is stored (0, 0).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshData {
|
||||
pub columns: u32,
|
||||
pub rows: u32,
|
||||
/// Bytes 8..20 as stored.
|
||||
pub tail: [u8; 12],
|
||||
/// Per vertex: position, top, right, bottom, left.
|
||||
pub vertices: Vec<[[f32; 2]; 5]>,
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
/// Decodes the data; `None` when the size does not fit the grid or the grid is beyond 31 x 31
|
||||
/// cells.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
let (r1, c1) = (be_u32(data, 0)?, be_u32(data, 4)?);
|
||||
if !(2..=32).contains(&c1) || !(2..=32).contains(&r1) {
|
||||
return None;
|
||||
}
|
||||
let count = usize::try_from(c1.checked_mul(r1)?).ok()?;
|
||||
if data.len() != 20usize.checked_add(count.checked_mul(40)?)? {
|
||||
return None;
|
||||
}
|
||||
let mut tail = [0u8; 12];
|
||||
tail.copy_from_slice(data.get(8..20)?);
|
||||
let mut vertices = Vec::with_capacity(count);
|
||||
for v in 0..count {
|
||||
let base = 20 + 40 * v;
|
||||
let mut rec = [[0.0f32; 2]; 5];
|
||||
for (j, p) in rec.iter_mut().enumerate() {
|
||||
*p = [be_f32(data, base + 8 * j)?, be_f32(data, base + 8 * j + 4)?];
|
||||
}
|
||||
vertices.push(rec);
|
||||
}
|
||||
Some(Self { columns: c1 - 1, rows: r1 - 1, tail, vertices })
|
||||
}
|
||||
|
||||
/// The vertex at column `i`, row `j` of an undistorted mesh with its default handles (one
|
||||
/// third of a cell along the grid lines; those pointing out of the mesh are (0, 0)).
|
||||
pub fn default_vertex(columns: u32, rows: u32, i: u32, j: u32) -> [[f32; 2]; 5] {
|
||||
let (cw, rh) = (1.0 / f64::from(columns.max(1)), 1.0 / f64::from(rows.max(1)));
|
||||
let (x, y) = (f64::from(i) * cw, f64::from(j) * rh);
|
||||
let p = |dx: f64, dy: f64, out: bool| if out { [0.0, 0.0] } else { [(x + dx) as f32, (y + dy) as f32] };
|
||||
[[x as f32, y as f32], p(0.0, -rh / 3.0, j == 0), p(cw / 3.0, 0.0, i == columns), p(0.0, rh / 3.0, j == rows), p(-cw / 3.0, 0.0, i == 0)]
|
||||
}
|
||||
|
||||
/// The bytes After Effects stores for this mesh.
|
||||
pub fn to_bytes(&self) -> Option<Vec<u8>> {
|
||||
let count = usize::try_from(self.columns.checked_add(1)?.checked_mul(self.rows.checked_add(1)?)?).ok()?;
|
||||
if self.vertices.len() != count {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(20 + 40 * count);
|
||||
out.extend((self.rows + 1).to_be_bytes());
|
||||
out.extend((self.columns + 1).to_be_bytes());
|
||||
out.extend(self.tail);
|
||||
for v in &self.vertices {
|
||||
for p in v {
|
||||
out.extend(p[0].to_be_bytes());
|
||||
out.extend(p[1].to_be_bytes());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian
|
||||
/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset
|
||||
/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records
|
||||
/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by
|
||||
/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one
|
||||
/// definition's default, so only byte 0 is read) and the channel name from byte 35,
|
||||
/// NUL-terminated. The other bytes are not read (they differ between instances with the same
|
||||
/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted
|
||||
/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExtractorChannels {
|
||||
/// Per output channel (red, green, blue, alpha): the kind and the name.
|
||||
pub channels: [(u32, String); 4],
|
||||
}
|
||||
|
||||
impl ExtractorChannels {
|
||||
/// The data's length.
|
||||
pub const LEN: usize = 688;
|
||||
const RECORD: usize = 164;
|
||||
|
||||
/// `None` when the data does not have this layout.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() {
|
||||
return None;
|
||||
}
|
||||
let mut channels: [(u32, String); 4] = Default::default();
|
||||
for (k, slot) in channels.iter_mut().enumerate() {
|
||||
let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?;
|
||||
let rec = data.get(at..at.checked_add(Self::RECORD)?)?;
|
||||
let kind = u32::from(*rec.first()?);
|
||||
let name = rec.get(35..)?;
|
||||
let end = name.iter().position(|b| *b == 0)?;
|
||||
let name = std::str::from_utf8(name.get(..end)?).ok()?;
|
||||
*slot = (kind, name.to_string());
|
||||
}
|
||||
Some(Self { channels })
|
||||
}
|
||||
|
||||
/// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names,
|
||||
/// Alpha copied from the input.
|
||||
pub fn is_default(&self) -> bool {
|
||||
let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")];
|
||||
self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn)
|
||||
}
|
||||
|
||||
/// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the
|
||||
/// position of the first record of another kind or without a name: a named channel by its
|
||||
/// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY),
|
||||
/// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`:
|
||||
/// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the
|
||||
/// file's own, which loses what an earlier effect did to the channel.
|
||||
pub fn sources(&self) -> Result<[String; 4], usize> {
|
||||
let mut out: [String; 4] = Default::default();
|
||||
for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() {
|
||||
*slot = match (kind, name.trim()) {
|
||||
(1, n) if !n.is_empty() => n.to_string(),
|
||||
(2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(),
|
||||
_ => return Err(k),
|
||||
};
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name,
|
||||
/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310
|
||||
/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in
|
||||
/// every other instance). The bytes around it differ between instances with the same settings
|
||||
/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the
|
||||
/// EXR's sorted channel list, as for EXtractoR).
|
||||
pub fn identifier_channel_type(data: &[u8]) -> Option<&str> {
|
||||
let s = data.get(19..)?;
|
||||
let end = s.iter().position(|b| *b == 0)?;
|
||||
std::str::from_utf8(s.get(..end)?).ok()
|
||||
}
|
||||
|
||||
/// The Cryptomatte plug-in's Settings data (`Cryptomatte-0001`, the plug-in's own layout,
|
||||
/// little-endian; read from 88 instances of one project and from our own sample 1400, fresh
|
||||
/// instances and two presets that pick a layer and an object, docs/aep-format.md 3.7): the magic
|
||||
/// `cry1`; bytes 4..32 are not read (they differ between instances holding the same settings);
|
||||
/// the Cryptomatte layer's name at 32, NUL-padded to 64 bytes (`CryptoObject`); at 96 a `u32` 1
|
||||
/// and a `u32` (neither read); at 104 the `u32` length of the selection text including its NUL,
|
||||
/// the `u32` MurmurHash3 (x86, 32-bit, seed 0) of that text with its NUL (not read) and a zero
|
||||
/// byte; the selection text from byte 113 (`<11a97486>, <deadbeef>`, the matte list as ID hashes,
|
||||
/// the hashes of the picked names). Bytes after it are left-overs of a longer earlier text. A
|
||||
/// fresh effect stores no layer and an empty text (length 1, its NUL); the `u32`s at 100 and 108
|
||||
/// are then both `0x514e28b7`, the hash of one NUL byte: the one at 100 looks like the hash of a
|
||||
/// first text that is empty in every sample, the zero byte at 112 like that text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CryptomatteData {
|
||||
/// The Cryptomatte layer's name (`CryptoMaterial`: the EXR layer's name, which is also its
|
||||
/// channel prefix in every sample); empty when none is chosen.
|
||||
pub layer: String,
|
||||
/// The selection (matte list) as the plug-in stores it.
|
||||
pub selection: String,
|
||||
}
|
||||
|
||||
impl CryptomatteData {
|
||||
/// Decodes the data; `None` when it is not this layout (no `cry1`, too short, a length past
|
||||
/// the end, no zero byte at 112, a text without its NUL, text that is not UTF-8).
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.get(..4)? != b"cry1" {
|
||||
return None;
|
||||
}
|
||||
let text = |b: &[u8]| -> Option<String> {
|
||||
let end = b.iter().position(|c| *c == 0).unwrap_or(b.len());
|
||||
String::from_utf8(b.get(..end)?.to_vec()).ok()
|
||||
};
|
||||
let layer = text(data.get(32..96)?)?;
|
||||
let len = usize::try_from(u32::from_le_bytes(data.get(104..108)?.try_into().ok()?)).ok()?;
|
||||
if *data.get(112)? != 0 {
|
||||
return None;
|
||||
}
|
||||
let selection = if len == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
// The length counts the text's NUL as its last byte: a text without it, or with an early
|
||||
// NUL before more bytes (which `text` would silently cut short), is not this layout.
|
||||
let b = data.get(113..113usize.checked_add(len)?)?;
|
||||
let (last, text_bytes) = b.split_last()?;
|
||||
if *last != 0 || text_bytes.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
text(b)?
|
||||
};
|
||||
Some(Self { layer, selection })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CryptomatteData;
|
||||
|
||||
/// Settings data in the layout [`CryptomatteData::parse`] reads, with `noise` in the bytes it
|
||||
/// does not read.
|
||||
fn crypto(layer: &str, selection: &str, noise: u8) -> Vec<u8> {
|
||||
let mut d = b"cry1".to_vec();
|
||||
d.resize(32, noise);
|
||||
d.extend(layer.as_bytes());
|
||||
d.resize(96, 0);
|
||||
d.extend(1u32.to_le_bytes());
|
||||
d.extend([noise; 4]);
|
||||
let len = if selection.is_empty() { 0 } else { selection.len() + 1 };
|
||||
d.extend(u32::try_from(len).unwrap_or(0).to_le_bytes());
|
||||
d.extend([noise; 4]);
|
||||
d.push(0);
|
||||
d.extend(selection.as_bytes());
|
||||
d.push(0);
|
||||
// Left-overs of an earlier, longer text.
|
||||
d.extend(b" <de");
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cryptomatte_settings_are_read() {
|
||||
for noise in [0, 0x5a] {
|
||||
let d = CryptomatteData::parse(&crypto("Layer_A", "<0000002a>, <deadbeef>", noise));
|
||||
assert_eq!(d, Some(CryptomatteData { layer: "Layer_A".into(), selection: "<0000002a>, <deadbeef>".into() }), "noise {noise}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_whose_nul_is_not_its_last_byte_is_refused() {
|
||||
// An early NUL followed by more declared bytes would shorten the selection silently.
|
||||
let mut d = crypto("Layer_A", "<0000002a>, <deadbeef>", 0);
|
||||
d[113 + 4] = 0;
|
||||
assert_eq!(CryptomatteData::parse(&d), None);
|
||||
// No NUL at the end of the declared length.
|
||||
let mut d = crypto("Layer_A", "<0000002a>", 0);
|
||||
d[113 + 10] = b'x';
|
||||
assert_eq!(CryptomatteData::parse(&d), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_cryptomatte_settings_are_empty() {
|
||||
let mut d = b"cry1".to_vec();
|
||||
d.resize(116, 0);
|
||||
assert_eq!(CryptomatteData::parse(&d), Some(CryptomatteData::default()));
|
||||
// What After Effects 2026 wrote for a fresh effect (plug-in 2.6.6, sample 1400
|
||||
// `crypto-00-defaults`, the `pard` default and the instance's record alike): an empty text
|
||||
// of length 1 and its hash, twice.
|
||||
let mut fresh = b"cry1".to_vec();
|
||||
fresh.resize(96, 0);
|
||||
for _ in 0..2 {
|
||||
fresh.extend(1u32.to_le_bytes());
|
||||
fresh.extend(0x514e_28b7u32.to_le_bytes());
|
||||
}
|
||||
fresh.extend([0; 4]);
|
||||
assert_eq!(fresh.len(), 116);
|
||||
assert_eq!(CryptomatteData::parse(&fresh), Some(CryptomatteData::default()));
|
||||
}
|
||||
|
||||
/// The Settings data After Effects 2026 wrote for the two presets of sample 1400 (plug-in
|
||||
/// 2.6.6, `crypto-06-preset-object-box` and `crypto-07-preset-material-paint`: a layer chosen
|
||||
/// and one object or material picked in the plug-in's UI), byte for byte: 127 bytes, the `u32`
|
||||
/// at 108 the MurmurHash3 of the text with its NUL, three zero bytes after the text.
|
||||
#[test]
|
||||
fn the_presets_of_our_sample_are_read() {
|
||||
for (layer, selection, hash) in [("CryptoObject", "<11a97486>", 0x2788_0767u32), ("CryptoMaterial", "<a5deeb9e>", 0xf737_4a5b)] {
|
||||
let mut d = b"cry1".to_vec();
|
||||
d.resize(32, 0);
|
||||
d.extend(layer.as_bytes());
|
||||
d.resize(96, 0);
|
||||
for v in [1, 0x514e_28b7, 11, hash] {
|
||||
d.extend(u32::to_le_bytes(v));
|
||||
}
|
||||
d.push(0);
|
||||
d.extend(selection.as_bytes());
|
||||
d.extend([0; 4]);
|
||||
assert_eq!(d.len(), 127);
|
||||
assert_eq!(CryptomatteData::parse(&d), Some(CryptomatteData { layer: layer.into(), selection: selection.into() }), "{layer}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_cryptomatte_settings_are_not_read() {
|
||||
let good = crypto("L", "<00000001>", 0);
|
||||
let mut bad_magic = good.clone();
|
||||
bad_magic[0] = b'x';
|
||||
let mut past_end = good.clone();
|
||||
past_end[104..108].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let mut no_zero = good.clone();
|
||||
no_zero[112] = b'<';
|
||||
let mut not_utf8 = good.clone();
|
||||
not_utf8[32] = 0xff;
|
||||
// The length covers the text without its NUL (`<00000001>` is 10 bytes, its NUL the 11th).
|
||||
let mut no_nul = good.clone();
|
||||
no_nul[104..108].copy_from_slice(&10u32.to_le_bytes());
|
||||
// No zero byte at 112 with an empty text.
|
||||
let mut empty_no_zero = crypto("L", "", 0);
|
||||
empty_no_zero[112] = b'<';
|
||||
for (what, d) in [
|
||||
("magic", bad_magic),
|
||||
("length", past_end),
|
||||
("zero byte", no_zero),
|
||||
("utf-8", not_utf8),
|
||||
("no NUL", no_nul),
|
||||
("empty text, zero byte", empty_no_zero),
|
||||
("short", good[..100].to_vec()),
|
||||
("empty", Vec::new()),
|
||||
] {
|
||||
assert_eq!(CryptomatteData::parse(&d), None, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Bounds-checked big-endian readers shared by the typed views. Every read returns `None` when the
|
||||
//! bytes are not there; nothing here indexes a slice directly.
|
||||
|
||||
pub(crate) fn text(b: &[u8]) -> String {
|
||||
String::from_utf8_lossy(b).into_owned()
|
||||
}
|
||||
|
||||
/// The bytes up to the first NUL (all of them when there is none).
|
||||
pub(crate) fn until_nul(b: &[u8]) -> &[u8] {
|
||||
let end = b.iter().position(|c| *c == 0).unwrap_or(b.len());
|
||||
b.get(..end).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// A NUL-terminated ASCII (or Latin-1) string at `at`, at most `max` bytes long.
|
||||
pub(crate) fn cstr(b: &[u8], at: usize, max: usize) -> Option<String> {
|
||||
let end = at.checked_add(max)?.min(b.len());
|
||||
let field = b.get(at..end)?;
|
||||
Some(until_nul(field).iter().map(|c| char::from(*c)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn array<const N: usize>(b: &[u8], at: usize) -> Option<[u8; N]> {
|
||||
let s = b.get(at..at.checked_add(N)?)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Some(a)
|
||||
}
|
||||
|
||||
pub(crate) fn be_u16(b: &[u8], at: usize) -> Option<u16> {
|
||||
array::<2>(b, at).map(u16::from_be_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn be_u32(b: &[u8], at: usize) -> Option<u32> {
|
||||
array::<4>(b, at).map(u32::from_be_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn be_i32(b: &[u8], at: usize) -> Option<i32> {
|
||||
array::<4>(b, at).map(i32::from_be_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn le_u32(b: &[u8], at: usize) -> Option<u32> {
|
||||
array::<4>(b, at).map(u32::from_le_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn be_f32(b: &[u8], at: usize) -> Option<f32> {
|
||||
array::<4>(b, at).map(f32::from_be_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn be_f64(b: &[u8], at: usize) -> Option<f64> {
|
||||
array::<8>(b, at).map(f64::from_be_bytes)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Cameras and lights.
|
||||
//!
|
||||
//! Both are layers without a source (`ldta` byte 40 is 0 for a camera and `0xffff_ffff` for a light)
|
||||
//! whose type is 2 (camera) or 1 (light). Their `ldta` carries the auto-orient bit (byte 38, `0x40`)
|
||||
//! and, for a light, the light type (see [`LayerHeader::light_type`](crate::container::LayerHeader)). The
|
||||
//! properties sit in the same Transform group as on other layers but follow other conventions:
|
||||
//!
|
||||
//! * **Position** is stored as given once the user has set it. While it is at its computed default
|
||||
//! the record is an "auto state" (`tdb4` bytes 76 and 77 are 1 although there is no expression)
|
||||
//! that holds `[0, 0, -zoom]`. After Effects reports and renders the stored value, also for a
|
||||
//! two-node camera (256 read back `[0, 0, -2666.67]` in session S16; 537 renders from there). An
|
||||
//! earlier batch-3 note had a two-node camera report the composition centre; that was not
|
||||
//! reproduced.
|
||||
//! * **Anchor Point** of a camera is its point of interest, in pixels (not a fraction).
|
||||
//! * A light's **Intensity** (`ADBE Light Options Group/ADBE Light Intensity`, percent) is stored
|
||||
//! only when it is not the default 100.
|
||||
|
||||
use crate::container::typed::{Layer, PropNode};
|
||||
use crate::container::values::{Tracked, ValueSource, read, read_opt};
|
||||
|
||||
/// The Position of a camera or light layer as stored.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CameraPosition {
|
||||
/// `[x, y, z]` in pixels as stored: the absolute position once set by the user, `[0, 0, -zoom]`
|
||||
/// in the auto state (camera) or `[0, 0, -1333.33]` (default light).
|
||||
pub stored: [f64; 3],
|
||||
/// True while the position is derived (`tdb4` bytes 76 and 77 are 1 without an expression).
|
||||
pub auto_state: bool,
|
||||
}
|
||||
|
||||
impl CameraPosition {
|
||||
/// The position as the batch-3 notes had After Effects report it: in the auto state of a
|
||||
/// two-node camera (`two_node`) x and y are the composition centre; otherwise the stored
|
||||
/// value. **Contradicted by 256 and 537 (session S16): After Effects reports and renders the
|
||||
/// stored value**; use `stored` for what After Effects shows.
|
||||
pub fn reported(&self, comp_width: u32, comp_height: u32, two_node: bool) -> [f64; 3] {
|
||||
if self.auto_state && two_node { [f64::from(comp_width) / 2.0, f64::from(comp_height) / 2.0, self.stored[2]] } else { self.stored }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The Position record of a camera or light layer.
|
||||
pub fn camera_position(&self) -> Option<CameraPosition> {
|
||||
let Some(PropNode::Group(transform)) = self.properties()?.get("ADBE Transform Group") else { return None };
|
||||
let Some(PropNode::Property(position)) = transform.get("ADBE Position") else { return None };
|
||||
let v = position.static_value()?;
|
||||
let stored = *v.as_slice().first_chunk::<3>()?;
|
||||
let h = position.header()?;
|
||||
let derived = h.get(76) == Some(&1) && h.get(77) == Some(&1) && position.expression().is_none();
|
||||
Some(CameraPosition { stored, auto_state: derived })
|
||||
}
|
||||
|
||||
/// The point of interest of a camera in pixels (its Anchor Point record); `None` when absent.
|
||||
pub fn camera_point_of_interest(&self) -> Option<[f64; 3]> {
|
||||
let Some(PropNode::Group(transform)) = self.properties()?.get("ADBE Transform Group") else { return None };
|
||||
let tracked = read(&transform, "ADBE Anchor Point", [0.0; 3], 1.0);
|
||||
matches!(tracked.source, ValueSource::Stored | ValueSource::Keyframed).then_some(tracked.value)
|
||||
}
|
||||
|
||||
/// A light's intensity in percent; the default 100 when the property is absent.
|
||||
pub fn light_intensity(&self) -> Option<Tracked<f64>> {
|
||||
let root = self.properties()?;
|
||||
let Some(PropNode::Group(options)) = root.get("ADBE Light Options Group") else { return None };
|
||||
let t = read(&options, "ADBE Light Intensity", [100.0], 1.0);
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera zoom After Effects uses when `ADBE Camera Zoom` is absent: 2666.67 px for the 50 mm
|
||||
/// default camera in the 1920 px wide corpus composition. Whether it follows other composition
|
||||
/// widths was not sampled.
|
||||
pub const DEFAULT_CAMERA_ZOOM: f64 = 2_666.666_666_666_666_5;
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
fn camera_options(&self) -> Option<crate::container::typed::PropGroup<'f, 'a>> {
|
||||
match self.properties()?.get("ADBE Camera Options Group") {
|
||||
Some(PropNode::Group(g)) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn light_options(&self) -> Option<crate::container::typed::PropGroup<'f, 'a>> {
|
||||
match self.properties()?.get("ADBE Light Options Group") {
|
||||
Some(PropNode::Group(g)) => Some(g),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A camera's zoom in pixels (`ADBE Camera Options Group/ADBE Camera Zoom`); absent = the
|
||||
/// default, [`DEFAULT_CAMERA_ZOOM`] (341: 2000 is stored). The auto Position keeps `-2666.67` and
|
||||
/// is not recomputed from the zoom.
|
||||
pub fn camera_zoom(&self) -> Option<Tracked<f64>> {
|
||||
let t = read(&self.camera_options()?, "ADBE Camera Zoom", [DEFAULT_CAMERA_ZOOM], 1.0);
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
|
||||
/// The depth of field switch (`ADBE Camera Depth of Field`, a checkbox; off by default, 342).
|
||||
pub fn camera_depth_of_field(&self) -> Option<Tracked<bool>> {
|
||||
let t = read(&self.camera_options()?, "ADBE Camera Depth of Field", [0.0], 1.0);
|
||||
Some(Tracked { value: t.value[0] != 0.0, source: t.source })
|
||||
}
|
||||
|
||||
/// A camera's focus distance in pixels (`ADBE Camera Focus Distance`); `None` while absent (its
|
||||
/// default is not known).
|
||||
pub fn camera_focus_distance(&self) -> Option<Tracked<f64>> {
|
||||
let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Focus Distance", 1.0)?;
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
|
||||
/// A camera's aperture (`ADBE Camera Aperture`); `None` while absent (its default is not known).
|
||||
pub fn camera_aperture(&self) -> Option<Tracked<f64>> {
|
||||
let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Aperture", 1.0)?;
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
|
||||
/// A light's colour as `[A, R, G, B]` fractions (`ADBE Light Options Group/ADBE Light Color`,
|
||||
/// stored as doubles 0..255); white when absent (343 stores red).
|
||||
pub fn light_color(&self) -> Option<Tracked<[f64; 4]>> {
|
||||
Some(read(&self.light_options()?, "ADBE Light Color", [1.0; 4], 1.0 / 255.0))
|
||||
}
|
||||
|
||||
/// A spot light's cone angle in degrees (`ADBE Light Cone Angle`); 90 when absent (344 stores 60).
|
||||
/// A point light stores 90 with `tdsb` 3.
|
||||
pub fn light_cone_angle(&self) -> Option<Tracked<f64>> {
|
||||
let t = read(&self.light_options()?, "ADBE Light Cone Angle", [90.0], 1.0);
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
|
||||
/// A spot light's cone feather in percent (`ADBE Light Cone Feather 2`); 50 when absent (345
|
||||
/// stores 30).
|
||||
pub fn light_cone_feather(&self) -> Option<Tracked<f64>> {
|
||||
let t = read(&self.light_options()?, "ADBE Light Cone Feather 2", [50.0], 1.0);
|
||||
Some(Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! The lossless chunk tree.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// The id of a list chunk.
|
||||
pub const LIST: [u8; 4] = *b"LIST";
|
||||
|
||||
/// Non-`LIST` chunk ids whose body is itself a sequence of chunks (observed: each holds one `Utf8`
|
||||
/// chunk). They are parsed as [`Body::Chunks`] when the body is a well-formed chunk sequence and
|
||||
/// kept as [`Body::Data`] otherwise.
|
||||
pub const NESTED_IDS: [[u8; 4]; 3] = [*b"tdsn", *b"pdnm", *b"fnam"];
|
||||
|
||||
/// One chunk: a 4-byte id, a body and an optional pad byte.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Chunk<'a> {
|
||||
/// The 4-byte id, as stored (case matters; some ids end in a space, e.g. `seq `).
|
||||
pub id: [u8; 4],
|
||||
/// The body.
|
||||
pub body: Body<'a>,
|
||||
/// The pad byte that followed an odd-sized body (normally `0`). `None` when the body is even,
|
||||
/// or when an odd body is the last thing of its parent and the file has no pad byte there.
|
||||
/// On writing, an odd body that is not the last sibling always gets a pad byte (`0` if this is
|
||||
/// `None`); the last sibling gets one only if this is `Some`.
|
||||
pub pad: Option<u8>,
|
||||
}
|
||||
|
||||
/// The body of a [`Chunk`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Body<'a> {
|
||||
/// Opaque bytes (the content of nearly all chunks).
|
||||
Data(Cow<'a, [u8]>),
|
||||
/// A body that is a sequence of chunks although the id is not `LIST` (see [`NESTED_IDS`]).
|
||||
Chunks(Vec<Chunk<'a>>),
|
||||
/// A `LIST`: a 4-byte list type followed by chunks.
|
||||
List { list_type: [u8; 4], children: Vec<Chunk<'a>> },
|
||||
/// A `LIST` whose content is not a well-formed chunk sequence (or is nested deeper than the
|
||||
/// limit), kept as bytes after the list type (observed: `LIST btdk` holds raw text).
|
||||
OpaqueList { list_type: [u8; 4], data: Cow<'a, [u8]> },
|
||||
}
|
||||
|
||||
impl<'a> Chunk<'a> {
|
||||
/// A data chunk with the pad byte its length needs.
|
||||
pub fn data(id: [u8; 4], data: impl Into<Cow<'a, [u8]>>) -> Self {
|
||||
let data = data.into();
|
||||
let pad = if data.len() % 2 == 1 { Some(0) } else { None };
|
||||
Self { id, body: Body::Data(data), pad }
|
||||
}
|
||||
|
||||
/// A `LIST` chunk.
|
||||
pub fn list(list_type: [u8; 4], children: Vec<Chunk<'a>>) -> Self {
|
||||
Self { id: LIST, body: Body::List { list_type, children }, pad: None }
|
||||
}
|
||||
|
||||
/// The raw bytes of a [`Body::Data`] chunk, or the bytes after the list type of an opaque list.
|
||||
pub fn bytes(&self) -> Option<&[u8]> {
|
||||
match &self.body {
|
||||
Body::Data(d) => Some(d),
|
||||
Body::OpaqueList { data, .. } => Some(data),
|
||||
Body::Chunks(_) | Body::List { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The child chunks of a list or nested chunk; empty for data.
|
||||
pub fn children(&self) -> &[Chunk<'a>] {
|
||||
match &self.body {
|
||||
Body::Chunks(c) | Body::List { children: c, .. } => c,
|
||||
Body::Data(_) | Body::OpaqueList { .. } => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable child chunks; empty for data.
|
||||
pub fn children_mut(&mut self) -> &mut [Chunk<'a>] {
|
||||
match &mut self.body {
|
||||
Body::Chunks(c) | Body::List { children: c, .. } => c,
|
||||
Body::Data(_) | Body::OpaqueList { .. } => &mut [],
|
||||
}
|
||||
}
|
||||
|
||||
/// The list type when this is a `LIST` (parsed or opaque).
|
||||
pub fn list_type(&self) -> Option<[u8; 4]> {
|
||||
match &self.body {
|
||||
Body::List { list_type, .. } | Body::OpaqueList { list_type, .. } => Some(*list_type),
|
||||
Body::Data(_) | Body::Chunks(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is a `LIST` of the given type.
|
||||
pub fn is_list(&self, list_type: &[u8; 4]) -> bool {
|
||||
self.id == LIST && self.list_type().is_some_and(|t| &t == list_type)
|
||||
}
|
||||
|
||||
/// True when this is a `LIST` whose content could not be read as chunks (see
|
||||
/// [`Body::OpaqueList`]): damaged, or nested deeper than the limit.
|
||||
pub fn is_opaque_list(&self) -> bool {
|
||||
matches!(self.body, Body::OpaqueList { .. })
|
||||
}
|
||||
|
||||
/// True when this is a chunk (not a `LIST`) with the given id.
|
||||
pub fn is(&self, id: &[u8; 4]) -> bool {
|
||||
&self.id == id
|
||||
}
|
||||
|
||||
/// The first child (non-list) chunk with this id.
|
||||
pub fn child(&self, id: &[u8; 4]) -> Option<&Chunk<'a>> {
|
||||
self.children().iter().find(|c| c.is(id))
|
||||
}
|
||||
|
||||
/// The first child `LIST` of this type.
|
||||
pub fn child_list(&self, list_type: &[u8; 4]) -> Option<&Chunk<'a>> {
|
||||
self.children().iter().find(|c| c.is_list(list_type))
|
||||
}
|
||||
|
||||
/// Visits every chunk below this one depth-first (children before later siblings), without
|
||||
/// recursion; the depth counts from 1 for the children of this chunk.
|
||||
pub fn descendants(&self) -> Walk<'_, 'a> {
|
||||
Walk { stack: vec![(self.children().iter(), 1)] }
|
||||
}
|
||||
|
||||
/// The first descendant `LIST` of this type, searching depth-first.
|
||||
pub fn find_list(&self, list_type: &[u8; 4]) -> Option<&Chunk<'a>> {
|
||||
self.descendants().map(|(_, c)| c).find(|c| c.is_list(list_type))
|
||||
}
|
||||
|
||||
/// Copies all borrowed bytes so the tree no longer borrows the input.
|
||||
pub fn into_owned(self) -> Chunk<'static> {
|
||||
let body = match self.body {
|
||||
Body::Data(d) => Body::Data(Cow::Owned(d.into_owned())),
|
||||
Body::Chunks(c) => Body::Chunks(c.into_iter().map(Chunk::into_owned).collect()),
|
||||
Body::List { list_type, children } => Body::List { list_type, children: children.into_iter().map(Chunk::into_owned).collect() },
|
||||
Body::OpaqueList { list_type, data } => Body::OpaqueList { list_type, data: Cow::Owned(data.into_owned()) },
|
||||
};
|
||||
Chunk { id: self.id, body, pad: self.pad }
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `.aep`: the `RIFX` container's chunks and the bytes after its declared end.
|
||||
///
|
||||
/// Writing it back with [`crate::container::write`] reproduces the input byte for byte (the container size
|
||||
/// and every list size are recomputed from the tree and equal what the file declared).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AepFile<'a> {
|
||||
/// The top-level chunks (after the `Egg!` form type).
|
||||
pub chunks: Vec<Chunk<'a>>,
|
||||
/// The bytes after the container's declared end. After Effects puts an XMP packet there.
|
||||
pub trailer: Cow<'a, [u8]>,
|
||||
}
|
||||
|
||||
impl<'a> AepFile<'a> {
|
||||
/// The first top-level chunk with this id.
|
||||
pub fn chunk(&self, id: &[u8; 4]) -> Option<&Chunk<'a>> {
|
||||
self.chunks.iter().find(|c| c.is(id))
|
||||
}
|
||||
|
||||
/// The first top-level `LIST` of this type.
|
||||
pub fn list(&self, list_type: &[u8; 4]) -> Option<&Chunk<'a>> {
|
||||
self.chunks.iter().find(|c| c.is_list(list_type))
|
||||
}
|
||||
|
||||
/// The XMP packet in the trailer (from `<?xpacket begin` to the end of `<?xpacket end ...?>`).
|
||||
pub fn xmp(&self) -> Option<&[u8]> {
|
||||
let start = find(&self.trailer, b"<?xpacket begin")?;
|
||||
let rest = self.trailer.get(start..)?;
|
||||
let end_marker = find(rest, b"<?xpacket end")?;
|
||||
let tail = rest.get(end_marker..)?;
|
||||
let close = find(tail, b"?>")?;
|
||||
rest.get(..end_marker.checked_add(close)?.checked_add(2)?)
|
||||
}
|
||||
|
||||
/// Visits every chunk depth-first with its nesting depth (the top level is 1), without recursion.
|
||||
pub fn walk(&self) -> Walk<'_, 'a> {
|
||||
Walk { stack: vec![(self.chunks.iter(), 1)] }
|
||||
}
|
||||
|
||||
/// Copies all borrowed bytes so the file no longer borrows the input.
|
||||
pub fn into_owned(self) -> AepFile<'static> {
|
||||
AepFile { chunks: self.chunks.into_iter().map(Chunk::into_owned).collect(), trailer: Cow::Owned(self.trailer.into_owned()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Depth-first iterator over all chunks, see [`AepFile::walk`].
|
||||
pub struct Walk<'f, 'a> {
|
||||
stack: Vec<(std::slice::Iter<'f, Chunk<'a>>, usize)>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Iterator for Walk<'f, 'a> {
|
||||
type Item = (usize, &'f Chunk<'a>);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let (iter, depth) = self.stack.last_mut()?;
|
||||
let depth = *depth;
|
||||
match iter.next() {
|
||||
Some(chunk) => {
|
||||
let kids = chunk.children();
|
||||
if !kids.is_empty() {
|
||||
self.stack.push((kids.iter(), depth.saturating_add(1)));
|
||||
}
|
||||
return Some((depth, chunk));
|
||||
}
|
||||
None => {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,727 @@
|
||||
//! Effect instances: match name, instance name, the parameter list from `parT`/`pard` and the
|
||||
//! values stored with the instance.
|
||||
//!
|
||||
//! An effect on a layer is a `LIST sspc` under `ADBE Effect Parade`. It holds `fnam` (the
|
||||
//! instance name), `LIST parT` (the parameter definitions: `parn`, then a `tdmn` and a 148-byte
|
||||
//! `pard` per parameter, a `pdnm` after a popup), `LIST tdgp` (the values) and `pgui`. Only
|
||||
//! parameters that differ from their `pard` default have a value record in the `tdgp` (the Layer
|
||||
//! Control and button records are the exceptions: they always exist).
|
||||
//!
|
||||
//! When several layers carry an effect of the same type, only one instance has a full `parT`; the
|
||||
//! others have an empty `LIST parT` (case 383). Their parameter definitions are the ones in the
|
||||
//! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy
|
||||
//! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of
|
||||
//! the instance it was copied from, so values are only ever read from the instance's own `tdgp`.
|
||||
//!
|
||||
//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made
|
||||
//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which
|
||||
//! one is not known) and it is the only place the definitions are written, so the other instances
|
||||
//! of that type borrow them from it (#3170).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::container::Chunk;
|
||||
use crate::container::bytes::{array, be_f32, be_f64, be_i32, be_u16, be_u32, cstr, text, until_nul};
|
||||
use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property};
|
||||
|
||||
/// The parameter type, the `u32` at byte 12 of `pard`. The numbering matches the plug-in
|
||||
/// parameter types of the After Effects effect interface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ParamKind {
|
||||
/// `0`: a layer reference (also the implicit first parameter, the input layer).
|
||||
Layer,
|
||||
/// `1`: an integer slider (Fast Box Blur's Iterations, 412-414): the default is an `i32` at
|
||||
/// `pard` byte 56 (3), the valid maximum an `i32` at byte 128 (50), the value record a plain
|
||||
/// `f64` (1.0).
|
||||
IntSlider,
|
||||
/// `2`: a fixed-point (16.16) slider.
|
||||
FixedSlider,
|
||||
/// `3`: an angle (16.16 degrees).
|
||||
Angle,
|
||||
/// `4`
|
||||
Checkbox,
|
||||
/// `5`
|
||||
Color,
|
||||
/// `6`
|
||||
Point,
|
||||
/// `7`: a popup menu (the items are in the `pdnm` chunk).
|
||||
Popup,
|
||||
/// `9`: no data (the group-end entry `ADBE Effect Built In Params`).
|
||||
NoData,
|
||||
/// `10`: a floating-point slider.
|
||||
FloatSlider,
|
||||
/// `11`: arbitrary data (Hue/Saturation's Channel Range, Levels' Histogram, 443, 511): the
|
||||
/// value record is a 4-byte `cdat`, the data itself the `aRbp` of the `LIST aRbs` after it,
|
||||
/// see [`Effect::arbitrary_data`].
|
||||
ArbitraryData,
|
||||
/// `12`: a path / mask reference.
|
||||
Path,
|
||||
/// `13`: the start of a twirl-down group (Exposure's Master, Red, Green and Blue, 389). It
|
||||
/// holds no value; its value record always exists with `cdat` 0.
|
||||
GroupStart,
|
||||
/// `14`: the end of a twirl-down group (its name repeats the group's name, 389). It holds no
|
||||
/// value; its value record always exists with `cdat` 0.
|
||||
GroupEnd,
|
||||
/// `15`: a button (Tint's and Gradient Ramp's Swap Colors). It holds no value; its value
|
||||
/// record always exists with `cdat` 0 (356, 368).
|
||||
Button,
|
||||
/// Any other value.
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl ParamKind {
|
||||
fn from_u32(v: u32) -> Self {
|
||||
match v {
|
||||
0 => Self::Layer,
|
||||
1 => Self::IntSlider,
|
||||
2 => Self::FixedSlider,
|
||||
3 => Self::Angle,
|
||||
4 => Self::Checkbox,
|
||||
5 => Self::Color,
|
||||
6 => Self::Point,
|
||||
7 => Self::Popup,
|
||||
9 => Self::NoData,
|
||||
10 => Self::FloatSlider,
|
||||
11 => Self::ArbitraryData,
|
||||
12 => Self::Path,
|
||||
13 => Self::GroupStart,
|
||||
14 => Self::GroupEnd,
|
||||
15 => Self::Button,
|
||||
other => Self::Other(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded parameter value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ParamValue {
|
||||
/// A slider or angle value as stored: float sliders, fixed sliders and angles keep a plain
|
||||
/// `f64` in their value record (an angle in degrees; a fixed slider in its own scale, see
|
||||
/// [`EffectParam::valid_max`]); defaults read from `pard` are 16.16 decoded for fixed sliders
|
||||
/// and angles.
|
||||
Number(f64),
|
||||
/// A checkbox: stored as 1.0 when on; an absent record means off.
|
||||
Bool(bool),
|
||||
/// A point control as stored: a fraction of the layer size (`[111, 222]` px on a 1920x1080
|
||||
/// layer is `[111/1920, 222/1080]`).
|
||||
Point([f64; 2]),
|
||||
/// A layer control: the id of the chosen layer (the `tdpi` chunk next to the value record).
|
||||
/// In the implicit first parameter it is the id of the layer that owns the effect.
|
||||
Layer(u32),
|
||||
/// A colour as ARGB fractions 0..1 (stored as doubles 0..255 in the order alpha, red, green,
|
||||
/// blue: a colour `[0.2, 0.6, 1]` is `[255, 51, 153, 255]`, with float rounding).
|
||||
Argb([f64; 4]),
|
||||
/// A popup's item index, 1-based (Gradient Ramp's Radial Ramp is 2, 377): the default in
|
||||
/// `pard`, or the stored selection.
|
||||
Index(u32),
|
||||
}
|
||||
|
||||
/// What a Layer Control points at, given the id of the layer that owns the effect.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LayerRef {
|
||||
/// The layer itself (the default, "Layer 1" on the first layer): the stored `tdpi` equals the
|
||||
/// owning layer's id.
|
||||
This,
|
||||
/// None: the stored `tdpi` is 0.
|
||||
Nothing,
|
||||
/// Another layer, by id.
|
||||
Layer(u32),
|
||||
}
|
||||
|
||||
/// An effect instance on a layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Effect<'f, 'a> {
|
||||
match_name: String,
|
||||
chunk: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
/// Where the parameter definitions of an effect instance come from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DefinitionSource {
|
||||
/// The instance's own `parT`.
|
||||
Instance,
|
||||
/// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT`
|
||||
/// is empty, 383).
|
||||
Project,
|
||||
/// The own `parT` of another instance of the same match name in the file: the instance's
|
||||
/// own `parT` is empty and the file has no project-level copy of the type (a project without
|
||||
/// `EfdG`, #3170).
|
||||
OtherInstance,
|
||||
/// None of these has any: the effect has no readable parameter definitions.
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// One parameter of an effect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectParam<'f, 'a> {
|
||||
/// The position in `parT` (0 is the implicit input layer).
|
||||
pub index: usize,
|
||||
/// The `tdmn` match name (`<effect match name>-NNNN`, the last one is
|
||||
/// `ADBE Effect Built In Params`).
|
||||
pub match_name: String,
|
||||
pub kind: ParamKind,
|
||||
/// The parameter name (`Blurriness`, `Slider`, ...; empty for the implicit parameters).
|
||||
pub name: String,
|
||||
/// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`).
|
||||
pub items: Option<Vec<String>>,
|
||||
/// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`])
|
||||
/// or another instance's ([`DefinitionSource::OtherInstance`]).
|
||||
pub borrowed_definition: bool,
|
||||
pard: &'f [u8],
|
||||
stored: Option<Property<'f, 'a>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Effect<'f, 'a> {
|
||||
/// The effect's match name (`ADBE Slider Control`, `ADBE Gaussian Blur 2`, ...).
|
||||
pub fn match_name(&self) -> &str {
|
||||
&self.match_name
|
||||
}
|
||||
|
||||
/// The underlying `LIST sspc` chunk.
|
||||
pub fn chunk(&self) -> &'f Chunk<'a> {
|
||||
self.chunk
|
||||
}
|
||||
|
||||
/// The instance name shown in the effect controls (`fnam`).
|
||||
pub fn name(&self) -> Option<String> {
|
||||
self.chunk.child(b"fnam")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)
|
||||
}
|
||||
|
||||
/// The name the user gave the instance: the `tdsn` of the instance's value group when it is
|
||||
/// not the placeholder `-_0_/-` (610: `My Tint`; `fnam` keeps the effect's own name `Tint`).
|
||||
/// `None` while the instance shows the effect's name.
|
||||
pub fn user_name(&self) -> Option<String> {
|
||||
let values = self.chunk.child_list(b"tdgp")?;
|
||||
let name = values.child(b"tdsn")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)?;
|
||||
(name != "-_0_/-" && !name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
/// The instance's fx switch: the `tdsb` of its value group is 1 when on and 0 when switched
|
||||
/// off (`fx-switch` cases: one instance on, one off, both in one save). `None` for another
|
||||
/// value or a missing group.
|
||||
pub fn enabled(&self) -> Option<bool> {
|
||||
match be_u32(self.chunk.child_list(b"tdgp")?.child(b"tdsb")?.bytes()?, 0)? {
|
||||
1 => Some(true),
|
||||
0 => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name After Effects shows for the instance: [`Effect::user_name`], else [`Effect::name`].
|
||||
pub fn display_name(&self) -> Option<String> {
|
||||
self.user_name().or_else(|| self.name())
|
||||
}
|
||||
|
||||
/// The number of parameters declared by `parn` (the implicit input layer and the built-in
|
||||
/// entry included).
|
||||
pub fn declared_param_count(&self) -> Option<u32> {
|
||||
be_u32(self.chunk.child_list(b"parT")?.child(b"parn")?.bytes()?, 0)
|
||||
}
|
||||
|
||||
/// True when the instance's own `parT` holds parameter definitions (a `pard`). A second
|
||||
/// instance of an effect type has an empty `parT` (383).
|
||||
pub fn has_own_definitions(&self) -> bool {
|
||||
self.chunk.child_list(b"parT").is_some_and(has_pard)
|
||||
}
|
||||
|
||||
/// The match names of the value records in the instance's own `tdgp` (properties only, not
|
||||
/// groups), in file order, each once.
|
||||
pub fn value_record_names(&self) -> Vec<String> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
self.chunk
|
||||
.child_list(b"tdgp")
|
||||
.and_then(PropGroup::from_chunk)
|
||||
.map(|g| g.entries())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e.node, PropNode::Property(_)))
|
||||
.map(|e| e.match_name)
|
||||
.filter(|n| seen.insert(n.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parameters in the order of the instance's own `parT`. Empty for an instance whose
|
||||
/// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions.
|
||||
pub fn params(&self) -> Vec<EffectParam<'f, 'a>> {
|
||||
match self.chunk.child_list(b"parT") {
|
||||
Some(part) => self.params_from(part, false),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters described by `part` (a `LIST parT`) with the values of this instance.
|
||||
fn params_from(&self, part: &'f Chunk<'a>, borrowed: bool) -> Vec<EffectParam<'f, 'a>> {
|
||||
let mut out: Vec<EffectParam<'f, 'a>> = Vec::new();
|
||||
// The stored values by match name: the first entry of a name, as `PropGroup::get` finds it
|
||||
// (a group of that name means no stored value). Indexed once: a lookup per parameter
|
||||
// would scan the whole group for each of them.
|
||||
let mut values: HashMap<String, Option<Property<'f, 'a>>> = HashMap::new();
|
||||
for e in self.chunk.child_list(b"tdgp").and_then(PropGroup::from_chunk).map(|g| g.entries()).unwrap_or_default() {
|
||||
let stored = if let PropNode::Property(p) = e.node { Some(p) } else { None };
|
||||
values.entry(e.match_name).or_insert(stored);
|
||||
}
|
||||
let mut pending: Option<String> = None;
|
||||
for c in part.children() {
|
||||
if c.is(b"tdmn") {
|
||||
pending = c.bytes().map(|b| text(until_nul(b)));
|
||||
} else if c.is(b"pard") {
|
||||
let Some(pard) = c.bytes() else { continue };
|
||||
let match_name = pending.take().unwrap_or_default();
|
||||
let stored = values.get(&match_name).copied().flatten();
|
||||
out.push(EffectParam {
|
||||
index: out.len(),
|
||||
match_name,
|
||||
kind: ParamKind::from_u32(be_u32(pard, 12).unwrap_or(u32::MAX)),
|
||||
name: cstr(pard, 16, 32).unwrap_or_default(),
|
||||
items: None,
|
||||
borrowed_definition: borrowed,
|
||||
pard,
|
||||
stored,
|
||||
});
|
||||
} else if c.is(b"pdnm")
|
||||
&& let Some(last) = out.last_mut()
|
||||
&& let Some(u) = c.children().iter().find(|u| u.is(b"Utf8"))
|
||||
&& let Some(b) = u.bytes()
|
||||
// An empty `pdnm` names nothing (EffectCraft's exports write one: the effect
|
||||
// supplies its items).
|
||||
&& !b.is_empty()
|
||||
{
|
||||
last.items = Some(text(b).split('|').map(str::to_string).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn has_pard(part: &Chunk<'_>) -> bool {
|
||||
part.children().iter().any(|c| c.is(b"pard"))
|
||||
}
|
||||
|
||||
impl<'f, 'a> Project<'f, 'a> {
|
||||
/// The project-level effect definitions (`LIST EfdG`): one entry per effect type, the match
|
||||
/// name and the `LIST parT` of the copied instance, in file order (the stack order of the
|
||||
/// types, 365/366). The `EfDC` count before them is little-endian and is not needed to read
|
||||
/// them. A type whose copy has no definitions is left out.
|
||||
pub fn effect_definitions(&self) -> Vec<(String, &'f Chunk<'a>)> {
|
||||
let mut out: Vec<(String, &'f Chunk<'a>)> = Vec::new();
|
||||
let Some(defs) = self.effect_defs else { return out };
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
for df in defs.children().iter().filter(|c| c.is_list(b"EfDf")) {
|
||||
let mut name: Option<String> = None;
|
||||
for c in df.children() {
|
||||
if c.is(b"tdmn") {
|
||||
name = c.bytes().map(|b| text(until_nul(b)));
|
||||
} else if c.is_list(b"sspc")
|
||||
&& let Some(n) = name.take()
|
||||
&& let Some(part) = c.child_list(b"parT").filter(|p| has_pard(p))
|
||||
&& seen.insert(n.clone())
|
||||
{
|
||||
out.push((n, part));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it
|
||||
/// once per file, not once per effect (a file can hold many definitions and many instances).
|
||||
/// For a type without a project-level copy it also keeps the own `parT` of the first instance
|
||||
/// in the file (items, layers and effects in file order) that has one.
|
||||
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
|
||||
let by_name: HashMap<String, &'f Chunk<'a>> = self.effect_definitions().into_iter().collect();
|
||||
let mut instances: HashMap<String, &'f Chunk<'a>> = HashMap::new();
|
||||
for item in self.all_items() {
|
||||
let Some(comp) = item.comp() else { continue };
|
||||
for layer in comp.layers() {
|
||||
for fx in layer.effects() {
|
||||
if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
|
||||
instances.insert(fx.match_name, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EffectDefinitions { by_name, instances }
|
||||
}
|
||||
|
||||
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
|
||||
/// [`Self::effect_definition_index`] for more than one effect.
|
||||
pub fn definition_source(&self, effect: &Effect<'f, 'a>) -> DefinitionSource {
|
||||
self.effect_definition_index().source(effect)
|
||||
}
|
||||
|
||||
/// The parameters of `effect` ([`EffectDefinitions::params`]). Builds the index each call:
|
||||
/// use [`Self::effect_definition_index`] for more than one effect.
|
||||
pub fn effect_params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
|
||||
self.effect_definition_index().params(effect)
|
||||
}
|
||||
}
|
||||
|
||||
/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EffectDefinitions<'f, 'a> {
|
||||
/// The project-level copies (`EfdG`).
|
||||
by_name: HashMap<String, &'f Chunk<'a>>,
|
||||
/// For a type without a project-level copy: the own `parT` of the first instance that has one.
|
||||
instances: HashMap<String, &'f Chunk<'a>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> EffectDefinitions<'f, 'a> {
|
||||
/// Where the parameter definitions of `effect` come from.
|
||||
pub fn source(&self, effect: &Effect<'f, 'a>) -> DefinitionSource {
|
||||
if effect.has_own_definitions() {
|
||||
DefinitionSource::Instance
|
||||
} else if self.by_name.contains_key(effect.match_name()) {
|
||||
DefinitionSource::Project
|
||||
} else if self.instances.contains_key(effect.match_name()) {
|
||||
DefinitionSource::OtherInstance
|
||||
} else {
|
||||
DefinitionSource::Missing
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of `effect`: from its own `parT`, or, when that is empty, from the
|
||||
/// project-level definition of its match name, else from another instance of it that has
|
||||
/// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own
|
||||
/// records (absent = the `pard` default); the values next to borrowed definitions belong to
|
||||
/// another instance and are never read.
|
||||
pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
|
||||
if effect.has_own_definitions() {
|
||||
return effect.params();
|
||||
}
|
||||
match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
|
||||
Some(part) => effect.params_from(part, true),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> EffectParam<'f, 'a> {
|
||||
/// The raw 148-byte `pard` record (it contains stale memory after the name).
|
||||
pub fn pard(&self) -> &'f [u8] {
|
||||
self.pard
|
||||
}
|
||||
|
||||
/// The value record stored with the instance (`<match>-NNNN` in the instance's `tdgp`), which
|
||||
/// exists only for parameters that differ from the default.
|
||||
pub fn stored(&self) -> Option<Property<'f, 'a>> {
|
||||
self.stored
|
||||
}
|
||||
|
||||
/// True when the parameter's value record has keyframes ([`EffectParam::keys`]); then
|
||||
/// [`EffectParam::stored_value`] is `None` and [`EffectParam::value`] is not the value.
|
||||
pub fn is_animated(&self) -> bool {
|
||||
self.stored.is_some_and(|p| p.is_animated())
|
||||
}
|
||||
|
||||
/// The keyframes of an animated parameter: plain 48-byte records for sliders (363), 152-byte
|
||||
/// colour records (364) and 104-byte spatial records for points (382), with the values in the
|
||||
/// stored units of [`EffectParam::stored_value`].
|
||||
pub fn keys(&self) -> Option<KeyList<'f>> {
|
||||
self.stored?.keys()
|
||||
}
|
||||
|
||||
/// The value in the value record: float and fixed sliders, angles, checkboxes, points,
|
||||
/// colours, popups and layer controls. A record counts when its `tdsb` is 1 and it has no
|
||||
/// keyframes; a layer control reads the `tdpi` chunk; a button has no value.
|
||||
pub fn stored_value(&self) -> Option<ParamValue> {
|
||||
let p = self.stored?;
|
||||
if self.kind != ParamKind::Layer && p.flags() != Some(1) {
|
||||
return None;
|
||||
}
|
||||
self.recorded_value()
|
||||
}
|
||||
|
||||
/// The value in the value record when the record is written (`tdsb` 1) or in the default
|
||||
/// state (`tdsb` 3): what [`EffectParam::stored_value`] reads, also from a default-state
|
||||
/// record. Lumetri Color writes the values of its hidden parameters so (High Dynamic Range,
|
||||
/// HDR Specular, Look: 1200 `lumetri-01-set-all`, `-02-sec-top`, `-06-sec-light`; After
|
||||
/// Effects reads back the recorded values). `None` with keyframes, without a record or for a
|
||||
/// record in any other state (no sample has one; the importer reports it as damaged).
|
||||
pub fn recorded_value(&self) -> Option<ParamValue> {
|
||||
let p = self.stored?;
|
||||
if self.kind == ParamKind::Layer {
|
||||
return be_u32(p.chunk().child(b"tdpi")?.bytes()?, 0).map(ParamValue::Layer);
|
||||
}
|
||||
if !matches!(p.flags(), Some(1 | 3)) || p.is_animated() {
|
||||
return None;
|
||||
}
|
||||
let v = p.static_value()?;
|
||||
match self.kind {
|
||||
ParamKind::FloatSlider | ParamKind::FixedSlider | ParamKind::Angle | ParamKind::IntSlider => v.first().copied().map(ParamValue::Number),
|
||||
ParamKind::Checkbox => v.first().map(|x| ParamValue::Bool(*x != 0.0)),
|
||||
ParamKind::Point => {
|
||||
let [x, y] = *v.as_slice().first_chunk::<2>()?;
|
||||
Some(ParamValue::Point([x, y]))
|
||||
}
|
||||
ParamKind::Popup => {
|
||||
let index = v.first().copied()?;
|
||||
(0.0..=f64::from(u32::MAX)).contains(&index).then(|| ParamValue::Index(index.round() as u32))
|
||||
}
|
||||
ParamKind::Color => {
|
||||
let [a, r, g, b] = *v.as_slice().first_chunk::<4>()?;
|
||||
Some(ParamValue::Argb([a / 255.0, r / 255.0, g / 255.0, b / 255.0]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The default stored in `pard` at byte 56: a float slider's `f64`, a fixed slider's or angle's
|
||||
/// 16.16 value, a colour's ARGB bytes, a popup's item index, a point's two 16.16 fractions of
|
||||
/// the layer size (Point Control 0.5, 0.5: the layer centre, also on a 100x100 layer;
|
||||
/// Gradient Ramp Start 0.5, 0 and End 0.5, 1), an integer slider's `i32` (Iterations 3, 412),
|
||||
/// a checkbox's `u32` (1 = on: Repeat Edge Pixels of Fast Box Blur and Gaussian Blur, read back
|
||||
/// on, 412, 417; 0 = off: Shadow Only 420, Bypass Linear Light Conversion 389); other types:
|
||||
/// `None`.
|
||||
///
|
||||
/// It is the default and not the current value: Gaussian Blur with Blurriness 40 still has 25
|
||||
/// here and a value record with 40 (After Effects itself reads a fresh Gaussian Blur back as 25).
|
||||
/// Exception: selecting a popup item also writes the selection to this slot. So a popup whose
|
||||
/// definition was borrowed from the project copy ([`EffectParam::borrowed_definition`]) has no
|
||||
/// reliable default here (the slot holds the copied instance's selection): `None`.
|
||||
pub fn pard_default(&self) -> Option<ParamValue> {
|
||||
match self.kind {
|
||||
ParamKind::Popup if self.borrowed_definition => None,
|
||||
ParamKind::FloatSlider => be_f64(self.pard, 56).map(ParamValue::Number),
|
||||
ParamKind::FixedSlider | ParamKind::Angle => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v) / 65536.0)),
|
||||
ParamKind::IntSlider => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v))),
|
||||
ParamKind::Checkbox => be_u32(self.pard, 56).map(|v| ParamValue::Bool(v != 0)),
|
||||
ParamKind::Popup => be_u32(self.pard, 56).map(ParamValue::Index),
|
||||
ParamKind::Point => {
|
||||
let p = [f64::from(be_i32(self.pard, 56)?) / 65536.0, f64::from(be_i32(self.pard, 60)?) / 65536.0];
|
||||
// A fresh instance stores fractions of the layer (0.5, 0.5); After Effects' save of
|
||||
// a project it opened stores percent (50, 50: the re-saves of read-back tests e1 and
|
||||
// e2, where Gradient Ramp's Start and End and every other point default came back
|
||||
// as 50 / 0 and 50 / 100). Point
|
||||
// defaults lie inside the layer, so a component beyond 1 tells percent.
|
||||
let percent = p.iter().any(|c| c.abs() > 1.0 + 1e-9);
|
||||
Some(ParamValue::Point(if percent { p.map(|c| c / 100.0) } else { p }))
|
||||
}
|
||||
ParamKind::Color => {
|
||||
let [a, r, g, b] = array::<4>(self.pard, 56)?.map(|c| f64::from(c) / 255.0);
|
||||
Some(ParamValue::Argb([a, r, g, b]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the parameter is at its default: it has no value record (a record exists exactly
|
||||
/// when the value differs from the `pard` default; setting a value back to the default removes
|
||||
/// the record again), or the record equals the default. A record in the default state (`tdsb`
|
||||
/// 3) counts with its value ([`EffectParam::recorded_value`]). `None` when that cannot be
|
||||
/// decided (unknown parameter type, keyframes).
|
||||
pub fn is_default(&self) -> Option<bool> {
|
||||
match (self.stored, self.kind) {
|
||||
// A Layer Control always has its record, and a popup selection overwrites the default
|
||||
// in `pard`. Buttons and group markers hold no value at all.
|
||||
(_, ParamKind::Layer | ParamKind::Button | ParamKind::GroupStart | ParamKind::GroupEnd) => None,
|
||||
(None, _) => Some(true),
|
||||
(Some(_), ParamKind::Popup) => Some(false),
|
||||
(Some(_), _) => {
|
||||
let stored = self.recorded_value()?;
|
||||
// A checkbox's default is in `pard` too (on for Repeat Edge Pixels, 412).
|
||||
Some(self.pard_default().is_some_and(|d| d == stored))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point control in pixels of a layer of this size (the stored point is a fraction of the
|
||||
/// layer size; the default is the layer centre). `None` for other parameter types.
|
||||
pub fn point_px(&self, layer_width: f64, layer_height: f64) -> Option<[f64; 2]> {
|
||||
match self.value()? {
|
||||
ParamValue::Point([x, y]) if self.kind == ParamKind::Point => Some([x * layer_width, y * layer_height]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a Layer Control: what the stored layer id means for the layer that owns the effect.
|
||||
/// `None` for other types and for the implicit first parameter of an effect.
|
||||
pub fn layer_ref(&self, owner_layer_id: u32) -> Option<LayerRef> {
|
||||
if self.kind != ParamKind::Layer || self.index == 0 {
|
||||
return None;
|
||||
}
|
||||
match self.stored_value()? {
|
||||
ParamValue::Layer(0) => Some(LayerRef::Nothing),
|
||||
ParamValue::Layer(id) if id == owner_layer_id => Some(LayerRef::This),
|
||||
ParamValue::Layer(id) => Some(LayerRef::Layer(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest valid value of a slider, from `pard`: a fixed slider's 16.16 at byte 128 (Tint
|
||||
/// Amount to Tint 100, Gradient Ramp Ramp Scatter 512 and Blend With Original 1.0, Drop Shadow
|
||||
/// Opacity 255), a float slider's `f32` at byte 108 (Blurriness 30000, Exposure 100, Exposure's
|
||||
/// Gamma Correction 10). The stored scale of a
|
||||
/// slider differs per parameter (Amount 37.5 % is stored 37.5, Blend 25 % is stored 0.25), so
|
||||
/// this is what tells a reader the scale. `None` for other types.
|
||||
pub fn valid_max(&self) -> Option<f64> {
|
||||
match self.kind {
|
||||
ParamKind::FixedSlider => be_i32(self.pard, 128).map(|v| f64::from(v) / 65536.0),
|
||||
ParamKind::FloatSlider => be_f32(self.pard, 108).map(f64::from),
|
||||
ParamKind::IntSlider => be_i32(self.pard, 128).map(f64::from),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest valid value of a float slider, the `f32` at byte 104 of `pard` (Exposure
|
||||
/// -100, Offset -2, Gamma Correction 0.1: 389). `None` for other types (a fixed slider's
|
||||
/// minimum is not decoded).
|
||||
pub fn valid_min(&self) -> Option<f64> {
|
||||
match self.kind {
|
||||
ParamKind::FloatSlider => be_f32(self.pard, 104).map(f64::from),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The range of a float slider's slider (what the control's drag covers, inside the valid
|
||||
/// range): the `f32` pair at bytes 112 and 116 of `pard` (Exposure -4..4, Offset -0.5..0.5,
|
||||
/// Gamma Correction 0.1..10: 389). Each value record repeats it as `tdum` / `tduM`. `None`
|
||||
/// for other types.
|
||||
pub fn slider_range(&self) -> Option<(f64, f64)> {
|
||||
match self.kind {
|
||||
ParamKind::FloatSlider => Some((f64::from(be_f32(self.pard, 112)?), f64::from(be_f32(self.pard, 116)?))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a popup: the number of items (`u16` at byte 60 of `pard`).
|
||||
pub fn popup_item_count(&self) -> Option<u16> {
|
||||
if self.kind != ParamKind::Popup {
|
||||
return None;
|
||||
}
|
||||
be_u16(self.pard, 60)
|
||||
}
|
||||
|
||||
/// For a popup: the selected item index (1-based): the stored selection, else the default.
|
||||
pub fn popup_selected_index(&self) -> Option<u32> {
|
||||
match self.value()? {
|
||||
ParamValue::Index(i) if self.kind == ParamKind::Popup => Some(i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a popup: the text of the selected item.
|
||||
pub fn popup_selected_item(&self) -> Option<&str> {
|
||||
let index = usize::try_from(self.popup_selected_index()?).ok()?.checked_sub(1)?;
|
||||
self.items.as_ref()?.get(index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The effective value: the value in the record when there is one, also in a record in the
|
||||
/// default state (`tdsb` 3, [`EffectParam::recorded_value`]), else the `pard` default (absent =
|
||||
/// default); a checkbox without a record is off.
|
||||
pub fn value(&self) -> Option<ParamValue> {
|
||||
self.recorded_value().or_else(|| self.pard_default()).or(match self.kind {
|
||||
ParamKind::Checkbox if self.stored.is_none() => Some(ParamValue::Bool(false)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The effects on the layer, in stack order (`ADBE Effect Parade`).
|
||||
pub fn effects(&self) -> Vec<Effect<'f, 'a>> {
|
||||
let Some(root) = self.properties() else { return Vec::new() };
|
||||
let Some(PropNode::Group(parade)) = root.get("ADBE Effect Parade") else { return Vec::new() };
|
||||
parade
|
||||
.entries()
|
||||
.into_iter()
|
||||
.filter_map(|e| match e.node {
|
||||
PropNode::Other(chunk) if chunk.is_list(b"sspc") => Some(Effect { match_name: e.match_name, chunk }),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tdmn(name: &str) -> Chunk<'static> {
|
||||
let mut b = name.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
fn pard() -> Chunk<'static> {
|
||||
Chunk::data(*b"pard", vec![0u8; 48])
|
||||
}
|
||||
|
||||
fn tdbs() -> Chunk<'static> {
|
||||
Chunk::list(*b"tdbs", Vec::new())
|
||||
}
|
||||
|
||||
/// An effect instance whose `parT` names `defs` and whose `tdgp` stores `stored` (in order).
|
||||
fn instance(defs: &[String], stored: &[(String, Chunk<'static>)]) -> Chunk<'static> {
|
||||
let part = defs.iter().flat_map(|n| [tdmn(n), pard()]).collect();
|
||||
let values = stored.iter().flat_map(|(n, c)| [tdmn(n), c.clone()]).collect();
|
||||
Chunk::list(*b"sspc", vec![Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_takes_the_first_stored_entry_of_its_name() {
|
||||
let names: Vec<String> = ["A", "B", "C"].map(String::from).to_vec();
|
||||
// `A` is stored twice (the first wins), `B` names a group (no stored value), `C` is absent.
|
||||
let stored = vec![
|
||||
("A".to_string(), tdbs()),
|
||||
("B".to_string(), Chunk::list(*b"tdgp", Vec::new())),
|
||||
("A".to_string(), Chunk::list(*b"tdbs", vec![Chunk::data(*b"cdat", vec![0u8; 8])])),
|
||||
];
|
||||
let chunk = instance(&names, &stored);
|
||||
let fx = Effect { match_name: "X".into(), chunk: &chunk };
|
||||
let params = fx.params();
|
||||
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["A", "B", "C"]);
|
||||
let first = params.first().and_then(|p| p.stored).expect("A stored");
|
||||
assert!(first.chunk.children().is_empty(), "the first A (the second has a cdat)");
|
||||
assert!(params.get(1).is_some_and(|p| p.stored.is_none()), "a group of that name is no stored value");
|
||||
assert!(params.get(2).is_some_and(|p| p.stored.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_parameters_are_read_in_linear_time() {
|
||||
// 100 000 parameters with 100 000 stored values: a lookup per parameter that scans the
|
||||
// group (as before) is 1e10 entry builds; one index is instant.
|
||||
let n = 100_000;
|
||||
let names: Vec<String> = (0..n).map(|i| format!("P{i}")).collect();
|
||||
let stored: Vec<(String, Chunk<'static>)> = names.iter().map(|m| (m.clone(), tdbs())).collect();
|
||||
let chunk = instance(&names, &stored);
|
||||
let fx = Effect { match_name: "X".into(), chunk: &chunk };
|
||||
let params = fx.params();
|
||||
assert_eq!(params.len(), n);
|
||||
assert!(params.iter().all(|p| p.stored.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_project_definitions_are_indexed_once() {
|
||||
// 50 000 definitions, each twice (the first copy wins), then 1000 instances without
|
||||
// their own definitions looked up through one index.
|
||||
let n = 50_000;
|
||||
let mut defs = Vec::new();
|
||||
for round in 0..2 {
|
||||
for i in 0..n {
|
||||
let part = Chunk::list(*b"parT", vec![tdmn(&format!("R{round}")), pard()]);
|
||||
defs.push(Chunk::list(*b"EfDf", vec![tdmn(&format!("FX{i}")), Chunk::list(*b"sspc", vec![part])]));
|
||||
}
|
||||
}
|
||||
let efdg = Chunk::list(*b"EfdG", defs);
|
||||
let fold = Chunk::list(*b"Fold", Vec::new());
|
||||
let project = Project { fold: &fold, effect_defs: Some(&efdg) };
|
||||
let all = project.effect_definitions();
|
||||
assert_eq!(all.len(), n);
|
||||
let index = project.effect_definition_index();
|
||||
let bare = Chunk::list(*b"sspc", vec![Chunk::list(*b"parT", Vec::new())]);
|
||||
for i in (0..n).step_by(50) {
|
||||
let fx = Effect { match_name: format!("FX{i}"), chunk: &bare };
|
||||
assert_eq!(index.source(&fx), DefinitionSource::Project);
|
||||
let params = index.params(&fx);
|
||||
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["R0"], "the first copy's definitions");
|
||||
assert!(params.iter().all(|p| p.borrowed_definition));
|
||||
}
|
||||
let unknown = Effect { match_name: "nope".into(), chunk: &bare };
|
||||
assert_eq!(index.source(&unknown), DefinitionSource::Missing);
|
||||
assert!(index.params(&unknown).is_empty());
|
||||
assert_eq!(project.definition_source(&unknown), DefinitionSource::Missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Errors of the container reader and writer.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Why a file could not be read or written.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AepError {
|
||||
/// The input is shorter than the 12-byte `RIFX` header, or does not start with `RIFX`.
|
||||
NotRifx,
|
||||
/// The form type after the declared size is not `Egg!`.
|
||||
BadFormType([u8; 4]),
|
||||
/// The declared container size runs past the end of the input.
|
||||
Truncated { declared: u64, available: u64 },
|
||||
/// A chunk declares more body bytes than remain in its parent (`offset` is the chunk header).
|
||||
ChunkTooLarge { offset: usize, size: u64, available: u64 },
|
||||
/// The chunk structure is not well formed at `offset` (what is wrong is in `what`).
|
||||
Malformed { offset: usize, what: &'static str },
|
||||
/// Nesting is deeper than the allowed maximum.
|
||||
TooDeep,
|
||||
/// More chunks than the allowed maximum.
|
||||
TooManyChunks,
|
||||
/// A chunk or the whole file would not fit the 32-bit size fields when writing.
|
||||
TooLarge,
|
||||
/// An `.aepx` is not well-formed XML of the subset After Effects writes, at byte `offset`
|
||||
/// (what is wrong is in `what`).
|
||||
Xml { offset: usize, what: &'static str },
|
||||
}
|
||||
|
||||
impl AepError {
|
||||
/// Structural problems of the input that a lenient caller may repair by keeping a body opaque;
|
||||
/// limits (`TooDeep`, `TooManyChunks`) are never repaired.
|
||||
pub(crate) fn is_structural(&self) -> bool {
|
||||
matches!(self, Self::ChunkTooLarge { .. } | Self::Malformed { .. } | Self::Truncated { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AepError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotRifx => write!(f, "not an After Effects project: no RIFX header"),
|
||||
Self::BadFormType(t) => write!(f, "RIFX form type {:?} is not \"Egg!\"", String::from_utf8_lossy(t)),
|
||||
Self::Truncated { declared, available } => {
|
||||
write!(f, "truncated file: the container declares {declared} bytes, {available} are available")
|
||||
}
|
||||
Self::ChunkTooLarge { offset, size, available } => {
|
||||
write!(f, "chunk at offset {offset} declares {size} bytes, only {available} remain")
|
||||
}
|
||||
Self::Malformed { offset, what } => write!(f, "malformed chunk structure at offset {offset}: {what}"),
|
||||
Self::TooDeep => write!(f, "chunks are nested too deeply"),
|
||||
Self::TooManyChunks => write!(f, "too many chunks"),
|
||||
Self::TooLarge => write!(f, "chunk too large for the 32-bit size field"),
|
||||
Self::Xml { offset, what } => write!(f, "not a readable After Effects XML project (.aepx): {what} at byte {offset}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AepError {}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Item ids and labels, footage items (solids and files), precomp linkage, and the name and size
|
||||
//! of a layer's source.
|
||||
|
||||
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
|
||||
use crate::container::chunk::Chunk;
|
||||
use crate::container::header::Rational;
|
||||
use crate::container::typed::{Item, ItemKind, Layer, Project};
|
||||
|
||||
/// The footage data of an item (`LIST Pin ` with `sspc`, `opti` and, for files, `Als2/alas`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Footage {
|
||||
/// The 4-character file type tag at byte 22 of `sspc` (`Soli` for a solid, `png!` for a png).
|
||||
pub file_type: String,
|
||||
/// The width in pixels (`u32` at byte 30 of `sspc`).
|
||||
pub width: u32,
|
||||
/// The height in pixels (`u32` at byte 34 of `sspc`).
|
||||
pub height: u32,
|
||||
/// The display name stored in `opti` (the item's own `Utf8` name is empty for footage): at byte
|
||||
/// 26 for a solid and at byte 58 for a file.
|
||||
pub name: Option<String>,
|
||||
/// True for a solid (tag `Soli` at the start of `opti`).
|
||||
pub is_solid: bool,
|
||||
/// The colour of a solid as ARGB floats 0..1 (`opti` bytes 10, 14, 18, 22); a red solid is
|
||||
/// `[1, 1, 0, 0]`.
|
||||
pub solid_argb: Option<[f32; 4]>,
|
||||
/// The file path from the alias JSON (`Als2/alas`, key `fullpath`); none for solids. For an
|
||||
/// image sequence it is the sequence **folder**.
|
||||
pub path: Option<String>,
|
||||
/// The duration in seconds as stored (`sspc` `u32` at byte 38 over `u32` at byte 42; unreduced):
|
||||
/// 0/1 for stills, 48000/48000 for the 1 s WAV (the sample rate is the denominator), 3/30 for
|
||||
/// the three-frame image sequence (the frame rate is the denominator).
|
||||
pub duration: Option<Rational>,
|
||||
/// The native frame rate (`sspc` `u32` at byte 56): 30 for the image sequence, 0 for stills,
|
||||
/// audio and solids (also 0 when the byte is missing).
|
||||
pub native_frame_rate: u32,
|
||||
/// The bits per pixel (`sspc` `u32` at byte 60): 32 for an RGBA still, 24 for an RGB sequence,
|
||||
/// 0 for solids and audio.
|
||||
pub bits_per_pixel: u32,
|
||||
/// The size in bytes of the source file(s) (`sspc` `u32` at byte 208): 149 for the png, 96044
|
||||
/// for the WAV (44 + 48000 x 2), 504 for three 168-byte png files.
|
||||
pub file_size: u32,
|
||||
/// The audio sample rate (`sspc` `f64` at byte 160, 48000.0 for the WAV and for an mp4 with
|
||||
/// 48 kHz sound); `None` for footage without audio.
|
||||
pub audio_sample_rate: Option<f64>,
|
||||
/// True when the alias points at a folder (`"target_is_folder":true`): an image sequence is ONE
|
||||
/// item whose alias is its folder and whose `opti` holds the first file name.
|
||||
pub is_sequence: bool,
|
||||
}
|
||||
|
||||
impl Footage {
|
||||
/// The number of frames of an image sequence (`duration * native_frame_rate`: 3/30 s at 30 fps is
|
||||
/// 3); `None` for anything else.
|
||||
pub fn sequence_frame_count(&self) -> Option<u32> {
|
||||
if !self.is_sequence {
|
||||
return None;
|
||||
}
|
||||
let d = self.duration?;
|
||||
let frames = i128::from(d.num).checked_mul(i128::from(self.native_frame_rate))?;
|
||||
let den = i128::from(d.den);
|
||||
if den <= 0 || frames % den != 0 {
|
||||
return None;
|
||||
}
|
||||
u32::try_from(frames / den).ok()
|
||||
}
|
||||
|
||||
/// The solid's colour as fractions R, G, B.
|
||||
pub fn solid_rgb(&self) -> Option<[f32; 3]> {
|
||||
let [_, r, g, b] = self.solid_argb?;
|
||||
Some([r, g, b])
|
||||
}
|
||||
}
|
||||
|
||||
/// The boolean value of `"key"` in a flat JSON object (`true` / `false`); `None` when missing.
|
||||
pub(crate) fn json_bool(json: &[u8], key: &str) -> Option<bool> {
|
||||
let needle = format!("\"{key}\":");
|
||||
let needle = needle.as_bytes();
|
||||
let at = json.windows(needle.len()).position(|w| w == needle)?;
|
||||
let rest = json.get(at.checked_add(needle.len())?..)?;
|
||||
if rest.starts_with(b"true") {
|
||||
Some(true)
|
||||
} else if rest.starts_with(b"false") {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The string value of `"key"` in a flat JSON object, with the standard escapes decoded.
|
||||
/// Returns `None` when the key is missing or the value is not a string. The key is the first
|
||||
/// occurrence of `"key"` that is followed by `:` (the same text as a value is skipped).
|
||||
pub(crate) fn json_string(json: &[u8], key: &str) -> Option<String> {
|
||||
let needle = format!("\"{key}\"");
|
||||
let needle = needle.as_bytes();
|
||||
let mut from = 0usize;
|
||||
loop {
|
||||
let at = from.checked_add(json.get(from..)?.windows(needle.len()).position(|w| w == needle)?)?;
|
||||
let after = at.checked_add(needle.len())?;
|
||||
if let Some(v) = json_string_at(json, after) {
|
||||
return Some(v);
|
||||
}
|
||||
from = at.checked_add(1)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The string value right after a key ending at `i` (`: "…"`), or `None` when none follows.
|
||||
fn json_string_at(json: &[u8], mut i: usize) -> Option<String> {
|
||||
let skip_ws = |i: &mut usize| {
|
||||
while json.get(*i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
*i = i.saturating_add(1);
|
||||
}
|
||||
};
|
||||
skip_ws(&mut i);
|
||||
if json.get(i) != Some(&b':') {
|
||||
return None;
|
||||
}
|
||||
i = i.saturating_add(1);
|
||||
skip_ws(&mut i);
|
||||
if json.get(i) != Some(&b'"') {
|
||||
return None;
|
||||
}
|
||||
i = i.saturating_add(1);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
let c = *json.get(i)?;
|
||||
i = i.saturating_add(1);
|
||||
match c {
|
||||
b'"' => return Some(text(&out)),
|
||||
b'\\' => {
|
||||
let e = *json.get(i)?;
|
||||
i = i.saturating_add(1);
|
||||
match e {
|
||||
b'n' => out.push(b'\n'),
|
||||
b't' => out.push(b'\t'),
|
||||
b'r' => out.push(b'\r'),
|
||||
b'b' => out.push(8),
|
||||
b'f' => out.push(12),
|
||||
b'u' => {
|
||||
let hex4 = |at: usize| {
|
||||
json.get(at..at.checked_add(4)?).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u32::from_str_radix(h, 16).ok())
|
||||
};
|
||||
let mut code = hex4(i)?;
|
||||
i = i.saturating_add(4);
|
||||
// A character outside the BMP is a high surrogate and a `\u` low surrogate.
|
||||
if (0xd800..0xdc00).contains(&code)
|
||||
&& json.get(i..i.saturating_add(2)) == Some(b"\\u")
|
||||
&& let Some(lo) = hex4(i.saturating_add(2)).filter(|lo| (0xdc00..0xe000).contains(lo))
|
||||
{
|
||||
code = 0x10000 + ((code - 0xd800) << 10) + (lo - 0xdc00);
|
||||
i = i.saturating_add(6);
|
||||
}
|
||||
let ch = char::from_u32(code).unwrap_or('\u{fffd}');
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbering of an image sequence item (353, 603), or its file list when it was imported with
|
||||
/// Force alphabetical order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SequenceInfo {
|
||||
/// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias.
|
||||
/// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix.
|
||||
pub prefix: String,
|
||||
/// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias.
|
||||
pub suffix: String,
|
||||
/// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103
|
||||
/// in 603; both `0xFFFFFFFF` for a sequence with a file list).
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
/// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`).
|
||||
pub digits: u32,
|
||||
/// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc`
|
||||
/// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file);
|
||||
/// empty for a numbered sequence. When set, it replaces the numbering.
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
impl SequenceInfo {
|
||||
/// The file name of frame `n` (`shot_0101.exr`).
|
||||
pub fn file_name(&self, n: u32) -> String {
|
||||
let width = usize::try_from(self.digits.min(16)).unwrap_or(0);
|
||||
format!("{}{n:0width$}{}", self.prefix, self.suffix)
|
||||
}
|
||||
|
||||
/// The number of files: the listed files, or the frame numbers from start to end (0 when the
|
||||
/// end lies before the start).
|
||||
pub fn frame_count(&self) -> u32 {
|
||||
if self.files.is_empty() {
|
||||
self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1))
|
||||
} else {
|
||||
u32::try_from(self.files.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None`
|
||||
/// past the last file.
|
||||
pub fn nth_file_name(&self, i: u32) -> Option<String> {
|
||||
if self.files.is_empty() {
|
||||
let n = self.start.checked_add(i).filter(|n| *n <= self.end)?;
|
||||
Some(self.file_name(n))
|
||||
} else {
|
||||
self.files.get(usize::try_from(i).ok()?).cloned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most
|
||||
/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number
|
||||
/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list
|
||||
/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
|
||||
/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
|
||||
/// are joined to the folder.
|
||||
fn sequence_file_list(list: &Chunk<'_>) -> Vec<String> {
|
||||
// After Effects writes the count first (the one sample); a list without a readable count is
|
||||
// not read rather than guessed.
|
||||
let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let names: Vec<String> =
|
||||
list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
|
||||
if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
|
||||
return Vec::new();
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves
|
||||
/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides:
|
||||
/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that
|
||||
/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read.
|
||||
fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> {
|
||||
if let Some(first) = first_file.filter(|_| !part.is_empty()) {
|
||||
match (first.starts_with(part), first.ends_with(part)) {
|
||||
(true, false) => return Some((part.to_string(), String::new())),
|
||||
(false, true) => return Some((String::new(), part.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
part.starts_with('.').then(|| (String::new(), part.to_string()))
|
||||
}
|
||||
|
||||
impl<'f, 'a> Item<'f, 'a> {
|
||||
/// The numbering of an image sequence (an item whose alias is a folder); `None` for anything
|
||||
/// else.
|
||||
pub fn sequence_info(&self) -> Option<SequenceInfo> {
|
||||
let f = self.footage()?;
|
||||
if !f.is_sequence {
|
||||
return None;
|
||||
}
|
||||
let pin = self.chunk.child_list(b"Pin ")?;
|
||||
let sspc = pin.child(b"sspc")?.bytes()?;
|
||||
let kids = pin.children();
|
||||
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
|
||||
let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
|
||||
let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
|
||||
// Force alphabetical order: the file list replaces the numbering.
|
||||
if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
|
||||
let files = sequence_file_list(list);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
|
||||
}
|
||||
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
|
||||
let (prefix, suffix) = match names.as_slice() {
|
||||
[prefix, suffix] => (prefix.clone(), suffix.clone()),
|
||||
// An empty prefix is not stored (`0001.png` has only `.png`).
|
||||
[one] => single_name_part(one, f.name.as_deref())?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() })
|
||||
}
|
||||
|
||||
/// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at
|
||||
/// byte 146): 30 for the 25 fps mp4 conformed to 30 (645), `None` when not conformed (0 in 644,
|
||||
/// 606) or for an item without footage data. The effective rate is then also at byte 150 while
|
||||
/// the stored duration (bytes 38 / 42) and byte 56 stay native. A conformed image sequence
|
||||
/// stores the new rate in bytes 42, 56 and 150 instead and keeps 0 here (615).
|
||||
pub fn conform_frame_rate(&self) -> Option<u32> {
|
||||
let sspc = self.chunk.child_list(b"Pin ")?.child(b"sspc")?.bytes()?;
|
||||
be_u32(sspc, 146).filter(|r| *r > 0)
|
||||
}
|
||||
|
||||
/// The item id: the big-endian `u32` at byte 16 of `idta` (what a layer's source id refers to).
|
||||
pub fn id(&self) -> Option<u32> {
|
||||
be_u32(self.chunk.child(b"idta")?.bytes()?, 16)
|
||||
}
|
||||
|
||||
/// The label colour index, byte 58 of `idta` (15 for a composition made by precomposing).
|
||||
pub fn label(&self) -> Option<u8> {
|
||||
self.chunk.child(b"idta")?.bytes()?.get(58).copied()
|
||||
}
|
||||
|
||||
/// The ids of the compositions this item uses as precomp layers: the little-endian `u32` of
|
||||
/// each `idpi` chunk.
|
||||
pub fn used_item_ids(&self) -> Vec<u32> {
|
||||
self.chunk.children().iter().filter(|c| c.is(b"idpi")).filter_map(|c| le_u32(c.bytes()?, 0)).collect()
|
||||
}
|
||||
|
||||
/// The footage data of a footage item.
|
||||
pub fn footage(&self) -> Option<Footage> {
|
||||
if self.kind()? != ItemKind::Footage {
|
||||
return None;
|
||||
}
|
||||
let pin = self.chunk.child_list(b"Pin ")?;
|
||||
let sspc = pin.child(b"sspc")?.bytes()?;
|
||||
let opti = pin.child(b"opti")?.bytes()?;
|
||||
let is_solid = opti.get(..4) == Some(&b"Soli"[..]);
|
||||
let alias = pin.child_list(b"Als2").and_then(|als| als.child(b"alas")).and_then(|alas| alas.bytes());
|
||||
let path = alias.and_then(|json| json_string(json, "fullpath"));
|
||||
let is_sequence = alias.and_then(|json| json_bool(json, "target_is_folder")).unwrap_or(false);
|
||||
let solid_argb = if is_solid { Some([be_f32(opti, 10)?, be_f32(opti, 14)?, be_f32(opti, 18)?, be_f32(opti, 22)?]) } else { None };
|
||||
Some(Footage {
|
||||
file_type: cstr(sspc, 22, 4)?,
|
||||
width: be_u32(sspc, 30)?,
|
||||
height: be_u32(sspc, 34)?,
|
||||
name: cstr(opti, if is_solid { 26 } else { 58 }, 256).filter(|n| !n.is_empty()),
|
||||
is_solid,
|
||||
solid_argb,
|
||||
path,
|
||||
duration: be_u32(sspc, 38).zip(be_u32(sspc, 42)).map(|(num, den)| Rational { num: i64::from(num), den: i64::from(den) }),
|
||||
native_frame_rate: be_u32(sspc, 56).unwrap_or(0),
|
||||
bits_per_pixel: be_u32(sspc, 60).unwrap_or(0),
|
||||
file_size: be_u32(sspc, 208).unwrap_or(0),
|
||||
audio_sample_rate: be_f64(sspc, 160).filter(|r| r.is_finite() && *r > 0.0),
|
||||
is_sequence,
|
||||
})
|
||||
}
|
||||
|
||||
/// The name this item shows in the project: the item name, or for footage the name stored in
|
||||
/// `opti`.
|
||||
pub fn display_name(&self) -> Option<String> {
|
||||
self.name().or_else(|| self.footage().and_then(|f| f.name))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Project<'f, 'a> {
|
||||
/// The item with this id (`idta` byte 16), searching every item including those in folders.
|
||||
pub fn item_by_id(&self, id: u32) -> Option<Item<'f, 'a>> {
|
||||
self.all_items().into_iter().find(|i| i.id() == Some(id))
|
||||
}
|
||||
|
||||
/// The item a layer is made from (its source id), e.g. the solid, the footage or the precomp.
|
||||
pub fn layer_source(&self, layer: &Layer<'f, 'a>) -> Option<Item<'f, 'a>> {
|
||||
self.item_by_id(layer.header()?.source_id?)
|
||||
}
|
||||
|
||||
/// The name a layer shows: its own name, or its source's name when it has none.
|
||||
pub fn layer_display_name(&self, layer: &Layer<'f, 'a>) -> Option<String> {
|
||||
layer.name().or_else(|| self.layer_source(layer)?.display_name())
|
||||
}
|
||||
|
||||
/// The pixel size of a layer's source: a footage item's width and height, or a precomp's
|
||||
/// composition size.
|
||||
pub fn layer_source_size(&self, layer: &Layer<'f, 'a>) -> Option<(u32, u32)> {
|
||||
let source = self.layer_source(layer)?;
|
||||
match source.kind()? {
|
||||
ItemKind::Footage => source.footage().map(|f| (f.width, f.height)),
|
||||
ItemKind::Composition => source.comp()?.header().map(|h| (u32::from(h.width), u32::from(h.height))),
|
||||
ItemKind::Folder | ItemKind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::json_string;
|
||||
|
||||
#[test]
|
||||
fn reads_a_string_with_escapes() {
|
||||
let json = br#"{"a":1,"fullpath":"C:\\dir\\f\u00e9.png","b":"x"}"#;
|
||||
assert_eq!(json_string(json, "fullpath").as_deref(), Some(r"C:\dir\fé.png"));
|
||||
assert_eq!(json_string(json, "b").as_deref(), Some("x"));
|
||||
assert_eq!(json_string(json, "missing"), None);
|
||||
assert_eq!(json_string(br#"{"a" : "spaced"}"#, "a").as_deref(), Some("spaced"));
|
||||
// The key's text as an earlier value is not the key.
|
||||
assert_eq!(json_string(br#"{"a":"fullpath","fullpath":"x"}"#, "fullpath").as_deref(), Some("x"));
|
||||
// A surrogate pair is one character; a lone half stays U+FFFD.
|
||||
assert_eq!(json_string(br#"{"a":"x\ud83d\ude00y"}"#, "a").as_deref(), Some("x😀y"));
|
||||
assert_eq!(json_string(br#"{"a":"\ud83dz"}"#, "a").as_deref(), Some("\u{fffd}z"));
|
||||
assert_eq!(json_string(br#"{"a":"\ude00"}"#, "a").as_deref(), Some("\u{fffd}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_json_never_panics() {
|
||||
for json in [
|
||||
&br#"{"fullpath":"#[..],
|
||||
br#"{"fullpath":"abc"#,
|
||||
br#"{"fullpath":"ab\"#,
|
||||
br#"{"fullpath":"\u12"}"#,
|
||||
br#"{"fullpath":"\uzzzz"}"#,
|
||||
br#"{"fullpath":5}"#,
|
||||
br#"{"fullpath"}"#,
|
||||
b"\"fullpath\"",
|
||||
b"",
|
||||
b"\"",
|
||||
br#"{"fullpath":"\ud800"}"#,
|
||||
] {
|
||||
let _ = json_string(json, "fullpath");
|
||||
}
|
||||
assert_eq!(json_string(br#"{"fullpath":"\ud800"}"#, "fullpath").as_deref(), Some("\u{fffd}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
//! The layer header (`ldta`) and the composition header (`cdta`, `PRin`).
|
||||
//!
|
||||
//! Every field here has a verified layout: the decoded value equals the value the generated corpus
|
||||
//! was made with (see `docs/aep-format.md`, sections 4 and 5, for the evidence per field). Fields that
|
||||
//! the corpus does not decide are not decoded; the raw bytes stay reachable through the chunk.
|
||||
|
||||
use crate::container::bytes::{array, be_i32, be_u16, be_u32, cstr, text, until_nul};
|
||||
use crate::container::typed::{Comp, Item, Layer, TimeBase, utf8_child};
|
||||
|
||||
/// An exact fraction. Used for times (`seconds = num / den`) and for the pixel aspect ratio. The
|
||||
/// fields hold the stored values unreduced; [`Rational::reduced`] normalises.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rational {
|
||||
pub num: i64,
|
||||
pub den: i64,
|
||||
}
|
||||
|
||||
impl Rational {
|
||||
/// A fraction with a positive denominator, reduced; `None` for a zero denominator.
|
||||
pub fn new(num: i64, den: i64) -> Option<Self> {
|
||||
Self { num, den }.reduced()
|
||||
}
|
||||
|
||||
/// The value as `f64`, `None` for a zero denominator.
|
||||
pub fn to_f64(&self) -> Option<f64> {
|
||||
(self.den != 0).then(|| self.num as f64 / self.den as f64)
|
||||
}
|
||||
|
||||
/// The same value in lowest terms with a positive denominator, `None` for a zero denominator.
|
||||
pub fn reduced(&self) -> Option<Self> {
|
||||
if self.den == 0 {
|
||||
return None;
|
||||
}
|
||||
let (mut a, mut b) = (i128::from(self.num).unsigned_abs(), i128::from(self.den).unsigned_abs());
|
||||
let (mut x, mut y) = (a, b);
|
||||
while y != 0 {
|
||||
(x, y) = (y, x % y);
|
||||
}
|
||||
if x == 0 {
|
||||
x = 1;
|
||||
}
|
||||
a /= x;
|
||||
b /= x;
|
||||
let negative = (self.num < 0) != (self.den < 0);
|
||||
let num = i64::try_from(a).ok()?;
|
||||
let den = i64::try_from(b).ok()?;
|
||||
Some(Self { num: if negative { num.checked_neg()? } else { num }, den })
|
||||
}
|
||||
|
||||
/// `self + other * factor`, exact, `None` on a zero denominator or overflow.
|
||||
fn plus_scaled(&self, other: &Rational, factor: &Rational) -> Option<Rational> {
|
||||
if self.den == 0 || other.den == 0 || factor.den == 0 {
|
||||
return None;
|
||||
}
|
||||
// other * factor = (other.num * factor.num) / (other.den * factor.den)
|
||||
let scaled_num = i128::from(other.num).checked_mul(i128::from(factor.num))?;
|
||||
let scaled_den = i128::from(other.den).checked_mul(i128::from(factor.den))?;
|
||||
let den = i128::from(self.den).checked_mul(scaled_den)?;
|
||||
let num = i128::from(self.num).checked_mul(scaled_den)?.checked_add(scaled_num.checked_mul(i128::from(self.den))?)?;
|
||||
Rational { num: i64::try_from(num).ok()?, den: i64::try_from(den).ok()? }.reduced()
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer quality switch, `ldta` byte 5.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Quality {
|
||||
/// `1`
|
||||
Draft,
|
||||
/// `2`
|
||||
Best,
|
||||
/// Any other value (`0` on cameras and lights; wireframe was not sampled).
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
/// The blend mode, `ldta` byte 99. Six modes were sampled; any other mode is kept as its raw code.
|
||||
/// The codes 2, 4, 5, 6 and 7 coincide with the numbers of the After Effects plug-in transfer
|
||||
/// modes; Difference is 26.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlendMode {
|
||||
/// `2`
|
||||
Normal,
|
||||
/// `4`
|
||||
Add,
|
||||
/// `5`
|
||||
Multiply,
|
||||
/// `6`
|
||||
Screen,
|
||||
/// `7`
|
||||
Overlay,
|
||||
/// `26`
|
||||
Difference,
|
||||
/// Any other value (not sampled; `0` on cameras and lights).
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl BlendMode {
|
||||
/// The mode for a code.
|
||||
pub fn from_code(code: u8) -> Self {
|
||||
match code {
|
||||
2 => Self::Normal,
|
||||
4 => Self::Add,
|
||||
5 => Self::Multiply,
|
||||
6 => Self::Screen,
|
||||
7 => Self::Overlay,
|
||||
26 => Self::Difference,
|
||||
other => Self::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The code as stored.
|
||||
pub fn code(&self) -> u8 {
|
||||
match self {
|
||||
Self::Normal => 2,
|
||||
Self::Add => 4,
|
||||
Self::Multiply => 5,
|
||||
Self::Screen => 6,
|
||||
Self::Overlay => 7,
|
||||
Self::Difference => 26,
|
||||
Self::Other(code) => *code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The light type, `ldta` `u32` at byte 136 of a light layer (0 on every other layer).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LightType {
|
||||
/// `0` (701)
|
||||
Parallel,
|
||||
/// `1`
|
||||
Spot,
|
||||
/// `2`
|
||||
Point,
|
||||
/// `3` (702; a light created as an environment light by script without the Advanced 3D
|
||||
/// renderer is stored as an ambient light too, 704)
|
||||
Ambient,
|
||||
/// Any other value (an environment light was not sampled).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
/// The layer type, `ldta` `u32` at byte 128.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LayerType {
|
||||
/// `0`: solid, footage, precomp or null object (an AV layer).
|
||||
Av,
|
||||
/// `1`
|
||||
Light,
|
||||
/// `2`
|
||||
Camera,
|
||||
/// `3`
|
||||
Text,
|
||||
/// `4`
|
||||
Shape,
|
||||
/// Any other value.
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
/// The decoded fields of a layer's `ldta` chunk (164 bytes; the fields below need the first 140).
|
||||
///
|
||||
/// Times are stored as a numerator and a denominator (the denominator is the composition's
|
||||
/// timescale, 1 in a precomp layer). The in and out points are stored **relative to the start**:
|
||||
/// the absolute in point is `start + in * stretch`, see [`LayerHeader::in_point`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LayerHeader {
|
||||
/// The layer id (unique in the project); parent links use it.
|
||||
pub id: u32,
|
||||
pub quality: Quality,
|
||||
/// The numerator of the time stretch (signed `i32` at byte 8). The stretch is the exact ratio
|
||||
/// `stretch / stretch_den`: `1/1` = 100 %, `2/1` = 200 %, `1/2` = 50 %, `3/2` = 150 %, `-1/1` =
|
||||
/// -100 % (a time-reversed layer, 981), `-1/2` = -50 % (983), see
|
||||
/// [`LayerHeader::stretch_ratio`].
|
||||
pub stretch: i32,
|
||||
/// The denominator of the time stretch (`u32` at byte 108; 1 in every file before 50 % was
|
||||
/// sampled).
|
||||
pub stretch_den: u32,
|
||||
/// The layer start time, as stored.
|
||||
pub start: Rational,
|
||||
/// The in point relative to the start, as stored.
|
||||
pub in_relative: Rational,
|
||||
/// The out point relative to the start, as stored.
|
||||
pub out_relative: Rational,
|
||||
/// Bytes 37, 38 and 39 of `ldta`: the switch bits (see the `is_*` methods).
|
||||
pub flag_bytes: [u8; 3],
|
||||
/// The id of the source item (`ldta` byte 40), `None` for the values `0` (shape, text, camera)
|
||||
/// and `0xffff_ffff` (light).
|
||||
pub source_id: Option<u32>,
|
||||
/// The label colour index (`ldta` byte 61: 1 red, 5, 8 blue, ...).
|
||||
pub label: u8,
|
||||
/// The copy of the layer name at byte 64 (31 characters at most; the layer's `Utf8` child holds
|
||||
/// the full name).
|
||||
pub name_copy: String,
|
||||
pub blend_mode: BlendMode,
|
||||
pub layer_type: LayerType,
|
||||
/// The id of the parent layer, `None` when there is none.
|
||||
pub parent_id: Option<u32>,
|
||||
/// The `u32` at byte 136 (the light type of a light layer, 0 elsewhere), see
|
||||
/// [`LayerHeader::light_type`].
|
||||
pub light_type_code: u32,
|
||||
}
|
||||
|
||||
impl LayerHeader {
|
||||
/// Decodes an `ldta` body; `None` when it is shorter than 140 bytes.
|
||||
pub fn parse(ldta: &[u8]) -> Option<Self> {
|
||||
let time = |at: usize| Some(Rational { num: i64::from(be_i32(ldta, at)?), den: i64::from(be_u32(ldta, at.checked_add(4)?)?) });
|
||||
let source = be_u32(ldta, 40)?;
|
||||
let parent = be_u32(ldta, 132)?;
|
||||
Some(Self {
|
||||
id: be_u32(ldta, 0)?,
|
||||
quality: match *ldta.get(5)? {
|
||||
1 => Quality::Draft,
|
||||
2 => Quality::Best,
|
||||
other => Quality::Other(other),
|
||||
},
|
||||
stretch: be_i32(ldta, 8)?,
|
||||
stretch_den: be_u32(ldta, 108)?,
|
||||
start: time(12)?,
|
||||
in_relative: time(20)?,
|
||||
out_relative: time(28)?,
|
||||
flag_bytes: array::<3>(ldta, 37)?,
|
||||
source_id: (source != 0 && source != u32::MAX).then_some(source),
|
||||
label: *ldta.get(61)?,
|
||||
name_copy: text(until_nul(ldta.get(64..96)?)),
|
||||
blend_mode: BlendMode::from_code(*ldta.get(99)?),
|
||||
layer_type: match be_u32(ldta, 128)? {
|
||||
0 => LayerType::Av,
|
||||
1 => LayerType::Light,
|
||||
2 => LayerType::Camera,
|
||||
3 => LayerType::Text,
|
||||
4 => LayerType::Shape,
|
||||
other => LayerType::Other(other),
|
||||
},
|
||||
parent_id: (parent != 0).then_some(parent),
|
||||
light_type_code: be_u32(ldta, 136)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn flag(&self, byte: usize, mask: u8) -> bool {
|
||||
self.flag_bytes.get(byte).is_some_and(|b| b & mask != 0)
|
||||
}
|
||||
|
||||
/// Byte 37 bit `0x01`: the layer has a name set by the user (clear on a default-named camera or
|
||||
/// light and on a precomp layer, whose name is the source's).
|
||||
pub fn name_is_user_set(&self) -> bool {
|
||||
self.flag(0, 0x01)
|
||||
}
|
||||
|
||||
/// Byte 37 bit `0x02`: guide layer.
|
||||
pub fn is_guide(&self) -> bool {
|
||||
self.flag(0, 0x02)
|
||||
}
|
||||
|
||||
/// Byte 38 bit `0x02`: adjustment layer.
|
||||
pub fn is_adjustment(&self) -> bool {
|
||||
self.flag(1, 0x02)
|
||||
}
|
||||
|
||||
/// Byte 38 bit `0x04`: the 3D layer switch.
|
||||
pub fn is_three_d(&self) -> bool {
|
||||
self.flag(1, 0x04)
|
||||
}
|
||||
|
||||
/// Byte 38 bit `0x40`: the layer orients itself towards its point of interest. Set on two-node
|
||||
/// cameras (byte 38 is `0x44`) and on all lights, clear on a one-node camera (`0x04`).
|
||||
pub fn is_auto_oriented(&self) -> bool {
|
||||
self.flag(1, 0x40)
|
||||
}
|
||||
|
||||
/// Byte 38 bit `0x08`: solo.
|
||||
pub fn is_solo(&self) -> bool {
|
||||
self.flag(1, 0x08)
|
||||
}
|
||||
|
||||
/// Byte 38 bit `0x80`: the layer was created as a null object.
|
||||
pub fn is_null_object(&self) -> bool {
|
||||
self.flag(1, 0x80)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x01`: the video switch (the layer's eye icon; `enabled` in the scripting API).
|
||||
pub fn is_video_enabled(&self) -> bool {
|
||||
self.flag(2, 0x01)
|
||||
}
|
||||
|
||||
/// The same as [`LayerHeader::is_video_enabled`].
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.is_video_enabled()
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x02`: the audio switch (`audioEnabled`).
|
||||
pub fn is_audio_enabled(&self) -> bool {
|
||||
self.flag(2, 0x02)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x04`: the effects switch (`effectsActive`).
|
||||
pub fn is_effects_enabled(&self) -> bool {
|
||||
self.flag(2, 0x04)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x08`: the motion blur switch.
|
||||
pub fn is_motion_blur(&self) -> bool {
|
||||
self.flag(2, 0x08)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x20`: locked.
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.flag(2, 0x20)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x40`: shy.
|
||||
pub fn is_shy(&self) -> bool {
|
||||
self.flag(2, 0x40)
|
||||
}
|
||||
|
||||
/// Byte 39 bit `0x80`: collapse transformations / continuously rasterize. Shape and text
|
||||
/// layers have it set from creation.
|
||||
pub fn collapses_transformations(&self) -> bool {
|
||||
self.flag(2, 0x80)
|
||||
}
|
||||
|
||||
/// The light type of a light layer: parallel 0, spot 1, point 2, ambient 3 (`None` for any
|
||||
/// other layer type).
|
||||
pub fn light_type(&self) -> Option<LightType> {
|
||||
(self.layer_type == LayerType::Light).then_some(match self.light_type_code {
|
||||
0 => LightType::Parallel,
|
||||
1 => LightType::Spot,
|
||||
2 => LightType::Point,
|
||||
3 => LightType::Ambient,
|
||||
other => LightType::Other(other),
|
||||
})
|
||||
}
|
||||
|
||||
/// The time stretch as an exact reduced ratio (`stretch / stretch_den`: 50 % is `1/2`, 150 %
|
||||
/// is `3/2`); `None` for a zero denominator.
|
||||
pub fn stretch_ratio(&self) -> Option<Rational> {
|
||||
Rational::new(i64::from(self.stretch), i64::from(self.stretch_den))
|
||||
}
|
||||
|
||||
/// The time stretch in percent (`stretch / stretch_den * 100`); a zero denominator reads as 1.
|
||||
pub fn stretch_percent(&self) -> f64 {
|
||||
let den = if self.stretch_den == 0 { 1.0 } else { f64::from(self.stretch_den) };
|
||||
f64::from(self.stretch) / den * 100.0
|
||||
}
|
||||
|
||||
/// The start time in seconds, `None` for a zero denominator.
|
||||
pub fn start_seconds(&self) -> Option<f64> {
|
||||
self.start.to_f64()
|
||||
}
|
||||
|
||||
/// The absolute in point, `start + in * stretch`, exact and reduced.
|
||||
pub fn in_point(&self) -> Option<Rational> {
|
||||
self.start.plus_scaled(&self.in_relative, &self.stretch_ratio()?)
|
||||
}
|
||||
|
||||
/// The absolute out point, `start + out * stretch`, exact and reduced. For a footage layer the
|
||||
/// stored denominator is the footage time base (the sample rate of an audio layer, the frame
|
||||
/// rate of an image sequence), not the composition timescale.
|
||||
pub fn out_point(&self) -> Option<Rational> {
|
||||
self.start.plus_scaled(&self.out_relative, &self.stretch_ratio()?)
|
||||
}
|
||||
|
||||
/// The absolute in point in seconds.
|
||||
pub fn in_point_seconds(&self) -> Option<f64> {
|
||||
self.in_point()?.to_f64()
|
||||
}
|
||||
|
||||
/// The absolute out point in seconds.
|
||||
pub fn out_point_seconds(&self) -> Option<f64> {
|
||||
self.out_point()?.to_f64()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The layer id (`ldta` byte 0).
|
||||
pub fn id(&self) -> Option<u32> {
|
||||
be_u32(self.chunk.child(b"ldta")?.bytes()?, 0)
|
||||
}
|
||||
|
||||
/// The decoded `ldta`, see [`LayerHeader`].
|
||||
pub fn header(&self) -> Option<LayerHeader> {
|
||||
LayerHeader::parse(self.chunk.child(b"ldta")?.bytes()?)
|
||||
}
|
||||
|
||||
/// The video switch (byte 39 bit `0x01`), `enabled` in the scripting API.
|
||||
pub fn is_enabled(&self) -> Option<bool> {
|
||||
self.header().map(|h| h.is_video_enabled())
|
||||
}
|
||||
|
||||
/// The audio switch (byte 39 bit `0x02`), `audioEnabled`.
|
||||
pub fn is_audio_enabled(&self) -> Option<bool> {
|
||||
self.header().map(|h| h.is_audio_enabled())
|
||||
}
|
||||
|
||||
/// The effects switch (byte 39 bit `0x04`), `effectsActive`.
|
||||
pub fn is_effects_enabled(&self) -> Option<bool> {
|
||||
self.header().map(|h| h.is_effects_enabled())
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer of a composition (`LIST PRin` / `prin`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Renderer {
|
||||
/// The match name (`ADBE Escher` for the Classic 3D renderer in the base project,
|
||||
/// `ADBE Calder` for Advanced 3D).
|
||||
pub match_name: String,
|
||||
/// The display name (`Classic 3D`, `Advanced 3D`).
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
/// The decoded fields of a composition's `cdta` chunk (204 bytes; the fields below need 176).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompHeader {
|
||||
/// Frame duration (`u32` at byte 4) and timescale (`u32` at byte 8).
|
||||
pub time_base: TimeBase,
|
||||
/// The frame rate as 16.16 fixed point (`u32` at byte 156).
|
||||
pub frame_rate_fixed: u32,
|
||||
/// The composition duration in time units.
|
||||
pub duration_units: u32,
|
||||
/// The work area start in time units.
|
||||
pub work_area_start_units: u32,
|
||||
/// The work area end (start + duration) in time units; `None` when it is the whole
|
||||
/// composition (stored as `0xffff_ffff`).
|
||||
pub work_area_end_units: Option<u32>,
|
||||
/// The background colour as R, G, B (bytes 52 to 54), 8 bits per channel: 0.5 is stored as 128
|
||||
/// and reads back as 128/255.
|
||||
pub background: [u8; 3],
|
||||
/// The shutter angle in degrees.
|
||||
pub shutter_angle: u32,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// The pixel aspect ratio as stored: numerator and denominator.
|
||||
pub pixel_aspect: Rational,
|
||||
/// The composition's motion blur switch.
|
||||
pub motion_blur: bool,
|
||||
/// The shutter phase in degrees (`i32` at byte 180, 0 by default, -90 in the sample); `None`
|
||||
/// when the chunk ends before byte 184.
|
||||
pub shutter_phase: Option<i32>,
|
||||
/// The display start time (the comp start timecode) as `num / den` seconds: `u32` at byte 164
|
||||
/// over `u32` at byte 168 (0/1 by default; display start frame 100 at 25 fps is 102400/25600).
|
||||
/// `None` when the chunk ends before byte 172.
|
||||
pub display_start: Option<Rational>,
|
||||
}
|
||||
|
||||
impl CompHeader {
|
||||
/// Decodes a `cdta` body; `None` when it is shorter than 176 bytes.
|
||||
pub fn parse(cdta: &[u8]) -> Option<Self> {
|
||||
let work_end = be_u32(cdta, 36)?;
|
||||
Some(Self {
|
||||
time_base: TimeBase { frame_duration: be_u32(cdta, 4)?, timescale: be_u32(cdta, 8)? },
|
||||
frame_rate_fixed: be_u32(cdta, 156)?,
|
||||
duration_units: be_u32(cdta, 44)?,
|
||||
work_area_start_units: be_u32(cdta, 28)?,
|
||||
work_area_end_units: (work_end != u32::MAX).then_some(work_end),
|
||||
background: array::<3>(cdta, 52)?,
|
||||
shutter_angle: be_u32(cdta, 172)?,
|
||||
width: be_u16(cdta, 140)?,
|
||||
height: be_u16(cdta, 142)?,
|
||||
pixel_aspect: Rational { num: i64::from(be_u32(cdta, 144)?), den: i64::from(be_u32(cdta, 148)?) },
|
||||
motion_blur: cdta.get(139)? & 0x08 != 0,
|
||||
shutter_phase: be_i32(cdta, 180),
|
||||
display_start: be_u32(cdta, 164).zip(be_u32(cdta, 168)).map(|(num, den)| Rational { num: i64::from(num), den: i64::from(den) }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Frames per second from the time base (`timescale / frame_duration`).
|
||||
pub fn frame_rate(&self) -> Option<f64> {
|
||||
self.time_base.frame_rate()
|
||||
}
|
||||
|
||||
/// The frame rate from the 16.16 fixed-point field (25 is `0x0019_0000`).
|
||||
pub fn frame_rate_from_fixed(&self) -> f64 {
|
||||
f64::from(self.frame_rate_fixed) / 65536.0
|
||||
}
|
||||
|
||||
/// The duration in seconds.
|
||||
pub fn duration_seconds(&self) -> Option<f64> {
|
||||
self.time_base.seconds(i32::try_from(self.duration_units).ok()?)
|
||||
}
|
||||
|
||||
/// The work area start in seconds.
|
||||
pub fn work_area_start_seconds(&self) -> Option<f64> {
|
||||
self.time_base.seconds(i32::try_from(self.work_area_start_units).ok()?)
|
||||
}
|
||||
|
||||
/// The work area end in seconds; the composition duration when the work area is the whole
|
||||
/// composition.
|
||||
pub fn work_area_end_seconds(&self) -> Option<f64> {
|
||||
match self.work_area_end_units {
|
||||
Some(end) => self.time_base.seconds(i32::try_from(end).ok()?),
|
||||
None => self.duration_seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The background colour as fractions 0..1 (`byte / 255`).
|
||||
pub fn background_rgb(&self) -> [f64; 3] {
|
||||
self.background.map(|c| f64::from(c) / 255.0)
|
||||
}
|
||||
|
||||
/// The pixel aspect ratio as a number.
|
||||
pub fn pixel_aspect_ratio(&self) -> Option<f64> {
|
||||
self.pixel_aspect.to_f64()
|
||||
}
|
||||
|
||||
/// The display start time in seconds (the start timecode), `None` when absent or the
|
||||
/// denominator is zero.
|
||||
pub fn display_start_seconds(&self) -> Option<f64> {
|
||||
self.display_start?.to_f64()
|
||||
}
|
||||
|
||||
/// The display start as a frame number (`display start * frame rate`, rounded); 100 at 25 fps
|
||||
/// for a display start of 4 s.
|
||||
pub fn display_start_frame(&self) -> Option<i64> {
|
||||
let rate = self.frame_rate()?;
|
||||
Some((self.display_start_seconds()? * rate).round() as i64)
|
||||
}
|
||||
|
||||
/// The frame rate as an exact fraction `timescale / frame_duration`, as typed: 29.97 fps is
|
||||
/// `23976 / 800` (not `30000 / 1001`), 23.976 fps is `23976 / 1000`.
|
||||
pub fn frame_rate_exact(&self) -> Option<Rational> {
|
||||
Rational::new(i64::from(self.time_base.timescale), i64::from(self.time_base.frame_duration))
|
||||
}
|
||||
|
||||
/// The duration in whole frames (`duration / frame_duration`). After Effects snaps a duration
|
||||
/// to a whole number of frames: 5 s at 29.97 fps becomes 150 frames (5.005 s).
|
||||
pub fn duration_frames(&self) -> Option<u32> {
|
||||
self.duration_units.checked_div(self.time_base.frame_duration)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Comp<'f, 'a> {
|
||||
/// The decoded `cdta`, see [`CompHeader`].
|
||||
pub fn header(&self) -> Option<CompHeader> {
|
||||
CompHeader::parse(self.item.child(b"cdta")?.bytes()?)
|
||||
}
|
||||
|
||||
/// The composition name (the item's `Utf8` child).
|
||||
pub fn name(&self) -> Option<String> {
|
||||
utf8_child(self.item).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// The item this composition data belongs to.
|
||||
pub fn item(&self) -> Item<'f, 'a> {
|
||||
Item { chunk: self.item }
|
||||
}
|
||||
|
||||
/// The renderer (`LIST PRin` / `prin`: match name at byte 4, display name at byte 52, 32
|
||||
/// bytes each).
|
||||
pub fn renderer(&self) -> Option<Renderer> {
|
||||
let prin = self.item.child_list(b"PRin")?.child(b"prin")?.bytes()?;
|
||||
Some(Renderer { match_name: cstr(prin, 4, 32)?, display_name: cstr(prin, 52, 32)? })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Keyframe records, property flags and expressions.
|
||||
//!
|
||||
//! Keyframe times are **layer time**, in units of the property's own time scale
|
||||
//! ([`Property::time_scale`], equal to the composition's timescale in every sample).
|
||||
//!
|
||||
//! Every record starts with an 8-byte header: `i32` time, `u8` at byte 4 = the interpolation of the
|
||||
//! **incoming** side, `u8` at byte 5 = the **outgoing** side (1 linear, 2 bezier, 3 hold), and a
|
||||
//! `u16` of flags (label, roving). The rest depends on the property header (`tdb4`), which selects
|
||||
//! one of two templates for `n` value components:
|
||||
//!
|
||||
//! * **plain** (`tdb4` byte 7 = 0; Opacity, Rotation, Scale, 2-D properties, sliders): the value
|
||||
//! (`n` x `f64`) at byte 8, then four `f64` per component of ease, type-major (`[in speed x n]
|
||||
//! [in influence x n][out speed x n][out influence x n]`). Size `8 + 40 n`.
|
||||
//! * **spatial** (`tdb4` byte 7 != 0; Position with `n` = 3, colours with `n` = 4): a `u32` at 8, the
|
||||
//! arc length (`f64`) at 16, the four ease slots at 24 to 55, the value (`n` x `f64`) at 56, then
|
||||
//! the spatial in tangent and out tangent (`n` x `f64` each). Size `56 + 24 n`.
|
||||
|
||||
use crate::container::bytes::{be_f64, be_u16, be_u32, text};
|
||||
use crate::container::typed::{Keyframe, Property};
|
||||
|
||||
/// A keyframe interpolation type (a byte of the record header).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Interpolation {
|
||||
/// `1`
|
||||
Linear,
|
||||
/// `2`
|
||||
Bezier,
|
||||
/// `3`
|
||||
Hold,
|
||||
/// Any other value.
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl Interpolation {
|
||||
fn from_byte(b: u8) -> Self {
|
||||
match b {
|
||||
1 => Self::Linear,
|
||||
2 => Self::Bezier,
|
||||
3 => Self::Hold,
|
||||
other => Self::Other(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a keyframe record is, judged by the property header (when the list belongs to a property)
|
||||
/// and the record size.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyframeKind {
|
||||
/// The plain template: `8 + 40 n` bytes, 48 for one component (Opacity, Rotation, Slider), 88 for
|
||||
/// two (Mask Feather, Rectangle Size), 128 for three (Scale).
|
||||
Plain { components: usize },
|
||||
/// The spatial template: `56 + 24 n` bytes, 128 for Position (3 components), 152 for a colour (4).
|
||||
Spatial { components: usize },
|
||||
/// 64 bytes: a mask path key; the path itself is in the matching `shap` chunk. The ease sits at
|
||||
/// byte 24 as in the spatial template (599, 700: speed 0, influences 0.75 / 0.20 / 0.50); the
|
||||
/// `f64` at byte 16 is 1 or 2 on a first key and 0 on a last one (093, 599, 700; meaning not
|
||||
/// decoded).
|
||||
MaskPath,
|
||||
/// 16 bytes: a marker (time and two hold bytes).
|
||||
Marker,
|
||||
/// Any other size.
|
||||
Other(usize),
|
||||
}
|
||||
|
||||
/// The ease of a key: speed and influence of the incoming and outgoing side. Speed is in stored
|
||||
/// value units per second (for Opacity a fraction, so 100 times smaller than the percent shown in
|
||||
/// the UI; for Position pixels per second); influence is a fraction (20 % is `0.2`). After Effects
|
||||
/// reports the first key's incoming speed and the last key's outgoing speed as 0, but the file
|
||||
/// keeps the requested values, so these two slots are not meaningful.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KeyEase {
|
||||
pub in_speed: f64,
|
||||
pub in_influence: f64,
|
||||
pub out_speed: f64,
|
||||
pub out_influence: f64,
|
||||
}
|
||||
|
||||
/// The fields of a spatial keyframe record.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SpatialKey {
|
||||
/// The `u32` at byte 8: `1` normally, `0` after the spatial interpolation was changed to
|
||||
/// non-auto-bezier / non-continuous (the two switches were changed together).
|
||||
pub auto_bezier_flag: u32,
|
||||
/// The `f64` at byte 16: the arc length of the segment to the next key, updated lazily by
|
||||
/// After Effects (0 right after creation). Safe to ignore.
|
||||
pub segment_length: f64,
|
||||
/// The four `f64` at bytes 24, 32, 40 and 48 as stored: in speed, in influence, out speed, out
|
||||
/// influence (see [`KeyEase`]).
|
||||
pub ease: [f64; 4],
|
||||
/// The value (x, y, z in pixels for Position; ARGB 0..255 for a colour), from byte 56.
|
||||
pub value: Vec<f64>,
|
||||
/// The spatial in tangent as an offset from the value.
|
||||
pub in_tangent: Vec<f64>,
|
||||
/// The spatial out tangent as an offset from the value.
|
||||
pub out_tangent: Vec<f64>,
|
||||
}
|
||||
|
||||
fn doubles(b: &[u8], at: usize, n: usize) -> Option<Vec<f64>> {
|
||||
(0..n).map(|i| be_f64(b, at.checked_add(i.checked_mul(8)?)?)).collect()
|
||||
}
|
||||
|
||||
fn ease_at(b: &[u8], at: usize) -> Option<KeyEase> {
|
||||
Some(KeyEase {
|
||||
in_speed: be_f64(b, at)?,
|
||||
in_influence: be_f64(b, at.checked_add(8)?)?,
|
||||
out_speed: be_f64(b, at.checked_add(16)?)?,
|
||||
out_influence: be_f64(b, at.checked_add(24)?)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl<'f> Keyframe<'f> {
|
||||
/// The record kind. For a list that belongs to a property, the header's component count and
|
||||
/// template flag decide; without a property, 48 bytes is read as plain with one component and
|
||||
/// 128 bytes as spatial with three.
|
||||
pub fn kind(&self) -> KeyframeKind {
|
||||
let len = self.record.len();
|
||||
if let Some(layout) = self.layout {
|
||||
let n = usize::from(layout.components);
|
||||
if n > 0 {
|
||||
if layout.spatial && Some(len) == n.checked_mul(24).and_then(|v| v.checked_add(56)) {
|
||||
return KeyframeKind::Spatial { components: n };
|
||||
}
|
||||
if !layout.spatial && Some(len) == n.checked_mul(40).and_then(|v| v.checked_add(8)) {
|
||||
return KeyframeKind::Plain { components: n };
|
||||
}
|
||||
}
|
||||
}
|
||||
match (len, self.layout) {
|
||||
(64, _) => KeyframeKind::MaskPath,
|
||||
(16, _) => KeyframeKind::Marker,
|
||||
(48, None) => KeyframeKind::Plain { components: 1 },
|
||||
(128, None) => KeyframeKind::Spatial { components: 3 },
|
||||
(other, _) => KeyframeKind::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The time in seconds given the property's time scale (units per second).
|
||||
pub fn seconds(&self, time_scale: u32) -> Option<f64> {
|
||||
if time_scale == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(f64::from(self.time_units()?) / f64::from(time_scale))
|
||||
}
|
||||
|
||||
/// The interpolation of the incoming side: the byte at offset 4.
|
||||
pub fn interpolation_in(&self) -> Option<Interpolation> {
|
||||
self.record.get(4).copied().map(Interpolation::from_byte)
|
||||
}
|
||||
|
||||
/// The interpolation of the outgoing side: the byte at offset 5.
|
||||
pub fn interpolation_out(&self) -> Option<Interpolation> {
|
||||
self.record.get(5).copied().map(Interpolation::from_byte)
|
||||
}
|
||||
|
||||
/// The flags word at byte 6.
|
||||
pub fn flags(&self) -> Option<u16> {
|
||||
be_u16(self.record, 6)
|
||||
}
|
||||
|
||||
/// The keyframe label: bits 6 to 9 of the flags (label 9 gives `0x0247` on a spatial key).
|
||||
pub fn label(&self) -> Option<u8> {
|
||||
u8::try_from((self.flags()? >> 6) & 0x0f).ok()
|
||||
}
|
||||
|
||||
/// True when the key is roving: bit `0x20` of the flags.
|
||||
pub fn is_roving(&self) -> Option<bool> {
|
||||
Some(self.flags()? & 0x0020 != 0)
|
||||
}
|
||||
|
||||
/// The number of value components of a plain or spatial record.
|
||||
pub fn components(&self) -> Option<usize> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components } | KeyframeKind::Spatial { components } => Some(components),
|
||||
KeyframeKind::MaskPath | KeyframeKind::Marker | KeyframeKind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value of a plain (from byte 8) or spatial (from byte 56) record, in the units of
|
||||
/// the property's static value (fractions for Opacity and Scale, ARGB 0..255 for colours).
|
||||
pub fn value(&self) -> Option<Vec<f64>> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components } => doubles(self.record, 8, components),
|
||||
KeyframeKind::Spatial { components } => doubles(self.record, 56, components),
|
||||
KeyframeKind::MaskPath | KeyframeKind::Marker | KeyframeKind::Other(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of a record with one component (Opacity, Rotation, a slider).
|
||||
pub fn scalar_value(&self) -> Option<f64> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components: 1 } => be_f64(self.record, 8),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of a 3-component spatial keyframe (Position), x, y and z in pixels.
|
||||
pub fn spatial_value(&self) -> Option<[f64; 3]> {
|
||||
let v = self.value()?;
|
||||
if !matches!(self.kind(), KeyframeKind::Spatial { components: 3 }) {
|
||||
return None;
|
||||
}
|
||||
Some([*v.first()?, *v.get(1)?, *v.get(2)?])
|
||||
}
|
||||
|
||||
/// The ease of the key: the four slots at byte 16 of a one-component plain record or at byte 24
|
||||
/// of a spatial record or a mask path record. For linear and hold keys all four are 0 (a bezier key stores what
|
||||
/// `setTemporalEase` was given). Plain records with several components keep four slots per
|
||||
/// component, see [`Keyframe::ease_component`] and [`Keyframe::plain_ease_slots`].
|
||||
pub fn ease(&self) -> Option<KeyEase> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components: 1 } => ease_at(self.record, 16),
|
||||
KeyframeKind::Spatial { .. } | KeyframeKind::MaskPath => ease_at(self.record, 24),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `4 n` ease `f64` of a plain record as stored (from byte `8 + 8 n`). With one component
|
||||
/// they are [`Keyframe::ease`]; with several they are type-major, see
|
||||
/// [`Keyframe::ease_component`].
|
||||
pub fn plain_ease_slots(&self) -> Option<Vec<f64>> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components } => doubles(self.record, components.checked_mul(8)?.checked_add(8)?, components.checked_mul(4)?),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ease of one value component. A plain record with `n` components stores the `4 n` ease
|
||||
/// doubles **type-major**: `[in speed x n][in influence x n][out speed x n][out influence x n]`
|
||||
/// starting at byte `8 + 8 n`, and component `i` uses slot `i` of each block (verified for the
|
||||
/// block layout with Scale `n = 3` and Mask Feather `n = 2` keys whose ease was the same in
|
||||
/// every dimension; that different values sit in the slots of their dimension is inferred).
|
||||
/// A spatial record (Position, colours) has one ease entry for all components, so only
|
||||
/// `component == 0` returns it. For one component this is [`Keyframe::ease`].
|
||||
pub fn ease_component(&self, component: usize) -> Option<KeyEase> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Plain { components } if component < components => {
|
||||
let block = components.checked_mul(8)?;
|
||||
let first = block.checked_add(8)?;
|
||||
let at = |k: usize| first.checked_add(block.checked_mul(k)?)?.checked_add(component.checked_mul(8)?);
|
||||
Some(KeyEase {
|
||||
in_speed: be_f64(self.record, at(0)?)?,
|
||||
in_influence: be_f64(self.record, at(1)?)?,
|
||||
out_speed: be_f64(self.record, at(2)?)?,
|
||||
out_influence: be_f64(self.record, at(3)?)?,
|
||||
})
|
||||
}
|
||||
KeyframeKind::Spatial { .. } if component == 0 => ease_at(self.record, 24),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ease of every value component of a plain record, see [`Keyframe::ease_component`]
|
||||
/// (one entry for a spatial record).
|
||||
pub fn ease_components(&self) -> Option<Vec<KeyEase>> {
|
||||
let n = match self.kind() {
|
||||
KeyframeKind::Plain { components } => components,
|
||||
KeyframeKind::Spatial { .. } => 1,
|
||||
KeyframeKind::MaskPath | KeyframeKind::Marker | KeyframeKind::Other(_) => return None,
|
||||
};
|
||||
(0..n).map(|i| self.ease_component(i)).collect()
|
||||
}
|
||||
|
||||
/// The Euclidean ARGB distance to the next key that After Effects writes at byte 16 of a colour
|
||||
/// (4-component spatial) record whose interpolation is not linear: 255 sqrt(2) = 360.6245 for
|
||||
/// red to blue; 0 on the last key and on linear keys. `None` for other records.
|
||||
pub fn color_distance(&self) -> Option<f64> {
|
||||
match self.kind() {
|
||||
KeyframeKind::Spatial { components: 4 } => be_f64(self.record, 16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoded spatial record (Position: 128 bytes, colour: 152 bytes).
|
||||
pub fn spatial(&self) -> Option<SpatialKey> {
|
||||
let KeyframeKind::Spatial { components: n } = self.kind() else { return None };
|
||||
let r = self.record;
|
||||
let span = n.checked_mul(8)?;
|
||||
let ease = ease_at(r, 24)?;
|
||||
Some(SpatialKey {
|
||||
auto_bezier_flag: be_u32(r, 8)?,
|
||||
segment_length: be_f64(r, 16)?,
|
||||
ease: [ease.in_speed, ease.in_influence, ease.out_speed, ease.out_influence],
|
||||
value: doubles(r, 56, n)?,
|
||||
in_tangent: doubles(r, 56usize.checked_add(span)?, n)?,
|
||||
out_tangent: doubles(r, 56usize.checked_add(span.checked_mul(2)?)?, n)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of a property header that mark an expression: bytes 76, 77 and 120 of `tdb4` are all 1
|
||||
/// when the property has an expression (76 and 77 are also 1 on a camera's auto-computed Position,
|
||||
/// which has none). Byte 119 is 1 when the expression is disabled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExpressionFlags {
|
||||
pub byte_76: u8,
|
||||
pub byte_77: u8,
|
||||
/// 1 when the expression is disabled (`expression.disable_all`); the text stays in the file.
|
||||
pub byte_119: u8,
|
||||
pub byte_120: u8,
|
||||
}
|
||||
|
||||
impl ExpressionFlags {
|
||||
/// True when bytes 76, 77 and 120 are all 1.
|
||||
pub fn all_set(&self) -> bool {
|
||||
self.byte_76 == 1 && self.byte_77 == 1 && self.byte_120 == 1
|
||||
}
|
||||
|
||||
/// True when byte 119 is 1.
|
||||
pub fn disabled(&self) -> bool {
|
||||
self.byte_119 == 1
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Property<'f, 'a> {
|
||||
/// The time scale (units per second) of this property's keyframe times: the `u32` at byte 12
|
||||
/// of `tdb4` (25600 at 25 fps, 30720 at 30 fps, 23976 at 29.97 and 23.976 fps).
|
||||
pub fn time_scale(&self) -> Option<u32> {
|
||||
be_u32(self.header()?, 12)
|
||||
}
|
||||
|
||||
/// The property's `tdsb` flags: `1` for a property that was written (non-default), `3` for
|
||||
/// the default state.
|
||||
pub fn flags(&self) -> Option<u32> {
|
||||
be_u32(self.chunk.child(b"tdsb")?.bytes()?, 0)
|
||||
}
|
||||
|
||||
/// True when `tdb4` byte 5 has bit 0 set: the property is static, it has no keyframes.
|
||||
pub fn is_static_flag_set(&self) -> Option<bool> {
|
||||
Some(self.header()?.get(5)? & 1 != 0)
|
||||
}
|
||||
|
||||
/// The expression source: the raw UTF-8 text of the `Utf8` child that follows the value in the
|
||||
/// property (no length prefix, no engine tag; the engine is project-wide).
|
||||
pub fn expression(&self) -> Option<String> {
|
||||
self.chunk.child(b"Utf8").and_then(|c| c.bytes()).map(text)
|
||||
}
|
||||
|
||||
/// The expression flag bytes of `tdb4`.
|
||||
pub fn expression_flags(&self) -> Option<ExpressionFlags> {
|
||||
let h = self.header()?;
|
||||
Some(ExpressionFlags { byte_76: *h.get(76)?, byte_77: *h.get(77)?, byte_119: *h.get(119)?, byte_120: *h.get(120)? })
|
||||
}
|
||||
|
||||
/// Whether the expression is enabled: `None` when the property has no expression (byte 120 is
|
||||
/// not 1 or there is no `Utf8` text), else `Some(false)` when byte 119 marks it disabled. Byte
|
||||
/// 119 also changes by itself on built-in view layers after a save, so it only counts together
|
||||
/// with an expression.
|
||||
pub fn expression_enabled(&self) -> Option<bool> {
|
||||
let flags = self.expression_flags()?;
|
||||
if flags.byte_120 != 1 || self.expression().is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(!flags.disabled())
|
||||
}
|
||||
|
||||
/// The value of the first keyframe (plain or spatial record), for reading an animated property
|
||||
/// at its first key.
|
||||
pub fn first_key_value(&self) -> Option<Vec<f64>> {
|
||||
self.keys()?.keyframes().next()?.value()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Masks and bezier paths.
|
||||
//!
|
||||
//! A layer's masks are in `tdgp[ADBE Mask Parade]`: one `mkif` chunk (mode and invert switch) next
|
||||
//! to one `tdgp[ADBE Mask Atom]` per mask. The atom holds the optional properties feather, opacity
|
||||
//! and expansion (stored only when not at their defaults) and `om-s[ADBE Mask Shape]`: the path
|
||||
//! property (`tdbs`, with a key list when animated) and `omks` with one `LIST shap` per keyframe (a
|
||||
//! single one when the path is static).
|
||||
|
||||
use crate::container::Chunk;
|
||||
use crate::container::bytes::{be_f32, be_u32, until_nul};
|
||||
use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property};
|
||||
use crate::container::values::{Tracked, read};
|
||||
|
||||
/// The mask mode (`mkif` `u32` at byte 4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MaskMode {
|
||||
/// `0` (329)
|
||||
None,
|
||||
/// `1`
|
||||
Add,
|
||||
/// `2`
|
||||
Subtract,
|
||||
/// `3`
|
||||
Intersect,
|
||||
/// `4`
|
||||
Lighten,
|
||||
/// `5`
|
||||
Darken,
|
||||
/// `6`
|
||||
Difference,
|
||||
/// Any other value (not sampled).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MaskMode {
|
||||
/// The mode for a stored id.
|
||||
pub fn from_id(id: u32) -> Self {
|
||||
match id {
|
||||
0 => Self::None,
|
||||
1 => Self::Add,
|
||||
2 => Self::Subtract,
|
||||
3 => Self::Intersect,
|
||||
4 => Self::Lighten,
|
||||
5 => Self::Darken,
|
||||
6 => Self::Difference,
|
||||
other => Self::Other(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertex of a bezier path with its two control points as absolute positions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PathVertex {
|
||||
pub position: [f64; 2],
|
||||
/// The control point of the incoming segment.
|
||||
pub in_handle: [f64; 2],
|
||||
/// The control point of the outgoing segment.
|
||||
pub out_handle: [f64; 2],
|
||||
}
|
||||
|
||||
impl PathVertex {
|
||||
/// The in tangent: the in handle minus the vertex (what the scripting API calls `inTangents`).
|
||||
pub fn in_tangent(&self) -> [f64; 2] {
|
||||
[self.in_handle[0] - self.position[0], self.in_handle[1] - self.position[1]]
|
||||
}
|
||||
|
||||
/// The out tangent: the out handle minus the vertex (`outTangents`).
|
||||
pub fn out_tangent(&self) -> [f64; 2] {
|
||||
[self.out_handle[0] - self.position[0], self.out_handle[1] - self.position[1]]
|
||||
}
|
||||
}
|
||||
|
||||
/// One path shape (`LIST shap`): a bounding box and the control points normalised inside it.
|
||||
///
|
||||
/// The `shph` chunk holds a magic word whose last byte carries the flags (`0x01` closed, `0x09` open:
|
||||
/// bit `0x08`), the box (left, top, right, bottom) as four `f32`, and the byte at offset 20 (`1` in
|
||||
/// every sample). The box is the box of the vertices **and** their handles. The `list` holds three
|
||||
/// points per vertex, each as two `f32` in [0, 1] of the box, in the order `[vertex i, out handle i,
|
||||
/// in handle i + 1]`; the last record is the in handle of vertex 0. For masks the box is normalised
|
||||
/// to the **layer** size (the size of the layer's source, not the composition's: on a 100x100 solid
|
||||
/// a 60x40 mask rectangle at the centre has the box `0.2, 0.3, 0.8, 0.7`, 309); for the paths of shape layers it is in pixels. Handles equal their vertex when a
|
||||
/// vertex has no tangents; a tangent is the handle minus the vertex, without a sign change.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShapePath {
|
||||
/// Left, top, right, bottom.
|
||||
pub bbox: [f32; 4],
|
||||
/// The byte at offset 20 of `shph` (`1` in every sample, also for the open path).
|
||||
pub closed_flag: u8,
|
||||
/// True for an open path: bit `0x08` of the last byte of the first `shph` word.
|
||||
pub open: bool,
|
||||
/// The normalised points as stored, three per vertex.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl ShapePath {
|
||||
/// Decodes a `LIST shap`; `None` when `shph` or the point list is malformed.
|
||||
pub fn from_chunk(shap: &Chunk<'_>) -> Option<Self> {
|
||||
let shph = shap.child(b"shph")?.bytes()?;
|
||||
let bbox = [be_f32(shph, 4)?, be_f32(shph, 8)?, be_f32(shph, 12)?, be_f32(shph, 16)?];
|
||||
let closed_flag = *shph.get(20)?;
|
||||
let open = shph.get(3)? & 0x08 != 0;
|
||||
let list = KeyList::from_chunk(shap.child_list(b"list")?)?;
|
||||
if list.header.record_size != 8 {
|
||||
return None;
|
||||
}
|
||||
let points = list.records().map(|r| Some([be_f32(r, 0)?, be_f32(r, 4)?])).collect::<Option<Vec<_>>>()?;
|
||||
Some(Self { bbox, closed_flag, open, points })
|
||||
}
|
||||
|
||||
/// The number of vertices (`points / 3`).
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.points.len() / 3
|
||||
}
|
||||
|
||||
/// The vertices with absolute control points: `box.left + u * (box.right - box.left)` (and the
|
||||
/// same for y), multiplied by `scale` (the layer size in pixels for a mask, `(1, 1)` for a
|
||||
/// shape layer path).
|
||||
pub fn vertices(&self, scale: (f64, f64)) -> Vec<PathVertex> {
|
||||
let [left, top, right, bottom] = self.bbox.map(f64::from);
|
||||
let abs = |p: Option<&[f32; 2]>| -> [f64; 2] {
|
||||
let [u, v] = p.copied().unwrap_or([0.0, 0.0]).map(f64::from);
|
||||
[(left + u * (right - left)) * scale.0, (top + v * (bottom - top)) * scale.1]
|
||||
};
|
||||
let n = self.vertex_count();
|
||||
let total = n.saturating_mul(3);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let base = i.saturating_mul(3);
|
||||
PathVertex {
|
||||
position: abs(self.points.get(base)),
|
||||
out_handle: abs(self.points.get(base.saturating_add(1))),
|
||||
// The in handle of vertex i is the record before vertex i (the last one for vertex 0).
|
||||
in_handle: abs(self.points.get(base.saturating_add(total).saturating_sub(1) % total.max(1))),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One mask of a layer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Mask {
|
||||
/// The mask name (`Mask 1`).
|
||||
pub name: Option<String>,
|
||||
/// The mode, from this mask's `mkif` (there is one `mkif` per mask, directly before its atom).
|
||||
pub mode: Option<MaskMode>,
|
||||
/// The invert switch (`mkif` byte 0).
|
||||
pub inverted: Option<bool>,
|
||||
/// The running mask id counter of the layer (`mkif` `u32` at byte 8): 1 for the first mask
|
||||
/// created on a layer, +1 per mask created later on the same layer (undo does not reset it).
|
||||
/// A session counter, not a stable id.
|
||||
pub counter: Option<u32>,
|
||||
/// Feather in pixels `[x, y]`; absent = `[0, 0]`.
|
||||
pub feather: Tracked<[f64; 2]>,
|
||||
/// Opacity in percent (stored as a fraction); absent = 100.
|
||||
pub opacity_percent: Tracked<f64>,
|
||||
/// Expansion in pixels; absent = 0.
|
||||
pub expansion: Tracked<f64>,
|
||||
/// True when the path has keyframes.
|
||||
pub path_animated: bool,
|
||||
/// The path keyframe times in seconds (empty for a static path).
|
||||
pub path_key_seconds: Vec<f64>,
|
||||
/// The path shapes: one for a static path, one per keyframe otherwise.
|
||||
pub shapes: Vec<ShapePath>,
|
||||
}
|
||||
|
||||
fn mask_from_atom(atom: &PropGroup<'_, '_>, mkif: Option<&[u8]>) -> Mask {
|
||||
let opacity = read(atom, "ADBE Mask Opacity", [100.0], 100.0);
|
||||
let expansion = read(atom, "ADBE Mask Offset", [0.0], 1.0);
|
||||
let mut mask = Mask {
|
||||
name: atom.user_name(),
|
||||
mode: mkif.and_then(|m| be_u32(m, 4)).map(MaskMode::from_id),
|
||||
inverted: mkif.and_then(|m| m.first()).map(|b| *b != 0),
|
||||
counter: mkif.and_then(|m| be_u32(m, 8)),
|
||||
feather: read(atom, "ADBE Mask Feather", [0.0, 0.0], 1.0),
|
||||
opacity_percent: Tracked { value: opacity.value[0], source: opacity.source },
|
||||
expansion: Tracked { value: expansion.value[0], source: expansion.source },
|
||||
path_animated: false,
|
||||
path_key_seconds: Vec::new(),
|
||||
shapes: Vec::new(),
|
||||
};
|
||||
if let Some(PropNode::Other(oms)) = atom.get("ADBE Mask Shape") {
|
||||
if let Some(prop) = oms.child_list(b"tdbs").and_then(Property::from_chunk) {
|
||||
mask.path_animated = prop.is_animated();
|
||||
if let (Some(keys), Some(scale)) = (prop.keys(), prop.time_scale()) {
|
||||
mask.path_key_seconds = keys.keyframes().filter_map(|k| k.seconds(scale)).collect();
|
||||
}
|
||||
}
|
||||
if let Some(omks) = oms.child_list(b"omks") {
|
||||
mask.shapes = omks.children().iter().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect();
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
impl<'f, 'a> Project<'f, 'a> {
|
||||
/// The vertices of every path shape of a mask in pixels of the layer: mask boxes are normalised
|
||||
/// to the **layer** size (the size of its source), not the composition's. `None` when the layer
|
||||
/// has no source with a size.
|
||||
pub fn mask_vertices_px(&self, layer: &Layer<'f, 'a>, mask: &Mask) -> Option<Vec<Vec<PathVertex>>> {
|
||||
let (width, height) = self.layer_source_size(layer)?;
|
||||
Some(mask.shapes.iter().map(|s| s.vertices((f64::from(width), f64::from(height)))).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// One mask with the views of its records, for readers that need more than [`Mask`]: the
|
||||
/// keyframes and expressions of the mask properties (in [`MaskAtom::atom`]: `ADBE Mask Feather`,
|
||||
/// `ADBE Mask Opacity`, `ADBE Mask Offset`) and the path keyframes paired with their shapes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MaskAtom<'f, 'a> {
|
||||
/// The decoded mask (as [`Layer::masks`] returns it).
|
||||
pub mask: Mask,
|
||||
/// The atom group (`tdgp[ADBE Mask Atom]`).
|
||||
pub atom: PropGroup<'f, 'a>,
|
||||
/// The path property (`om-s[ADBE Mask Shape]/tdbs`): its key list holds one 64-byte record
|
||||
/// per path keyframe (time, interpolation, label and roving flags; 093).
|
||||
pub path: Option<Property<'f, 'a>>,
|
||||
/// Every `LIST shap` of `omks` in file order, `None` where one is malformed, so that index `i`
|
||||
/// stays the shape of path keyframe `i` (one entry for a static path).
|
||||
pub path_shapes: Vec<Option<ShapePath>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The layer's masks, in stack order.
|
||||
pub fn masks(&self) -> Vec<Mask> {
|
||||
self.mask_atoms().into_iter().map(|m| m.mask).collect()
|
||||
}
|
||||
|
||||
/// The layer's masks with the views of their records, in stack order.
|
||||
pub fn mask_atoms(&self) -> Vec<MaskAtom<'f, 'a>> {
|
||||
let Some(root) = self.properties() else { return Vec::new() };
|
||||
let Some(PropNode::Group(parade)) = root.get("ADBE Mask Parade") else { return Vec::new() };
|
||||
// Per mask: `tdmn` (Mask Atom), `mkif`, then the atom's `tdgp`.
|
||||
let mut masks = Vec::new();
|
||||
let (mut name, mut mkif): (Option<&[u8]>, Option<&[u8]>) = (None, None);
|
||||
for c in parade.chunk().children() {
|
||||
if c.is(b"tdmn") {
|
||||
name = c.bytes();
|
||||
} else if c.is(b"mkif") {
|
||||
mkif = c.bytes();
|
||||
} else if c.is_list(b"tdgp") {
|
||||
let is_atom = name.take().is_some_and(|n| until_nul(n) == b"ADBE Mask Atom");
|
||||
if is_atom && let Some(atom) = PropGroup::from_chunk(c) {
|
||||
let mask = mask_from_atom(&atom, mkif.take());
|
||||
let (mut path, mut path_shapes) = (None, Vec::new());
|
||||
if let Some(PropNode::Other(oms)) = atom.get("ADBE Mask Shape") {
|
||||
path = oms.child_list(b"tdbs").and_then(Property::from_chunk);
|
||||
if let Some(omks) = oms.child_list(b"omks") {
|
||||
path_shapes = omks.children().iter().filter(|c| c.is_list(b"shap")).map(ShapePath::from_chunk).collect();
|
||||
}
|
||||
}
|
||||
masks.push(MaskAtom { mask, atom, path, path_shapes });
|
||||
}
|
||||
}
|
||||
}
|
||||
masks
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Track mattes: the matte mode and the matte layer, two fields of the layer header `ldta`.
|
||||
//!
|
||||
//! Cases 402-408 (a 960x540 grey solid `M` above the red solid `L`): setting a track matte on a
|
||||
//! layer changes exactly two fields of that layer's `ldta` (and the video switch of the matte
|
||||
//! layer, which After Effects turns off by itself):
|
||||
//!
|
||||
//! * byte 107 (`u8`; bytes 104..106 are 0 in every file of the corpus): the matte mode, 1 alpha
|
||||
//! (403), 2 alpha inverted (404), 3 luma (405), 4 luma inverted (406), 0 no matte (every other
|
||||
//! case);
|
||||
//! * the `u32` at byte 160: the id of the matte layer (`ldta` byte 0 of that layer), `0x1d` = `M`
|
||||
//! in 403-406 and 408, `0x1b` = `L` in 407, where the matte layer is BELOW the layer it mattes.
|
||||
//! 0 in every file without a matte.
|
||||
//!
|
||||
//! A matte mode with a matte layer id of 0 (or an `ldta` too short to hold the id) has no case in
|
||||
//! the corpus: After Effects 2026 writes the id. Before After Effects 23.0 a track matte always
|
||||
//! used the layer directly above (the documented behaviour of those versions), so such a matte is
|
||||
//! returned with [`TrackMatte::layer`] `None` and the caller decides.
|
||||
|
||||
use crate::container::Layer;
|
||||
use crate::container::bytes::be_u32;
|
||||
|
||||
/// The matte mode (`ldta` byte 107).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatteMode {
|
||||
/// `1` (403)
|
||||
Alpha,
|
||||
/// `2` (404)
|
||||
AlphaInverted,
|
||||
/// `3` (405)
|
||||
Luma,
|
||||
/// `4` (406)
|
||||
LumaInverted,
|
||||
/// Any other non-zero value (not sampled).
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl MatteMode {
|
||||
/// The mode for a stored code; `None` for 0 (no matte).
|
||||
pub fn from_code(code: u8) -> Option<Self> {
|
||||
Some(match code {
|
||||
0 => return None,
|
||||
1 => Self::Alpha,
|
||||
2 => Self::AlphaInverted,
|
||||
3 => Self::Luma,
|
||||
4 => Self::LumaInverted,
|
||||
other => Self::Other(other),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The track matte of a layer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrackMatte {
|
||||
pub mode: MatteMode,
|
||||
/// The id of the matte layer (`u32` at `ldta` byte 160); `None` when it is 0 or the header is
|
||||
/// too short to hold it (the older "layer above" form, see the module documentation).
|
||||
pub layer: Option<u32>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The track matte of the layer, `None` when it has none (or the header is unreadable).
|
||||
pub fn track_matte(&self) -> Option<TrackMatte> {
|
||||
let ldta = self.chunk().child(b"ldta")?.bytes()?;
|
||||
let mode = MatteMode::from_code(*ldta.get(107)?)?;
|
||||
let layer = be_u32(ldta, 160).filter(|id| *id != 0);
|
||||
Some(TrackMatte { mode, layer })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Parsing the `RIFX` container into an [`AepFile`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::container::chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS};
|
||||
use crate::container::error::AepError;
|
||||
|
||||
/// Bounds for hostile input.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Deepest list nesting that is parsed (the top level is depth 1). A list or nested chunk
|
||||
/// whose children would be deeper is kept opaque instead.
|
||||
pub max_depth: usize,
|
||||
/// Most chunks a file may contain (counted across all parse attempts) before parsing fails.
|
||||
pub max_chunks: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_depth: 64, max_chunks: 10_000_000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an `.aep` with the default [`Limits`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when the input is not a well-formed `RIFX`/`Egg!` container at the top level.
|
||||
/// Odd bodies *inside* the container (a `LIST` whose content is not a chunk sequence) do not
|
||||
/// fail the parse: they are kept opaque.
|
||||
pub fn parse(data: &[u8]) -> Result<AepFile<'_>, AepError> {
|
||||
parse_with_limits(data, Limits::default())
|
||||
}
|
||||
|
||||
/// Parses an `.aep` with explicit [`Limits`].
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`parse`]; also fails with [`AepError::TooManyChunks`].
|
||||
pub fn parse_with_limits(data: &[u8], limits: Limits) -> Result<AepFile<'_>, AepError> {
|
||||
if id4(data, 0) != Some(*b"RIFX") {
|
||||
return Err(AepError::NotRifx);
|
||||
}
|
||||
let declared = u64::from(be32(data, 4).ok_or(AepError::NotRifx)?);
|
||||
let form = id4(data, 8).ok_or(AepError::NotRifx)?;
|
||||
if &form != b"Egg!" {
|
||||
return Err(AepError::BadFormType(form));
|
||||
}
|
||||
let available = (data.len() as u64).saturating_sub(8);
|
||||
if declared < 4 || declared > available {
|
||||
return Err(AepError::Truncated { declared, available });
|
||||
}
|
||||
// `declared <= available < data.len()`, so the end is inside the input.
|
||||
let end = usize::try_from(declared).map_err(|_| AepError::TooLarge)?.checked_add(8).ok_or(AepError::TooLarge)?;
|
||||
let mut parser = Parser { data, limits, count: 0 };
|
||||
let chunks = parser.sequence(12, end, 1)?;
|
||||
let trailer = data.get(end..).unwrap_or(&[]);
|
||||
Ok(AepFile { chunks, trailer: Cow::Borrowed(trailer) })
|
||||
}
|
||||
|
||||
fn be32(data: &[u8], at: usize) -> Option<u32> {
|
||||
id4(data, at).map(u32::from_be_bytes)
|
||||
}
|
||||
|
||||
fn id4(data: &[u8], at: usize) -> Option<[u8; 4]> {
|
||||
let b = data.get(at..at.checked_add(4)?)?;
|
||||
Some([*b.first()?, *b.get(1)?, *b.get(2)?, *b.get(3)?])
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
data: &'a [u8],
|
||||
limits: Limits,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
/// Parses the sibling chunks that exactly fill `start..end`.
|
||||
fn sequence(&mut self, start: usize, end: usize, depth: usize) -> Result<Vec<Chunk<'a>>, AepError> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = start;
|
||||
while pos < end {
|
||||
let short = AepError::Malformed { offset: pos, what: "bytes left over that are too short for a chunk header" };
|
||||
let body_start = pos.checked_add(8).filter(|b| *b <= end).ok_or(short)?;
|
||||
let id = id4(self.data, pos).ok_or(AepError::Malformed { offset: pos, what: "short read" })?;
|
||||
let size = be32(self.data, pos + 4).ok_or(AepError::Malformed { offset: pos, what: "short read" })? as usize;
|
||||
let avail = end - body_start;
|
||||
if size > avail {
|
||||
return Err(AepError::ChunkTooLarge { offset: pos, size: size as u64, available: avail as u64 });
|
||||
}
|
||||
self.count = self.count.saturating_add(1);
|
||||
if self.count > self.limits.max_chunks {
|
||||
return Err(AepError::TooManyChunks);
|
||||
}
|
||||
let body_end = body_start + size;
|
||||
let body = self.body(id, body_start, body_end, depth)?;
|
||||
let mut next = body_end;
|
||||
let mut pad = None;
|
||||
if size % 2 == 1 && next < end {
|
||||
pad = Some(self.data.get(next).copied().ok_or(AepError::Malformed { offset: next, what: "missing pad byte" })?);
|
||||
next += 1;
|
||||
}
|
||||
out.push(Chunk { id, body, pad });
|
||||
pos = next;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Like [`Self::sequence`] but a structural problem yields `None` (the caller keeps bytes).
|
||||
fn tentative(&mut self, start: usize, end: usize, depth: usize) -> Result<Option<Vec<Chunk<'a>>>, AepError> {
|
||||
match self.sequence(start, end, depth) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(e) if e.is_structural() => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn slice(&self, start: usize, end: usize) -> &'a [u8] {
|
||||
self.data.get(start..end).unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn body(&mut self, id: [u8; 4], start: usize, end: usize, depth: usize) -> Result<Body<'a>, AepError> {
|
||||
let len = end - start;
|
||||
if id == LIST && len >= 4 {
|
||||
let list_type = id4(self.data, start).ok_or(AepError::Malformed { offset: start, what: "short read" })?;
|
||||
let opaque = |p: &Self| Body::OpaqueList { list_type, data: Cow::Borrowed(p.slice(start + 4, end)) };
|
||||
if depth >= self.limits.max_depth {
|
||||
return Ok(opaque(self));
|
||||
}
|
||||
return Ok(match self.tentative(start + 4, end, depth + 1)? {
|
||||
Some(children) => Body::List { list_type, children },
|
||||
None => opaque(self),
|
||||
});
|
||||
}
|
||||
if NESTED_IDS.contains(&id)
|
||||
&& len >= 8
|
||||
&& depth < self.limits.max_depth
|
||||
&& let Some(children) = self.tentative(start, end, depth + 1)?
|
||||
&& !children.is_empty()
|
||||
{
|
||||
return Ok(Body::Chunks(children));
|
||||
}
|
||||
Ok(Body::Data(Cow::Borrowed(self.slice(start, end))))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Project settings (File ▸ Project Settings): colour depth, working space, working gamma, the two
|
||||
//! linear-light switches, the time display and the frame count start. Cases 620-634: one change
|
||||
//! each against the base project 620; every field below is the only byte (or chunk) that changes.
|
||||
|
||||
use crate::container::chunk::AepFile;
|
||||
use crate::container::footage::json_string;
|
||||
|
||||
/// The time display style (`nhed` byte 8, feet + frames byte 11, film type byte 13).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TimeDisplay {
|
||||
/// Timecode (the base, 620): byte 8 = 0.
|
||||
Timecode,
|
||||
/// Frames (629): byte 8 = 1.
|
||||
Frames,
|
||||
/// Feet + frames (633, 634): byte 8 = 1 and byte 11 = 1; byte 13 is the frames per foot,
|
||||
/// 16 for 35 mm (633) and 40 for 16 mm (634).
|
||||
FeetFrames { frames_per_foot: u8 },
|
||||
}
|
||||
|
||||
/// The working colour space (`cpid` and the JSON `Utf8` after `PwCs`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkingSpace {
|
||||
/// No colour management (623): `cpid` is 16 bytes of `0xff` and the JSON is `{}`.
|
||||
None,
|
||||
/// A colour profile, named as After Effects lists it (`baseColorProfile/colorProfileName`):
|
||||
/// `sRGB IEC61966-2.1` in the base (620), `Rec.709 Gamma 2.4` in 624. The profile data next to
|
||||
/// the name is kept as stored.
|
||||
Profile(String),
|
||||
}
|
||||
|
||||
/// The project settings that are decoded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectSettings {
|
||||
/// Bits per channel: 8, 16 or 32 (`nhed` byte 15 = 0 / 1 / 2: 621, 622, 620; the same byte
|
||||
/// is `nnhd` byte 24). `None` for another value.
|
||||
pub bits_per_channel: Option<u8>,
|
||||
/// The working space; `None` when the chunks are missing or unreadable.
|
||||
pub working_space: Option<WorkingSpace>,
|
||||
/// Working gamma 2.4 (`dwga` `u32` @0 = 1, 625) or 2.2 (0, 620).
|
||||
pub working_gamma_24: Option<bool>,
|
||||
/// Linearize Working Space: a 1-byte `lnrp` chunk holding 1 exists (626, 632).
|
||||
pub linearize: bool,
|
||||
/// Blend Colors Using 1.0 Gamma: a 1-byte `lnrb` chunk holding 1 exists (627, 632).
|
||||
pub blend_linear: bool,
|
||||
/// Compensate for Scene-referred Profiles (`acer` = 1 in the base, 0 in 628).
|
||||
pub scene_compensation: Option<bool>,
|
||||
/// The time display style.
|
||||
pub time_display: Option<TimeDisplay>,
|
||||
/// Frame count start: `nhed` byte 14 holds 2 for a count from 0 (620) and 1 for a count from
|
||||
/// 1 (630); this maps 2 to 0 and 1 to 1, anything else to `None`.
|
||||
pub frame_start: Option<u8>,
|
||||
}
|
||||
|
||||
impl ProjectSettings {
|
||||
/// Reads the settings from the top-level chunks.
|
||||
pub fn read(file: &AepFile<'_>) -> ProjectSettings {
|
||||
let nhed = file.chunk(b"nhed").and_then(|c| c.bytes());
|
||||
let byte = |i: usize| nhed.and_then(|b| b.get(i).copied());
|
||||
let bits_per_channel = byte(15).and_then(|v| match v {
|
||||
0 => Some(8),
|
||||
1 => Some(16),
|
||||
2 => Some(32),
|
||||
_ => None,
|
||||
});
|
||||
let time_display = match (byte(8), byte(11), byte(13)) {
|
||||
(Some(0), _, _) => Some(TimeDisplay::Timecode),
|
||||
(Some(1), Some(0), _) => Some(TimeDisplay::Frames),
|
||||
(Some(1), Some(1), Some(fpf)) => Some(TimeDisplay::FeetFrames { frames_per_foot: fpf }),
|
||||
_ => None,
|
||||
};
|
||||
let frame_start = byte(14).and_then(|v| match v {
|
||||
2 => Some(0),
|
||||
1 => Some(1),
|
||||
_ => None,
|
||||
});
|
||||
let flag = |id: &[u8; 4]| file.chunk(id).and_then(|c| c.bytes()).and_then(|b| b.first().copied()) == Some(1);
|
||||
let working_gamma_24 = file.chunk(b"dwga").and_then(|c| c.bytes()).and_then(|b| b.get(..4)).map(|b| b != [0, 0, 0, 0]);
|
||||
let scene_compensation = file.chunk(b"acer").and_then(|c| c.bytes()).and_then(|b| b.first().copied()).map(|v| v != 0);
|
||||
ProjectSettings {
|
||||
bits_per_channel,
|
||||
working_space: working_space(file),
|
||||
working_gamma_24,
|
||||
linearize: flag(b"lnrp"),
|
||||
blend_linear: flag(b"lnrb"),
|
||||
scene_compensation,
|
||||
time_display,
|
||||
frame_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The working space: the `Utf8` right after `PwCs`.
|
||||
fn working_space(file: &AepFile<'_>) -> Option<WorkingSpace> {
|
||||
let at = file.chunks.iter().position(|c| c.is(b"PwCs"))?;
|
||||
let json = file.chunks.get(at.checked_add(1)?).filter(|c| c.is(b"Utf8"))?.bytes()?;
|
||||
let trimmed: Vec<u8> = json.iter().copied().filter(|b| !b.is_ascii_whitespace() && *b != 0).collect();
|
||||
if trimmed == b"{}" {
|
||||
return Some(WorkingSpace::None);
|
||||
}
|
||||
// After Effects 26.2.1 saves the profile data without the name (settings-c, 902): the name is
|
||||
// then the profile's own description (ICC.1 `desc`).
|
||||
json_string(json, "colorProfileName")
|
||||
.or_else(|| json_string(json, "colorProfileData").and_then(|b| base64_decode(&b)).and_then(|icc| icc_description(&icc)))
|
||||
.map(WorkingSpace::Profile)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648) with or without padding; `None` for any other character.
|
||||
fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in text.bytes().filter(|c| *c != b'=') {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return None,
|
||||
};
|
||||
acc = (acc << 6 | u32::from(v)) & 0x00ff_ffff;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The profile description (ICC.1 tag `desc`): a v2 `desc` (ASCII) or a v4 `mluc` (its first
|
||||
/// record, UTF-16BE). `None` when the profile or the tag is cut short.
|
||||
fn icc_description(icc: &[u8]) -> Option<String> {
|
||||
let be32 = |at: usize| icc.get(at..at.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
|
||||
let count = be32(128)?;
|
||||
let tag = (0..count.min(1024)).find_map(|i| {
|
||||
let at = 132usize.checked_add(usize::try_from(i).ok()?.checked_mul(12)?)?;
|
||||
(icc.get(at..at.checked_add(4)?)? == b"desc").then_some(at)
|
||||
})?;
|
||||
let off = usize::try_from(be32(tag.checked_add(4)?)?).ok()?;
|
||||
let len = usize::try_from(be32(tag.checked_add(8)?)?).ok()?;
|
||||
let body = icc.get(off..off.checked_add(len)?)?;
|
||||
let text = match body.get(..4)? {
|
||||
b"desc" => {
|
||||
let n = usize::try_from(u32::from_be_bytes(body.get(8..12)?.try_into().ok()?)).ok()?;
|
||||
let ascii = body.get(12..12usize.checked_add(n)?)?;
|
||||
String::from_utf8_lossy(ascii.split(|b| *b == 0).next().unwrap_or_default()).into_owned()
|
||||
}
|
||||
b"mluc" => {
|
||||
let n = usize::try_from(u32::from_be_bytes(body.get(20..24)?.try_into().ok()?)).ok()?;
|
||||
let at = usize::try_from(u32::from_be_bytes(body.get(24..28)?.try_into().ok()?)).ok()?;
|
||||
let units: Vec<u16> = body.get(at..at.checked_add(n)?)?.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let text = text.trim_end_matches('\0').trim().to_owned();
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_with_and_without_padding() {
|
||||
assert_eq!(base64_decode("Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
|
||||
assert_eq!(base64_decode("Zm9vYmE=").as_deref(), Some(&b"fooba"[..]));
|
||||
assert_eq!(base64_decode("Zm9vYg").as_deref(), Some(&b"foob"[..]));
|
||||
assert_eq!(base64_decode("Zm9v*"), None);
|
||||
}
|
||||
|
||||
/// A profile with one tag, `desc`, holding `body`.
|
||||
fn profile(body: &[u8]) -> Vec<u8> {
|
||||
let mut icc = vec![0u8; 128];
|
||||
icc.extend_from_slice(&1u32.to_be_bytes());
|
||||
icc.extend_from_slice(b"desc");
|
||||
icc.extend_from_slice(&144u32.to_be_bytes());
|
||||
icc.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes());
|
||||
icc.extend_from_slice(body);
|
||||
icc
|
||||
}
|
||||
|
||||
/// A version 2 `desc` body.
|
||||
fn desc(text: &str) -> Vec<u8> {
|
||||
let mut b = b"desc\0\0\0\0".to_vec();
|
||||
b.extend_from_slice(&u32::try_from(text.len() + 1).unwrap().to_be_bytes());
|
||||
b.extend_from_slice(text.as_bytes());
|
||||
b.push(0);
|
||||
b
|
||||
}
|
||||
|
||||
/// A version 4 `mluc` body with one record.
|
||||
fn mluc(text: &str) -> Vec<u8> {
|
||||
let units: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let mut b = b"mluc\0\0\0\0".to_vec();
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&12u32.to_be_bytes());
|
||||
b.extend_from_slice(b"enUS");
|
||||
b.extend_from_slice(&u32::try_from(units.len()).unwrap().to_be_bytes());
|
||||
b.extend_from_slice(&28u32.to_be_bytes());
|
||||
b.extend_from_slice(&units);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_description_of_a_profile_is_read() {
|
||||
assert_eq!(icc_description(&profile(&desc("Rec.709 Gamma 2.4"))).as_deref(), Some("Rec.709 Gamma 2.4"));
|
||||
assert_eq!(icc_description(&profile(&mluc("sRGB IEC61966-2.1"))).as_deref(), Some("sRGB IEC61966-2.1"));
|
||||
assert_eq!(icc_description(&profile(&desc(""))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_or_damaged_profile_gives_no_description() {
|
||||
let icc = profile(&mluc("Rec.709 Gamma 2.4"));
|
||||
for n in 0..icc.len() {
|
||||
let mut cut = icc.get(..n).unwrap_or_default().to_vec();
|
||||
let _ = icc_description(&cut);
|
||||
cut.iter_mut().for_each(|b| *b = 0xff);
|
||||
let _ = icc_description(&cut);
|
||||
}
|
||||
assert_eq!(icc_description(&icc[..150]), None);
|
||||
assert_eq!(icc_description(&[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Shape layers: the groups under `ADBE Root Vectors Group` and the items in them.
|
||||
//!
|
||||
//! A shape layer (layer type 4) holds `tdgp[ADBE Root Vectors Group]` with one `ADBE Vector Group`
|
||||
//! per shape group; the group holds `ADBE Vectors Group` (the contents, in z-order) and
|
||||
//! `ADBE Vector Transform Group` (the group transform, only the properties that differ from the
|
||||
//! default). Only the items and properties whose layout was verified are decoded; anything else is
|
||||
//! listed as [`ShapeItem::Other`] with its match name.
|
||||
//!
|
||||
//! Colours are stored as ARGB doubles 0..255 and returned as fractions. Sizes, positions and path
|
||||
//! points are in pixels relative to the layer origin.
|
||||
|
||||
use crate::container::masks::ShapePath;
|
||||
use crate::container::typed::{Layer, PropGroup, PropNode, Property};
|
||||
use crate::container::values::{Tracked, read, read_opt};
|
||||
|
||||
/// The stroke line cap (`ADBE Vector Stroke Line Cap`, stored as a double; butt is the default and
|
||||
/// is not stored).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StrokeCap {
|
||||
/// `1`
|
||||
Butt,
|
||||
/// `2`
|
||||
Round,
|
||||
/// Any other value (projecting square was not sampled).
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
/// The stroke line join (`ADBE Vector Stroke Line Join`; miter is the default and is not stored).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StrokeJoin {
|
||||
/// `1`
|
||||
Miter,
|
||||
/// `2`
|
||||
Round,
|
||||
/// Any other value (bevel was not sampled).
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
/// A stroke item.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
/// The color as `[A, R, G, B]` fractions; `None` while the property is absent (its default is
|
||||
/// not verified).
|
||||
pub color: Option<Tracked<[f64; 4]>>,
|
||||
/// The width in pixels; `None` while absent.
|
||||
pub width: Option<Tracked<f64>>,
|
||||
pub cap: Tracked<StrokeCap>,
|
||||
pub join: Tracked<StrokeJoin>,
|
||||
/// The miter limit; after the join was touched it is stored in its default state (4).
|
||||
pub miter_limit: Option<Tracked<f64>>,
|
||||
}
|
||||
|
||||
/// An item of a shape group's contents.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ShapeItem {
|
||||
/// `ADBE Vector Shape - Rect`: the rectangle path.
|
||||
Rect { name: Option<String>, size: Option<Tracked<[f64; 2]>> },
|
||||
/// `ADBE Vector Shape - Group`: a bezier path (one shape per keyframe), points in pixels.
|
||||
Path { name: Option<String>, paths: Vec<ShapePath> },
|
||||
/// `ADBE Vector Graphic - Fill`: the color as `[A, R, G, B]` fractions.
|
||||
Fill { name: Option<String>, color: Option<Tracked<[f64; 4]>> },
|
||||
/// `ADBE Vector Graphic - Stroke`
|
||||
Stroke { name: Option<String>, stroke: Stroke },
|
||||
/// `ADBE Vector Graphic - G-Fill`: the start and end point of the gradient in pixels (the
|
||||
/// gradient type and the colour stops are not stored while they are at After Effects' default
|
||||
/// and their encoding is not known).
|
||||
GradientFill { name: Option<String>, start: Option<Tracked<[f64; 2]>>, end: Option<Tracked<[f64; 2]>> },
|
||||
/// `ADBE Vector Filter - RC`: rounded corners, the radius in pixels.
|
||||
RoundCorners { name: Option<String>, radius: Option<Tracked<f64>> },
|
||||
/// Any other item (ellipse, polystar, trim, repeater, ...: not ported).
|
||||
Other { match_name: String, name: Option<String> },
|
||||
}
|
||||
|
||||
/// The polystar type (`ADBE Vector Star Type`; star is the default and is not stored).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PolystarType {
|
||||
/// `1`
|
||||
Star,
|
||||
/// `2`
|
||||
Polygon,
|
||||
/// Any other value.
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
/// The gradient type (`ADBE Vector Grad Type`; linear is the default and is not stored).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GradientType {
|
||||
/// `1`
|
||||
Linear,
|
||||
/// `2`
|
||||
Radial,
|
||||
/// Any other value.
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
/// Fields of shape items that [`ShapeItem`] does not carry, in the order of the group's items.
|
||||
/// `item_index` is the index into [`ShapeGroup::items`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ShapeDetail {
|
||||
/// `ADBE Vector Shape - Star` (listed as [`ShapeItem::Other`]). The defaults After Effects
|
||||
/// applies when a property is absent are star, outer radius 100, inner radius 50, so a default
|
||||
/// star has no property at all (330); a polygon stores the type and writes the inner radius and
|
||||
/// the inner roundness (`ADBE Vector Star Inner Roundess`, After Effects' own spelling) in their
|
||||
/// default state (331).
|
||||
Polystar {
|
||||
item_index: usize,
|
||||
star_type: Tracked<PolystarType>,
|
||||
outer_radius: Tracked<f64>,
|
||||
inner_radius: Tracked<f64>,
|
||||
/// `None` unless the roundness is stored.
|
||||
inner_roundness: Option<Tracked<f64>>,
|
||||
},
|
||||
/// `ADBE Vector Graphic - G-Fill`: the gradient type and, for a radial gradient, the highlight
|
||||
/// length and angle (written as stored records with value 0 when the type is radial, 332).
|
||||
Gradient { item_index: usize, gradient_type: Tracked<GradientType>, highlight_length: Option<Tracked<f64>>, highlight_angle: Option<Tracked<f64>> },
|
||||
/// The `ADBE Vector Rect Position` of a rectangle path (absent = `[0, 0]`), in pixels (333).
|
||||
RectPosition { item_index: usize, position: Tracked<[f64; 2]> },
|
||||
}
|
||||
|
||||
/// A shape group (`ADBE Vector Group`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShapeGroup {
|
||||
/// The group's name (`Group 1`).
|
||||
pub name: Option<String>,
|
||||
/// The group transform's Position in pixels; absent = `[0, 0]`.
|
||||
pub position: Tracked<[f64; 2]>,
|
||||
/// The contents in z-order (top first, as in the file). Two siblings with the same match name
|
||||
/// (two rectangles) are two items.
|
||||
pub items: Vec<ShapeItem>,
|
||||
/// More fields of the items (polystar, gradient type, rectangle position), see [`ShapeDetail`].
|
||||
pub details: Vec<ShapeDetail>,
|
||||
}
|
||||
|
||||
const COLOR_SCALE: f64 = 1.0 / 255.0;
|
||||
|
||||
fn number(group: &PropGroup<'_, '_>, name: &str) -> Option<Tracked<f64>> {
|
||||
read_opt::<1>(group, name, 1.0).map(|t| Tracked { value: t.value[0], source: t.source })
|
||||
}
|
||||
|
||||
fn paths_of(group: &PropGroup<'_, '_>) -> Vec<ShapePath> {
|
||||
let Some(PropNode::Other(oms)) = group.get("ADBE Vector Shape") else { return Vec::new() };
|
||||
let Some(omks) = oms.child_list(b"omks") else { return Vec::new() };
|
||||
omks.children().iter().filter(|c| c.is_list(b"shap")).filter_map(ShapePath::from_chunk).collect()
|
||||
}
|
||||
|
||||
fn item(match_name: String, group: &PropGroup<'_, '_>) -> ShapeItem {
|
||||
let name = group.user_name();
|
||||
match match_name.as_str() {
|
||||
"ADBE Vector Shape - Rect" => ShapeItem::Rect { name, size: read_opt::<2>(group, "ADBE Vector Rect Size", 1.0) },
|
||||
"ADBE Vector Shape - Group" => ShapeItem::Path { name, paths: paths_of(group) },
|
||||
"ADBE Vector Graphic - Fill" => ShapeItem::Fill { name, color: read_opt::<4>(group, "ADBE Vector Fill Color", COLOR_SCALE) },
|
||||
"ADBE Vector Graphic - Stroke" => {
|
||||
let cap = read(group, "ADBE Vector Stroke Line Cap", [1.0], 1.0);
|
||||
let join = read(group, "ADBE Vector Stroke Line Join", [1.0], 1.0);
|
||||
let stroke = Stroke {
|
||||
color: read_opt::<4>(group, "ADBE Vector Stroke Color", COLOR_SCALE),
|
||||
width: number(group, "ADBE Vector Stroke Width"),
|
||||
cap: Tracked {
|
||||
value: match cap.value[0].round() as i64 {
|
||||
1 => StrokeCap::Butt,
|
||||
2 => StrokeCap::Round,
|
||||
other => StrokeCap::Other(other),
|
||||
},
|
||||
source: cap.source,
|
||||
},
|
||||
join: Tracked {
|
||||
value: match join.value[0].round() as i64 {
|
||||
1 => StrokeJoin::Miter,
|
||||
2 => StrokeJoin::Round,
|
||||
other => StrokeJoin::Other(other),
|
||||
},
|
||||
source: join.source,
|
||||
},
|
||||
miter_limit: number(group, "ADBE Vector Stroke Miter Limit"),
|
||||
};
|
||||
ShapeItem::Stroke { name, stroke }
|
||||
}
|
||||
"ADBE Vector Graphic - G-Fill" => ShapeItem::GradientFill {
|
||||
name,
|
||||
start: read_opt::<2>(group, "ADBE Vector Grad Start Pt", 1.0),
|
||||
end: read_opt::<2>(group, "ADBE Vector Grad End Pt", 1.0),
|
||||
},
|
||||
"ADBE Vector Filter - RC" => ShapeItem::RoundCorners { name, radius: number(group, "ADBE Vector RoundCorner Radius") },
|
||||
_ => ShapeItem::Other { match_name, name },
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(match_name: &str, group: &PropGroup<'_, '_>, item_index: usize) -> Option<ShapeDetail> {
|
||||
let tracked = |name: &str, default: f64| {
|
||||
let t = read(group, name, [default], 1.0);
|
||||
Tracked { value: t.value[0], source: t.source }
|
||||
};
|
||||
match match_name {
|
||||
"ADBE Vector Shape - Star" => {
|
||||
let star_type = tracked("ADBE Vector Star Type", 1.0);
|
||||
Some(ShapeDetail::Polystar {
|
||||
item_index,
|
||||
star_type: Tracked {
|
||||
value: match star_type.value.round() as i64 {
|
||||
1 => PolystarType::Star,
|
||||
2 => PolystarType::Polygon,
|
||||
other => PolystarType::Other(other),
|
||||
},
|
||||
source: star_type.source,
|
||||
},
|
||||
outer_radius: tracked("ADBE Vector Star Outer Radius", 100.0),
|
||||
inner_radius: tracked("ADBE Vector Star Inner Radius", 50.0),
|
||||
inner_roundness: number(group, "ADBE Vector Star Inner Roundess"),
|
||||
})
|
||||
}
|
||||
"ADBE Vector Graphic - G-Fill" => {
|
||||
let gradient_type = tracked("ADBE Vector Grad Type", 1.0);
|
||||
Some(ShapeDetail::Gradient {
|
||||
item_index,
|
||||
gradient_type: Tracked {
|
||||
value: match gradient_type.value.round() as i64 {
|
||||
1 => GradientType::Linear,
|
||||
2 => GradientType::Radial,
|
||||
other => GradientType::Other(other),
|
||||
},
|
||||
source: gradient_type.source,
|
||||
},
|
||||
highlight_length: number(group, "ADBE Vector Grad HiLite Length"),
|
||||
highlight_angle: number(group, "ADBE Vector Grad HiLite Angle"),
|
||||
})
|
||||
}
|
||||
"ADBE Vector Shape - Rect" => Some(ShapeDetail::RectPosition { item_index, position: read(group, "ADBE Vector Rect Position", [0.0, 0.0], 1.0) }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_of(group: &PropGroup<'_, '_>) -> ShapeGroup {
|
||||
let (mut items, mut details) = (Vec::new(), Vec::new());
|
||||
if let Some(PropNode::Group(contents)) = group.get("ADBE Vectors Group") {
|
||||
for e in contents.entries() {
|
||||
if let PropNode::Group(g) = e.node {
|
||||
details.extend(detail(&e.match_name, &g, items.len()));
|
||||
items.push(item(e.match_name, &g));
|
||||
}
|
||||
}
|
||||
}
|
||||
let position = match group.get("ADBE Vector Transform Group") {
|
||||
Some(PropNode::Group(t)) => read(&t, "ADBE Vector Position", [0.0, 0.0], 1.0),
|
||||
_ => Tracked { value: [0.0, 0.0], source: crate::container::values::ValueSource::Default },
|
||||
};
|
||||
ShapeGroup { name: group.user_name(), position, items, details }
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The shape groups of a shape layer (`ADBE Root Vectors Group`), top group first.
|
||||
pub fn shape_groups(&self) -> Vec<ShapeGroup> {
|
||||
let Some(root) = self.properties() else { return Vec::new() };
|
||||
let Some(PropNode::Group(vectors)) = root.get("ADBE Root Vectors Group") else { return Vec::new() };
|
||||
vectors
|
||||
.entries()
|
||||
.into_iter()
|
||||
.filter(|e| e.match_name == "ADBE Vector Group")
|
||||
.filter_map(|e| match e.node {
|
||||
PropNode::Group(g) => Some(group_of(&g)),
|
||||
PropNode::Property(_) | PropNode::Other(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Property<'f, 'a> {
|
||||
/// The ARGB fractions of a static colour property (4 components stored as doubles 0..255).
|
||||
pub fn color_argb(&self) -> Option<[f64; 4]> {
|
||||
let v = self.static_value()?;
|
||||
let [a, r, g, b] = *v.as_slice().first_chunk::<4>()?;
|
||||
Some([a / 255.0, r / 255.0, g / 255.0, b / 255.0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! The layer-style table shared by the importer (`crate::import`) and the exporter
|
||||
//! (`crate::export`): the After Effects layer styles (`ADBE Layer Styles`, docs
|
||||
//! `aep-format.md` 3.14) and their parameters, the EffectCraft style and parameter each maps to,
|
||||
//! the unit conversion and After Effects' default as it would be stored.
|
||||
//!
|
||||
//! Layout (950-980): every layer's root group holds `ADBE Layer Styles`; under it `ADBE Blend
|
||||
//! Options Group` and one switch group per style (`dropShadow/enabled`, ...), whose `tdsb` is 1
|
||||
//! while the style is applied and 2 while it is not. A parameter has a record only when it
|
||||
//! differs from its default, in the order of [`StyleMap::params`] (After Effects' property
|
||||
//! order). Scalars are stored in the units shown (opacity in percent, pixels, degrees), colours
|
||||
//! as ARGB 0..255, popups 1-based, checkboxes 1.0 when on.
|
||||
|
||||
/// The match name of the layer-styles group in a layer's root group.
|
||||
pub const GROUP: &str = "ADBE Layer Styles";
|
||||
/// The Blending Options group.
|
||||
pub const BLEND_OPTIONS: &str = "ADBE Blend Options Group";
|
||||
/// The Advanced Blending group inside Blending Options.
|
||||
pub const ADV_BLEND: &str = "ADBE Adv Blend Group";
|
||||
/// Global Light Angle in Blending Options (degrees; 977).
|
||||
pub const GLOBAL_ANGLE: &str = "ADBE Global Angle2";
|
||||
/// Global Light Altitude in Blending Options (degrees; 977).
|
||||
pub const GLOBAL_ALTITUDE: &str = "ADBE Global Altitude2";
|
||||
/// The group `tdsb` of an applied style (and of Layer Styles and Blending Options once a style is
|
||||
/// applied, 951-960).
|
||||
pub const FLAGS_ON: u32 = 1;
|
||||
/// The group `tdsb` of a style that is applied but switched off (its eye; 988: not drawn). Bit 0
|
||||
/// is the switch, as on shape items (986, 987).
|
||||
pub const FLAGS_OFF: u32 = 0;
|
||||
/// The group `tdsb` of a style that is not applied (950).
|
||||
pub const FLAGS_UNUSED: u32 = 2;
|
||||
/// The `tdsb` of Layer Styles and Blending Options while no style is applied (950).
|
||||
pub const FLAGS_GROUP_UNUSED: u32 = 3;
|
||||
/// The switch group of Pattern Overlay, which has no EffectCraft counterpart (and no menu
|
||||
/// command in After Effects 2026).
|
||||
pub const PATTERN_OVERLAY: &str = "patternFill";
|
||||
|
||||
/// How a stored style value becomes an EffectCraft value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum StyleConv {
|
||||
/// A number used as stored (percent, pixels, degrees; 966-974, 977).
|
||||
Number,
|
||||
/// A colour: ARGB 0..255 (967, 970, 977).
|
||||
Color,
|
||||
/// A style blend mode: After Effects' 1-based item of [`STYLE_MODES`] (978).
|
||||
Mode,
|
||||
/// Another popup: `map[stored - 1]` is the EffectCraft item (0-based).
|
||||
Popup(&'static [u32]),
|
||||
/// A checkbox, stored 1.0 when on (977).
|
||||
Checkbox,
|
||||
/// A two-component value used as stored (Gradient Overlay Offset; 977).
|
||||
Vec2,
|
||||
/// A gradient (`GCst`), not decoded: reported on import, never written.
|
||||
Gradient,
|
||||
}
|
||||
|
||||
/// One parameter: its match name without the style prefix, the EffectCraft parameter id, the
|
||||
/// conversion and After Effects' default as stored (MANIFEST 951 read-back).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StyleParam {
|
||||
pub ae: &'static str,
|
||||
pub ec: &'static str,
|
||||
pub conv: StyleConv,
|
||||
pub default: &'static [f64],
|
||||
}
|
||||
|
||||
/// One style: its switch-group prefix, the EffectCraft style id and the parameters in After
|
||||
/// Effects' property order (the order records are written in, 970).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StyleMap {
|
||||
pub ae: &'static str,
|
||||
pub ec: &'static str,
|
||||
pub params: &'static [StyleParam],
|
||||
}
|
||||
|
||||
impl StyleMap {
|
||||
/// The switch group's match name (`dropShadow/enabled`).
|
||||
pub fn switch_name(&self) -> String {
|
||||
format!("{}/enabled", self.ae)
|
||||
}
|
||||
/// A parameter's full match name (`dropShadow/opacity`).
|
||||
pub fn param_name(&self, p: &StyleParam) -> String {
|
||||
format!("{}/{}", self.ae, p.ae)
|
||||
}
|
||||
}
|
||||
|
||||
/// The style blend modes by their stored value (1-based): EffectCraft's mode name, `None` for a
|
||||
/// separator of the menu (renders Normal). Sweep 978 (16 bpc, each value rendered over a known
|
||||
/// colour): Normal and Dissolve (at 100 % opacity) render alike, as do Lighter Color and Normal
|
||||
/// for that colour pair (E); the others matched one formula within 1/255.
|
||||
pub const STYLE_MODES: &[Option<&str>] = &[
|
||||
Some("Normal"),
|
||||
Some("Dissolve"),
|
||||
None,
|
||||
Some("Darken"),
|
||||
Some("Multiply"),
|
||||
Some("Color Burn"),
|
||||
Some("Linear Burn"),
|
||||
Some("Darker Color"),
|
||||
None,
|
||||
Some("Lighten"),
|
||||
Some("Screen"),
|
||||
Some("Color Dodge"),
|
||||
Some("Linear Dodge (Add)"),
|
||||
Some("Lighter Color"),
|
||||
None,
|
||||
Some("Overlay"),
|
||||
Some("Soft Light"),
|
||||
Some("Hard Light"),
|
||||
Some("Vivid Light"),
|
||||
Some("Linear Light"),
|
||||
Some("Pin Light"),
|
||||
Some("Hard Mix"),
|
||||
None,
|
||||
Some("Difference"),
|
||||
Some("Exclusion"),
|
||||
None,
|
||||
Some("Hue"),
|
||||
Some("Saturation"),
|
||||
Some("Color"),
|
||||
Some("Luminosity"),
|
||||
None,
|
||||
Some("Subtract"),
|
||||
Some("Divide"),
|
||||
];
|
||||
|
||||
/// The stored value (1-based) of a style blend mode by EffectCraft's name.
|
||||
pub fn mode_code(name: &str) -> Option<u32> {
|
||||
STYLE_MODES.iter().position(|m| *m == Some(name)).and_then(|i| u32::try_from(i + 1).ok())
|
||||
}
|
||||
|
||||
/// EffectCraft's name of a stored style blend mode; `None` for a separator or out of range.
|
||||
pub fn mode_name(code: f64) -> Option<&'static str> {
|
||||
if !code.is_finite() || code < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let i = code.round() as usize;
|
||||
*STYLE_MODES.get(i.checked_sub(1)?)?
|
||||
}
|
||||
|
||||
/// A popup's stored value for an EffectCraft item (the inverse of `map`).
|
||||
pub fn popup_code(map: &[u32], item: u32) -> Option<u32> {
|
||||
map.iter().position(|m| *m == item).and_then(|i| u32::try_from(i + 1).ok())
|
||||
}
|
||||
|
||||
/// The EffectCraft item of a stored popup value.
|
||||
pub fn popup_item(map: &[u32], code: f64) -> Option<u32> {
|
||||
if !code.is_finite() || code < 1.0 {
|
||||
return None;
|
||||
}
|
||||
map.get((code.round() as usize).checked_sub(1)?).copied()
|
||||
}
|
||||
|
||||
use StyleConv as C;
|
||||
|
||||
const fn p(ae: &'static str, ec: &'static str, conv: StyleConv, default: &'static [f64]) -> StyleParam {
|
||||
StyleParam { ae, ec, conv, default }
|
||||
}
|
||||
|
||||
/// Popups whose items are in the same order in both programs (1-based there, 0-based here).
|
||||
const SAME2: &[u32] = &[0, 1];
|
||||
const SAME3: &[u32] = &[0, 1, 2];
|
||||
const SAME5: &[u32] = &[0, 1, 2, 3, 4];
|
||||
/// Inner Glow Source: After Effects stores Edge as 1 and Center as 2 (955, 979); EffectCraft
|
||||
/// lists Center, Edge.
|
||||
const GLOW_SOURCE: &[u32] = &[1, 0];
|
||||
|
||||
const BLACK: &[f64] = &[255.0, 0.0, 0.0, 0.0];
|
||||
const WHITE: &[f64] = &[255.0, 255.0, 255.0, 255.0];
|
||||
const RED: &[f64] = &[255.0, 255.0, 0.0, 0.0];
|
||||
const GLOW: &[f64] = &[255.0, 255.0, 255.0, 190.0];
|
||||
|
||||
/// The nine styles with a counterpart, in menu order. Defaults: the read-back of 951 (MANIFEST);
|
||||
/// popup items: 977-980 and the read-back (E where only the stored number is known: Technique
|
||||
/// Precise, Bevel Technique and Direction, Gradient Style beyond Linear).
|
||||
pub const STYLES: &[StyleMap] = &[
|
||||
StyleMap {
|
||||
ae: "dropShadow",
|
||||
ec: "dropShadow",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[5.0]),
|
||||
p("color", "color", C::Color, BLACK),
|
||||
p("opacity", "opacity", C::Number, &[75.0]),
|
||||
p("useGlobalAngle", "useGlobalLight", C::Checkbox, &[0.0]),
|
||||
p("localLightingAngle", "angle", C::Number, &[120.0]),
|
||||
p("distance", "distance", C::Number, &[5.0]),
|
||||
p("chokeMatte", "spread", C::Number, &[0.0]),
|
||||
p("blur", "size", C::Number, &[5.0]),
|
||||
p("noise", "noise", C::Number, &[0.0]),
|
||||
p("layerConceals", "knocksOut", C::Checkbox, &[1.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "innerShadow",
|
||||
ec: "innerShadow",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[5.0]),
|
||||
p("color", "color", C::Color, BLACK),
|
||||
p("opacity", "opacity", C::Number, &[75.0]),
|
||||
p("useGlobalAngle", "useGlobalLight", C::Checkbox, &[0.0]),
|
||||
p("localLightingAngle", "angle", C::Number, &[120.0]),
|
||||
p("distance", "distance", C::Number, &[5.0]),
|
||||
p("chokeMatte", "choke", C::Number, &[0.0]),
|
||||
p("blur", "size", C::Number, &[5.0]),
|
||||
p("noise", "noise", C::Number, &[0.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "outerGlow",
|
||||
ec: "outerGlow",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[11.0]),
|
||||
p("opacity", "opacity", C::Number, &[75.0]),
|
||||
p("noise", "noise", C::Number, &[0.0]),
|
||||
p("AEColorChoice", "colorType", C::Popup(SAME2), &[1.0]),
|
||||
p("color", "color", C::Color, GLOW),
|
||||
p("gradient", "colors", C::Gradient, &[]),
|
||||
p("gradientSmoothness", "gradientSmoothness", C::Number, &[100.0]),
|
||||
p("glowTechnique", "technique", C::Popup(SAME2), &[1.0]),
|
||||
p("chokeMatte", "spread", C::Number, &[0.0]),
|
||||
p("blur", "size", C::Number, &[5.0]),
|
||||
p("inputRange", "range", C::Number, &[50.0]),
|
||||
p("shadingNoise", "jitter", C::Number, &[0.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "innerGlow",
|
||||
ec: "innerGlow",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[11.0]),
|
||||
p("opacity", "opacity", C::Number, &[75.0]),
|
||||
p("noise", "noise", C::Number, &[0.0]),
|
||||
p("AEColorChoice", "colorType", C::Popup(SAME2), &[1.0]),
|
||||
p("color", "color", C::Color, GLOW),
|
||||
p("gradient", "colors", C::Gradient, &[]),
|
||||
p("gradientSmoothness", "gradientSmoothness", C::Number, &[100.0]),
|
||||
p("glowTechnique", "technique", C::Popup(SAME2), &[1.0]),
|
||||
p("innerGlowSource", "source", C::Popup(GLOW_SOURCE), &[1.0]),
|
||||
p("chokeMatte", "choke", C::Number, &[0.0]),
|
||||
p("blur", "size", C::Number, &[5.0]),
|
||||
p("inputRange", "range", C::Number, &[50.0]),
|
||||
p("shadingNoise", "jitter", C::Number, &[0.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "bevelEmboss",
|
||||
ec: "bevelEmboss",
|
||||
params: &[
|
||||
p("bevelStyle", "style", C::Popup(SAME5), &[2.0]),
|
||||
p("bevelTechnique", "technique", C::Popup(SAME3), &[1.0]),
|
||||
p("strengthRatio", "depth", C::Number, &[100.0]),
|
||||
p("bevelDirection", "direction", C::Popup(SAME2), &[1.0]),
|
||||
p("blur", "size", C::Number, &[5.0]),
|
||||
p("softness", "soften", C::Number, &[0.0]),
|
||||
p("useGlobalAngle", "useGlobalLight", C::Checkbox, &[0.0]),
|
||||
p("localLightingAngle", "angle", C::Number, &[120.0]),
|
||||
p("localLightingAltitude", "altitude", C::Number, &[30.0]),
|
||||
p("highlightMode", "highlightMode", C::Mode, &[11.0]),
|
||||
p("highlightColor", "highlightColor", C::Color, WHITE),
|
||||
p("highlightOpacity", "highlightOpacity", C::Number, &[75.0]),
|
||||
p("shadowMode", "shadowMode", C::Mode, &[5.0]),
|
||||
p("shadowColor", "shadowColor", C::Color, BLACK),
|
||||
p("shadowOpacity", "shadowOpacity", C::Number, &[75.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "chromeFX",
|
||||
ec: "satin",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[5.0]),
|
||||
p("color", "color", C::Color, BLACK),
|
||||
p("opacity", "opacity", C::Number, &[50.0]),
|
||||
p("localLightingAngle", "angle", C::Number, &[19.0]),
|
||||
p("distance", "distance", C::Number, &[11.0]),
|
||||
p("blur", "size", C::Number, &[14.0]),
|
||||
p("invert", "invert", C::Checkbox, &[1.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "solidFill",
|
||||
ec: "colorOverlay",
|
||||
params: &[p("mode2", "blendMode", C::Mode, &[1.0]), p("color", "color", C::Color, RED), p("opacity", "opacity", C::Number, &[100.0])],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "gradientFill",
|
||||
ec: "gradientOverlay",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[1.0]),
|
||||
p("opacity", "opacity", C::Number, &[100.0]),
|
||||
p("gradient", "colors", C::Gradient, &[]),
|
||||
p("gradientSmoothness", "gradientSmoothness", C::Number, &[100.0]),
|
||||
p("angle", "angle", C::Number, &[90.0]),
|
||||
p("type", "style", C::Popup(SAME5), &[1.0]),
|
||||
p("reverse", "reverse", C::Checkbox, &[0.0]),
|
||||
p("align", "alignWithLayer", C::Checkbox, &[1.0]),
|
||||
p("scale", "scale", C::Number, &[100.0]),
|
||||
p("offset", "offset", C::Vec2, &[0.0, 0.0]),
|
||||
],
|
||||
},
|
||||
StyleMap {
|
||||
ae: "frameFX",
|
||||
ec: "stroke",
|
||||
params: &[
|
||||
p("mode2", "blendMode", C::Mode, &[1.0]),
|
||||
p("color", "color", C::Color, RED),
|
||||
p("size", "size", C::Number, &[3.0]),
|
||||
p("opacity", "opacity", C::Number, &[100.0]),
|
||||
p("style", "position", C::Popup(SAME3), &[1.0]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// Advanced Blending (in Blending Options): full match name, EffectCraft id in
|
||||
/// `blendingOptions/advancedBlending`, conversion and default (977; Fill Opacity 975). After
|
||||
/// Effects has no Knockout.
|
||||
pub const ADVANCED_BLENDING: &[StyleParam] = &[
|
||||
p("ADBE Layer Fill Opacity2", "fillOpacity", C::Number, &[100.0]),
|
||||
p("ADBE R Channel Blend", "red", C::Checkbox, &[1.0]),
|
||||
p("ADBE G Channel Blend", "green", C::Checkbox, &[1.0]),
|
||||
p("ADBE B Channel Blend", "blue", C::Checkbox, &[1.0]),
|
||||
p("ADBE Blend Interior", "blendInteriorAsGroup", C::Checkbox, &[0.0]),
|
||||
p("ADBE Blend Ranges", "useBlendRanges", C::Checkbox, &[1.0]),
|
||||
];
|
||||
|
||||
/// The style with this switch-group prefix.
|
||||
pub fn by_ae(prefix: &str) -> Option<&'static StyleMap> {
|
||||
STYLES.iter().find(|s| s.ae == prefix)
|
||||
}
|
||||
|
||||
/// The style with this EffectCraft id.
|
||||
pub fn by_ec(id: &str) -> Option<&'static StyleMap> {
|
||||
STYLES.iter().find(|s| s.ec == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_round_trip_and_defaults_are_the_photoshop_ones() {
|
||||
assert_eq!(mode_name(5.0), Some("Multiply"));
|
||||
assert_eq!(mode_name(11.0), Some("Screen"));
|
||||
assert_eq!(mode_name(1.0), Some("Normal"));
|
||||
assert_eq!(mode_name(3.0), None);
|
||||
assert_eq!(mode_name(34.0), None);
|
||||
assert_eq!(mode_name(0.0), None);
|
||||
assert_eq!(mode_name(f64::NAN), None);
|
||||
for (i, m) in STYLE_MODES.iter().enumerate() {
|
||||
if let Some(m) = m {
|
||||
assert_eq!(mode_code(m), Some(i as u32 + 1));
|
||||
}
|
||||
}
|
||||
assert_eq!(STYLE_MODES.iter().flatten().count(), 27);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popups_invert() {
|
||||
assert_eq!(popup_item(GLOW_SOURCE, 1.0), Some(1));
|
||||
assert_eq!(popup_item(GLOW_SOURCE, 2.0), Some(0));
|
||||
assert_eq!(popup_code(GLOW_SOURCE, 0), Some(2));
|
||||
assert_eq!(popup_item(SAME3, 4.0), None);
|
||||
assert_eq!(popup_item(SAME3, -1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_param_has_a_default_of_its_size() {
|
||||
for s in STYLES {
|
||||
for prm in s.params {
|
||||
let n = match prm.conv {
|
||||
C::Color => 4,
|
||||
C::Vec2 => 2,
|
||||
C::Gradient => 0,
|
||||
_ => 1,
|
||||
};
|
||||
assert_eq!(prm.default.len(), n, "{}/{}", s.ae, prm.ae);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,539 @@
|
||||
//! A small typed view over the chunk tree: project items, compositions, layers, the property
|
||||
//! tree and keyframe lists. The field decoders for headers, values, keyframes, effects, masks, text,
|
||||
//! markers and footage are in the sibling modules and extend these types.
|
||||
//!
|
||||
//! Only facts that were established from our own one-change-per-file corpus and are checked by
|
||||
//! tests against it are decoded (see `docs/aep-format.md` for the evidence). Everything else
|
||||
//! stays reachable through the raw [`Chunk`] tree. Views borrow the file and never fail loudly:
|
||||
//! a chunk that does not have the expected shape yields `None` or is skipped.
|
||||
|
||||
use crate::container::bytes::{be_f64, be_u16, be_u32, text, until_nul};
|
||||
use crate::container::chunk::{AepFile, Chunk};
|
||||
|
||||
/// The name a user gave a property or group: the `Utf8` inside its `tdsn` chunk. `None` for the
|
||||
/// sentinel `-_0_/-` ("no custom name") and for empty names.
|
||||
pub(crate) fn user_name(chunk: &Chunk<'_>) -> Option<String> {
|
||||
let name = chunk.child(b"tdsn")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)?;
|
||||
(!name.is_empty() && name != "-_0_/-").then_some(name)
|
||||
}
|
||||
|
||||
/// The content of a `Utf8` child as text (not trimmed).
|
||||
pub(crate) fn utf8_child(chunk: &Chunk<'_>) -> Option<String> {
|
||||
chunk.child(b"Utf8").and_then(Chunk::bytes).map(text)
|
||||
}
|
||||
|
||||
/// The project: the top-level `LIST Fold`, which holds the project items.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Project<'f, 'a> {
|
||||
pub(crate) fold: &'f Chunk<'a>,
|
||||
/// The top-level `LIST EfdG` (project-level effect definitions), when there is one.
|
||||
pub(crate) effect_defs: Option<&'f Chunk<'a>>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Project<'f, 'a> {
|
||||
/// The project view of a file, or `None` when it has no `LIST Fold`.
|
||||
pub fn new(file: &'f AepFile<'a>) -> Option<Self> {
|
||||
file.list(b"Fold").map(|fold| Self { fold, effect_defs: file.list(b"EfdG") })
|
||||
}
|
||||
|
||||
/// The top-level items (the project panel's root; folders hold their children, see
|
||||
/// [`Item::children`]).
|
||||
pub fn items(&self) -> impl Iterator<Item = Item<'f, 'a>> + 'f {
|
||||
self.fold.children().iter().filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
|
||||
}
|
||||
|
||||
/// True when the `LIST Fold` could not be read as chunks (damaged): its items cannot be seen.
|
||||
pub fn items_unreadable(&self) -> bool {
|
||||
self.fold.is_opaque_list()
|
||||
}
|
||||
|
||||
/// Every item including those inside folders, depth-first, without recursion.
|
||||
pub fn all_items(&self) -> Vec<Item<'f, 'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<Item<'f, 'a>> = self.items().collect();
|
||||
stack.reverse();
|
||||
while let Some(item) = stack.pop() {
|
||||
let mut kids: Vec<Item<'f, 'a>> = item.children().collect();
|
||||
kids.reverse();
|
||||
out.push(item);
|
||||
stack.extend(kids);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// What a project item is (the first 16-bit word of its `idta` chunk).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ItemKind {
|
||||
/// `1`
|
||||
Folder,
|
||||
/// `4`
|
||||
Composition,
|
||||
/// `7`
|
||||
Footage,
|
||||
/// Any other value.
|
||||
Other(u16),
|
||||
}
|
||||
|
||||
/// A project item (`LIST Item`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Item<'f, 'a> {
|
||||
pub(crate) chunk: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Item<'f, 'a> {
|
||||
/// The underlying chunk.
|
||||
pub fn chunk(&self) -> &'f Chunk<'a> {
|
||||
self.chunk
|
||||
}
|
||||
|
||||
/// The item kind, from `idta`.
|
||||
pub fn kind(&self) -> Option<ItemKind> {
|
||||
let idta = self.chunk.child(b"idta")?.bytes()?;
|
||||
Some(match be_u16(idta, 0)? {
|
||||
1 => ItemKind::Folder,
|
||||
4 => ItemKind::Composition,
|
||||
7 => ItemKind::Footage,
|
||||
other => ItemKind::Other(other),
|
||||
})
|
||||
}
|
||||
|
||||
/// The item name (the `Utf8` child). `None` when absent or empty: a solid's footage item
|
||||
/// stores no name there.
|
||||
pub fn name(&self) -> Option<String> {
|
||||
utf8_child(self.chunk).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// The items inside a folder (`LIST Sfdr`).
|
||||
pub fn children(&self) -> impl Iterator<Item = Item<'f, 'a>> + 'f {
|
||||
self.chunk.child_list(b"Sfdr").into_iter().flat_map(|sfdr| sfdr.children().iter()).filter(|c| c.is_list(b"Item")).map(|chunk| Item { chunk })
|
||||
}
|
||||
|
||||
/// Whether the item has the `LIST Sfdr` of a folder's contents (After Effects writes an empty
|
||||
/// one for an empty folder).
|
||||
pub fn has_folder_contents(&self) -> bool {
|
||||
self.chunk.child_list(b"Sfdr").is_some()
|
||||
}
|
||||
|
||||
/// True when the folder's `LIST Sfdr` is there but could not be read as chunks (damaged).
|
||||
pub fn folder_contents_unreadable(&self) -> bool {
|
||||
self.chunk.child_list(b"Sfdr").is_some_and(Chunk::is_opaque_list)
|
||||
}
|
||||
|
||||
/// The composition view when this item has composition data (`cdta`).
|
||||
pub fn comp(&self) -> Option<Comp<'f, 'a>> {
|
||||
self.chunk.child(b"cdta").map(|_| Comp { item: self.chunk })
|
||||
}
|
||||
}
|
||||
|
||||
/// The time base of a composition: one frame lasts `frame_duration` units and a second has
|
||||
/// `timescale` units. Keyframe times are counted in these units.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeBase {
|
||||
pub frame_duration: u32,
|
||||
pub timescale: u32,
|
||||
}
|
||||
|
||||
impl TimeBase {
|
||||
/// Frames per second (`timescale / frame_duration`), `None` for a zero duration.
|
||||
pub fn frame_rate(&self) -> Option<f64> {
|
||||
(self.frame_duration != 0).then(|| f64::from(self.timescale) / f64::from(self.frame_duration))
|
||||
}
|
||||
|
||||
/// Converts time units to seconds, `None` for a zero timescale.
|
||||
pub fn seconds(&self, units: i32) -> Option<f64> {
|
||||
(self.timescale != 0).then(|| f64::from(units) / f64::from(self.timescale))
|
||||
}
|
||||
}
|
||||
|
||||
/// A composition item (an [`Item`] with `cdta`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Comp<'f, 'a> {
|
||||
pub(crate) item: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Comp<'f, 'a> {
|
||||
/// The time base from `cdta` (`u32` at byte 4 is the frame duration, `u32` at byte 8 the
|
||||
/// timescale).
|
||||
pub fn time_base(&self) -> Option<TimeBase> {
|
||||
let cdta = self.item.child(b"cdta")?.bytes()?;
|
||||
Some(TimeBase { frame_duration: be_u32(cdta, 4)?, timescale: be_u32(cdta, 8)? })
|
||||
}
|
||||
|
||||
/// The layers, top layer first (`LIST Layr`; the `DLay`/`SLay`/`CLay`/`SecL` lists next to
|
||||
/// them are not layers).
|
||||
pub fn layers(&self) -> impl Iterator<Item = Layer<'f, 'a>> + 'f {
|
||||
self.item.children().iter().filter(|c| c.is_list(b"Layr")).map(|chunk| Layer { chunk })
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer (`LIST Layr`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Layer<'f, 'a> {
|
||||
pub(crate) chunk: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The underlying chunk.
|
||||
pub fn chunk(&self) -> &'f Chunk<'a> {
|
||||
self.chunk
|
||||
}
|
||||
|
||||
/// The layer name when it was set (the `Utf8` child); `None` when empty, in which case the
|
||||
/// layer shows its source's name.
|
||||
pub fn name(&self) -> Option<String> {
|
||||
utf8_child(self.chunk).filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// The 3D layer switch: bit `0x04` of byte 38 of `ldta`.
|
||||
pub fn is_three_d(&self) -> Option<bool> {
|
||||
let ldta = self.chunk.child(b"ldta")?.bytes()?;
|
||||
Some(ldta.get(38)? & 0x04 != 0)
|
||||
}
|
||||
|
||||
/// The root of the layer's property tree.
|
||||
pub fn properties(&self) -> Option<PropGroup<'f, 'a>> {
|
||||
self.chunk.child_list(b"tdgp").map(|chunk| PropGroup { chunk })
|
||||
}
|
||||
}
|
||||
|
||||
/// A node of the property tree.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PropNode<'f, 'a> {
|
||||
/// A property group (`LIST tdgp`).
|
||||
Group(PropGroup<'f, 'a>),
|
||||
/// A property (`LIST tdbs`).
|
||||
Property(Property<'f, 'a>),
|
||||
/// Any other list that follows a match name (observed: `otst`, the effect instance `sspc`).
|
||||
Other(&'f Chunk<'a>),
|
||||
}
|
||||
|
||||
impl<'f, 'a> PropNode<'f, 'a> {
|
||||
/// The node a `LIST` after a match name is.
|
||||
fn of(c: &'f Chunk<'a>) -> Self {
|
||||
if c.is_list(b"tdgp") {
|
||||
PropNode::Group(PropGroup { chunk: c })
|
||||
} else if c.is_list(b"tdbs") {
|
||||
PropNode::Property(Property { chunk: c })
|
||||
} else {
|
||||
PropNode::Other(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A match name with the node it names.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PropEntry<'f, 'a> {
|
||||
pub match_name: String,
|
||||
pub node: PropNode<'f, 'a>,
|
||||
}
|
||||
|
||||
/// A property group (`LIST tdgp`): after its `tdsb` and `tdsn` chunks come pairs of a `tdmn`
|
||||
/// (the 40-byte NUL-padded ASCII match name) and the list it names; a `tdmn` named
|
||||
/// `ADBE Group End` closes the group and names nothing.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PropGroup<'f, 'a> {
|
||||
pub(crate) chunk: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> PropGroup<'f, 'a> {
|
||||
/// A group view of a `LIST tdgp` chunk.
|
||||
pub fn from_chunk(chunk: &'f Chunk<'a>) -> Option<Self> {
|
||||
chunk.is_list(b"tdgp").then_some(Self { chunk })
|
||||
}
|
||||
|
||||
/// The underlying chunk.
|
||||
pub fn chunk(&self) -> &'f Chunk<'a> {
|
||||
self.chunk
|
||||
}
|
||||
|
||||
/// The name the user gave the group (e.g. `Mask 1`, `Group 1`); `None` when it has none.
|
||||
pub fn user_name(&self) -> Option<String> {
|
||||
user_name(self.chunk)
|
||||
}
|
||||
|
||||
/// The group's flags (`tdsb`, a `u32`).
|
||||
pub fn flags(&self) -> Option<u32> {
|
||||
be_u32(self.chunk.child(b"tdsb")?.bytes()?, 0)
|
||||
}
|
||||
|
||||
/// The entries in file order.
|
||||
pub fn entries(&self) -> Vec<PropEntry<'f, 'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut pending: Option<String> = None;
|
||||
for c in self.chunk.children() {
|
||||
if c.is(b"tdmn") {
|
||||
pending = c.bytes().map(match_name);
|
||||
} else if c.id == crate::container::chunk::LIST
|
||||
&& let Some(name) = pending.take()
|
||||
{
|
||||
out.push(PropEntry { match_name: name, node: PropNode::of(c) });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The match names of the entries, in file order.
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
self.entries().into_iter().map(|e| e.match_name).collect()
|
||||
}
|
||||
|
||||
/// The first entry with this match name. One pass that stops at it, without collecting the
|
||||
/// group's entries (callers look names up in loops).
|
||||
pub fn get(&self, name: &str) -> Option<PropNode<'f, 'a>> {
|
||||
let mut pending: Option<&[u8]> = None;
|
||||
for c in self.chunk.children() {
|
||||
if c.is(b"tdmn") {
|
||||
pending = c.bytes();
|
||||
} else if c.id == crate::container::chunk::LIST
|
||||
&& let Some(body) = pending.take()
|
||||
&& match_name(body) == name
|
||||
{
|
||||
return Some(PropNode::of(c));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Follows a path of match names through nested groups.
|
||||
pub fn find(&self, path: &[&str]) -> Option<PropNode<'f, 'a>> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let node = self.get(first)?;
|
||||
if rest.is_empty() {
|
||||
return Some(node);
|
||||
}
|
||||
match node {
|
||||
PropNode::Group(g) => g.find(rest),
|
||||
PropNode::Property(_) | PropNode::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The match name in a `tdmn` body: ASCII up to the first NUL.
|
||||
fn match_name(body: &[u8]) -> String {
|
||||
text(until_nul(body))
|
||||
}
|
||||
|
||||
/// A property (`LIST tdbs`): `tdsb`, `tdsn`, the 124-byte header `tdb4`, then either the static
|
||||
/// value `cdat` or a keyframe `LIST list`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Property<'f, 'a> {
|
||||
pub(crate) chunk: &'f Chunk<'a>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Property<'f, 'a> {
|
||||
/// A property view of a `LIST tdbs` chunk.
|
||||
pub fn from_chunk(chunk: &'f Chunk<'a>) -> Option<Self> {
|
||||
chunk.is_list(b"tdbs").then_some(Self { chunk })
|
||||
}
|
||||
|
||||
/// The underlying chunk.
|
||||
pub fn chunk(&self) -> &'f Chunk<'a> {
|
||||
self.chunk
|
||||
}
|
||||
|
||||
/// The name the user gave the property; `None` when it has none.
|
||||
pub fn user_name(&self) -> Option<String> {
|
||||
user_name(self.chunk)
|
||||
}
|
||||
|
||||
/// The raw 124-byte `tdb4` header.
|
||||
pub fn header(&self) -> Option<&'f [u8]> {
|
||||
self.chunk.child(b"tdb4")?.bytes()
|
||||
}
|
||||
|
||||
/// The number of value components: byte 3 of `tdb4`.
|
||||
pub fn dims(&self) -> Option<u8> {
|
||||
self.header()?.get(3).copied()
|
||||
}
|
||||
|
||||
/// True when the property has keyframes, i.e. its value is a `LIST list` instead of `cdat`.
|
||||
/// `tdb4` byte 68 is 1 in exactly these properties.
|
||||
pub fn is_animated(&self) -> bool {
|
||||
self.chunk.child_list(b"list").is_some()
|
||||
}
|
||||
|
||||
/// The static value: the first `dims` big-endian `f64` of `cdat`. `None` for animated
|
||||
/// properties and for values that are not `f64` arrays.
|
||||
pub fn static_value(&self) -> Option<Vec<f64>> {
|
||||
let dims = usize::from(self.dims()?);
|
||||
let cdat = self.chunk.child(b"cdat")?.bytes()?;
|
||||
(0..dims).map(|i| be_f64(cdat, i.checked_mul(8)?)).collect()
|
||||
}
|
||||
|
||||
/// The keyframe list of an animated property, with the record layout the property header
|
||||
/// selects (`tdb4` byte 3 = components, byte 7 non-zero = spatial template, unless the record
|
||||
/// size tells).
|
||||
pub fn keys(&self) -> Option<KeyList<'f>> {
|
||||
let list = KeyList::from_chunk(self.chunk.child_list(b"list")?)?;
|
||||
let header = self.header();
|
||||
// The record size decides where the two templates differ in size: a shape colour's keys
|
||||
// are spatial (152 bytes for 4 components) although its `tdb4` byte 7 is 0 (854). For 3
|
||||
// components both templates are 128 bytes; byte 7 decides.
|
||||
let size = list.header.record_size;
|
||||
let layout = header.and_then(|h| {
|
||||
let components = *h.get(3)?;
|
||||
let n = u32::from(components);
|
||||
let (spatial_size, plain_size) = (56 + 24 * n, 8 + 40 * n);
|
||||
let spatial = if size == spatial_size && size != plain_size {
|
||||
true
|
||||
} else if size == plain_size && size != spatial_size {
|
||||
false
|
||||
} else {
|
||||
*h.get(7)? != 0
|
||||
};
|
||||
Some(KeyLayout { components, spatial })
|
||||
});
|
||||
Some(KeyList { layout, ..list })
|
||||
}
|
||||
}
|
||||
|
||||
/// The header `lhd3` of a `LIST list`: 13 big-endian `u32`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ListHeader {
|
||||
/// Word 0, `0x00d00bee` in every list.
|
||||
pub magic: u32,
|
||||
/// Word 2: the number of records.
|
||||
pub count: u32,
|
||||
/// Word 4: the size of one record in bytes.
|
||||
pub record_size: u32,
|
||||
/// All 13 words.
|
||||
pub words: [u32; 13],
|
||||
}
|
||||
|
||||
impl ListHeader {
|
||||
/// Parses a 52-byte `lhd3` body.
|
||||
pub fn parse(body: &[u8]) -> Option<Self> {
|
||||
if body.len() != 52 {
|
||||
return None;
|
||||
}
|
||||
let mut words = [0u32; 13];
|
||||
for (i, w) in words.iter_mut().enumerate() {
|
||||
*w = be_u32(body, i * 4)?;
|
||||
}
|
||||
Some(Self { magic: words[0], count: words[2], record_size: words[4], words })
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LIST list`: `lhd3` and `ldat`, where `len(ldat) == count * record_size`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct KeyList<'f> {
|
||||
pub header: ListHeader,
|
||||
ldat: &'f [u8],
|
||||
layout: Option<KeyLayout>,
|
||||
}
|
||||
|
||||
/// What the property header says about its keyframe records: how many value components each
|
||||
/// record holds and which of the two record templates applies (see [`KeyframeKind`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KeyLayout {
|
||||
/// `tdb4` byte 3.
|
||||
pub components: u8,
|
||||
/// `tdb4` byte 7 is non-zero: the spatial template (Position, colours) instead of the plain one.
|
||||
pub spatial: bool,
|
||||
}
|
||||
|
||||
impl<'f> KeyList<'f> {
|
||||
/// A view of a `LIST list` chunk. `None` when `lhd3` is not 52 bytes or `ldat` is not exactly
|
||||
/// `count * record_size` bytes (a list without `ldat` must have no records).
|
||||
pub fn from_chunk<'a>(chunk: &'f Chunk<'a>) -> Option<Self> {
|
||||
if !chunk.is_list(b"list") {
|
||||
return None;
|
||||
}
|
||||
let header = ListHeader::parse(chunk.child(b"lhd3")?.bytes()?)?;
|
||||
let ldat: &[u8] = chunk.child(b"ldat").and_then(Chunk::bytes).unwrap_or(&[]);
|
||||
let expected = usize::try_from(header.count).ok()?.checked_mul(usize::try_from(header.record_size).ok()?)?;
|
||||
(ldat.len() == expected).then_some(Self { header, ldat, layout: None })
|
||||
}
|
||||
|
||||
/// The number of records actually present (what [`Self::records`] yields). Not the declared
|
||||
/// count: a list with record size 0 and no data passes [`Self::from_chunk`] whatever count it
|
||||
/// declares, and a caller sizing an allocation by that count would abort.
|
||||
pub fn len(&self) -> usize {
|
||||
self.records().count()
|
||||
}
|
||||
|
||||
/// True when there are no records.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ldat.is_empty()
|
||||
}
|
||||
|
||||
/// The records as byte slices.
|
||||
pub fn records(&self) -> impl Iterator<Item = &'f [u8]> {
|
||||
let size = usize::try_from(self.header.record_size).unwrap_or(0);
|
||||
self.ldat.chunks_exact(size.max(1))
|
||||
}
|
||||
|
||||
/// The record layout from the property header, `None` for a list that does not belong to a
|
||||
/// property (the point list of a path).
|
||||
pub fn layout(&self) -> Option<KeyLayout> {
|
||||
self.layout
|
||||
}
|
||||
|
||||
/// The records of a keyframe list as [`Keyframe`]s.
|
||||
pub fn keyframes(&self) -> impl Iterator<Item = Keyframe<'f>> {
|
||||
let layout = self.layout;
|
||||
self.records().map(move |record| Keyframe { record, layout })
|
||||
}
|
||||
}
|
||||
|
||||
/// One keyframe record: the time is the `i32` at byte 0, in the composition's time units
|
||||
/// ([`TimeBase`]).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Keyframe<'f> {
|
||||
pub(crate) record: &'f [u8],
|
||||
pub(crate) layout: Option<KeyLayout>,
|
||||
}
|
||||
|
||||
impl<'f> Keyframe<'f> {
|
||||
/// The whole record.
|
||||
pub fn raw(&self) -> &'f [u8] {
|
||||
self.record
|
||||
}
|
||||
|
||||
/// The keyframe time in time units (layer time).
|
||||
pub fn time_units(&self) -> Option<i32> {
|
||||
be_u32(self.record, 0).map(|u| i32::from_be_bytes(u.to_be_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key_list(count: u32, record_size: u32, ldat: &[u8]) -> Chunk<'static> {
|
||||
let mut lhd3 = vec![0u8; 52];
|
||||
lhd3[8..12].copy_from_slice(&count.to_be_bytes());
|
||||
lhd3[16..20].copy_from_slice(&record_size.to_be_bytes());
|
||||
Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", ldat.to_vec())])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_list_counts_the_records_it_holds() {
|
||||
let two = key_list(2, 4, &[0; 8]);
|
||||
assert_eq!(KeyList::from_chunk(&two).map(|l| l.len()), Some(2));
|
||||
// Record size 0 with no data passes the size check whatever count is declared; the length
|
||||
// is what is there, so no caller sizes an allocation by 4 billion records.
|
||||
let hostile = key_list(u32::MAX, 0, &[]);
|
||||
let list = KeyList::from_chunk(&hostile).expect("a well-formed empty list");
|
||||
assert_eq!((list.len(), list.is_empty(), list.records().count()), (0, true, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_finds_the_first_entry_of_a_name() {
|
||||
let tdmn = |n: &str| {
|
||||
let mut b = n.as_bytes().to_vec();
|
||||
b.resize(40, 0);
|
||||
Chunk::data(*b"tdmn", b)
|
||||
};
|
||||
let group = Chunk::list(
|
||||
*b"tdgp",
|
||||
vec![tdmn("A"), Chunk::list(*b"tdgp", Vec::new()), tdmn("B"), Chunk::list(*b"tdbs", Vec::new()), tdmn("A"), Chunk::list(*b"tdbs", Vec::new())],
|
||||
);
|
||||
let g = PropGroup::from_chunk(&group).expect("group");
|
||||
assert!(matches!(g.get("A"), Some(PropNode::Group(_))), "the first A is the group");
|
||||
assert!(matches!(g.get("B"), Some(PropNode::Property(_))));
|
||||
assert!(g.get("C").is_none());
|
||||
assert_eq!(g.names(), ["A", "B", "A"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Static transform values with their units, and the "absent = default" rule.
|
||||
//!
|
||||
//! After Effects stores a property only when it differs from the default it computes for the layer
|
||||
//! and composition (sparse storage), so a reader has to supply the defaults: Position defaults to
|
||||
//! the composition centre `[width / 2, height / 2, 0]` (verified: after the composition is resized
|
||||
//! without moving the layer, the old centre appears as a stored value), Anchor Point to the layer
|
||||
//! centre (`0.5, 0.5` of the layer **source** size), Scale to 100 %, Rotation to 0 and Opacity to
|
||||
//! 100 %. These defaults are verified for AV layers (solids); cameras and lights use other
|
||||
//! conventions (see the `camera` module).
|
||||
|
||||
use crate::container::typed::{Layer, Project, PropGroup, PropNode};
|
||||
|
||||
/// Where a [`Tracked`] value came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValueSource {
|
||||
/// The property is absent from the file: the value is the computed default.
|
||||
Default,
|
||||
/// The property stores a static value.
|
||||
Stored,
|
||||
/// The property has keyframes: the value is that of the first keyframe.
|
||||
Keyframed,
|
||||
/// The property is present but its value could not be read: the value is the default.
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
/// A value and where it came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Tracked<T> {
|
||||
pub value: T,
|
||||
pub source: ValueSource,
|
||||
}
|
||||
|
||||
/// The five basic transform properties of an AV layer, in the units After Effects shows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Transform {
|
||||
/// Pixels `[x, y, z]`.
|
||||
pub position: Tracked<[f64; 3]>,
|
||||
/// Stored as a fraction of the layer's **source** size for x and y (`10, 20` px on a 100x100
|
||||
/// solid in a 1920x1080 composition is `0.1, 0.2`; on a 400x300 solid `100, 50` is `0.25, 1/6`)
|
||||
/// and in pixels for z; see [`Transform::anchor_px`] and [`Project::layer_anchor_px`].
|
||||
pub anchor_fraction: Tracked<[f64; 3]>,
|
||||
/// Percent `[x, y, z]` (stored as fractions: 50 % is `0.5`).
|
||||
pub scale_percent: Tracked<[f64; 3]>,
|
||||
/// Degrees (Rotation, the Z rotation).
|
||||
pub rotation_degrees: Tracked<f64>,
|
||||
/// Percent (stored as a fraction: 25 % is `0.25`).
|
||||
pub opacity_percent: Tracked<f64>,
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// The anchor point in pixels for a layer whose source has this size.
|
||||
pub fn anchor_px(&self, width: f64, height: f64) -> [f64; 3] {
|
||||
let [x, y, z] = self.anchor_fraction.value;
|
||||
[x * width, y * height, z]
|
||||
}
|
||||
|
||||
/// The anchor point of a **text** layer in layer pixels. A text layer has no source, so the
|
||||
/// stored value is not a fraction: it is pixels as stored (385: Anchor Point `10, 20` is stored
|
||||
/// `10, 20`), and an
|
||||
/// absent anchor is the text origin `[0, 0, 0]` (After Effects' default for new text, absent in
|
||||
/// every other text sample 110-114, 243-248, 334-340, 386).
|
||||
pub fn text_anchor_px(&self) -> [f64; 3] {
|
||||
match self.anchor_fraction.source {
|
||||
ValueSource::Stored | ValueSource::Keyframed => self.anchor_fraction.value,
|
||||
ValueSource::Default | ValueSource::Unreadable => [0.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `N` components from the property `name` of `group` and multiplies them by `scale` (stored
|
||||
/// fractions to percent); `default` is in the output units and used when the property is absent.
|
||||
pub(crate) fn read<const N: usize>(group: &PropGroup<'_, '_>, name: &str, default: [f64; N], scale: f64) -> Tracked<[f64; N]> {
|
||||
let unreadable = Tracked { value: default, source: ValueSource::Unreadable };
|
||||
let property = match group.get(name) {
|
||||
None => return Tracked { value: default, source: ValueSource::Default },
|
||||
Some(PropNode::Property(p)) => p,
|
||||
Some(PropNode::Group(_) | PropNode::Other(_)) => return unreadable,
|
||||
};
|
||||
let (values, source) =
|
||||
if property.is_animated() { (property.first_key_value(), ValueSource::Keyframed) } else { (property.static_value(), ValueSource::Stored) };
|
||||
let Some(values) = values else { return unreadable };
|
||||
if values.len() < N {
|
||||
return unreadable;
|
||||
}
|
||||
let mut out = default;
|
||||
for (slot, v) in out.iter_mut().zip(&values) {
|
||||
*slot = *v * scale;
|
||||
}
|
||||
Tracked { value: out, source }
|
||||
}
|
||||
|
||||
/// Like [`read`] but `None` when the property is absent or unreadable (for properties whose default
|
||||
/// is not known), keeping the value's source.
|
||||
pub(crate) fn read_opt<const N: usize>(group: &PropGroup<'_, '_>, name: &str, scale: f64) -> Option<Tracked<[f64; N]>> {
|
||||
let tracked = read(group, name, [0.0; N], scale);
|
||||
matches!(tracked.source, ValueSource::Stored | ValueSource::Keyframed).then_some(tracked)
|
||||
}
|
||||
|
||||
impl<'f, 'a> Project<'f, 'a> {
|
||||
/// The anchor point of an AV layer in pixels: the stored fraction times the size of the layer's
|
||||
/// source (a solid or footage item, or a precomp's composition), z unchanged. `None` when the
|
||||
/// layer has no source (shape, text, camera, light) or no Transform group.
|
||||
pub fn layer_anchor_px(&self, layer: &Layer<'f, 'a>) -> Option<Tracked<[f64; 3]>> {
|
||||
let (width, height) = self.layer_source_size(layer)?;
|
||||
let Some(PropNode::Group(group)) = layer.properties()?.get("ADBE Transform Group") else { return None };
|
||||
let fraction = read(&group, "ADBE Anchor Point", [0.5, 0.5, 0.0], 1.0);
|
||||
let [x, y, z] = fraction.value;
|
||||
Some(Tracked { value: [x * f64::from(width), y * f64::from(height), z], source: fraction.source })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The transform of an AV layer in a composition of this size (pixels), with absent
|
||||
/// properties replaced by their defaults. `None` when the layer has no Transform group.
|
||||
pub fn transform(&self, comp_width: u32, comp_height: u32) -> Option<Transform> {
|
||||
let Some(PropNode::Group(group)) = self.properties()?.get("ADBE Transform Group") else { return None };
|
||||
let centre = [f64::from(comp_width) / 2.0, f64::from(comp_height) / 2.0, 0.0];
|
||||
let rotation = read(&group, "ADBE Rotate Z", [0.0], 1.0);
|
||||
let opacity = read(&group, "ADBE Opacity", [100.0], 100.0);
|
||||
Some(Transform {
|
||||
position: read(&group, "ADBE Position", centre, 1.0),
|
||||
anchor_fraction: read(&group, "ADBE Anchor Point", [0.5, 0.5, 0.0], 1.0),
|
||||
scale_percent: read(&group, "ADBE Scale", [100.0, 100.0, 100.0], 100.0),
|
||||
rotation_degrees: Tracked { value: rotation.value[0], source: rotation.source },
|
||||
opacity_percent: Tracked { value: opacity.value[0], source: opacity.source },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One key of a layer's Time Remapping property: the layer time of the key and the source time it
|
||||
/// maps to, both in seconds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TimeRemapKey {
|
||||
/// The key time in the property's time units.
|
||||
pub time_units: i32,
|
||||
/// The key time in seconds (layer time).
|
||||
pub time_seconds: Option<f64>,
|
||||
/// The value in seconds (the plain record's value at byte 8).
|
||||
pub value_seconds: f64,
|
||||
}
|
||||
|
||||
/// The state of a layer's `ADBE Time Remapping` property. Every layer has the property; while the
|
||||
/// remap is off it is an inert record (`tdsb` 3, static value 0, no keys). Enabling the remap turns
|
||||
/// it into a keyed property (`tdsb` 1, `tdb4` byte 68 = 1, no `cdat`) with two keys: the layer's in
|
||||
/// and out time mapped to the source time (319: 0 s and 5 s; 354: 0 s and 0.1 s).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TimeRemap {
|
||||
/// True when the property has keys.
|
||||
pub enabled: bool,
|
||||
pub keys: Vec<TimeRemapKey>,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The Time Remapping state; `None` when the layer has no such property.
|
||||
pub fn time_remap(&self) -> Option<TimeRemap> {
|
||||
let Some(PropNode::Property(p)) = self.properties()?.get("ADBE Time Remapping") else { return None };
|
||||
let scale = p.time_scale().unwrap_or(0);
|
||||
let keys = match p.keys() {
|
||||
Some(list) => list
|
||||
.keyframes()
|
||||
.filter_map(|k| Some(TimeRemapKey { time_units: k.time_units()?, time_seconds: k.seconds(scale), value_seconds: k.scalar_value()? }))
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Some(TimeRemap { enabled: p.is_animated(), keys })
|
||||
}
|
||||
}
|
||||
|
||||
/// The Orientation of a 3D layer (`ADBE Orientation`, 488): a `LIST otst` holding a `tdbs` and a
|
||||
/// `LIST otky` whose `otda` is three big-endian `f64` degrees `[x, y, z]`; absent at `[0, 0, 0]`
|
||||
/// (485-487).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Orientation {
|
||||
/// Degrees `[x, y, z]`: the static value (for a keyed Orientation the first `otda`, whose
|
||||
/// meaning is not sampled).
|
||||
pub degrees: [f64; 3],
|
||||
/// The `tdbs` of the `otst` has keys (see [`Layer::orientation_keys`]).
|
||||
pub animated: bool,
|
||||
/// The `tdbs` of the `otst` has an expression.
|
||||
pub has_expression: bool,
|
||||
}
|
||||
|
||||
impl<'f, 'a> Layer<'f, 'a> {
|
||||
/// The Orientation; `None` when it is absent (the default `[0, 0, 0]`) or unreadable.
|
||||
pub fn orientation(&self) -> Option<Orientation> {
|
||||
let Some(PropNode::Group(group)) = self.properties()?.get("ADBE Transform Group") else { return None };
|
||||
let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") else { return None };
|
||||
if !otst.is_list(b"otst") {
|
||||
return None;
|
||||
}
|
||||
let property = otst.child_list(b"tdbs").and_then(crate::container::typed::Property::from_chunk);
|
||||
let otda = otst.child_list(b"otky")?.child(b"otda")?.bytes()?;
|
||||
let mut degrees = [0.0; 3];
|
||||
for (i, slot) in degrees.iter_mut().enumerate() {
|
||||
let b = otda.get(i * 8..i * 8 + 8)?;
|
||||
*slot = f64::from_be_bytes(b.try_into().ok()?);
|
||||
}
|
||||
Some(Orientation {
|
||||
degrees,
|
||||
animated: property.as_ref().is_some_and(|p| p.is_animated()),
|
||||
has_expression: property.as_ref().is_some_and(|p| p.expression().is_some()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The time scale of the Orientation's key times (`tdb4` bytes 12..15), which follows the
|
||||
/// layer's stretch like every property of the layer.
|
||||
pub fn orientation_time_scale(&self) -> Option<u32> {
|
||||
let Some(PropNode::Group(group)) = self.properties().and_then(|p| p.get("ADBE Transform Group")) else { return None };
|
||||
let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") else { return None };
|
||||
otst.child_list(b"tdbs").and_then(crate::container::typed::Property::from_chunk).and_then(|p| p.time_scale())
|
||||
}
|
||||
|
||||
/// The keyframes of a keyed Orientation (703): the `tdbs` of the `otst` holds one 80-byte
|
||||
/// record per key (time, interpolation sides, flags; the rest is not decoded) and `otky` one
|
||||
/// `otda` (three `f64` degrees) per key, in the same order. Empty when the Orientation is not
|
||||
/// keyed or the counts differ. After Effects interpolates between the keys by a quaternion slerp
|
||||
/// of the rotations `Rx Ry Rz`, not per component (703's values at 0.5 / 1 / 1.5 s).
|
||||
pub fn orientation_keys(&self) -> Vec<OrientationKey> {
|
||||
let Some(PropNode::Group(group)) = self.properties().and_then(|p| p.get("ADBE Transform Group")) else { return Vec::new() };
|
||||
let Some(PropNode::Other(otst)) = group.get("ADBE Orientation") else { return Vec::new() };
|
||||
let Some(list) = otst.child_list(b"tdbs").and_then(crate::container::typed::Property::from_chunk).and_then(|p| p.keys()) else { return Vec::new() };
|
||||
let values: Vec<Option<[f64; 3]>> = otst
|
||||
.child_list(b"otky")
|
||||
.map(|k| {
|
||||
k.children()
|
||||
.iter()
|
||||
.filter(|c| c.is(b"otda"))
|
||||
.map(|c| {
|
||||
let b = c.bytes()?;
|
||||
let at = |i: usize| b.get(i * 8..i * 8 + 8).and_then(|s| s.try_into().ok()).map(f64::from_be_bytes);
|
||||
Some([at(0)?, at(1)?, at(2)?])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let keys: Vec<_> = list.keyframes().collect();
|
||||
if keys.len() != values.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
keys.iter()
|
||||
.zip(values)
|
||||
.filter_map(|(k, v)| {
|
||||
Some(OrientationKey {
|
||||
time_units: k.time_units()?,
|
||||
interpolation_in: k.interpolation_in()?,
|
||||
interpolation_out: k.interpolation_out()?,
|
||||
degrees: v?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A keyframe of a keyed Orientation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct OrientationKey {
|
||||
/// The time in the property's time units (layer time).
|
||||
pub time_units: i32,
|
||||
pub interpolation_in: crate::container::Interpolation,
|
||||
pub interpolation_out: crate::container::Interpolation,
|
||||
/// Degrees `[x, y, z]`.
|
||||
pub degrees: [f64; 3],
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! The After Effects version a project is written for (`head` bytes 4 to 7) and the version of the
|
||||
//! After Effects that wrote it (`svap`).
|
||||
//!
|
||||
//! Both hold the same packed `u32` (see `docs/aep-format.md`, section 1, "Version"). A project saved
|
||||
//! normally has the same word in both; File ▸ Save a Copy As 25.x / 24.x of After Effects 2026 keeps
|
||||
//! `svap` (the 2026 version) and writes the older version into `head`. The XMP packet after the
|
||||
//! container names the application of the first save (`CreatorTool`) and is no guide to either.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::container::bytes::{be_u16, be_u32};
|
||||
use crate::container::chunk::AepFile;
|
||||
|
||||
/// An After Effects version as packed in `head` / `svap`: `26.2.1x2` is major 26, minor 2, bug-fix
|
||||
/// 1, build 2 (the form of the scripting API's `app.version`).
|
||||
///
|
||||
/// Bit layout of the big-endian word (bit 31 first), decoded from the samples (the 2026 word
|
||||
/// `0x0f11_0e02` is the version the saving After Effects reports, `26.2.1x2`; the down-saves give
|
||||
/// `0x0f0b_0661` and `0x0f03_0641`, read as `25.6.0x97` and `24.6.0x65`):
|
||||
///
|
||||
/// | bits | field |
|
||||
/// |---|---|
|
||||
/// | 31-26 | not decoded (`000011` in every sample) |
|
||||
/// | 25-24, 21-19 | major (high two bits, then low three bits) |
|
||||
/// | 23-22 | not decoded (`00` in every sample) |
|
||||
/// | 18-15 | minor |
|
||||
/// | 14-11 | bug-fix |
|
||||
/// | 10-8 | stage (6 in every sample) |
|
||||
/// | 7-0 | build |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AeVersion {
|
||||
pub major: u8,
|
||||
pub minor: u8,
|
||||
pub bugfix: u8,
|
||||
/// The release stage (6 in every sample; not shown in [`AeVersion::text`]).
|
||||
pub stage: u8,
|
||||
pub build: u8,
|
||||
/// The word as stored.
|
||||
pub raw: u32,
|
||||
}
|
||||
|
||||
impl AeVersion {
|
||||
/// Decodes a stored word; `None` for a word that holds no version (zero, all ones, or major 0:
|
||||
/// After Effects itself refuses a file whose `head` version is zero, "[0.0x0]").
|
||||
pub fn from_word(raw: u32) -> Option<Self> {
|
||||
if raw == 0 || raw == u32::MAX {
|
||||
return None;
|
||||
}
|
||||
let [b0, b1, b2, b3] = raw.to_be_bytes();
|
||||
let major = ((b0 & 0x03) << 3) | ((b1 >> 3) & 0x07);
|
||||
if major == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { major, minor: ((b1 & 0x07) << 1) | (b2 >> 7), bugfix: (b2 >> 3) & 0x0f, stage: b2 & 0x07, build: b3, raw })
|
||||
}
|
||||
|
||||
/// `26.2.1x2`.
|
||||
pub fn text(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AeVersion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}.{}.{}x{}", self.major, self.minor, self.bugfix, self.build)
|
||||
}
|
||||
}
|
||||
|
||||
impl AepFile<'_> {
|
||||
/// The version the file is written for: `head` bytes 4 to 7. After Effects 2026 writes its own
|
||||
/// version there, and the older version for File ▸ Save a Copy As 25.x / 24.x. `None` when the
|
||||
/// chunk is missing, short or holds no version.
|
||||
pub fn file_version(&self) -> Option<AeVersion> {
|
||||
AeVersion::from_word(be_u32(self.chunk(b"head")?.bytes()?, 4)?)
|
||||
}
|
||||
|
||||
/// The version of the After Effects that wrote the file: `svap` bytes 0 to 3 (the same word in a
|
||||
/// down-saved copy as in a normal save). `None` when the chunk is missing (After Effects opens such
|
||||
/// a file), short or holds no version.
|
||||
pub fn saved_by_version(&self) -> Option<AeVersion> {
|
||||
AeVersion::from_word(be_u32(self.chunk(b"svap")?.bytes()?, 0)?)
|
||||
}
|
||||
|
||||
/// The `u16` at `head` byte 0: 97 (`0x61`) in a 2026 file, 96 in a copy saved as 25.x, 95 as
|
||||
/// 24.x. It steps with the major version of [`AepFile::file_version`]; what it counts is not
|
||||
/// known, so nothing is derived from it.
|
||||
pub fn head_revision(&self) -> Option<u16> {
|
||||
be_u16(self.chunk(b"head")?.bytes()?, 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AeVersion;
|
||||
|
||||
#[test]
|
||||
fn decodes_the_sampled_words() {
|
||||
let v = AeVersion::from_word(0x0f11_0e02).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.stage, v.build), (26, 2, 1, 6, 2));
|
||||
assert_eq!(v.text(), "26.2.1x2");
|
||||
assert_eq!(AeVersion::from_word(0x0f0b_0661).unwrap().text(), "25.6.0x97");
|
||||
assert_eq!(AeVersion::from_word(0x0f03_0641).unwrap().text(), "24.6.0x65");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_without_a_version_is_none() {
|
||||
assert_eq!(AeVersion::from_word(0), None);
|
||||
assert_eq!(AeVersion::from_word(u32::MAX), None);
|
||||
// Major 0 (only the undecoded high bits set).
|
||||
assert_eq!(AeVersion::from_word(0x0c00_0000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_takes_its_extremes() {
|
||||
let v = AeVersion::from_word(0x03ff_ffff).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.stage, v.build), (31, 15, 15, 7, 255));
|
||||
let v = AeVersion::from_word(0x0000_0800 | 0x0008_0000).unwrap();
|
||||
assert_eq!((v.major, v.minor, v.bugfix, v.build), (1, 0, 1, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Writing an [`AepFile`] back to bytes.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use crate::container::chunk::{AepFile, Body, Chunk};
|
||||
use crate::container::error::AepError;
|
||||
|
||||
/// Deepest tree [`write`] accepts (a hand-built tree may be deeper than any parsed one).
|
||||
const MAX_WRITE_DEPTH: usize = 256;
|
||||
|
||||
/// Serialises `file`: `RIFX`, the recomputed container size, `Egg!`, the chunks and the trailer.
|
||||
/// Sizes of the container and of every chunk are recomputed from the tree; an unmodified parsed
|
||||
/// file comes back byte for byte.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AepError::TooLarge`] when a chunk or the container exceeds the 32-bit size field,
|
||||
/// [`AepError::TooDeep`] for an absurdly deep hand-built tree.
|
||||
pub fn write(file: &AepFile<'_>) -> Result<Vec<u8>, AepError> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"RIFX");
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
out.extend_from_slice(b"Egg!");
|
||||
write_sequence(&mut out, &file.chunks, 1)?;
|
||||
let size = u32::try_from(out.len().saturating_sub(8)).map_err(|_| AepError::TooLarge)?;
|
||||
patch_u32(&mut out, 4, size)?;
|
||||
out.extend_from_slice(&file.trailer);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Like [`write`], into any writer.
|
||||
///
|
||||
/// # Errors
|
||||
/// A size or depth error of [`write`] becomes `ErrorKind::InvalidData`; otherwise I/O errors.
|
||||
pub fn write_to(file: &AepFile<'_>, w: &mut impl Write) -> std::io::Result<()> {
|
||||
let bytes = write(file).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
w.write_all(&bytes)
|
||||
}
|
||||
|
||||
fn patch_u32(out: &mut [u8], at: usize, v: u32) -> Result<(), AepError> {
|
||||
let end = at.checked_add(4).ok_or(AepError::TooLarge)?;
|
||||
let dst = out.get_mut(at..end).ok_or(AepError::TooLarge)?;
|
||||
for (d, s) in dst.iter_mut().zip(v.to_be_bytes()) {
|
||||
*d = s;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_sequence(out: &mut Vec<u8>, chunks: &[Chunk<'_>], depth: usize) -> Result<(), AepError> {
|
||||
if depth > MAX_WRITE_DEPTH {
|
||||
return Err(AepError::TooDeep);
|
||||
}
|
||||
let count = chunks.len();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
write_chunk(out, chunk, i + 1 == count, depth)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_chunk(out: &mut Vec<u8>, chunk: &Chunk<'_>, is_last: bool, depth: usize) -> Result<(), AepError> {
|
||||
out.extend_from_slice(&chunk.id);
|
||||
let size_at = out.len();
|
||||
out.extend_from_slice(&[0; 4]);
|
||||
let body_start = out.len();
|
||||
match &chunk.body {
|
||||
Body::Data(d) => out.extend_from_slice(d),
|
||||
Body::Chunks(c) => write_sequence(out, c, depth + 1)?,
|
||||
Body::List { list_type, children } => {
|
||||
out.extend_from_slice(list_type);
|
||||
write_sequence(out, children, depth + 1)?;
|
||||
}
|
||||
Body::OpaqueList { list_type, data } => {
|
||||
out.extend_from_slice(list_type);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
let len = out.len().saturating_sub(body_start);
|
||||
patch_u32(out, size_at, u32::try_from(len).map_err(|_| AepError::TooLarge)?)?;
|
||||
if len % 2 == 1 {
|
||||
match chunk.pad {
|
||||
Some(b) => out.push(b),
|
||||
None if !is_last => out.push(0),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! After Effects project (`.aep`) export: an EffectCraft [`Project`] written as an `.aep` that
|
||||
//! After Effects 2026 opens.
|
||||
//!
|
||||
//! Every chunk is built in code: the project skeleton, the item, layer and property records are
|
||||
//! prototypes in the layout After Effects writes (`template.rs` gathers them), and the exporter
|
||||
//! clones them and writes only the fields whose layout is verified (`docs/aep-format.md` section 3, `docs/aep-export.md`):
|
||||
//! item and layer ids, project settings, the composition header, the layer header (switches
|
||||
//! including 3D, time stretch, blend modes, track mattes), solid colour and size, the Transform
|
||||
//! properties (static values, keyframes with interpolation and ease, expressions), layer masks,
|
||||
//! markers, time remapping, precomposition, footage, text (with animators), shape, camera and
|
||||
//! light layers, footage items and image sequences, layer styles, and the effects of the table
|
||||
//! shared with the importer (`crate::effect_map`). `docs/aep-export.md` has the full
|
||||
//! list and the rules After Effects needs.
|
||||
//!
|
||||
//! What cannot be written is **reported**, never dropped silently ([`ExportReport`]); by default an
|
||||
//! export that would lose anything is refused ([`ExportOptions::allow_losses`]).
|
||||
//!
|
||||
//! The crate sits at L3 (above `effectcraft-project`, next to the importer) and builds for wasm32:
|
||||
//! it does no file access, the caller gets the bytes.
|
||||
|
||||
mod base;
|
||||
mod camlight;
|
||||
mod effects;
|
||||
mod emit;
|
||||
mod footage;
|
||||
mod fx_defs;
|
||||
mod fxsynth;
|
||||
pub(crate) mod icc;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod plan;
|
||||
mod report;
|
||||
mod settings;
|
||||
mod shapes;
|
||||
mod skeleton;
|
||||
mod style_defs;
|
||||
mod styles;
|
||||
mod synth;
|
||||
mod template;
|
||||
mod text;
|
||||
mod textanim;
|
||||
mod textdoc;
|
||||
mod textproto;
|
||||
mod timebase;
|
||||
mod tree;
|
||||
mod tree_defs;
|
||||
mod tree_spec;
|
||||
|
||||
use effectcraft_project::Project;
|
||||
|
||||
pub use report::{ExportReport, Exported, LIMITATIONS, Loss, LossCategory, MAX_DETAILS};
|
||||
|
||||
/// Why an `.aep` was not written.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum AepExportError {
|
||||
/// Parts of the project cannot be exported and losses were not allowed. Nothing was written;
|
||||
/// the report says what would be lost.
|
||||
#[error("exporting would lose {}; allow losses to export anyway", .0.summary())]
|
||||
Refused(Box<ExportReport>),
|
||||
/// A prototype built in code does not have the shape the exporter expects (a bug in the crate).
|
||||
#[error("internal error: {0}")]
|
||||
Internal(&'static str),
|
||||
/// The container could not be written (a chunk or the file exceeds the format's 4 GB limit).
|
||||
#[error("{0}")]
|
||||
Container(#[from] crate::AepError),
|
||||
}
|
||||
|
||||
/// Options of [`export`].
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ExportOptions {
|
||||
/// Export even when [`ExportReport::losses`] is not empty.
|
||||
pub allow_losses: bool,
|
||||
}
|
||||
|
||||
/// An exported project.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ExportResult {
|
||||
/// The `.aep` file.
|
||||
pub bytes: Vec<u8>,
|
||||
pub report: ExportReport,
|
||||
}
|
||||
|
||||
/// A neutral XMP packet for the end of the file (After Effects writes one; it only holds metadata).
|
||||
const XMP: &[u8] = b"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n <dc:format>application/vnd.adobe.aftereffects.project</dc:format>\n </rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
|
||||
|
||||
/// Exports `project` as an `.aep`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AepExportError::Refused`] when something would be lost and `allow_losses` is not set (nothing
|
||||
/// is written), [`AepExportError::Container`] for a file beyond the container's size limits.
|
||||
pub fn export(project: &Project, options: &ExportOptions) -> Result<ExportResult, AepExportError> {
|
||||
export_with(project, options, &|_| None)
|
||||
}
|
||||
|
||||
/// [`export`] with the modification times of the image-sequence folders: `folder_time` is asked
|
||||
/// with a sequence's first file path as the project stores it and answers with the modification
|
||||
/// time of the folder that holds it, in whole seconds since 1970 (UTC), or `None`. The caller
|
||||
/// decides whether that path may be followed (the engine applies its document-path policy) before
|
||||
/// it looks at the folder.
|
||||
///
|
||||
/// After Effects keeps a sequence's stored numbering only while the stored folder time
|
||||
/// (`sspc@118`) equals the folder's; otherwise it scans the folder again when the project opens
|
||||
/// and warns about every gap in the numbering in a modal dialog (860, `export-tests` x1-x3).
|
||||
/// Without a time it is written as 0, and such a folder is scanned again.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`export`].
|
||||
pub fn export_with(project: &Project, options: &ExportOptions, folder_time: &dyn Fn(&str) -> Option<u64>) -> Result<ExportResult, AepExportError> {
|
||||
let mut col = report::Collector::default();
|
||||
let mut plan = plan::plan(project, &mut col);
|
||||
let report = col.finish();
|
||||
if !options.allow_losses && !report.is_lossless() {
|
||||
return Err(AepExportError::Refused(Box::new(report)));
|
||||
}
|
||||
footage::stamp_sequences(&mut plan.root, folder_time);
|
||||
let bytes = emit::emit(&plan, XMP)?;
|
||||
Ok(ExportResult { bytes, report })
|
||||
}
|
||||
|
||||
/// The report an export would produce, without writing anything (a dry run).
|
||||
pub fn analyze(project: &Project) -> ExportReport {
|
||||
let mut col = report::Collector::default();
|
||||
let _ = plan::plan(project, &mut col);
|
||||
col.finish()
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The project skeleton built in code (aep-format.md 3.1-3.3, 3.12a): the top-level chunks, the
|
||||
//! composition shell with its view layers, the `Solids` folder, a solid item and the records that
|
||||
//! follow a composition. Fields the exporter sets per item are left zero here; the bytes that are
|
||||
//! not decoded are named constants, each with the value it has in every generated test file of
|
||||
//! that kind (cases 000, 022, 211, 259, 620-634) and kept because After Effects refuses the file without them
|
||||
//! (read-backs t-no<id>, t-zcdta, u-zsspc, u-noclrs, u-noftgi, u-nofrec, u-nocdrp).
|
||||
|
||||
use crate::Chunk;
|
||||
use crate::export::synth::{tdmn, tdsn};
|
||||
|
||||
/// A zero buffer of `len` bytes with `fields` (offset, bytes) written into it.
|
||||
pub(crate) fn bytes(len: usize, fields: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; len];
|
||||
for (at, v) in fields {
|
||||
if let Some(dst) = b.get_mut(*at..at.saturating_add(v.len())) {
|
||||
dst.copy_from_slice(v);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn utf8(text: &str) -> Chunk<'static> {
|
||||
Chunk::data(*b"Utf8", text.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// The After Effects version the file claims (`svap`, `head` 4..7): 26.x, the version every
|
||||
/// sample was saved with.
|
||||
const VERSION: [u8; 4] = [0x0f, 0x11, 0x0e, 0x02];
|
||||
/// `head` 0..3 and byte 8: the same in every sample (not decoded; a zero `head` is refused as
|
||||
/// "created with an older version", read-back u-zhead).
|
||||
const HEAD_FORMAT: [u8; 4] = [0x00, 0x61, 0x00, 0x05];
|
||||
/// The code page of the project's strings (`nhed` 18, `nnhd` 26): 65001, UTF-8.
|
||||
const UTF8_CODE_PAGE: [u8; 2] = 65001u16.to_be_bytes();
|
||||
/// The size of the render-queue settings blocks (`AFsi`, `ARsi`); zero bodies of this size are
|
||||
/// read (read-back e1-zafsi), a missing one is refused (e1-noafsi).
|
||||
const RQ_SETTINGS_LEN: usize = 1872;
|
||||
/// The record size of the render-queue item list (`LRdr/list/lhd3` word 4).
|
||||
const RQ_ITEM_LEN: u32 = 2246;
|
||||
/// The id of the `Solids` folder, which `sfnm/sfid` names.
|
||||
pub(crate) const SOLIDS_FOLDER_ID: u32 = 0x19;
|
||||
/// The default end time of a view layer (5 s in 25 600 units): view layers keep it whatever the
|
||||
/// composition's duration (000, 031, 032).
|
||||
const VIEW_OUT: u32 = 128_000;
|
||||
|
||||
/// The top-level chunks of a project: the headers (`settings::emit` writes the settings into
|
||||
/// them, `emit` the next free id), the project lists (`effects::put_project_lists` fills `Pefl`),
|
||||
/// an empty `Fold` with its `fdta`, the workspace name and the render queue.
|
||||
pub(crate) fn project_chunks() -> Vec<Chunk<'static>> {
|
||||
let head = bytes(20, &[(0, &HEAD_FORMAT), (4, &VERSION), (8, &[0x80])]);
|
||||
// nhed / nnhd: bytes 7 and 9 the same in every sample, byte 12 / 15 the timecode base (30),
|
||||
// 13 / 19 the frames per foot (16), 14 / 20 the frame count start (2: from 0), 15 / 24 the
|
||||
// colour depth (2: 32 bits); the save noise after the code page is left zero.
|
||||
let nhed = bytes(32, &[(7, &[5]), (9, &[1]), (12, &[0x1e, 16, 2, 2]), (18, &UTF8_CODE_PAGE)]);
|
||||
let nnhd = bytes(40, &[(7, &[5]), (9, &[1]), (15, &[0x1e]), (19, &[16, 2]), (24, &[2]), (26, &UTF8_CODE_PAGE)]);
|
||||
let mut lhd3 = bytes(52, &[(0, &0x00d0_0bee_u32.to_be_bytes()), (12, &1u32.to_be_bytes()), (16, &RQ_ITEM_LEN.to_be_bytes())]);
|
||||
for at in [20, 24, 28] {
|
||||
if let Some(dst) = lhd3.get_mut(at..at + 4) {
|
||||
dst.copy_from_slice(&1u32.to_be_bytes());
|
||||
}
|
||||
}
|
||||
let wsnm: Vec<u8> = "Default".encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
vec![
|
||||
Chunk::data(*b"svap", VERSION.to_vec()),
|
||||
Chunk::data(*b"head", head),
|
||||
Chunk::data(*b"nhed", nhed),
|
||||
Chunk::data(*b"nnhd", nnhd),
|
||||
// The project's audio sample rate.
|
||||
Chunk::data(*b"adfr", 48_000f64.to_be_bytes().to_vec()),
|
||||
Chunk::list(*b"Pefl", Vec::new()),
|
||||
Chunk::data(*b"qtlg", vec![0]),
|
||||
Chunk::list(*b"sfnm", vec![utf8("Solids"), Chunk::data(*b"sfid", SOLIDS_FOLDER_ID.to_le_bytes().to_vec())]),
|
||||
Chunk::data(*b"mrid", vec![0; 4]),
|
||||
// Compensate for Scene-referred Profiles: on.
|
||||
Chunk::data(*b"acer", vec![1]),
|
||||
Chunk::list(*b"CPPl", Vec::new()),
|
||||
Chunk::data(*b"cpid", vec![0xff; 16]),
|
||||
Chunk::data(*b"dwga", vec![0; 4]),
|
||||
Chunk::data(*b"pcms", vec![1]),
|
||||
utf8("{\"ocioConfigurationFile\":\"ACES 1.2\"}"),
|
||||
Chunk::data(*b"PwCs", vec![1]),
|
||||
utf8("{}"),
|
||||
Chunk::data(*b"pdvc", vec![1]),
|
||||
utf8("{}"),
|
||||
Chunk::list(*b"ExEn", vec![utf8("javascript-1.0")]),
|
||||
Chunk::list(*b"Fold", vec![Chunk::data(*b"fdta", vec![0; 14])]),
|
||||
Chunk::data(*b"wsns", vec![0, 0x0e]),
|
||||
Chunk::data(*b"wsnm", wsnm),
|
||||
utf8("Default"),
|
||||
Chunk::data(*b"fcid", vec![0; 4]),
|
||||
Chunk::data(*b"oacc", vec![0; 2]),
|
||||
Chunk::list(*b"LSIf", vec![Chunk::data(*b"AFsi", vec![0; RQ_SETTINGS_LEN])]),
|
||||
Chunk::list(
|
||||
*b"LRdr",
|
||||
vec![
|
||||
Chunk::data(*b"Rhed", vec![0; 20]),
|
||||
Chunk::data(*b"Rout", vec![0; 4]),
|
||||
Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3)]),
|
||||
Chunk::list(*b"LItm", Vec::new()),
|
||||
Chunk::list(*b"LSIf", vec![Chunk::data(*b"ARsi", vec![0; RQ_SETTINGS_LEN])]),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `idta` of an item: the kind at 0, byte 23 per kind (0x20 composition, 0x30 footage, 0 folder;
|
||||
/// After Effects renders differently without it, read-backs t-zidta and u-zidta2); `emit` writes
|
||||
/// the id and the label.
|
||||
pub(crate) fn idta(kind: u16, byte23: u8) -> Chunk<'static> {
|
||||
Chunk::data(*b"idta", bytes(84, &[(0, &kind.to_be_bytes()), (23, &[byte23])]))
|
||||
}
|
||||
|
||||
/// The guides of a layer or item (`Gide`: `gdta` zero and an empty list header).
|
||||
pub(crate) fn guides() -> Chunk<'static> {
|
||||
let header = crate::export::synth::key_list(16, 0, Vec::new()).children().iter().filter(|c| c.is(b"lhd3")).cloned().collect();
|
||||
Chunk::list(*b"Gide", vec![Chunk::data(*b"gdta", vec![0; 8]), Chunk::list(*b"list", header)])
|
||||
}
|
||||
|
||||
/// `cdta` with the bytes `emit::build_comp` does not write: 0..3, the shutter angle and phase
|
||||
/// limits (360 at 176 and 184), the motion blur samples per frame (16 at 200) and the adaptive
|
||||
/// sample limit (128 at 196); the same in every sample, and a `cdta` without them crashes After
|
||||
/// Effects (read-back t-zcdta).
|
||||
fn cdta() -> Chunk<'static> {
|
||||
Chunk::data(
|
||||
*b"cdta",
|
||||
bytes(
|
||||
204,
|
||||
&[(0, &[0, 1, 0, 1]), (176, &360u32.to_be_bytes()), (184, &360u32.to_be_bytes()), (196, &128u32.to_be_bytes()), (200, &16u32.to_be_bytes())],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// The renderer (`PRin`): the Classic 3D match name and display name (aep-format.md 3.2).
|
||||
fn renderer() -> Chunk<'static> {
|
||||
let prin = bytes(104, &[(4, b"ADBE Escher"), (52, b"Classic 3D"), (103, &[1])]);
|
||||
Chunk::list(*b"PRin", vec![Chunk::data(*b"prin", prin), Chunk::data(*b"prda", vec![0; 12])])
|
||||
}
|
||||
|
||||
/// The built-in view layers of a composition: list type, name, `ldta` byte 61 (label) and byte
|
||||
/// 128 (layer type: 2 camera, 4 for the markers layer).
|
||||
const VIEWS: [(&[u8; 4], &str, u8, u8); 11] = [
|
||||
(b"DLay", "Default", 4, 2),
|
||||
(b"SLay", "Front", 4, 2),
|
||||
(b"SLay", "Left", 4, 2),
|
||||
(b"SLay", "Top", 4, 2),
|
||||
(b"SLay", "Back", 4, 2),
|
||||
(b"SLay", "Right", 4, 2),
|
||||
(b"SLay", "Bottom", 4, 2),
|
||||
(b"CLay", "Custom View 1", 4, 2),
|
||||
(b"CLay", "Custom View 2", 4, 2),
|
||||
(b"CLay", "Custom View 3", 4, 2),
|
||||
(b"SecL", "Markers", 8, 4),
|
||||
];
|
||||
|
||||
/// One view layer: `ldta` with the decoded fields only (read-back t-zldta), an empty property
|
||||
/// tree (t-vempty), the guides, `Ewst` and the two f-record sets. `emit` writes the id.
|
||||
fn view_layer(ty: &[u8; 4], name: &str, label: u8, kind: u8) -> Vec<Chunk<'static>> {
|
||||
let markers = kind == 4;
|
||||
let mut name_copy = name.as_bytes().to_vec();
|
||||
name_copy.truncate(31);
|
||||
let mut ldta = bytes(
|
||||
164,
|
||||
&[
|
||||
(8, &1u32.to_be_bytes()),
|
||||
(16, &600u32.to_be_bytes()),
|
||||
(24, &25_600u32.to_be_bytes()),
|
||||
(28, &VIEW_OUT.to_be_bytes()),
|
||||
(32, &25_600u32.to_be_bytes()),
|
||||
(61, &[label]),
|
||||
(64, &name_copy),
|
||||
(108, &1u32.to_be_bytes()),
|
||||
(128, &u32::from(kind).to_be_bytes()),
|
||||
],
|
||||
);
|
||||
// Byte 38: 3D and auto-orient on the cameras; byte 39: the video switch (and on the markers
|
||||
// layer also effects, locked and collapse); quality Draft and blend mode Normal on the
|
||||
// markers layer.
|
||||
let flags: &[(usize, u8)] = if markers { &[(5, 2), (39, 0x87), (99, 2)] } else { &[(38, 0x44), (39, 1)] };
|
||||
for (at, v) in flags {
|
||||
if let Some(b) = ldta.get_mut(*at) {
|
||||
*b = *v;
|
||||
}
|
||||
}
|
||||
let root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Group End")];
|
||||
let layer = Chunk::list(*ty, vec![Chunk::data(*b"ldta", ldta), utf8(name), Chunk::list(*b"tdgp", root), guides()]);
|
||||
let mut out = vec![layer, Chunk::list(*b"Ewst", Vec::new())];
|
||||
out.extend(crate::export::skeleton::f_records());
|
||||
out.extend(crate::export::skeleton::f_records());
|
||||
out
|
||||
}
|
||||
|
||||
/// The composition item without layers, and the index of its children where the layer blocks go.
|
||||
pub(crate) fn comp_shell() -> (Chunk<'static>, usize) {
|
||||
let mut kids = vec![
|
||||
Chunk::data(*b"iide", vec![0; 4]),
|
||||
Chunk::data(*b"idpc", vec![0; 8]),
|
||||
idta(4, 0x20),
|
||||
utf8(""),
|
||||
Chunk::list(*b"dats", vec![Chunk::data(*b"numS", vec![0; 4])]),
|
||||
cdta(),
|
||||
Chunk::data(*b"cdrp", vec![0]),
|
||||
Chunk::data(*b"comr", vec![0]),
|
||||
renderer(),
|
||||
];
|
||||
let layer_at = kids.len();
|
||||
for (ty, name, label, kind) in VIEWS {
|
||||
kids.extend(view_layer(ty, name, label, kind));
|
||||
}
|
||||
kids.push(guides());
|
||||
(Chunk::list(*b"Item", kids), layer_at)
|
||||
}
|
||||
|
||||
/// The list that follows a composition item (`FEE `, `ppSn` zero: read-back u-zppsn).
|
||||
pub(crate) fn fee() -> Chunk<'static> {
|
||||
Chunk::list(*b"FEE ", vec![Chunk::data(*b"ppSn", vec![0; 8])])
|
||||
}
|
||||
|
||||
/// An empty folder item (`sfdt` 1 in every sample).
|
||||
pub(crate) fn folder_item() -> Chunk<'static> {
|
||||
Chunk::list(
|
||||
*b"Item",
|
||||
vec![
|
||||
Chunk::data(*b"iide", vec![0; 4]),
|
||||
Chunk::data(*b"idpc", vec![0; 8]),
|
||||
idta(1, 0),
|
||||
utf8(""),
|
||||
Chunk::data(*b"sfdt", 1u32.to_be_bytes().to_vec()),
|
||||
Chunk::list(*b"Sfdr", Vec::new()),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// `ftgi` of a footage item, the same in every sample (required, read-back u-noftgi).
|
||||
pub(crate) fn ftgi() -> Chunk<'static> {
|
||||
Chunk::data(*b"ftgi", bytes(16, &[(7, &[1]), (8, &[0xff; 4]), (12, &600u32.to_be_bytes())]))
|
||||
}
|
||||
|
||||
/// The `sspc` bytes of a solid that are not decoded, the same in every solid (000, 022, 211, 259);
|
||||
/// After Effects refuses a solid with only the decoded fields (read-back u-zsspc).
|
||||
const SOLID_SSPC: [(usize, &[u8]); 11] = [
|
||||
(50, &[0, 0, 0x02, 0x58]),
|
||||
(73, &[3]),
|
||||
(105, &[1]),
|
||||
(109, &[1]),
|
||||
(125, &[0x0c]),
|
||||
(129, &[1]),
|
||||
(134, &[1]),
|
||||
(139, &[1]),
|
||||
(143, &[1]),
|
||||
(188, &[0xff; 8]),
|
||||
(212, &[1]),
|
||||
];
|
||||
|
||||
/// A solid's footage item: `sspc` (type `Soli`, duration 0 / 1), `opti` (`Soli`; `emit` writes
|
||||
/// the colour and the name), `pgui`, the colour-management list (no profile, the defaults of
|
||||
/// every sample; required, read-back u-noclrs), `ftgi` (required, u-noftgi) and the guides. The
|
||||
/// solid of a null layer is the same item.
|
||||
pub(crate) fn solid_item() -> Chunk<'static> {
|
||||
let mut sspc_fields: Vec<(usize, &[u8])> = vec![(22, b"Soli"), (45, &[1])];
|
||||
sspc_fields.extend(SOLID_SSPC);
|
||||
let clrs = Chunk::list(
|
||||
*b"CLRS",
|
||||
vec![
|
||||
Chunk::data(*b"epid", vec![0xff; 16]),
|
||||
Chunk::data(*b"apid", vec![0xff; 16]),
|
||||
Chunk::data(*b"linl", vec![2, 0, 0, 0]),
|
||||
Chunk::data(*b"embp", vec![1]),
|
||||
Chunk::data(*b"ipws", vec![1]),
|
||||
Chunk::data(*b"dcui", vec![1]),
|
||||
Chunk::data(*b"prgb", vec![1]),
|
||||
Chunk::data(*b"Mcsp", vec![1]),
|
||||
utf8(""),
|
||||
Chunk::data(*b"ocsp", vec![1]),
|
||||
utf8(""),
|
||||
Chunk::data(*b"hdrm", vec![1]),
|
||||
utf8("{}"),
|
||||
],
|
||||
);
|
||||
let pin = Chunk::list(
|
||||
*b"Pin ",
|
||||
vec![
|
||||
Chunk::data(*b"sspc", bytes(222, &sspc_fields)),
|
||||
utf8(""),
|
||||
Chunk::data(*b"opti", bytes(282, &[(0, b"Soli")])),
|
||||
Chunk::data(*b"pgui", vec![0; 16]),
|
||||
clrs,
|
||||
utf8(""),
|
||||
],
|
||||
);
|
||||
Chunk::list(*b"Item", vec![Chunk::data(*b"iide", vec![0; 4]), Chunk::data(*b"idpc", vec![0; 8]), idta(7, 0x30), utf8(""), pin, ftgi(), utf8(""), guides()])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The chunks After Effects refuses a file without, or crashes on (aep-export.md, "What After
|
||||
/// Effects needs").
|
||||
#[test]
|
||||
fn the_skeleton_has_what_after_effects_needs() {
|
||||
let top = project_chunks();
|
||||
for id in [b"adfr", b"qtlg", b"mrid", b"acer", b"fcid", b"oacc", b"head", b"nhed"] {
|
||||
assert!(top.iter().any(|c| c.is(id)), "{}", String::from_utf8_lossy(id));
|
||||
}
|
||||
assert!(top.iter().any(|c| c.is_list(b"CPPl")));
|
||||
let head = top.iter().find(|c| c.is(b"head")).and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!(&head[..8], &[0x00, 0x61, 0x00, 0x05, 0x0f, 0x11, 0x0e, 0x02]);
|
||||
let rq = top.iter().find(|c| c.is_list(b"LRdr")).unwrap();
|
||||
let arsi = rq.child_list(b"LSIf").and_then(|l| l.child(b"ARsi")).and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!(arsi.len(), RQ_SETTINGS_LEN);
|
||||
let afsi = top.iter().find(|c| c.is_list(b"LSIf")).and_then(|l| l.child(b"AFsi")).and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!(afsi.len(), RQ_SETTINGS_LEN);
|
||||
|
||||
let (comp, at) = comp_shell();
|
||||
let kids = comp.children();
|
||||
assert!(comp.child_list(b"dats").is_some(), "no dats: After Effects crashes");
|
||||
assert!(kids.iter().any(|c| c.is(b"cdrp")) && kids.iter().any(|c| c.is(b"comr")));
|
||||
let cdta = comp.child(b"cdta").and_then(|c| c.bytes()).unwrap();
|
||||
for at in [1usize, 3, 179, 187, 199, 203] {
|
||||
assert_ne!(cdta[at], 0, "cdta byte {at}: a zero cdta crashes After Effects");
|
||||
}
|
||||
let views = kids.iter().filter(|c| [*b"DLay", *b"SLay", *b"CLay", *b"SecL"].iter().any(|t| c.is_list(t))).count();
|
||||
assert_eq!(views, 11);
|
||||
assert!(kids.get(at).is_some_and(|c| c.is_list(b"DLay")), "the layers go before the view layers");
|
||||
|
||||
let solid = solid_item();
|
||||
let pin = solid.child_list(b"Pin ").unwrap();
|
||||
assert!(pin.child_list(b"CLRS").is_some() && solid.child(b"ftgi").is_some());
|
||||
let sspc = pin.child(b"sspc").and_then(|c| c.bytes()).unwrap();
|
||||
assert!(SOLID_SSPC.iter().all(|(at, v)| &sspc[*at..*at + v.len()] == *v));
|
||||
assert_eq!(solid.child(b"idta").and_then(|c| c.bytes()).unwrap()[23], 0x30);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Camera and light layers (`docs/aep-format.md` 3.12; cases 251-256, 342-346).
|
||||
//!
|
||||
//! Both are written from a layer block of our own cases: the default two-node camera 256 and the
|
||||
//! spot (254, its Intensity record removed) and point (252) lights. The layer header keeps the
|
||||
//! case's type (`ldta@128` 2 camera, 1 light), source id (0 / `0xffffffff`), 3D bit and light type
|
||||
//! (`ldta@136` 1 spot, 2 point); the exporter sets the auto-orient bit (`ldta@38` 0x40: a two-node
|
||||
//! camera and a spot light orient towards their point of interest, 251 vs 256). The point of
|
||||
//! interest is the Anchor Point in pixels and the Position an explicit value (253: 76 / 77 = 0),
|
||||
//! both through the Transform writer. Options records are cloned from the one-change cases.
|
||||
|
||||
use crate::Chunk;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Layer, LightKind};
|
||||
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::tree::{R, child_data, kids_mut, list_mut, put_f64, tpl};
|
||||
|
||||
/// A camera's options in the file's units (pixels, percent, a switch).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CameraPlan {
|
||||
pub two_node: bool,
|
||||
pub zoom: f64,
|
||||
pub dof: bool,
|
||||
pub focus: f64,
|
||||
pub aperture: f64,
|
||||
pub blur: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct LightPlan {
|
||||
/// `ldta@136`: 1 spot, 2 point (252, 254).
|
||||
pub kind: u32,
|
||||
pub intensity: f64,
|
||||
/// `[A, R, G, B]` 0..255 (343).
|
||||
pub color: [f64; 4],
|
||||
pub cone_angle: f64,
|
||||
pub cone_feather: f64,
|
||||
}
|
||||
|
||||
/// The finite scalar at `path`; a property there that is not one is reported (its default is
|
||||
/// written), an absent one is not.
|
||||
fn scalar(layer: &Layer, path: &str, what: &str, col: &mut Collector) -> Option<f64> {
|
||||
match layer.props.prop(path).map(|p| &p.value) {
|
||||
Some(Value::Scalar(v)) if v.is_finite() => Some(*v),
|
||||
None => None,
|
||||
Some(_) => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: {path} is not a number, the default is written"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_keys_or_expr(layer: &Layer, path: &str) -> bool {
|
||||
layer.props.prop(path).is_some_and(|p| !p.keys.is_empty() || p.expr.as_ref().is_some_and(|e| !e.text.is_empty()))
|
||||
}
|
||||
|
||||
/// The camera's options; keys and expressions on them are reported (the static value is written).
|
||||
pub(crate) fn plan_camera(layer: &Layer, comp_width: u32, what: &str, col: &mut Collector) -> CameraPlan {
|
||||
let default_zoom = f64::from(comp_width) * 50.0 / 36.0;
|
||||
if matches!(layer.auto_orient, effectcraft_project::AutoOrient::AlongPath | effectcraft_project::AutoOrient::TowardsCamera) {
|
||||
col.lose(LossCategory::Cameras, format!("{what}: the camera's Auto-Orient {:?} is not written (written as a one-node camera)", layer.auto_orient));
|
||||
}
|
||||
for id in ["zoom", "dof", "focusDistance", "aperture", "blurLevel"] {
|
||||
if has_keys_or_expr(layer, &format!("cameraOptions/{id}")) {
|
||||
col.lose(LossCategory::Cameras, format!("{what}: keys or an expression on the camera's {id} (the static value is written)"));
|
||||
}
|
||||
}
|
||||
let iris_default = [
|
||||
("irisShape", 0.0),
|
||||
("irisRotation", 0.0),
|
||||
("irisRoundness", 0.0),
|
||||
("irisAspectRatio", 1.0),
|
||||
("irisDiffractionFringe", 0.0),
|
||||
("highlightGain", 0.0),
|
||||
("highlightThreshold", 255.0),
|
||||
("highlightSaturation", 0.0),
|
||||
];
|
||||
for (id, d) in iris_default {
|
||||
let v = match layer.props.prop(&format!("cameraOptions/{id}")).map(|p| &p.value) {
|
||||
Some(Value::Scalar(v)) => Some(*v),
|
||||
Some(Value::Enum(e)) => Some(f64::from(*e)),
|
||||
_ => None,
|
||||
};
|
||||
if v.is_some_and(|v| (v - d).abs() > 1e-9) || has_keys_or_expr(layer, &format!("cameraOptions/{id}")) {
|
||||
col.lose(LossCategory::Cameras, format!("{what}: the camera's {id} is not written (not sampled)"));
|
||||
}
|
||||
}
|
||||
let dof = matches!(layer.props.prop("cameraOptions/dof").map(|p| &p.value), Some(Value::Bool(true)));
|
||||
CameraPlan {
|
||||
two_node: layer.auto_orient == effectcraft_project::AutoOrient::TowardsPointOfInterest,
|
||||
zoom: scalar(layer, "cameraOptions/zoom", what, col).unwrap_or(default_zoom),
|
||||
dof,
|
||||
focus: scalar(layer, "cameraOptions/focusDistance", what, col).unwrap_or(default_zoom),
|
||||
aperture: scalar(layer, "cameraOptions/aperture", what, col).unwrap_or(25.3 * f64::from(comp_width) / 1920.0),
|
||||
blur: scalar(layer, "cameraOptions/blurLevel", what, col).unwrap_or(100.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The light's options, or `None` (reported) for a type without a sampled case.
|
||||
pub(crate) fn plan_light(layer: &Layer, kind: LightKind, what: &str, col: &mut Collector) -> Option<LightPlan> {
|
||||
let code = match kind {
|
||||
LightKind::Spot => 1,
|
||||
LightKind::Point => 2,
|
||||
other => {
|
||||
col.lose(LossCategory::Lights, format!("{what}: a {} light is not written (not sampled)", other.label()));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// A spot light is written oriented towards its point of interest (a point light has no
|
||||
// orientation that shows).
|
||||
if code == 1 && layer.auto_orient != effectcraft_project::AutoOrient::TowardsPointOfInterest {
|
||||
col.lose(LossCategory::Lights, format!("{what}: the light's Auto-Orient {:?} is written as towards the point of interest", layer.auto_orient));
|
||||
}
|
||||
for id in ["intensity", "color", "coneAngle", "coneFeather"] {
|
||||
if has_keys_or_expr(layer, &format!("lightOptions/{id}")) {
|
||||
col.lose(LossCategory::Lights, format!("{what}: keys or an expression on the light's {id} (the static value is written)"));
|
||||
}
|
||||
}
|
||||
let defaults = [("radius", 500.0), ("falloffDistance", 500.0), ("shadowDarkness", 100.0), ("shadowDiffusion", 0.0)];
|
||||
for (id, d) in defaults {
|
||||
if scalar(layer, &format!("lightOptions/{id}"), what, col).is_some_and(|v| (v - d).abs() > 1e-9)
|
||||
|| has_keys_or_expr(layer, &format!("lightOptions/{id}"))
|
||||
{
|
||||
col.lose(LossCategory::Lights, format!("{what}: the light's {id} is not written (not sampled)"));
|
||||
}
|
||||
}
|
||||
// Options that are not written at all: a value other than the default, or keys or an expression
|
||||
// (which change the render even when the static value is the default).
|
||||
if matches!(layer.props.prop("lightOptions/falloff").map(|p| &p.value), Some(Value::Enum(e)) if *e != 0) || has_keys_or_expr(layer, "lightOptions/falloff")
|
||||
{
|
||||
col.lose(LossCategory::Lights, format!("{what}: the light's falloff type is not written (not sampled)"));
|
||||
}
|
||||
if matches!(layer.props.prop("lightOptions/castsShadows").map(|p| &p.value), Some(Value::Bool(true)))
|
||||
|| has_keys_or_expr(layer, "lightOptions/castsShadows")
|
||||
{
|
||||
col.lose(LossCategory::Lights, format!("{what}: Casts Shadows is not written (not sampled)"));
|
||||
}
|
||||
let color = match layer.props.prop("lightOptions/color").map(|p| &p.value) {
|
||||
Some(Value::Color(c)) => {
|
||||
let out = [255.0, c[0] * 255.0, c[1] * 255.0, c[2] * 255.0].map(|v| if v.is_finite() { v.clamp(0.0, 255.0) } else { 255.0 });
|
||||
if c.iter().take(3).zip(&out[1..]).any(|(v, o)| (v * 255.0).to_bits() != o.to_bits()) {
|
||||
col.lose(LossCategory::Lights, format!("{what}: a light colour channel outside 0 to 1 is written as the nearest limit"));
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => [255.0; 4],
|
||||
};
|
||||
Some(LightPlan {
|
||||
kind: code,
|
||||
intensity: scalar(layer, "lightOptions/intensity", what, col).unwrap_or(100.0),
|
||||
color,
|
||||
cone_angle: scalar(layer, "lightOptions/coneAngle", what, col).unwrap_or(90.0),
|
||||
cone_feather: scalar(layer, "lightOptions/coneFeather", what, col).unwrap_or(50.0),
|
||||
})
|
||||
}
|
||||
|
||||
/// The option records: per match name the `tdbs` After Effects wrote when it was changed alone.
|
||||
pub(crate) struct OptionProtos {
|
||||
pub records: Vec<(String, Chunk<'static>)>,
|
||||
}
|
||||
|
||||
impl OptionProtos {
|
||||
fn get(&self, name: &str) -> R<Chunk<'static>> {
|
||||
self.records.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).ok_or_else(|| tpl("camera / light option prototype missing"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The order of the properties inside the Camera and Light Options groups (After Effects' own,
|
||||
/// as in 342 and 254 / 343: a record only where the value is not the default).
|
||||
const CAMERA_ORDER: &[&str] =
|
||||
&["ADBE Camera Zoom", "ADBE Camera Depth of Field", "ADBE Camera Focus Distance", "ADBE Camera Aperture", "ADBE Camera Blur Level"];
|
||||
const LIGHT_ORDER: &[&str] = &[
|
||||
"ADBE Light Env Atom",
|
||||
"ADBE Light Backgd Visible",
|
||||
"ADBE Light Backgd Opacity",
|
||||
"ADBE Light Backgd Blur",
|
||||
"ADBE Light Intensity",
|
||||
"ADBE Light Color",
|
||||
"ADBE Light Cone Angle",
|
||||
"ADBE Light Cone Feather 2",
|
||||
"ADBE Light Falloff Start",
|
||||
"ADBE Light Falloff Distance",
|
||||
"ADBE Casts Shadows",
|
||||
];
|
||||
|
||||
pub(crate) fn tdmn_chunk(name: &str) -> Chunk<'static> {
|
||||
let mut b = vec![0u8; 40];
|
||||
for (i, c) in name.bytes().take(39).enumerate() {
|
||||
b[i] = c;
|
||||
}
|
||||
Chunk::data(*b"tdmn", b)
|
||||
}
|
||||
|
||||
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let b = c.bytes()?;
|
||||
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
|
||||
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
|
||||
}
|
||||
|
||||
/// Sets (or inserts) the records of an options group (`tdmn` named `group`) of a layer.
|
||||
fn put_group(layr: &mut Chunk<'static>, group: &str, order: &[&str], set: Vec<(&str, Chunk<'static>)>, remove: &[&str]) -> R<()> {
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(group)).ok_or_else(|| tpl("options group missing"))?;
|
||||
let g = kids.get_mut(at + 1).ok_or_else(|| tpl("options group without a list"))?;
|
||||
let gk = kids_mut(g)?;
|
||||
let first = gk.iter().position(|c| c.is(b"tdmn")).unwrap_or(gk.len());
|
||||
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
|
||||
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
|
||||
let mut it = std::mem::take(gk).into_iter();
|
||||
while let Some(m) = it.next() {
|
||||
let name = tdmn_name(&m).unwrap_or_default();
|
||||
if name == "ADBE Group End" {
|
||||
pairs.push((name, vec![m]));
|
||||
continue;
|
||||
}
|
||||
let Some(node) = it.next() else { break };
|
||||
// An atom group (Light Env Atom) carries its own Group End inside the list.
|
||||
pairs.push((name, vec![m, node]));
|
||||
}
|
||||
pairs.retain(|(n, _)| !remove.contains(&n.as_str()));
|
||||
for (name, rec) in set {
|
||||
let entry = vec![tdmn_chunk(name), rec];
|
||||
match pairs.iter_mut().find(|(n, _)| n == name) {
|
||||
Some(slot) => slot.1 = entry,
|
||||
None => pairs.push((name.to_string(), entry)),
|
||||
}
|
||||
}
|
||||
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { order.iter().position(|g| *g == n).unwrap_or(order.len()) };
|
||||
pairs.sort_by_key(|(n, _)| rank(n));
|
||||
gk.extend(header);
|
||||
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A copy of `proto` with the plain value `v` (`cdat` @0).
|
||||
fn with_value(mut proto: Chunk<'static>, v: f64) -> R<Chunk<'static>> {
|
||||
put_f64(child_data(&mut proto, b"cdat")?, 0, v)?;
|
||||
Ok(proto)
|
||||
}
|
||||
|
||||
pub(crate) fn put_camera(layr: &mut Chunk<'static>, protos: &OptionProtos, p: &CameraPlan) -> R<()> {
|
||||
// Every option is written explicitly (After Effects' defaults depend on the composition
|
||||
// width); Zoom and Blur Level take the plain-double layout of Focus Distance (342).
|
||||
let plain = protos.get("ADBE Camera Focus Distance")?;
|
||||
let set = vec![
|
||||
("ADBE Camera Zoom", with_value(plain.clone(), p.zoom)?),
|
||||
("ADBE Camera Depth of Field", with_value(protos.get("ADBE Camera Depth of Field")?, if p.dof { 1.0 } else { 0.0 })?),
|
||||
("ADBE Camera Focus Distance", with_value(plain.clone(), p.focus)?),
|
||||
("ADBE Camera Aperture", with_value(protos.get("ADBE Camera Aperture")?, p.aperture)?),
|
||||
("ADBE Camera Blur Level", with_value(plain, p.blur)?),
|
||||
];
|
||||
put_group(layr, "ADBE Camera Options Group", CAMERA_ORDER, set, &[])
|
||||
}
|
||||
|
||||
pub(crate) fn put_light(layr: &mut Chunk<'static>, protos: &OptionProtos, p: &LightPlan) -> R<()> {
|
||||
let mut set = Vec::new();
|
||||
let mut remove = Vec::new();
|
||||
if (p.intensity - 100.0).abs() > 1e-9 {
|
||||
set.push(("ADBE Light Intensity", with_value(protos.get("ADBE Light Intensity")?, p.intensity)?));
|
||||
} else {
|
||||
remove.push("ADBE Light Intensity");
|
||||
}
|
||||
if p.color != [255.0; 4] {
|
||||
let mut rec = protos.get("ADBE Light Color")?;
|
||||
let d = child_data(&mut rec, b"cdat")?;
|
||||
for (i, v) in p.color.iter().enumerate() {
|
||||
put_f64(d, i * 8, *v)?;
|
||||
}
|
||||
set.push(("ADBE Light Color", rec));
|
||||
}
|
||||
// A spot light stores its cone only when it differs (343-345); a point light keeps the inert
|
||||
// records of its block (252).
|
||||
if p.kind == 1 {
|
||||
if (p.cone_angle - 90.0).abs() > 1e-9 {
|
||||
set.push(("ADBE Light Cone Angle", with_value(protos.get("ADBE Light Cone Angle")?, p.cone_angle)?));
|
||||
}
|
||||
if (p.cone_feather - 50.0).abs() > 1e-9 {
|
||||
set.push(("ADBE Light Cone Feather 2", with_value(protos.get("ADBE Light Cone Feather 2")?, p.cone_feather)?));
|
||||
}
|
||||
}
|
||||
put_group(layr, "ADBE Light Options Group", LIGHT_ORDER, set, &remove)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,820 @@
|
||||
//! Builds the chunk tree from the plan by cloning prototypes of the template and editing only the
|
||||
//! fields whose layout is verified (`docs/aep-format.md`, section 3, and `docs/aep-export.md`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{AepFile, Chunk};
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
|
||||
use crate::export::plan::{CompPlan, Entry, ItemPlan, KeyPlan, LayerPlan, LayerSrc, ProjectPlan, PropKind, PropPlan, SolidPlan, natural_key};
|
||||
use crate::export::template::Template;
|
||||
use crate::export::tree::{
|
||||
R, child_data, data_mut, kids_mut, list_mut, put, put_bits, put_cstr, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, set_utf8, tpl,
|
||||
};
|
||||
|
||||
/// The first id given to anything the export creates. The template's `Solids` folder keeps the id
|
||||
/// 0x19 that the project header (`sfnm`/`sfid`) names; everything else is allocated from here.
|
||||
const FIRST_ID: u32 = 0x1c;
|
||||
const SOLIDS_FOLDER_ID: u32 = 0x19;
|
||||
/// A composition item uses its own id and one id per built-in view layer (3D views, custom views,
|
||||
/// the markers layer).
|
||||
const VIEW_LAYERS: u32 = 11;
|
||||
/// The name field of a solid (`opti` 26 to 281) holds 255 bytes and a NUL (a 140 character name
|
||||
/// saved by After Effects fills it as far as it goes).
|
||||
pub(crate) const MAX_SOLID_NAME: usize = 255;
|
||||
|
||||
struct Ids {
|
||||
next: u32,
|
||||
}
|
||||
|
||||
impl Ids {
|
||||
fn alloc(&mut self) -> R<u32> {
|
||||
let id = self.next;
|
||||
self.next = self.next.checked_add(1).ok_or_else(|| tpl("more than 2^32 ids"))?;
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
struct Cx<'t> {
|
||||
t: &'t Template,
|
||||
ids: Ids,
|
||||
/// Item ids of the project's solids (for the layer source ids).
|
||||
solid_ids: HashMap<ItemId, u32>,
|
||||
/// Item id and duration in seconds of every exported composition (precomposition layers).
|
||||
comp_ids: HashMap<ItemId, (u32, f64)>,
|
||||
/// Item id and duration in seconds of every exported footage item (footage layers).
|
||||
footage_ids: HashMap<ItemId, (u32, f64)>,
|
||||
/// Hidden solid items of null layers, written into the `Solids` folder.
|
||||
extra_solids: Vec<(String, Chunk<'static>)>,
|
||||
guid: u64,
|
||||
/// A root folder named `Solids` in the project is After Effects' own `Solids` folder (id 0x19).
|
||||
special: Option<ItemId>,
|
||||
special_slot: Option<(Chunk<'static>, Vec<Sorted>)>,
|
||||
/// Effect types written so far (the first instance of a type holds its definitions).
|
||||
fx_use: crate::export::effects::FxUse,
|
||||
}
|
||||
|
||||
/// A sortable group of chunks: name, tie-break key, chunks.
|
||||
type Sorted = (String, u64, Vec<Chunk<'static>>);
|
||||
|
||||
pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R<Vec<u8>> {
|
||||
let t = Template::load()?;
|
||||
let special = plan.root.iter().find(|e| e.name == "Solids" && matches!(e.plan, ItemPlan::Folder(_))).map(|e| e.ec);
|
||||
let mut cx = Cx {
|
||||
t: &t,
|
||||
ids: Ids { next: FIRST_ID },
|
||||
solid_ids: HashMap::new(),
|
||||
comp_ids: HashMap::new(),
|
||||
footage_ids: HashMap::new(),
|
||||
extra_solids: Vec::new(),
|
||||
guid: 0x9e37_79b9_7f4a_7c15,
|
||||
special,
|
||||
special_slot: None,
|
||||
fx_use: crate::export::effects::FxUse::default(),
|
||||
};
|
||||
let mut ids_by_entry: HashMap<ItemId, u32> = HashMap::new();
|
||||
assign_ids(&plan.root, &mut cx, &mut ids_by_entry)?;
|
||||
|
||||
let mut root: Vec<Sorted> = Vec::new();
|
||||
build_level(&plan.root, &mut cx, &ids_by_entry, &mut |name, key, chunks| root.push((name, key, chunks)))?;
|
||||
// The Solids folder: root-level solids and the hidden solids of null layers.
|
||||
let mut solids_children: Vec<Sorted> = Vec::new();
|
||||
for (name, key, chunks) in root.iter().filter(|(_, k, _)| *k & ROOT_SOLID != 0) {
|
||||
solids_children.push((name.clone(), *key, chunks.clone()));
|
||||
}
|
||||
root.retain(|(_, k, c)| *k & ROOT_SOLID == 0 && !c.is_empty());
|
||||
for (i, (name, chunk)) in cx.extra_solids.iter().enumerate() {
|
||||
let mut v = vec![chunk.clone()];
|
||||
v.extend(cx.t.fset.iter().cloned());
|
||||
solids_children.push((name.clone(), i as u64, v));
|
||||
}
|
||||
let mut folder = match cx.special_slot.take() {
|
||||
Some((item, inner)) => {
|
||||
solids_children.extend(inner);
|
||||
item
|
||||
}
|
||||
None => {
|
||||
let mut f = cx.t.folder_item.clone();
|
||||
set_item_header(&mut f, SOLIDS_FOLDER_ID, 2, "Solids")?;
|
||||
f
|
||||
}
|
||||
};
|
||||
sort_named(&mut solids_children);
|
||||
kids_mut(list_mut(&mut folder, b"Sfdr")?)?.extend(solids_children.into_iter().flat_map(|(_, _, c)| c));
|
||||
root.push(("Solids".to_string(), 0, vec![folder]));
|
||||
sort_named(&mut root);
|
||||
|
||||
let mut file: AepFile<'static> = AepFile { chunks: t.file.chunks.clone(), trailer: std::borrow::Cow::Owned(extra.to_vec()) };
|
||||
{
|
||||
let fold = file.chunks.iter_mut().find(|c| c.is_list(b"Fold")).ok_or_else(|| tpl("template without Fold"))?;
|
||||
let kids = kids_mut(fold)?;
|
||||
let fdta = kids.iter().find(|c| c.is(b"fdta")).cloned().ok_or_else(|| tpl("template without fdta"))?;
|
||||
kids.clear();
|
||||
kids.push(fdta);
|
||||
kids.extend(root.into_iter().flat_map(|(_, _, c)| c));
|
||||
}
|
||||
crate::export::effects::put_project_lists(&t.effects, &mut file, &cx.fx_use)?;
|
||||
crate::export::settings::emit(&mut file, &plan.settings)?;
|
||||
{
|
||||
let head = file.chunks.iter_mut().find(|c| c.is(b"head")).ok_or_else(|| tpl("template without head"))?;
|
||||
put_u32(data_mut(head)?, 12, cx.ids.next)?;
|
||||
}
|
||||
crate::write(&file).map_err(crate::export::AepExportError::Container)
|
||||
}
|
||||
|
||||
/// Marks a root entry that goes into the `Solids` folder.
|
||||
const ROOT_SOLID: u64 = 1 << 63;
|
||||
|
||||
fn sort_named(v: &mut [Sorted]) {
|
||||
v.sort_by(|a, b| natural_cmp(&a.0, &b.0).then(a.1.cmp(&b.1)));
|
||||
}
|
||||
|
||||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let (ka, kb) = (natural_key(a), natural_key(b));
|
||||
for (x, y) in ka.iter().zip(kb.iter()) {
|
||||
let c = match (x.0, y.0) {
|
||||
(true, true) => x.2.cmp(&y.2).then_with(|| x.1.cmp(&y.1)),
|
||||
(false, false) => x.1.cmp(&y.1),
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
};
|
||||
if c != std::cmp::Ordering::Equal {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
ka.len().cmp(&kb.len())
|
||||
}
|
||||
|
||||
fn assign_ids(entries: &[Entry], cx: &mut Cx<'_>, map: &mut HashMap<ItemId, u32>) -> R<()> {
|
||||
// Iterative to bound the stack by the folder depth the plan already limits.
|
||||
let mut stack: Vec<&Entry> = entries.iter().rev().collect();
|
||||
while let Some(e) = stack.pop() {
|
||||
let id = if Some(e.ec) == cx.special { SOLIDS_FOLDER_ID } else { cx.ids.alloc()? };
|
||||
match &e.plan {
|
||||
ItemPlan::Comp(cp) => {
|
||||
let seconds = f64::from(cp.duration_units) / f64::from(cp.tb.timescale.max(1));
|
||||
cx.comp_ids.insert(e.ec, (id, seconds));
|
||||
for _ in 0..VIEW_LAYERS {
|
||||
cx.ids.alloc()?;
|
||||
}
|
||||
}
|
||||
ItemPlan::Solid(_) => {
|
||||
cx.solid_ids.insert(e.ec, id);
|
||||
}
|
||||
ItemPlan::Footage(fp) => {
|
||||
cx.footage_ids.insert(e.ec, (id, fp.seconds));
|
||||
}
|
||||
ItemPlan::Folder(kids) => stack.extend(kids.iter().rev()),
|
||||
}
|
||||
map.insert(e.ec, id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type Out<'a> = &'a mut dyn FnMut(String, u64, Vec<Chunk<'static>>);
|
||||
|
||||
/// Builds the chunks of one folder level (items and their trailing records) and hands each entry
|
||||
/// to `sink`. Root-level solids are flagged [`ROOT_SOLID`] in their sort key.
|
||||
fn build_level(entries: &[Entry], cx: &mut Cx<'_>, ids: &HashMap<ItemId, u32>, sink: Out<'_>) -> R<()> {
|
||||
build_level_depth(entries, cx, ids, sink, true, 0)
|
||||
}
|
||||
|
||||
fn build_level_depth(entries: &[Entry], cx: &mut Cx<'_>, ids: &HashMap<ItemId, u32>, sink: Out<'_>, root: bool, depth: usize) -> R<()> {
|
||||
if depth > 80 {
|
||||
return Err(tpl("folders nested too deeply"));
|
||||
}
|
||||
for e in entries {
|
||||
let id = *ids.get(&e.ec).ok_or_else(|| tpl("item without an id"))?;
|
||||
match &e.plan {
|
||||
ItemPlan::Folder(kids) => {
|
||||
let mut item = cx.t.folder_item.clone();
|
||||
set_item_header(&mut item, id, e.label, &e.name)?;
|
||||
let mut inner: Vec<Sorted> = Vec::new();
|
||||
build_level_depth(kids, cx, ids, &mut |n, k, c| inner.push((n, k, c)), false, depth + 1)?;
|
||||
if root && Some(e.ec) == cx.special {
|
||||
// Completed once the hidden solids of null layers are known.
|
||||
cx.special_slot = Some((item, inner));
|
||||
sink(e.name.clone(), e.ec.0, Vec::new());
|
||||
continue;
|
||||
}
|
||||
sort_named(&mut inner);
|
||||
kids_mut(list_mut(&mut item, b"Sfdr")?)?.extend(inner.into_iter().flat_map(|(_, _, c)| c));
|
||||
sink(e.name.clone(), e.ec.0, vec![item]);
|
||||
}
|
||||
ItemPlan::Comp(cp) => {
|
||||
let item = build_comp(cx, id, e, cp)?;
|
||||
let mut v = vec![item, cx.t.fee.clone()];
|
||||
v.extend(cx.t.fset.iter().cloned());
|
||||
sink(e.name.clone(), e.ec.0, v);
|
||||
}
|
||||
ItemPlan::Footage(fp) => {
|
||||
let guid = unique_guid(cx);
|
||||
let chunks = crate::export::footage::build_footage(&cx.t.footage, id, e.label, &e.name, fp, guid)?;
|
||||
sink(e.name.clone(), e.ec.0, chunks);
|
||||
}
|
||||
ItemPlan::Solid(sp) => {
|
||||
let item = build_solid(cx, id, e.label, &e.name, sp)?;
|
||||
let mut v = vec![item];
|
||||
v.extend(cx.t.fset.iter().cloned());
|
||||
sink(e.name.clone(), e.ec.0 | if root { ROOT_SOLID } else { 0 }, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// iide (little endian), idta id at 16 and label at 58, and the name.
|
||||
fn set_item_header(item: &mut Chunk<'static>, id: u32, label: u8, name: &str) -> R<()> {
|
||||
put(child_data(item, b"iide")?, 0, &id.to_le_bytes())?;
|
||||
{
|
||||
let idta = child_data(item, b"idta")?;
|
||||
put_u32(idta, 16, id)?;
|
||||
put_u8(idta, 58, label)?;
|
||||
}
|
||||
set_utf8(item, name)
|
||||
}
|
||||
|
||||
fn unique_guid(cx: &mut Cx<'_>) -> [u8; 16] {
|
||||
// A splitmix64 stream: distinct, deterministic, and no machine data.
|
||||
let mut out = [0u8; 16];
|
||||
for half in 0..2 {
|
||||
cx.guid = cx.guid.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
let mut z = cx.guid;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||
z ^= z >> 31;
|
||||
if let Some(dst) = out.get_mut(half * 8..half * 8 + 8) {
|
||||
dst.copy_from_slice(&z.to_be_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn solid_item(cx: &mut Cx<'_>, proto: &Chunk<'static>, id: u32, label: u8, name: &str, color: [f32; 3], (w, h): (u32, u32)) -> R<Chunk<'static>> {
|
||||
let mut item = proto.clone();
|
||||
put(child_data(&mut item, b"iide")?, 0, &id.to_le_bytes())?;
|
||||
{
|
||||
let idta = child_data(&mut item, b"idta")?;
|
||||
put_u32(idta, 16, id)?;
|
||||
put_u8(idta, 58, label)?;
|
||||
}
|
||||
let guid = unique_guid(cx);
|
||||
let pin = list_mut(&mut item, b"Pin ")?;
|
||||
{
|
||||
let sspc = child_data(pin, b"sspc")?;
|
||||
put_u32(sspc, 30, w)?;
|
||||
put_u32(sspc, 34, h)?;
|
||||
}
|
||||
{
|
||||
let opti = child_data(pin, b"opti")?;
|
||||
let [r, g, b] = color;
|
||||
put_f32(opti, 10, 1.0)?;
|
||||
put_f32(opti, 14, r)?;
|
||||
put_f32(opti, 18, g)?;
|
||||
put_f32(opti, 22, b)?;
|
||||
put_cstr(opti, 26, MAX_SOLID_NAME + 1, name)?;
|
||||
}
|
||||
put(child_data(pin, b"pgui")?, 0, &guid)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_solid(cx: &mut Cx<'_>, id: u32, label: u8, name: &str, sp: &SolidPlan) -> R<Chunk<'static>> {
|
||||
let proto = cx.t.solid_item.clone();
|
||||
solid_item(cx, &proto, id, label, name, sp.color, (sp.width, sp.height))
|
||||
}
|
||||
|
||||
/// Rebuilds all `tdb4` time scales (byte 12) below `c` to the composition's.
|
||||
fn set_time_scales(c: &mut Chunk<'static>, ts: u32, depth: usize) -> R<()> {
|
||||
if depth > 64 {
|
||||
return Err(tpl("property tree nested too deeply"));
|
||||
}
|
||||
if c.is_list(b"tdbs") {
|
||||
let tdb4 = child_data(c, b"tdb4")?;
|
||||
put_u32(tdb4, 12, ts)?;
|
||||
}
|
||||
if let Ok(kids) = kids_mut(c) {
|
||||
for k in kids.iter_mut() {
|
||||
if k.list_type().is_some() {
|
||||
set_time_scales(k, ts, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_comp(cx: &mut Cx<'_>, id: u32, e: &Entry, cp: &CompPlan) -> R<Chunk<'static>> {
|
||||
let mut item = cx.t.comp_shell.clone();
|
||||
set_item_header(&mut item, id, e.label, &e.name)?;
|
||||
{
|
||||
let cdta = child_data(&mut item, b"cdta")?;
|
||||
put_u32(cdta, 4, cp.tb.frame_duration)?;
|
||||
put_u32(cdta, 8, cp.tb.timescale)?;
|
||||
put_u32(cdta, 16, 600)?;
|
||||
put_u32(cdta, 24, 600)?;
|
||||
put_u32(cdta, 28, cp.work_start)?;
|
||||
// Start and end of the work area are fractions: the time scale is their denominator once
|
||||
// a work area is set, 600 while it spans the whole composition.
|
||||
let set = cp.work_end.is_some() || cp.work_start != 0;
|
||||
put_u32(cdta, 32, if set { cp.tb.timescale } else { 600 })?;
|
||||
put_u32(cdta, 36, cp.work_end.unwrap_or(u32::MAX))?;
|
||||
put_u32(cdta, 40, if set { cp.tb.timescale } else { 600 })?;
|
||||
put_u32(cdta, 44, cp.duration_units)?;
|
||||
put_u32(cdta, 48, cp.tb.timescale)?;
|
||||
put(cdta, 52, &cp.background)?;
|
||||
put_bits(cdta, 139, 0x08, cp.motion_blur)?;
|
||||
put_u16(cdta, 140, cp.width)?;
|
||||
put_u16(cdta, 142, cp.height)?;
|
||||
put_u32(cdta, 144, cp.pixel_aspect.0)?;
|
||||
put_u32(cdta, 148, cp.pixel_aspect.1)?;
|
||||
put_u32(cdta, 156, crate::export::timebase::fixed_rate(cp.rate))?;
|
||||
put_u32(cdta, 164, cp.display_start)?;
|
||||
put_u32(cdta, 168, if cp.display_start == 0 { 1 } else { cp.tb.timescale })?;
|
||||
put_u32(cdta, 172, cp.shutter_angle)?;
|
||||
put_i32(cdta, 180, cp.shutter_phase)?;
|
||||
}
|
||||
// View layers: ids right after the composition's.
|
||||
{
|
||||
let mut view = id.checked_add(1).ok_or_else(|| tpl("id overflow"))?;
|
||||
for k in kids_mut(&mut item)?.iter_mut() {
|
||||
if k.is_list(b"DLay") || k.is_list(b"SLay") || k.is_list(b"CLay") || k.is_list(b"SecL") {
|
||||
put_u32(child_data(k, b"ldta")?, 0, view)?;
|
||||
view = view.checked_add(1).ok_or_else(|| tpl("id overflow"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The layers.
|
||||
let mut layer_ids: HashMap<LayerId, u32> = HashMap::new();
|
||||
for lp in &cp.layers {
|
||||
layer_ids.insert(lp.ec_id, cx.ids.alloc()?);
|
||||
}
|
||||
let mut blocks: Vec<Chunk<'static>> = Vec::new();
|
||||
for lp in &cp.layers {
|
||||
let lid = *layer_ids.get(&lp.ec_id).ok_or_else(|| tpl("layer without an id"))?;
|
||||
let block = build_layer(cx, lid, lp, cp, &layer_ids)?;
|
||||
blocks.extend(block);
|
||||
}
|
||||
// Composition markers live in the view layer named `Markers` (`SecL`, 122, 509).
|
||||
if !cp.markers.is_empty() {
|
||||
let group = crate::export::markers::build(&cx.t.marker_group, &cp.markers)?;
|
||||
let secl = kids_mut(&mut item)?.iter_mut().find(|c| c.is_list(b"SecL")).ok_or_else(|| tpl("composition without its markers layer"))?;
|
||||
crate::export::markers::insert(list_mut(secl, b"tdgp")?, group)?;
|
||||
}
|
||||
// Every property of the composition counts keyframe times in its time scale (the layers
|
||||
// have theirs already: a stretched layer's is scaled, R36).
|
||||
set_time_scales(&mut item, cp.tb.timescale, 0)?;
|
||||
let at = cx.t.layer_at.min(kids_mut(&mut item)?.len());
|
||||
kids_mut(&mut item)?.splice(at..at, blocks);
|
||||
put_used_comps(cx, &mut item, cp)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// A composition that shows other compositions as layers records each of them once in an `idpi`
|
||||
/// chunk (little-endian item id) after `idpc`, and `idpc` starts with their number as a
|
||||
/// little-endian `u32` (124, 257, 258, 600, 602: one; 601: two; the After Effects re-save of
|
||||
/// `export-tests\precomp\p1`, written with the count 1, rewrote it to 2). The order carries no
|
||||
/// meaning: After Effects reverses the list on every re-save (p1 and p1b); the exporter writes
|
||||
/// ascending ids, the order After Effects created in 601.
|
||||
fn put_used_comps(cx: &Cx<'_>, item: &mut Chunk<'static>, cp: &CompPlan) -> R<()> {
|
||||
let mut used: Vec<u32> = Vec::new();
|
||||
for lp in &cp.layers {
|
||||
if let LayerSrc::Comp(c) = &lp.src {
|
||||
let (id, _) = *cx.comp_ids.get(c).ok_or_else(|| tpl("precomposition without an id"))?;
|
||||
if !used.contains(&id) {
|
||||
used.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if used.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
used.sort_unstable();
|
||||
let count = u32::try_from(used.len()).map_err(|_| tpl("too many precompositions"))?;
|
||||
let kids = kids_mut(item)?;
|
||||
let at = kids.iter().position(|c| c.is(b"idpc")).ok_or_else(|| tpl("composition without idpc"))?;
|
||||
if let Some(idpc) = kids.get_mut(at) {
|
||||
put(data_mut(idpc)?, 0, &count.to_le_bytes())?;
|
||||
}
|
||||
let chunks: Vec<Chunk<'static>> = used.iter().map(|id| Chunk::data(*b"idpi", id.to_le_bytes().to_vec())).collect();
|
||||
kids.splice(at + 1..at + 1, chunks);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the maximum of the inert `ADBE Time Remapping` property (`tduM`, seconds): the duration
|
||||
/// of the layer's source (5 for a 5 s precomposition: 124, 257, 258; 0 for a solid: 000).
|
||||
fn put_time_remap_max(layr: &mut Chunk<'static>, seconds: f64) -> R<()> {
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at =
|
||||
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
|
||||
put_f64(child_data(tdbs, b"tduM")?, 0, seconds)
|
||||
}
|
||||
|
||||
/// Enables the layer's `ADBE Time Remapping` (aep-format.md, Time Remapping; 319, 354 against
|
||||
/// 124, 353): `tdsb` 3 -> 1; with keys `tdb4` byte 5 bit 0 cleared and byte 68 = 1, and the `cdat`
|
||||
/// replaced by a key list of plain 48-byte records (value in seconds at 8), the list header as a
|
||||
/// keyed Opacity's (the same record kind); the plan always has a key (a remap without keys reads
|
||||
/// back switched off, r1). An expression
|
||||
/// is written as on the Transform properties (R15). The maximum (`tduM`) stays the source's
|
||||
/// duration, as After Effects keeps it (319: 5).
|
||||
fn put_time_remap(cx: &Cx<'_>, layr: &mut Chunk<'static>, remap: &PropPlan) -> R<()> {
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at =
|
||||
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Time Remapping")).ok_or_else(|| tpl("layer without Time Remapping"))?;
|
||||
let tdbs = kids.get_mut(at + 1).ok_or_else(|| tpl("Time Remapping without a record"))?;
|
||||
put_u32(child_data(tdbs, b"tdsb")?, 0, 1)?;
|
||||
{
|
||||
let tdb4 = child_data(tdbs, b"tdb4")?;
|
||||
if !remap.keys.is_empty() {
|
||||
let b5 = tdb4.get(5).copied().ok_or_else(|| tpl("short tdb4"))?;
|
||||
put_u8(tdb4, 5, b5 & !0x01)?;
|
||||
put_u8(tdb4, 68, 1)?;
|
||||
}
|
||||
if let Some((_, enabled)) = &remap.expr {
|
||||
put_u8(tdb4, 76, 1)?;
|
||||
put_u8(tdb4, 77, 1)?;
|
||||
put_u8(tdb4, 120, 1)?;
|
||||
put_u8(tdb4, 119, u8::from(!*enabled))?;
|
||||
}
|
||||
}
|
||||
let rk = kids_mut(tdbs)?;
|
||||
let cdat_at = rk.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("Time Remapping without a value"))?;
|
||||
if remap.keys.is_empty() {
|
||||
if let Some(cdat) = rk.get_mut(cdat_at) {
|
||||
put_f64(data_mut(cdat)?, 0, remap.value.first().copied().unwrap_or(0.0))?;
|
||||
}
|
||||
} else {
|
||||
// The key list of a keyed one-component property (Opacity's prototype).
|
||||
let idx = PropKind::ALL.iter().position(|k| *k == PropKind::Opacity).ok_or_else(|| tpl("unknown property kind"))?;
|
||||
let proto = cx.t.keyd.get(idx * 2 + 1).ok_or_else(|| tpl("prototype missing"))?;
|
||||
let mut list = proto
|
||||
.children()
|
||||
.iter()
|
||||
.find(|k| matches!(&k.body, crate::Body::List { list_type, .. } if list_type == b"list"))
|
||||
.cloned()
|
||||
.ok_or_else(|| tpl("keyed prototype without a list"))?;
|
||||
let n = u32::try_from(remap.keys.len()).map_err(|_| tpl("too many keys"))?;
|
||||
let size = 48usize;
|
||||
{
|
||||
let lhd3 = child_data(&mut list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, 48)?;
|
||||
}
|
||||
let mut ldat = Vec::with_capacity(size * remap.keys.len());
|
||||
for k in &remap.keys {
|
||||
ldat.extend(plain_record(1, k, size)?);
|
||||
}
|
||||
let ld = kids_mut(&mut list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => ld.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
if let Some(slot) = rk.get_mut(cdat_at) {
|
||||
*slot = list;
|
||||
}
|
||||
}
|
||||
if let Some((text, _)) = &remap.expr {
|
||||
let at = rk.iter().position(|c| c.is(b"tdum")).unwrap_or(rk.len());
|
||||
rk.insert(at, Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_layer(cx: &mut Cx<'_>, id: u32, lp: &LayerPlan, cp: &CompPlan, layer_ids: &HashMap<LayerId, u32>) -> R<Vec<Chunk<'static>>> {
|
||||
let is_null = lp.src == LayerSrc::Null;
|
||||
let mut block = match &lp.src {
|
||||
LayerSrc::Null => cx.t.null_block.clone(),
|
||||
LayerSrc::Text(tp) if tp.frame.is_some() => cx.t.text_box.clone(),
|
||||
LayerSrc::Text(_) => cx.t.text_point.clone(),
|
||||
LayerSrc::Camera(_) => cx.t.camera_block.clone(),
|
||||
LayerSrc::Light(l) if l.kind == 2 => cx.t.point_block.clone(),
|
||||
LayerSrc::Light(_) => cx.t.spot_block.clone(),
|
||||
LayerSrc::Shape(_) => cx.t.shapes.block.clone(),
|
||||
_ => cx.t.layer_block.clone(),
|
||||
};
|
||||
let mut remap_max = None;
|
||||
let source_id = match &lp.src {
|
||||
LayerSrc::Solid(item) => *cx.solid_ids.get(item).ok_or_else(|| tpl("layer source without an id"))?,
|
||||
LayerSrc::Comp(item) => {
|
||||
let (id, seconds) = *cx.comp_ids.get(item).ok_or_else(|| tpl("precomposition without an id"))?;
|
||||
remap_max = Some(seconds);
|
||||
id
|
||||
}
|
||||
LayerSrc::Footage(item) => {
|
||||
let (id, seconds) = *cx.footage_ids.get(item).ok_or_else(|| tpl("footage without an id"))?;
|
||||
remap_max = Some(seconds);
|
||||
id
|
||||
}
|
||||
// A text layer and a camera have no source (110, 256), a light `0xffffffff` (252).
|
||||
LayerSrc::Text(_) | LayerSrc::Camera(_) | LayerSrc::Shape(_) => 0,
|
||||
LayerSrc::Light(_) => u32::MAX,
|
||||
LayerSrc::Null => {
|
||||
// The hidden solid every null object has.
|
||||
let item_id = cx.ids.alloc()?;
|
||||
let proto = cx.t.null_item.clone();
|
||||
let name = format!("Null {}", item_id);
|
||||
let item = solid_item(cx, &proto, item_id, 0, &name, [1.0, 1.0, 1.0], (100, 100))?;
|
||||
cx.extra_solids.push((name, item));
|
||||
item_id
|
||||
}
|
||||
};
|
||||
let layr = block.get_mut(0).ok_or_else(|| tpl("empty layer block"))?;
|
||||
{
|
||||
let ldta = child_data(layr, b"ldta")?;
|
||||
put_u32(ldta, 0, id)?;
|
||||
put_u8(ldta, 5, lp.quality)?;
|
||||
put_i32(ldta, 8, lp.stretch.0)?;
|
||||
put_u32(ldta, 108, lp.stretch.1)?;
|
||||
let den = cp.tb.timescale;
|
||||
put_i32(ldta, 12, lp.start)?;
|
||||
put_u32(ldta, 16, den)?;
|
||||
put_i32(ldta, 20, lp.in_rel)?;
|
||||
put_u32(ldta, 24, den)?;
|
||||
put_i32(ldta, 28, lp.out_rel)?;
|
||||
put_u32(ldta, 32, den)?;
|
||||
put_bits(ldta, 37, 0x01, !lp.name.is_empty())?;
|
||||
put_bits(ldta, 37, 0x02, lp.guide)?;
|
||||
put_bits(ldta, 38, 0x02, lp.adjustment)?;
|
||||
put_bits(ldta, 38, 0x04, lp.three_d)?;
|
||||
put_bits(ldta, 38, 0x08, lp.solo)?;
|
||||
put_bits(ldta, 38, 0x80, is_null)?;
|
||||
put_bits(ldta, 39, 0x01, lp.video)?;
|
||||
put_bits(ldta, 39, 0x02, lp.audio)?;
|
||||
put_bits(ldta, 39, 0x04, lp.effects)?;
|
||||
put_bits(ldta, 39, 0x08, lp.motion_blur)?;
|
||||
put_bits(ldta, 39, 0x20, lp.locked)?;
|
||||
put_bits(ldta, 39, 0x40, lp.shy)?;
|
||||
put_bits(ldta, 39, 0x80, lp.collapse)?;
|
||||
put_u32(ldta, 40, source_id)?;
|
||||
put_u8(ldta, 61, lp.label)?;
|
||||
put_cstr(ldta, 64, 32, &lp.name)?;
|
||||
put_u8(ldta, 99, lp.blend)?;
|
||||
// Layer type: 3 text, 2 camera, 1 light, 0 AV (solid, footage, precomposition, null) (3.1);
|
||||
// the light type at 136 (1 spot, 2 point); auto-orient towards the point of interest is
|
||||
// byte 38 bit 0x40 (two-node camera 256 vs one-node 251; set on every light).
|
||||
let ty = match &lp.src {
|
||||
LayerSrc::Text(_) => 3,
|
||||
LayerSrc::Camera(_) => 2,
|
||||
LayerSrc::Light(_) => 1,
|
||||
LayerSrc::Shape(_) => 4,
|
||||
_ => 0,
|
||||
};
|
||||
put_u32(ldta, 128, ty)?;
|
||||
match &lp.src {
|
||||
LayerSrc::Camera(c) => put_bits(ldta, 38, 0x40, c.two_node)?,
|
||||
LayerSrc::Light(l) => {
|
||||
put_u32(ldta, 136, l.kind)?;
|
||||
put_bits(ldta, 38, 0x40, true)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let parent = lp.parent.and_then(|p| layer_ids.get(&p).copied()).unwrap_or(0);
|
||||
put_u32(ldta, 132, parent)?;
|
||||
// Track matte: mode at 107, the matte layer's id at 160 (403-408).
|
||||
let (matte_id, matte_mode) = lp.matte.and_then(|(m, mode)| layer_ids.get(&m).map(|id| (*id, mode))).unwrap_or((0, 0));
|
||||
put_u8(ldta, 107, matte_mode)?;
|
||||
put_u32(ldta, 160, matte_id)?;
|
||||
}
|
||||
set_utf8(layr, &lp.name)?;
|
||||
if let LayerSrc::Text(tp) = &lp.src {
|
||||
let guid = unique_guid(cx);
|
||||
crate::export::text::put_doc(layr, tp, guid)?;
|
||||
crate::export::textanim::put_animators(layr, &cx.t.anims, &tp.animators, &cx.t.shapes.key_list)?;
|
||||
}
|
||||
if let Some(seconds) = remap_max {
|
||||
put_time_remap_max(layr, seconds)?;
|
||||
}
|
||||
if let Some(remap) = &lp.remap {
|
||||
put_time_remap(cx, layr, remap)?;
|
||||
}
|
||||
put_properties(cx, layr, lp, cp)?;
|
||||
put_masks(cx, layr, lp)?;
|
||||
put_effects(cx, layr, lp, id, layer_ids)?;
|
||||
if let Some(styles) = &lp.styles {
|
||||
crate::export::styles::put_styles(&cx.t.styles, layr, styles)?;
|
||||
}
|
||||
match &lp.src {
|
||||
LayerSrc::Camera(c) => crate::export::camlight::put_camera(layr, &cx.t.options, c)?,
|
||||
LayerSrc::Light(l) => crate::export::camlight::put_light(layr, &cx.t.options, l)?,
|
||||
LayerSrc::Shape(items) => crate::export::shapes::put_contents(layr, &cx.t.shapes, items)?,
|
||||
_ => {}
|
||||
}
|
||||
if !lp.markers.is_empty() {
|
||||
let group = crate::export::markers::build(&cx.t.marker_group, &lp.markers)?;
|
||||
crate::export::markers::insert(list_mut(layr, b"tdgp")?, group)?;
|
||||
}
|
||||
set_time_scales(layr, lp.time_scale, 0)?;
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// Inserts the layer's `ADBE Mask Parade` before its Transform group (after Time Remapping, as
|
||||
/// After Effects stores it: 090).
|
||||
fn put_masks(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan) -> R<()> {
|
||||
if lp.masks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parade = crate::export::masks::build_parade(&cx.t.masks, &lp.masks, lp.source_size)?;
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
kids.splice(at..at, parade);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserts the layer's `ADBE Effect Parade` right before its Transform group (after the masks:
|
||||
/// 357, 398).
|
||||
fn put_effects(cx: &mut Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, layer_id: u32, layer_ids: &HashMap<LayerId, u32>) -> R<()> {
|
||||
if lp.fx.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let parade = crate::export::effects::build_parade(&cx.t.effects, &lp.fx, layer_id, layer_ids, lp.source_size, &mut cx.fx_use)?;
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
kids.splice(at..at, parade);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The order of a Transform group's records; `ADBE Group End` closes the group whatever else it
|
||||
/// holds.
|
||||
const GROUP_ORDER: [&str; 13] = [
|
||||
"ADBE Anchor Point",
|
||||
"ADBE Position",
|
||||
"ADBE Position_0",
|
||||
"ADBE Position_1",
|
||||
"ADBE Position_2",
|
||||
"ADBE Scale",
|
||||
"ADBE Orientation",
|
||||
"ADBE Rotate X",
|
||||
"ADBE Rotate Y",
|
||||
"ADBE Rotate Z",
|
||||
"ADBE Opacity",
|
||||
"ADBE Envir Appear in Reflect",
|
||||
"ADBE Group End",
|
||||
];
|
||||
|
||||
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let d = c.bytes()?;
|
||||
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
|
||||
}
|
||||
|
||||
/// Inserts the properties of `lp` into the layer's Transform group, in the order the file uses.
|
||||
fn put_properties(cx: &Cx<'_>, layr: &mut Chunk<'static>, lp: &LayerPlan, cp: &CompPlan) -> R<()> {
|
||||
if lp.props.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Transform Group"))
|
||||
.ok_or_else(|| tpl("layer without a Transform group"))?;
|
||||
let group = kids.get_mut(at + 1).ok_or_else(|| tpl("Transform group without a list"))?;
|
||||
let gk = kids_mut(group)?;
|
||||
// Split the group into its header (up to the first match name) and (tdmn, node) pairs.
|
||||
let first = gk.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty Transform group"))?;
|
||||
let header: Vec<Chunk<'static>> = gk.drain(..first).collect();
|
||||
let mut pairs: Vec<(String, Vec<Chunk<'static>>)> = Vec::new();
|
||||
let mut it = std::mem::take(gk).into_iter();
|
||||
while let Some(m) = it.next() {
|
||||
let name = tdmn_name(&m).unwrap_or_default();
|
||||
if name == "ADBE Group End" {
|
||||
pairs.push((name, vec![m]));
|
||||
continue;
|
||||
}
|
||||
let node = it.next().ok_or_else(|| tpl("match name without a node"))?;
|
||||
pairs.push((name, vec![m, node]));
|
||||
}
|
||||
for p in &lp.props {
|
||||
let name = p.kind.match_name();
|
||||
let built = build_prop(cx, p, lp, cp)?;
|
||||
let tdmn = pairs.iter().find(|(n, _)| n == name).and_then(|(_, v)| v.first().cloned()).unwrap_or_else(|| {
|
||||
cx.t.stat
|
||||
.iter()
|
||||
.chain(cx.t.keyd.iter())
|
||||
.find(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Chunk::data(*b"tdmn", Vec::new()))
|
||||
});
|
||||
let entry = vec![tdmn, built];
|
||||
if let Some(slot) = pairs.iter_mut().find(|(n, _)| n == name) {
|
||||
slot.1 = entry;
|
||||
} else {
|
||||
pairs.push((name.to_string(), entry));
|
||||
}
|
||||
}
|
||||
let rank = |n: &str| if n == "ADBE Group End" { usize::MAX } else { GROUP_ORDER.iter().position(|g| *g == n).unwrap_or(GROUP_ORDER.len()) };
|
||||
pairs.sort_by_key(|(n, _)| rank(n));
|
||||
gk.extend(header);
|
||||
gk.extend(pairs.into_iter().flat_map(|(_, v)| v));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_prop(cx: &Cx<'_>, p: &PropPlan, lp: &LayerPlan, cp: &CompPlan) -> R<Chunk<'static>> {
|
||||
let idx = PropKind::ALL.iter().position(|k| *k == p.kind).ok_or_else(|| tpl("unknown property kind"))?;
|
||||
let pool = if p.keys.is_empty() { &cx.t.stat } else { &cx.t.keyd };
|
||||
let tdbs = pool.get(idx * 2 + 1).ok_or_else(|| tpl("prototype missing"))?;
|
||||
let mut tdbs = tdbs.clone();
|
||||
{
|
||||
let tdb4 = child_data(&mut tdbs, b"tdb4")?;
|
||||
put_u32(tdb4, 12, cp.tb.timescale)?;
|
||||
if p.kind == PropKind::Anchor {
|
||||
// The width / height of the layer's source (kept next to the anchor's other
|
||||
// normalisation constants).
|
||||
put_f64(tdb4, 24, f64::from(lp.source_size.0) / f64::from(lp.source_size.1.max(1)))?;
|
||||
}
|
||||
if let Some((_, enabled)) = &p.expr {
|
||||
put_u8(tdb4, 76, 1)?;
|
||||
put_u8(tdb4, 77, 1)?;
|
||||
put_u8(tdb4, 120, 1)?;
|
||||
put_u8(tdb4, 119, u8::from(!*enabled))?;
|
||||
}
|
||||
}
|
||||
if p.keys.is_empty() {
|
||||
let cdat = child_data(&mut tdbs, b"cdat")?;
|
||||
cdat.iter_mut().for_each(|b| *b = 0);
|
||||
for (i, v) in p.value.iter().enumerate() {
|
||||
put_f64(cdat, i * 8, *v)?;
|
||||
}
|
||||
} else {
|
||||
let list = list_mut(&mut tdbs, b"list")?;
|
||||
let n = u32::try_from(p.keys.len()).map_err(|_| tpl("too many keys"))?;
|
||||
let size = if p.kind.spatial() { 56 + 24 * p.kind.dims() } else { 8 + 40 * p.kind.dims() };
|
||||
{
|
||||
let lhd3 = child_data(list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
|
||||
}
|
||||
let mut ldat = Vec::with_capacity(size * p.keys.len());
|
||||
for k in &p.keys {
|
||||
ldat.extend(key_record(p.kind, k, size)?);
|
||||
}
|
||||
let ld = kids_mut(list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => ld.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
}
|
||||
if let Some((text, _)) = &p.expr {
|
||||
let kids = kids_mut(&mut tdbs)?;
|
||||
let at = kids.iter().position(|c| c.is(b"tdum")).unwrap_or(kids.len());
|
||||
kids.insert(at, Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
|
||||
}
|
||||
Ok(tdbs)
|
||||
}
|
||||
|
||||
/// One keyframe record (`docs/aep-format.md`, 3.5).
|
||||
fn key_record(kind: PropKind, k: &KeyPlan, size: usize) -> R<Vec<u8>> {
|
||||
let mut r = vec![0u8; size];
|
||||
let n = kind.dims();
|
||||
put_i32(&mut r, 0, k.time)?;
|
||||
put_u8(&mut r, 4, k.in_interp)?;
|
||||
put_u8(&mut r, 5, k.out_interp)?;
|
||||
let base: u16 = if kind.spatial() { 0x0007 } else { 0 };
|
||||
let flags = base | if k.roving { 0x0020 } else { 0 } | (u16::from(k.label) << 6);
|
||||
put_u16(&mut r, 6, flags)?;
|
||||
if kind.spatial() {
|
||||
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
|
||||
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
|
||||
put_f64(&mut r, 24, i.speed)?;
|
||||
put_f64(&mut r, 32, i.influence)?;
|
||||
put_f64(&mut r, 40, o.speed)?;
|
||||
put_f64(&mut r, 48, o.influence)?;
|
||||
for j in 0..n {
|
||||
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
|
||||
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
|
||||
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
|
||||
}
|
||||
} else {
|
||||
r = plain_record(n, k, size)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A plain keyframe record with `n` components: value at 8, then the type-major ease blocks.
|
||||
pub(crate) fn plain_record(n: usize, k: &KeyPlan, size: usize) -> R<Vec<u8>> {
|
||||
let mut r = vec![0u8; size];
|
||||
put_i32(&mut r, 0, k.time)?;
|
||||
put_u8(&mut r, 4, k.in_interp)?;
|
||||
put_u8(&mut r, 5, k.out_interp)?;
|
||||
let flags = if k.roving { 0x0020 } else { 0 } | (u16::from(k.label) << 6);
|
||||
put_u16(&mut r, 6, flags)?;
|
||||
for j in 0..n {
|
||||
put_f64(&mut r, 8 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
|
||||
}
|
||||
let base = 8 + 8 * n;
|
||||
for j in 0..n {
|
||||
let i = k.in_ease.get(j).or(k.in_ease.last()).copied().unwrap_or_default();
|
||||
let o = k.out_ease.get(j).or(k.out_ease.last()).copied().unwrap_or_default();
|
||||
put_f64(&mut r, base + 8 * j, i.speed)?;
|
||||
put_f64(&mut r, base + 8 * n + 8 * j, i.influence)?;
|
||||
put_f64(&mut r, base + 16 * n + 8 * j, o.speed)?;
|
||||
put_f64(&mut r, base + 24 * n + 8 * j, o.influence)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
//! Footage items (files), built in code per kind ([`FtProtos::build`]) with the layout of the items
|
||||
//! After Effects writes when it imports a file of that kind: png still 607, jpeg 605, OpenEXR 604,
|
||||
//! TIFF 613, H.264 mp4 606, QuickTime mov 612, WAV 351, png sequence 353, OpenEXR sequence 603,
|
||||
//! jpeg sequence 611, Targa, Photoshop, Illustrator and EPS 650-653. The colour management names
|
||||
//! our own sRGB profile (`icc.rs`). The exporter then writes the decoded fields
|
||||
//! (`docs/aep-format.md` 3.3, `docs/aep-export.md` R28):
|
||||
//!
|
||||
//! * the alias JSON (`Als2/alas`): the full path, for a sequence its folder with
|
||||
//! `target_is_folder`;
|
||||
//! * `sspc`: width, height, duration (`num / den`), native frame rate, for sequences the first and
|
||||
//! last frame number and the digits, and the folder's modification time when the caller gives
|
||||
//! it ([`crate::export::export_with`]); the two `Utf8` around the number;
|
||||
//! * `opti` of png: width, height and the first file's name;
|
||||
//! * the item header: id, label, name; a fresh `pgui`.
|
||||
//!
|
||||
//! After Effects reads the format options of the other kinds from the file (their `opti` beyond
|
||||
//! the header can be zero: read-backs v-fN-zoptiA).
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind};
|
||||
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
|
||||
/// The prototype a footage item is cloned from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FtKind {
|
||||
Png,
|
||||
Jpeg,
|
||||
Exr,
|
||||
Mp4,
|
||||
Wav,
|
||||
PngSeq,
|
||||
ExrSeq,
|
||||
JpegSeq,
|
||||
Mov,
|
||||
Tiff,
|
||||
Tga,
|
||||
Psd,
|
||||
Ai,
|
||||
Eps,
|
||||
}
|
||||
|
||||
impl FtKind {
|
||||
/// The key a kind's prototype is found under.
|
||||
pub(crate) fn key(self) -> [u8; 4] {
|
||||
match self {
|
||||
FtKind::Png => *b"png ",
|
||||
FtKind::Jpeg => *b"jpg ",
|
||||
FtKind::Exr => *b"exr ",
|
||||
FtKind::Mp4 => *b"mp4 ",
|
||||
FtKind::Wav => *b"wav ",
|
||||
FtKind::PngSeq => *b"pngS",
|
||||
FtKind::ExrSeq => *b"exrS",
|
||||
FtKind::JpegSeq => *b"jpgS",
|
||||
FtKind::Mov => *b"mov ",
|
||||
FtKind::Tiff => *b"tif ",
|
||||
FtKind::Tga => *b"tga ",
|
||||
FtKind::Psd => *b"psd ",
|
||||
FtKind::Ai => *b"ai ",
|
||||
FtKind::Eps => *b"eps ",
|
||||
}
|
||||
}
|
||||
|
||||
/// The denominator of a still's duration (`sspc@42`: png 1, jpeg 600, OpenEXR 100).
|
||||
fn still_den(self) -> u32 {
|
||||
match self {
|
||||
FtKind::Jpeg | FtKind::Tiff | FtKind::Tga | FtKind::Psd | FtKind::Ai | FtKind::Eps => 600,
|
||||
FtKind::Exr => 100,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_sequence(self) -> bool {
|
||||
matches!(self, FtKind::PngSeq | FtKind::ExrSeq | FtKind::JpegSeq)
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbering of a sequence.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Numbering {
|
||||
pub prefix: String,
|
||||
pub suffix: String,
|
||||
pub start: u32,
|
||||
pub end: u32,
|
||||
pub digits: u32,
|
||||
}
|
||||
|
||||
/// A footage item in the file's units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct FootagePlan {
|
||||
pub kind: FtKind,
|
||||
/// The alias target: the file, or a sequence's folder.
|
||||
pub alias: String,
|
||||
/// The (first) file's path as the project stores it: what the caller of
|
||||
/// [`crate::export::export_with`] checks before it reads the sequence folder's time.
|
||||
pub source: String,
|
||||
/// The (first) file's name.
|
||||
pub file_name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Duration as stored, `num / den` seconds.
|
||||
pub duration: (u32, u32),
|
||||
/// Native frame rate (sequences and clips), 0 otherwise.
|
||||
pub fps: u32,
|
||||
/// The rate a clip is conformed to (`sspc@146`, also the effective rate at @150; 645), 0 when
|
||||
/// it plays at its native rate.
|
||||
pub conform: u32,
|
||||
pub numbering: Option<Numbering>,
|
||||
/// The duration in seconds (the Time Remapping maximum of a layer showing it).
|
||||
pub seconds: f64,
|
||||
/// A sequence's folder modification time as stored (`sspc@118`, seconds since 1904, UTC; 860),
|
||||
/// when the caller supplied it ([`crate::export::export_with`]).
|
||||
pub folder_time: Option<u32>,
|
||||
}
|
||||
|
||||
/// Seconds between 1904-01-01 (the stored time's epoch) and 1970-01-01.
|
||||
const SECONDS_1904_TO_1970: u64 = 2_082_844_800;
|
||||
|
||||
/// Fills in the folder time of every image sequence in `root` (folders included) from
|
||||
/// `folder_time`, asked with the sequence's first file as the project stores it (seconds since
|
||||
/// 1970); a time that does not fit the stored field is left out.
|
||||
pub(crate) fn stamp_sequences(root: &mut [crate::export::plan::Entry], folder_time: &dyn Fn(&str) -> Option<u64>) {
|
||||
use crate::export::plan::ItemPlan;
|
||||
// Folders were planned with a bounded depth; walk them with an explicit stack anyway.
|
||||
let mut stack: Vec<&mut [crate::export::plan::Entry]> = vec![root];
|
||||
while let Some(entries) = stack.pop() {
|
||||
for e in entries.iter_mut() {
|
||||
match &mut e.plan {
|
||||
ItemPlan::Folder(kids) => stack.push(kids),
|
||||
ItemPlan::Footage(fp) if fp.kind.is_sequence() => {
|
||||
fp.folder_time = folder_time(&fp.source).and_then(|t| u32::try_from(t.checked_add(SECONDS_1904_TO_1970)?).ok());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
let name = path.rsplit(['\\', '/']).next().unwrap_or_default();
|
||||
match name.rsplit_once('.') {
|
||||
Some((_, e)) => e.to_ascii_lowercase(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_dir(path: &str) -> (&str, &str) {
|
||||
match path.rfind(['\\', '/']) {
|
||||
Some(i) => (path.get(..i).unwrap_or_default(), path.get(i + 1..).unwrap_or_default()),
|
||||
None => ("", path),
|
||||
}
|
||||
}
|
||||
|
||||
/// `shot_0101.exr` -> (`shot_`, 101, 4, `.exr`): the last run of digits before the extension.
|
||||
fn parse_numbered(name: &str) -> Option<(String, u32, u32, String)> {
|
||||
let dot = name.rfind('.').unwrap_or(name.len());
|
||||
let stem = name.get(..dot)?;
|
||||
let digits = stem.chars().rev().take_while(char::is_ascii_digit).count();
|
||||
if digits == 0 || digits > 9 {
|
||||
return None;
|
||||
}
|
||||
let split = stem.len().checked_sub(digits)?;
|
||||
let number: u32 = stem.get(split..)?.parse().ok()?;
|
||||
Some((stem.get(..split)?.to_string(), number, u32::try_from(digits).ok()?, name.get(dot..)?.to_string()))
|
||||
}
|
||||
|
||||
/// Whole frames at an integer rate, or `None` (a rate After Effects' footage header stores as an
|
||||
/// integer: 30 in 353 and 603, 25 in 606).
|
||||
fn integer_rate(f: &Footage) -> Option<u32> {
|
||||
let r = f.frame_rate;
|
||||
(r.den == 1 && r.num > 0).then(|| u32::try_from(r.num).ok()).flatten()
|
||||
}
|
||||
|
||||
/// Plans one footage item, or reports why it cannot be written.
|
||||
pub(crate) fn plan_footage(name: &str, f: &Footage, col: &mut Collector) -> Option<FootagePlan> {
|
||||
let what = |why: &str| format!("footage '{name}': {why}");
|
||||
if f.layer.is_some() {
|
||||
col.lose(LossCategory::FootageItems, what("one layer of a layered file is not exported"));
|
||||
return None;
|
||||
}
|
||||
// Interpret Footage settings other than the defaults are not written (After Effects
|
||||
// interprets the file itself).
|
||||
let d = Footage::default();
|
||||
let unwritten = [
|
||||
((f.pixel_aspect - d.pixel_aspect).abs() > 1e-9, "pixel aspect"),
|
||||
(f.alpha != d.alpha || f.premul_color != d.premul_color, "alpha interpretation"),
|
||||
(f.invert_alpha, "Invert Alpha"),
|
||||
(f.loop_count != d.loop_count, "loop count"),
|
||||
(f.color_profile.is_some(), "colour profile"),
|
||||
(f.fields != d.fields, "field order"),
|
||||
(f.linear_light != d.linear_light, "Interpret As Linear Light"),
|
||||
(f.preserve_rgb != d.preserve_rgb, "Preserve RGB"),
|
||||
(f.cineon != d.cineon, "Cineon settings"),
|
||||
];
|
||||
for (_, setting) in unwritten.iter().filter(|(on, _)| *on) {
|
||||
col.lose(LossCategory::FootageItems, what(&format!("the Interpret Footage {setting} is not written (the default is)")));
|
||||
}
|
||||
let e = ext(&f.path);
|
||||
let kind = match (f.kind, e.as_str()) {
|
||||
(FootageKind::Still, "png") => FtKind::Png,
|
||||
(FootageKind::Still, "jpg" | "jpeg") => FtKind::Jpeg,
|
||||
(FootageKind::Still, "exr") => FtKind::Exr,
|
||||
(FootageKind::Still, "tif" | "tiff") => FtKind::Tiff,
|
||||
(FootageKind::Still, "tga") => FtKind::Tga,
|
||||
(FootageKind::Still, "psd") => FtKind::Psd,
|
||||
(FootageKind::Still, "ai") => FtKind::Ai,
|
||||
(FootageKind::Still, "eps") => FtKind::Eps,
|
||||
(FootageKind::Sequence, "jpg" | "jpeg") => FtKind::JpegSeq,
|
||||
(FootageKind::Video, "mov") => FtKind::Mov,
|
||||
(FootageKind::Sequence, "png") => FtKind::PngSeq,
|
||||
(FootageKind::Sequence, "exr") => FtKind::ExrSeq,
|
||||
(FootageKind::Video, "mp4" | "m4v") => FtKind::Mp4,
|
||||
(FootageKind::Audio, "wav") => FtKind::Wav,
|
||||
_ => {
|
||||
col.lose(LossCategory::FootageItems, what(&format!("{:?} footage of type '.{e}' is not exported (no prototype)", f.kind)));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if f.path.is_empty() || f.path.chars().any(char::is_control) {
|
||||
col.lose(LossCategory::FootageItems, what("no usable path"));
|
||||
return None;
|
||||
}
|
||||
if f.width > crate::export::plan::MAX_SIDE || f.height > crate::export::plan::MAX_SIDE {
|
||||
col.lose(
|
||||
LossCategory::FootageItems,
|
||||
what(&format!("size {} x {} is larger than {}, written smaller", f.width, f.height, crate::export::plan::MAX_SIDE)),
|
||||
);
|
||||
}
|
||||
let width = f.width.min(crate::export::plan::MAX_SIDE);
|
||||
let height = f.height.min(crate::export::plan::MAX_SIDE);
|
||||
let (dir, file) = split_dir(&f.path);
|
||||
let mut plan = FootagePlan {
|
||||
kind,
|
||||
alias: f.path.clone(),
|
||||
source: f.path.clone(),
|
||||
file_name: file.to_string(),
|
||||
width,
|
||||
height,
|
||||
duration: (0, kind.still_den()),
|
||||
fps: 0,
|
||||
conform: 0,
|
||||
numbering: None,
|
||||
seconds: 0.0,
|
||||
folder_time: None,
|
||||
};
|
||||
match kind {
|
||||
FtKind::PngSeq | FtKind::ExrSeq | FtKind::JpegSeq => {
|
||||
let Some(fps) = integer_rate(f) else {
|
||||
col.lose(
|
||||
LossCategory::FootageItems,
|
||||
what(&format!("a sequence at {} fps is not exported (only whole rates are sampled)", f.frame_rate.label())),
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let Some((prefix, start, digits, suffix)) = parse_numbered(file) else {
|
||||
col.lose(LossCategory::FootageItems, what("a sequence whose first file has no frame number"));
|
||||
return None;
|
||||
};
|
||||
// The files must be the run start, start + 1, ... with the same name around the number.
|
||||
let files: Vec<&str> = if f.sequence.is_empty() { vec![file] } else { f.sequence.iter().map(|p| split_dir(p).1).collect() };
|
||||
let mut expect = start;
|
||||
for name in &files {
|
||||
let ok = parse_numbered(name).is_some_and(|(p, n, _, s)| p == prefix && s == suffix && n == expect);
|
||||
if !ok {
|
||||
col.lose(LossCategory::FootageItems, what("a sequence with gaps or mixed names is not exported"));
|
||||
return None;
|
||||
}
|
||||
expect = expect.saturating_add(1);
|
||||
}
|
||||
let count = u32::try_from(files.len()).unwrap_or(u32::MAX);
|
||||
plan.alias = dir.to_string();
|
||||
plan.fps = fps;
|
||||
plan.duration = (count, fps);
|
||||
plan.seconds = f64::from(count) / f64::from(fps.max(1));
|
||||
plan.numbering = Some(Numbering { prefix, suffix, start, end: start.saturating_add(count.saturating_sub(1)), digits });
|
||||
if dir.is_empty() {
|
||||
col.lose(LossCategory::FootageItems, what("a sequence without a folder"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
FtKind::Mp4 | FtKind::Mov => {
|
||||
let Some(fps) = integer_rate(f) else {
|
||||
col.lose(LossCategory::FootageItems, what(&format!("a clip at {} fps is not exported (only whole rates are sampled)", f.frame_rate.label())));
|
||||
return None;
|
||||
};
|
||||
// A conformed clip (645): the frames play at `fps`, the stored duration stays in the
|
||||
// native rate's frames, and the conformed rate goes to @146 and @150.
|
||||
let native = match f.native_rate {
|
||||
Some(n) if n != f.frame_rate => {
|
||||
if n.den == 1 && n.num > 0 && n.num <= i64::from(u32::MAX) {
|
||||
u32::try_from(n.num).ok()
|
||||
} else {
|
||||
col.lose(
|
||||
LossCategory::FootageItems,
|
||||
what(&format!("a clip with the native rate {} fps is written at its conformed rate only", n.label())),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let frames = (f.duration.seconds() * f64::from(fps)).round();
|
||||
let frames = if frames.is_finite() && frames >= 1.0 && frames < f64::from(u32::MAX) {
|
||||
frames as u32
|
||||
} else {
|
||||
col.lose(LossCategory::FootageItems, what("a duration that cannot be stored, written as 1 frame"));
|
||||
1
|
||||
};
|
||||
plan.seconds = f64::from(frames) / f64::from(fps.max(1));
|
||||
match native {
|
||||
Some(n) => {
|
||||
plan.fps = n;
|
||||
plan.conform = fps;
|
||||
plan.duration = (frames, n);
|
||||
}
|
||||
None => {
|
||||
plan.fps = fps;
|
||||
plan.duration = (frames, fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
FtKind::Wav => {
|
||||
// The sample rate is the denominator (351: 48 000 / 48 000 for 1 s).
|
||||
let secs = f.duration.seconds();
|
||||
let samples = (secs * 48_000.0).round();
|
||||
let samples = if samples.is_finite() && samples >= 1.0 && samples < f64::from(u32::MAX) {
|
||||
samples as u32
|
||||
} else {
|
||||
col.lose(LossCategory::FootageItems, what("a duration that cannot be stored, written as 1 second"));
|
||||
48_000
|
||||
};
|
||||
plan.duration = (samples, 48_000);
|
||||
plan.seconds = f64::from(samples) / 48_000.0;
|
||||
}
|
||||
FtKind::Png | FtKind::Jpeg | FtKind::Exr | FtKind::Tiff | FtKind::Tga | FtKind::Psd | FtKind::Ai | FtKind::Eps => {}
|
||||
}
|
||||
col.exported.footage = col.exported.footage.saturating_add(1);
|
||||
Some(plan)
|
||||
}
|
||||
|
||||
/// The alias JSON After Effects reads (`Als2/alas`, 126, 351, 353, 603), with the path escaped.
|
||||
pub(crate) fn alias_json(p: &FootagePlan) -> Vec<u8> {
|
||||
let mut path = String::with_capacity(p.alias.len() + 8);
|
||||
for c in p.alias.chars() {
|
||||
match c {
|
||||
'"' => path.push_str("\\\""),
|
||||
'\\' => path.push_str("\\\\"),
|
||||
c if (c as u32) < 0x20 => path.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => path.push(c),
|
||||
}
|
||||
}
|
||||
let folder = p.kind.is_sequence();
|
||||
format!(
|
||||
"{{\"ascendcount_base\":1,\"ascendcount_target\":{},\"fullpath\":\"{path}\",\"platform\":1,\"server_name\":\"\",\"server_volume_name\":\"\",\"target_is_folder\":{folder}}}",
|
||||
if folder { 2 } else { 1 }
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Emit.
|
||||
|
||||
use crate::Chunk;
|
||||
|
||||
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_cstr, put_u8, put_u16, put_u32, tpl};
|
||||
|
||||
/// The footage prototypes: per kind the item and the f-records that follow it.
|
||||
pub(crate) struct FtProtos {
|
||||
items: Vec<([u8; 4], Vec<Chunk<'static>>)>,
|
||||
}
|
||||
|
||||
/// Every kind the exporter writes.
|
||||
const KINDS: [FtKind; 14] = [
|
||||
FtKind::Png,
|
||||
FtKind::Jpeg,
|
||||
FtKind::Exr,
|
||||
FtKind::Mp4,
|
||||
FtKind::Wav,
|
||||
FtKind::PngSeq,
|
||||
FtKind::ExrSeq,
|
||||
FtKind::JpegSeq,
|
||||
FtKind::Mov,
|
||||
FtKind::Tiff,
|
||||
FtKind::Tga,
|
||||
FtKind::Psd,
|
||||
FtKind::Ai,
|
||||
FtKind::Eps,
|
||||
];
|
||||
|
||||
/// The colour management a kind's item carries (`Pin /CLRS`, 351, 353, 603-607, 611-613, 650-653).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Clrs {
|
||||
/// Stills, sequences and audio: interpreted in the sRGB profile (`apid` its id).
|
||||
Assigned,
|
||||
/// A file with an embedded profile (TIFF, Photoshop): `epid` its id, the profile twice.
|
||||
Embedded,
|
||||
/// Video and JPEG: Rec. 709 with the codec's colour description.
|
||||
Video(&'static str),
|
||||
}
|
||||
|
||||
/// The colour description After Effects gives a JPEG, a jpeg sequence and a ProRes clip.
|
||||
const BT709_FULL: &str = r#"{"baseColorProfile":{"colorProfileName":"BT.709 RGB Full"},"baseProfileType":1}"#;
|
||||
/// The colour description of an H.264 clip.
|
||||
const BT709_DISPLAY: &str =
|
||||
r#"{"baseColorProfile":{"colorProfileData":"AQAAAP////8=","colorProfileName":"BT.709,8-bit,Display-Referred"},"baseProfileType":1}"#;
|
||||
|
||||
/// What a kind's item stores beyond the decoded fields: the file type tag (`sspc` 22, `opti` 0),
|
||||
/// `idta` byte 23, the bits per pixel of the sampled file (`sspc` 63), `sspc` byte 73 (3, 1 for
|
||||
/// OpenEXR, 0 for Illustrator and EPS), the `opti` size and its header bytes 4, 5 and 10 (the rest of
|
||||
/// `opti` can be zero: read-back v-fN-zoptiA), and the colour management.
|
||||
struct KindSpec {
|
||||
tag: [u8; 4],
|
||||
idta23: u8,
|
||||
bpp: u8,
|
||||
b73: u8,
|
||||
opti: (u16, u8, u8, u8),
|
||||
clrs: Clrs,
|
||||
}
|
||||
|
||||
fn spec(kind: FtKind) -> KindSpec {
|
||||
let (tag, idta23, bpp, b73, opti, clrs) = match kind {
|
||||
FtKind::Png => (*b"png!", 0x30, 24, 3, (322, 0, 1, 1), Clrs::Assigned),
|
||||
FtKind::Jpeg => (*b"ZPEG", 0x30, 24, 3, (58, 0, 5, 0), Clrs::Video(BT709_FULL)),
|
||||
FtKind::Exr => (*b"oEXR", 0x30, 128, 1, (9750, 0, 1, 1), Clrs::Assigned),
|
||||
FtKind::Mp4 => (*b"XCEX", 0x20, 24, 3, (58, 0, 5, 0), Clrs::Video(BT709_DISPLAY)),
|
||||
FtKind::Wav => (*b"WAVE", 0x04, 0, 3, (58, 0, 5, 0), Clrs::Assigned),
|
||||
FtKind::PngSeq => (*b"png!", 0x20, 24, 3, (322, 0, 1, 1), Clrs::Assigned),
|
||||
FtKind::ExrSeq => (*b"oEXR", 0x20, 128, 1, (9750, 0, 1, 1), Clrs::Assigned),
|
||||
FtKind::JpegSeq => (*b"ZPEG", 0x20, 24, 3, (58, 0, 5, 0), Clrs::Video(BT709_FULL)),
|
||||
FtKind::Mov => (*b"MOoV", 0x20, 48, 3, (58, 0, 5, 0), Clrs::Video(BT709_FULL)),
|
||||
FtKind::Tiff => (*b"TIF ", 0x30, 24, 3, (602, 1, 9, 0), Clrs::Embedded),
|
||||
FtKind::Tga => (*b"TPIC", 0x30, 24, 3, (30, 0, 46, 5), Clrs::Assigned),
|
||||
FtKind::Psd => (*b"8BPS", 0x30, 24, 3, (602, 1, 9, 0), Clrs::Embedded),
|
||||
FtKind::Ai | FtKind::Eps => (*b"TEXT", 0x30, 32, 0, (596, 0, 8, 0), Clrs::Assigned),
|
||||
};
|
||||
KindSpec { tag, idta23, bpp, b73, opti, clrs }
|
||||
}
|
||||
|
||||
/// The colour-management list of a kind (the order After Effects writes).
|
||||
fn clrs(kind: Clrs) -> Chunk<'static> {
|
||||
use crate::export::base::utf8;
|
||||
let icc = crate::export::icc::srgb();
|
||||
let id = crate::export::icc::profile_id(&icc).to_vec();
|
||||
let json = crate::export::icc::profile_json(&icc, crate::export::icc::SRGB_NAME);
|
||||
let none = vec![0xffu8; 16];
|
||||
let one = |id: &[u8; 4]| Chunk::data(*id, vec![1u8]);
|
||||
let tail = |kids: &mut Vec<Chunk<'static>>| {
|
||||
kids.push(one(b"hdrm"));
|
||||
kids.push(utf8("{}"));
|
||||
};
|
||||
let mut kids = match kind {
|
||||
Clrs::Assigned => vec![
|
||||
Chunk::data(*b"epid", none),
|
||||
Chunk::data(*b"apid", id),
|
||||
Chunk::data(*b"linl", vec![2, 0, 0, 0]),
|
||||
one(b"embp"),
|
||||
Chunk::data(*b"ipws", vec![0]),
|
||||
one(b"Mcsp"),
|
||||
utf8(""),
|
||||
one(b"ocsp"),
|
||||
utf8(&json),
|
||||
],
|
||||
Clrs::Embedded => vec![
|
||||
Chunk::data(*b"epid", id),
|
||||
Chunk::data(*b"apid", none),
|
||||
Chunk::data(*b"linl", vec![2, 0, 0, 0]),
|
||||
one(b"embp"),
|
||||
Chunk::data(*b"ipws", vec![0]),
|
||||
one(b"mcsp"),
|
||||
utf8(&json),
|
||||
one(b"Mcsp"),
|
||||
utf8(&json),
|
||||
one(b"ocsp"),
|
||||
utf8(""),
|
||||
],
|
||||
Clrs::Video(desc) => vec![
|
||||
Chunk::data(*b"epid", none.clone()),
|
||||
Chunk::data(*b"apid", none),
|
||||
one(b"empd"),
|
||||
utf8("Rec. 709"),
|
||||
Chunk::data(*b"linl", vec![0; 4]),
|
||||
one(b"embp"),
|
||||
Chunk::data(*b"ipws", vec![0]),
|
||||
one(b"mcsp"),
|
||||
utf8(desc),
|
||||
one(b"Mcsp"),
|
||||
utf8(desc),
|
||||
one(b"ocsp"),
|
||||
utf8(""),
|
||||
],
|
||||
};
|
||||
tail(&mut kids);
|
||||
Chunk::list(*b"CLRS", kids)
|
||||
}
|
||||
|
||||
/// The item of a kind with zero ids and an empty alias (`build_footage` fills them), and the
|
||||
/// f-records after it. `sspc` holds the tag, the bits per pixel, the time base of a still (600:
|
||||
/// a clip's is its rate, written per item) and the bytes that have the same value in every
|
||||
/// generated test file of the kind (125, 129,
|
||||
/// 139, 143, 188..195; a sequence also 168, 184, 186, 187; a WAV its sample rate at 160); the bytes
|
||||
/// left zero were zero in read-backs v-fN-zsspc1..3. `mnfo` is left out (v-fN-nomnfo).
|
||||
fn kind_item(kind: FtKind) -> Vec<Chunk<'static>> {
|
||||
use crate::export::base::{bytes, utf8};
|
||||
let s = spec(kind);
|
||||
let mut fields: Vec<(usize, Vec<u8>)> = vec![
|
||||
(22, s.tag.to_vec()),
|
||||
(50, 600u32.to_be_bytes().to_vec()),
|
||||
(63, vec![s.bpp]),
|
||||
(73, vec![s.b73]),
|
||||
(125, vec![0x0c]),
|
||||
(129, vec![1]),
|
||||
(139, vec![1]),
|
||||
(143, vec![1]),
|
||||
(188, vec![0xff; 8]),
|
||||
];
|
||||
if kind.is_sequence() {
|
||||
fields.extend([(168, 2u32.to_be_bytes().to_vec()), (184, vec![1]), (186, vec![1, 1])]);
|
||||
}
|
||||
if kind == FtKind::Wav {
|
||||
fields.push((160, 48_000f64.to_be_bytes().to_vec()));
|
||||
}
|
||||
let refs: Vec<(usize, &[u8])> = fields.iter().map(|(at, v)| (*at, v.as_slice())).collect();
|
||||
let (len, b4, b5, b10) = s.opti;
|
||||
let opti = bytes(usize::from(len), &[(0, &s.tag), (4, &[b4, b5]), (8, &len.to_be_bytes()), (10, &[b10])]);
|
||||
let mut pin = vec![Chunk::data(*b"sspc", bytes(222, &refs)), utf8(""), Chunk::list(*b"Als2", vec![Chunk::data(*b"alas", b"{}".to_vec())])];
|
||||
if kind.is_sequence() {
|
||||
pin.extend([utf8(""), utf8("")]);
|
||||
}
|
||||
pin.extend([Chunk::data(*b"opti", opti), Chunk::data(*b"pgui", vec![0; 16]), clrs(s.clrs), utf8("")]);
|
||||
let item = Chunk::list(
|
||||
*b"Item",
|
||||
vec![
|
||||
Chunk::data(*b"iide", vec![0; 4]),
|
||||
Chunk::data(*b"idpc", vec![0; 8]),
|
||||
crate::export::base::idta(7, s.idta23),
|
||||
utf8(""),
|
||||
Chunk::list(*b"Pin ", pin),
|
||||
crate::export::base::ftgi(),
|
||||
utf8(""),
|
||||
crate::export::base::guides(),
|
||||
],
|
||||
);
|
||||
let mut out = vec![item];
|
||||
out.extend(crate::export::skeleton::f_records());
|
||||
out
|
||||
}
|
||||
|
||||
impl FtProtos {
|
||||
/// The items of every kind, built in code.
|
||||
pub(crate) fn build() -> FtProtos {
|
||||
FtProtos { items: KINDS.iter().map(|k| (k.key(), kind_item(*k))).collect() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The item (and its f-records) for `p`, with the id, label and name, and a fresh `pgui`.
|
||||
pub(crate) fn build_footage(protos: &FtProtos, id: u32, label: u8, name: &str, p: &FootagePlan, guid: [u8; 16]) -> R<Vec<Chunk<'static>>> {
|
||||
let key = p.kind.key();
|
||||
let mut chunks = protos.items.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone()).ok_or_else(|| tpl("footage prototype missing"))?;
|
||||
let item = chunks.get_mut(0).ok_or_else(|| tpl("empty footage prototype"))?;
|
||||
put(child_data(item, b"iide")?, 0, &id.to_le_bytes())?;
|
||||
{
|
||||
let idta = child_data(item, b"idta")?;
|
||||
put_u32(idta, 16, id)?;
|
||||
put_u8(idta, 58, label)?;
|
||||
}
|
||||
// After Effects names a footage item after its file and then stores no name of its own (126,
|
||||
// 603-607); a name of the user's is the item's `Utf8`. The WAV prototype keeps the name (351).
|
||||
let default = match &p.numbering {
|
||||
Some(n) => format!("{}[{:0w$}-{:0w$}]{}", n.prefix, n.start, n.end, n.suffix, w = usize::try_from(n.digits.min(16)).unwrap_or(0)),
|
||||
None => p.file_name.clone(),
|
||||
};
|
||||
let stored = if name == default && p.kind != FtKind::Wav { "" } else { name };
|
||||
crate::export::tree::set_utf8(item, stored)?;
|
||||
let pin = list_mut(item, b"Pin ")?;
|
||||
{
|
||||
let sspc = child_data(pin, b"sspc")?;
|
||||
put_u32(sspc, 30, p.width)?;
|
||||
put_u32(sspc, 34, p.height)?;
|
||||
put_u32(sspc, 38, p.duration.0)?;
|
||||
put_u32(sspc, 42, p.duration.1)?;
|
||||
if p.fps > 0 {
|
||||
// A clip counts its time in its native rate (606, 612: 25); stills, sequences and
|
||||
// audio in 600ths.
|
||||
if matches!(p.kind, FtKind::Mp4 | FtKind::Mov) {
|
||||
put_u32(sspc, 50, p.fps)?;
|
||||
}
|
||||
put_u32(sspc, 56, p.fps)?;
|
||||
put_u32(sspc, 150, if p.conform > 0 { p.conform } else { p.fps })?;
|
||||
}
|
||||
if p.conform > 0 {
|
||||
put_u32(sspc, 146, p.conform)?;
|
||||
}
|
||||
if let Some(n) = &p.numbering {
|
||||
put_u32(sspc, 172, n.start)?;
|
||||
put_u32(sspc, 176, n.end)?;
|
||||
put_u32(sspc, 180, n.digits)?;
|
||||
}
|
||||
if let (true, Some(t)) = (p.kind.is_sequence(), p.folder_time) {
|
||||
put_u32(sspc, 118, t)?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let als = list_mut(pin, b"Als2")?;
|
||||
let alas = kids_mut(als)?.iter_mut().find(|c| c.is(b"alas")).ok_or_else(|| tpl("footage prototype without alias"))?;
|
||||
*alas = Chunk::data(*b"alas", alias_json(p));
|
||||
}
|
||||
if let Some(n) = &p.numbering {
|
||||
// The two Utf8 between the alias and opti: the name around the number (353, 603).
|
||||
let kids = kids_mut(pin)?;
|
||||
let at = kids.iter().position(|c| c.is_list(b"Als2")).ok_or_else(|| tpl("footage prototype without alias"))?;
|
||||
let mut names = [n.prefix.as_str(), n.suffix.as_str()].into_iter();
|
||||
for c in kids.iter_mut().skip(at + 1).take_while(|c| !c.is(b"opti")) {
|
||||
if c.is(b"Utf8")
|
||||
&& let Some(text) = names.next()
|
||||
{
|
||||
*c = Chunk::data(*b"Utf8", text.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(p.kind, FtKind::Png | FtKind::PngSeq) {
|
||||
// png options: width and height (u16 at 20 and 24) and the (first) file's name at 58
|
||||
// (126, 353, 607).
|
||||
let opti = child_data(pin, b"opti")?;
|
||||
put_u16(opti, 20, u16::try_from(p.width).unwrap_or(u16::MAX))?;
|
||||
put_u16(opti, 24, u16::try_from(p.height).unwrap_or(u16::MAX))?;
|
||||
put_cstr(opti, 58, 256, &p.file_name)?;
|
||||
}
|
||||
put(child_data(pin, b"pgui")?, 0, &guid)?;
|
||||
Ok(chunks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every kind's item has the chunks After Effects refuses it without (`CLRS`, `ftgi`: read-backs
|
||||
/// u-noclrs, u-noftgi) and the f-records after it; `opti` has its header and its size.
|
||||
#[test]
|
||||
fn every_kind_has_what_after_effects_needs() {
|
||||
let protos = FtProtos::build();
|
||||
assert_eq!(protos.items.len(), KINDS.len());
|
||||
for (key, chunks) in &protos.items {
|
||||
let what = String::from_utf8_lossy(key).into_owned();
|
||||
let item = &chunks[0];
|
||||
let pin = item.child_list(b"Pin ").unwrap();
|
||||
assert!(pin.child_list(b"CLRS").is_some() && item.child(b"ftgi").is_some(), "{what}");
|
||||
assert_eq!(chunks.len(), 8, "{what}: the item and seven f-records");
|
||||
let opti = pin.child(b"opti").and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!(usize::from(u16::from_be_bytes([opti[8], opti[9]])), opti.len(), "{what}");
|
||||
let sspc = pin.child(b"sspc").and_then(|c| c.bytes()).unwrap();
|
||||
assert_eq!(&sspc[22..26], &opti[0..4], "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
//! The effects the exporter writes and the parameters of each, in `parT` order: what After
|
||||
//! Effects shows for an effect (its match name and name, each parameter's id, type, default, slider
|
||||
//! range and valid maximum; aep-format.md 3.7). The exporter builds an effect's `parT` and value
|
||||
//! group from this table (`synth`); After Effects takes the full parameter definitions from the
|
||||
//! effect itself (read-back e1-zpardfull).
|
||||
//!
|
||||
//! The rows were listed from our effect cases (356-397 and the later effect batches) through the
|
||||
//! reader; `effect_map` names the parameters the importer and exporter convert.
|
||||
|
||||
/// The id of the `ADBE Effect Built In Params` entry, the last of every effect.
|
||||
pub(crate) const BUILT_IN: u16 = u16::MAX;
|
||||
|
||||
/// One parameter: its id (the `-NNNN` of its match name, not its position), its type (`pard`
|
||||
/// code: 0 layer, 1 integer slider, 2 fixed slider, 3 angle, 4 checkbox, 5 colour, 6 point, 7
|
||||
/// popup, 9 no data, 10 float slider, 11 arbitrary data, 12 path, 13 / 14 group start / end, 15
|
||||
/// button), its default in stored units, its slider range and its valid maximum.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct PDef {
|
||||
pub id: u16,
|
||||
pub code: u8,
|
||||
pub default: &'static [f64],
|
||||
pub range: Option<(f64, f64)>,
|
||||
pub valid_max: Option<f64>,
|
||||
/// The valid minimum of a slider.
|
||||
pub valid_min: Option<f64>,
|
||||
/// The display word of a slider: the decimals and the percent switch (fixed and integer
|
||||
/// `pard@144`, float `pard@124`: aep-format.md 3.7); of a popup, its item count.
|
||||
pub display: u32,
|
||||
}
|
||||
|
||||
/// One effect.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FxDef {
|
||||
pub match_name: &'static str,
|
||||
/// The effect's name (`fnam`).
|
||||
pub name: &'static str,
|
||||
pub params: &'static [PDef],
|
||||
}
|
||||
|
||||
impl FxDef {
|
||||
/// The match name of parameter `p` (`ADBE Tint-0003`).
|
||||
pub fn record(&self, p: &PDef) -> String {
|
||||
if p.id == BUILT_IN { "ADBE Effect Built In Params".to_string() } else { format!("{}-{:04}", self.match_name, p.id) }
|
||||
}
|
||||
}
|
||||
|
||||
const fn p(id: u16, code: u8, default: &'static [f64], range: Option<(f64, f64)>, valid_max: Option<f64>, valid_min: Option<f64>, display: u32) -> PDef {
|
||||
PDef { id, code, default, range, valid_max, valid_min, display }
|
||||
}
|
||||
|
||||
const fn fx(match_name: &'static str, name: &'static str, params: &'static [PDef]) -> FxDef {
|
||||
FxDef { match_name, name, params }
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(crate) static FX_DEFS: &[FxDef] = &[
|
||||
fx("ADBE Tint", "Tint", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 5, &[0.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(2, 5, &[0.0, 255.0, 255.0, 255.0], None, None, None, 0x00000000),
|
||||
p(3, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(4, 15, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Ramp", "Gradient Ramp", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.0], None, None, None, 0x00000000),
|
||||
p(2, 5, &[255.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(3, 6, &[0.5, 1.0], None, None, None, 0x00000000),
|
||||
p(4, 5, &[255.0, 255.0, 255.0, 255.0], None, None, None, 0x00000000),
|
||||
p(5, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(6, 2, &[0.0], Some((0.0, 50.0)), Some(512.0), Some(0.0), 0x00010000),
|
||||
p(7, 2, &[0.0], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(8, 15, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Exposure2", "Exposure", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(2, 13, &[], None, None, None, 0x00000000),
|
||||
p(3, 10, &[0.0], Some((-4.0, 4.0)), Some(100.0), Some(-100.0), 0x00020000),
|
||||
p(4, 10, &[0.0], Some((-0.5, 0.5)), Some(2.0), Some(-2.0), 0x00040000),
|
||||
p(5, 10, &[1.0], Some((0.10000000149011612, 10.0)), Some(10.0), Some(0.10000000149011612), 0x00020000),
|
||||
p(6, 14, &[], None, None, None, 0x00000000),
|
||||
p(7, 13, &[], None, None, None, 0x00000000),
|
||||
p(8, 10, &[0.0], Some((-4.0, 4.0)), Some(100.0), Some(-100.0), 0x00020000),
|
||||
p(9, 10, &[0.0], Some((-0.5, 0.5)), Some(2.0), Some(-2.0), 0x00040000),
|
||||
p(10, 10, &[1.0], Some((0.10000000149011612, 10.0)), Some(10.0), Some(0.10000000149011612), 0x00020000),
|
||||
p(11, 14, &[], None, None, None, 0x00000000),
|
||||
p(12, 13, &[], None, None, None, 0x00000000),
|
||||
p(13, 10, &[0.0], Some((-4.0, 4.0)), Some(100.0), Some(-100.0), 0x00020000),
|
||||
p(14, 10, &[0.0], Some((-0.5, 0.5)), Some(2.0), Some(-2.0), 0x00040000),
|
||||
p(15, 10, &[1.0], Some((0.10000000149011612, 10.0)), Some(10.0), Some(0.10000000149011612), 0x00020000),
|
||||
p(16, 14, &[], None, None, None, 0x00000000),
|
||||
p(17, 13, &[], None, None, None, 0x00000000),
|
||||
p(18, 10, &[0.0], Some((-4.0, 4.0)), Some(100.0), Some(-100.0), 0x00020000),
|
||||
p(19, 10, &[0.0], Some((-0.5, 0.5)), Some(2.0), Some(-2.0), 0x00040000),
|
||||
p(20, 10, &[1.0], Some((0.10000000149011612, 10.0)), Some(10.0), Some(0.10000000149011612), 0x00020000),
|
||||
p(21, 14, &[], None, None, None, 0x00000000),
|
||||
p(22, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Invert", "Invert", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000010),
|
||||
p(2, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00000001),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Box Blur2", "Fast Box Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 127.0)), Some(30000.0), Some(0.0), 0x00010000),
|
||||
p(2, 1, &[3.0], Some((1.0, 5.0)), Some(50.0), Some(1.0), 0x00000000),
|
||||
p(3, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(4, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Gaussian Blur 2", "Gaussian Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 10, &[25.0], Some((0.0, 50.0)), Some(30000.0), Some(0.0), 0x00010000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(3, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Drop Shadow", "Drop Shadow", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 5, &[255.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(2, 2, &[127.5], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000001),
|
||||
p(3, 3, &[135.0], None, None, None, 0x00000000),
|
||||
p(4, 2, &[5.0], Some((0.0, 120.0)), Some(4000.0), Some(0.0), 0x00010000),
|
||||
p(5, 2, &[0.0], Some((0.0, 250.0)), Some(30000.0), Some(0.0), 0x00010000),
|
||||
p(6, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Timewarp", "Timewarp", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(3, 2, &[50.0], Some((-100.0, 100.0)), Some(5000.0), Some(-5000.0), 0x00020000),
|
||||
p(4, 2, &[0.0], Some((0.0, 1000.0)), Some(5000.0), Some(-5000.0), 0x00020000),
|
||||
p(5, 13, &[], None, None, None, 0x00000000),
|
||||
p(6, 2, &[20.0], Some((5.0, 100.0)), Some(100.0), Some(5.0), 0x00020000),
|
||||
p(7, 13, &[], None, None, None, 0x00000000),
|
||||
p(8, 2, &[500.0], Some((-1000.0, 1000.0)), Some(5000.0), Some(-5000.0), 0x00020000),
|
||||
p(9, 2, &[500.0], Some((-1000.0, 1000.0)), Some(5000.0), Some(-5000.0), 0x00020000),
|
||||
p(10, 1, &[5.0], Some((1.0, 50.0)), Some(300.0), Some(1.0), 0x00000000),
|
||||
p(11, 14, &[], None, None, None, 0x00000000),
|
||||
p(12, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(13, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(14, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(15, 2, &[1.0], Some((0.0, 10.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(16, 1, &[6.0], Some((4.0, 40.0)), Some(1000.0), Some(4.0), 0x00000000),
|
||||
p(17, 13, &[], None, None, None, 0x00000000),
|
||||
p(18, 2, &[29.899993896484375], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(19, 2, &[58.69999694824219], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(20, 2, &[11.44000244140625], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(277, 14, &[], None, None, None, 0x00000000),
|
||||
p(278, 14, &[], None, None, None, 0x00000000),
|
||||
p(23, 13, &[], None, None, None, 0x00000000),
|
||||
p(24, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(25, 7, &[2.0], None, None, None, 0x00000002),
|
||||
p(26, 2, &[180.0], Some((0.0, 360.0)), Some(5000.0), Some(0.0), 0x00020000),
|
||||
p(27, 1, &[5.0], Some((1.0, 20.0)), Some(1000.0), Some(1.0), 0x00000000),
|
||||
p(28, 14, &[], None, None, None, 0x00000000),
|
||||
p(29, 0, &[], None, None, None, 0x00000000),
|
||||
p(30, 7, &[3.0], None, None, None, 0x00000004),
|
||||
p(31, 0, &[], None, None, None, 0x00000000),
|
||||
p(32, 7, &[1.0], None, None, None, 0x00000004),
|
||||
p(33, 13, &[], None, None, None, 0x00000000),
|
||||
p(34, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(35, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(36, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(37, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(38, 14, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Glo2", "Glow", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[2.0], None, None, None, 0x00000002),
|
||||
p(2, 2, &[153.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00010001),
|
||||
p(3, 2, &[10.0], Some((0.0, 100.0)), Some(1000.0), Some(0.0), 0x00010000),
|
||||
p(4, 2, &[1.0], Some((0.0, 4.0)), Some(255.0), Some(0.0), 0x00010000),
|
||||
p(5, 7, &[2.0], None, None, None, 0x00000003),
|
||||
p(6, 7, &[3.0], None, None, None, 0x00000019),
|
||||
p(7, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(8, 7, &[3.0], None, None, None, 0x00000004),
|
||||
p(9, 2, &[1.0], Some((1.0, 10.0)), Some(127.0), Some(1.0), 0x00010000),
|
||||
p(10, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(11, 2, &[0.5], Some((0.0099945068359375, 0.9900054931640625)), Some(0.9900054931640625), Some(0.0099945068359375), 0x00000001),
|
||||
p(12, 5, &[255.0, 255.0, 255.0, 255.0], None, None, None, 0x00000000),
|
||||
p(13, 5, &[255.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(14, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Fill", "Fill", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 12, &[], None, None, None, 0x00000000),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(2, 5, &[255.0, 255.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(6, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(3, 2, &[0.0], Some((0.0, 50.0)), Some(999.0), Some(0.0), 0x00010000),
|
||||
p(4, 2, &[0.0], Some((0.0, 50.0)), Some(999.0), Some(0.0), 0x00010000),
|
||||
p(5, 2, &[1.0], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE HUE SATURATION", "Hue/Saturation", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000007),
|
||||
p(3, 11, &[], None, None, None, 0x00000000),
|
||||
p(4, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(5, 2, &[0.0], Some((-100.0, 100.0)), Some(100.0), Some(-100.0), 0x00000000),
|
||||
p(6, 2, &[0.0], Some((-100.0, 100.0)), Some(100.0), Some(-100.0), 0x00000000),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(8, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(9, 2, &[25.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00000000),
|
||||
p(10, 2, &[0.0], Some((-100.0, 100.0)), Some(100.0), Some(-100.0), 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Easy Levels2", "Levels", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000005),
|
||||
p(2, 11, &[], None, None, None, 0x00000000),
|
||||
p(3, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(4, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(5, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(6, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(7, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(8, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(9, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Motion Blur", "Directional Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 3, &[90.0], None, None, None, 0x00000000),
|
||||
p(2, 2, &[10.0], Some((0.0, 20.0)), Some(1000.0), Some(0.0), 0x00010000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Radial Blur", "Radial Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(5, 9, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[10.0], Some((0.0, 100.0)), Some(1000.0), Some(0.0), 0x00010000),
|
||||
p(2, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(3, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(4, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(6, 1, &[0.0], Some((0.0, 1000.0)), Some(10000.0), Some(0.0), 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("CC Radial Blur", "CC Radial Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[6.0], None, None, None, 0x00000007),
|
||||
p(2, 2, &[0.0], Some((-100.0, 100.0)), Some(250.0), Some(-250.0), 0x00010000),
|
||||
p(3, 2, &[50.0], Some((25.0, 100.0)), Some(100.0), Some(10.0), 0x00010000),
|
||||
p(4, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Venetian Blinds", "Venetian Blinds", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00000001),
|
||||
p(2, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(3, 2, &[20.0], Some((1.0, 127.0)), Some(32000.0), Some(1.0), 0x00000000),
|
||||
p(4, 2, &[0.0], Some((0.0, 100.0)), Some(32000.0), Some(0.0), 0x00010000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Channel Blur", "Channel Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 127.0)), Some(32767.0), Some(0.0), 0x00010000),
|
||||
p(2, 2, &[0.0], Some((0.0, 127.0)), Some(32767.0), Some(0.0), 0x00010000),
|
||||
p(3, 2, &[0.0], Some((0.0, 127.0)), Some(32767.0), Some(0.0), 0x00010000),
|
||||
p(4, 2, &[0.0], Some((0.0, 127.0)), Some(32767.0), Some(0.0), 0x00010000),
|
||||
p(5, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(6, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Black&White", "Black & White", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 10, &[40.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(2, 10, &[60.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(3, 10, &[40.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(4, 10, &[60.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(5, 10, &[20.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(6, 10, &[80.0], Some((-200.0, 300.0)), Some(300.0), Some(-200.0), 0x00010000),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(8, 5, &[255.0, 225.0, 211.0, 179.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Tritone", "Tritone", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 5, &[0.0, 255.0, 255.0, 255.0], None, None, None, 0x00000000),
|
||||
p(2, 5, &[0.0, 127.0, 100.0, 70.0], None, None, None, 0x00000000),
|
||||
p(3, 5, &[0.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(4, 10, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Luma Key", "Luma Key", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[2.0], None, None, None, 0x00000004),
|
||||
p(2, 1, &[0.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(3, 1, &[0.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(4, 1, &[0.0], Some((-5.0, 5.0)), Some(5.0), Some(-5.0), 0x00000000),
|
||||
p(5, 2, &[0.0], Some((0.0, 10.0)), Some(100.0), Some(0.0), 0x00010000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Simple Choker", "Simple Choker", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(2, 2, &[0.0], Some((-10.0, 10.0)), Some(100.0), Some(-100.0), 0x00020000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Matte Choker", "Matte Choker", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[4.0], Some((0.0, 10.0)), Some(100.0), Some(0.0), 0x00010000),
|
||||
p(2, 1, &[75.0], Some((-127.0, 127.0)), Some(127.0), Some(-127.0), 0x00000000),
|
||||
p(3, 2, &[0.100006103515625], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00000001),
|
||||
p(4, 2, &[0.0], Some((0.0, 10.0)), Some(100.0), Some(0.0), 0x00020000),
|
||||
p(5, 1, &[0.0], Some((-127.0, 127.0)), Some(127.0), Some(-127.0), 0x00000000),
|
||||
p(6, 2, &[1.0], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(7, 1, &[1.0], Some((1.0, 10.0)), Some(100.0), Some(1.0), 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Geometry2", "Transform", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(2, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(11, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(3, 2, &[100.0], Some((-200.0, 200.0)), Some(30000.0), Some(-30000.0), 0x00010000),
|
||||
p(4, 2, &[100.0], Some((-200.0, 200.0)), Some(30000.0), Some(-30000.0), 0x00010000),
|
||||
p(5, 2, &[0.0], Some((-70.0, 70.0)), Some(70.0), Some(-70.0), 0x00010000),
|
||||
p(6, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(7, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(8, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010000),
|
||||
p(9, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(10, 2, &[0.0], Some((0.0, 360.0)), Some(360.0), Some(0.0), 0x00020000),
|
||||
p(12, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Brightness & Contrast 2", "Brightness & Contrast", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((-150.0, 150.0)), Some(150.0), Some(-150.0), 0x00000000),
|
||||
p(2, 2, &[0.0], Some((-50.0, 100.0)), Some(100.0), Some(-100.0), 0x00000000),
|
||||
p(3, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Turbulent Displace", "Turbulent Displace", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x0000000b),
|
||||
p(2, 2, &[50.0], Some((0.0, 100.0)), Some(10000.0), Some(-10000.0), 0x00010000),
|
||||
p(3, 2, &[100.0], Some((5.0, 400.0)), Some(1000.0), Some(2.0), 0x00010000),
|
||||
p(4, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(5, 2, &[1.0], Some((1.0, 5.0)), Some(10.0), Some(1.0), 0x00010000),
|
||||
p(6, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(7, 13, &[], None, None, None, 0x00000000),
|
||||
p(8, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(9, 1, &[1.0], Some((1.0, 30.0)), Some(88.0), Some(1.0), 0x00000000),
|
||||
p(10, 1, &[0.0], Some((0.0, 1000.0)), Some(100000.0), Some(0.0), 0x00000000),
|
||||
p(11, 14, &[], None, None, None, 0x00000000),
|
||||
p(12, 7, &[3.0], None, None, None, 0x00000011),
|
||||
p(13, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(14, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Fractal Noise", "Fractal Noise", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000014),
|
||||
p(2, 7, &[3.0], None, None, None, 0x00000004),
|
||||
p(3, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(4, 2, &[100.0], Some((0.0, 400.0)), Some(10000.0), Some(0.0), 0x00010000),
|
||||
p(5, 2, &[0.0], Some((-100.0, 100.0)), Some(10000.0), Some(-10000.0), 0x00010000),
|
||||
p(6, 7, &[4.0], None, None, None, 0x00000004),
|
||||
p(7, 13, &[], None, None, None, 0x00000000),
|
||||
p(8, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(9, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(10, 2, &[100.0], Some((20.0, 600.0)), Some(10000.0), Some(1.0), 0x00010000),
|
||||
p(11, 2, &[100.0], Some((20.0, 600.0)), Some(10000.0), Some(1.0), 0x00010000),
|
||||
p(12, 2, &[100.0], Some((20.0, 600.0)), Some(10000.0), Some(1.0), 0x00010000),
|
||||
p(13, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(31, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(14, 14, &[], None, None, None, 0x00000000),
|
||||
p(15, 2, &[6.0], Some((1.0, 10.0)), Some(20.0), Some(1.0), 0x00010000),
|
||||
p(16, 13, &[], None, None, None, 0x00000000),
|
||||
p(17, 2, &[70.0], Some((25.0, 100.0)), Some(10000.0), Some(1.52587890625e-05), 0x00010000),
|
||||
p(18, 2, &[56.0], Some((25.0, 100.0)), Some(10000.0), Some(10.0), 0x00010000),
|
||||
p(19, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(20, 6, &[0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(21, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(22, 14, &[], None, None, None, 0x00000000),
|
||||
p(23, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(24, 13, &[], None, None, None, 0x00000000),
|
||||
p(25, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(26, 1, &[1.0], Some((1.0, 30.0)), Some(88.0), Some(1.0), 0x00000000),
|
||||
p(27, 1, &[0.0], Some((0.0, 1000.0)), Some(100000.0), Some(0.0), 0x00000000),
|
||||
p(28, 14, &[], None, None, None, 0x00000000),
|
||||
p(29, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(30, 7, &[2.0], None, None, None, 0x00000015),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Laser", "Beam", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.100006103515625, 0.5], None, None, None, 0x00000000),
|
||||
p(2, 6, &[0.899993896484375, 0.5], None, None, None, 0x00000000),
|
||||
p(3, 2, &[0.25], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(4, 2, &[0.5], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(5, 2, &[8.0], Some((0.0, 50.0)), Some(4000.0), Some(0.0), 0x00020000),
|
||||
p(6, 2, &[8.0], Some((0.0, 50.0)), Some(4000.0), Some(0.0), 0x00020000),
|
||||
p(7, 2, &[0.5], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(8, 5, &[185.0, 255.0, 190.0, 250.0], None, None, None, 0x00000000),
|
||||
p(9, 5, &[150.0, 255.0, 25.0, 25.0], None, None, None, 0x00000000),
|
||||
p(10, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(11, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("CC Radial Fast Blur", "CC Radial Fast Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(2, 2, &[50.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010000),
|
||||
p(3, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Noise2", "Noise", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(2, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(3, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Pro Levels2", "Levels (Individual Controls)", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000005),
|
||||
p(2, 11, &[], None, None, None, 0x00000000),
|
||||
p(3, 13, &[], None, None, None, 0x00000000),
|
||||
p(4, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(5, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(6, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(7, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(8, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(9, 14, &[], None, None, None, 0x00000000),
|
||||
p(10, 13, &[], None, None, None, 0x00000000),
|
||||
p(11, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(12, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(13, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(14, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(15, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(16, 14, &[], None, None, None, 0x00000000),
|
||||
p(17, 13, &[], None, None, None, 0x00000000),
|
||||
p(18, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(19, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(20, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(21, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(22, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(23, 14, &[], None, None, None, 0x00000000),
|
||||
p(24, 13, &[], None, None, None, 0x00000000),
|
||||
p(25, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(26, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(27, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(28, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(29, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(30, 14, &[], None, None, None, 0x00000000),
|
||||
p(31, 13, &[], None, None, None, 0x00000000),
|
||||
p(32, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(33, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(34, 10, &[1.0], Some((0.0, 5.0)), Some(5.0), Some(0.0), 0x00020000),
|
||||
p(35, 10, &[0.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(36, 10, &[1.0], Some((0.0, 1.0)), Some(10000.0), Some(-10000.0), 0x00010002),
|
||||
p(37, 14, &[], None, None, None, 0x00000000),
|
||||
p(38, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(39, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Corner Pin", "Corner Pin", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(2, 6, &[1.0, 0.0], None, None, None, 0x00000000),
|
||||
p(3, 6, &[0.0, 1.0], None, None, None, 0x00000000),
|
||||
p(4, 6, &[1.0, 1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Grid", "Grid", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(3, 6, &[0.600006103515625, 0.600006103515625], None, None, None, 0x00000000),
|
||||
p(4, 2, &[32.0], Some((4.0, 200.0)), Some(4000.0), Some(1.0), 0x00010000),
|
||||
p(5, 2, &[24.0], Some((4.0, 200.0)), Some(4000.0), Some(1.0), 0x00010000),
|
||||
p(6, 2, &[5.0], Some((0.0, 100.0)), Some(2000.0), Some(0.0), 0x00010000),
|
||||
p(7, 13, &[], None, None, None, 0x00000000),
|
||||
p(8, 2, &[0.0], Some((0.0, 20.0)), Some(400.0), Some(0.0), 0x00010000),
|
||||
p(9, 2, &[0.0], Some((0.0, 20.0)), Some(400.0), Some(0.0), 0x00010000),
|
||||
p(10, 14, &[], None, None, None, 0x00000000),
|
||||
p(11, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(12, 5, &[255.0, 255.0, 255.0, 255.0], None, None, None, 0x00000000),
|
||||
p(13, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(14, 7, &[1.0], None, None, None, 0x00000017),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Sharpen", "Sharpen", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 1, &[0.0], Some((0.0, 100.0)), Some(4000.0), Some(0.0), 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Linear Color Key2", "Linear Color Key", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 9, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(3, 5, &[255.0, 0.0, 0.0, 255.0], None, None, None, 0x00000000),
|
||||
p(4, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(5, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(6, 2, &[10.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(7, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Radial Wipe", "Radial Wipe", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00000001),
|
||||
p(2, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(3, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(4, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(5, 2, &[0.0], Some((0.0, 100.0)), Some(32000.0), Some(0.0), 0x00010000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Remove Color Matting", "Remove Color Matting", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 5, &[0.0, 0.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(2, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("CC Bend It", "CC Bend It", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((-100.0, 100.0)), Some(250.0), Some(-250.0), 0x00010000),
|
||||
p(2, 6, &[0.5, 0.75], None, None, None, 0x00000000),
|
||||
p(3, 6, &[0.5, 0.25], None, None, None, 0x00000000),
|
||||
p(4, 7, &[3.0], None, None, None, 0x00000004),
|
||||
p(5, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("CC Power Pin", "CC Power Pin", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 6, &[0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(3, 6, &[1.0, 0.0], None, None, None, 0x00000000),
|
||||
p(4, 6, &[0.0, 1.0], None, None, None, 0x00000000),
|
||||
p(5, 6, &[1.0, 1.0], None, None, None, 0x00000000),
|
||||
p(6, 2, &[1.0], Some((0.0, 1.0)), Some(1.0), Some(0.0), 0x00010001),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(8, 13, &[], None, None, None, 0x00000000),
|
||||
p(9, 2, &[0.0], Some((0.0, 50.0)), Some(200.0), Some(-200.0), 0x00010000),
|
||||
p(10, 2, &[0.0], Some((0.0, 50.0)), Some(200.0), Some(-200.0), 0x00010000),
|
||||
p(11, 2, &[0.0], Some((0.0, 50.0)), Some(200.0), Some(-200.0), 0x00010000),
|
||||
p(12, 2, &[0.0], Some((0.0, 50.0)), Some(200.0), Some(-200.0), 0x00010000),
|
||||
p(13, 14, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE 4ColorGradient", "4-Color Gradient", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(11, 13, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.100006103515625, 0.100006103515625], None, None, None, 0x00000000),
|
||||
p(2, 5, &[255.0, 255.0, 255.0, 0.0], None, None, None, 0x00000000),
|
||||
p(3, 6, &[0.899993896484375, 0.100006103515625], None, None, None, 0x00000000),
|
||||
p(4, 5, &[255.0, 0.0, 255.0, 0.0], None, None, None, 0x00000000),
|
||||
p(5, 6, &[0.100006103515625, 0.899993896484375], None, None, None, 0x00000000),
|
||||
p(6, 5, &[255.0, 255.0, 0.0, 255.0], None, None, None, 0x00000000),
|
||||
p(7, 6, &[0.899993896484375, 0.899993896484375], None, None, None, 0x00000000),
|
||||
p(8, 5, &[255.0, 0.0, 0.0, 255.0], None, None, None, 0x00000000),
|
||||
p(12, 14, &[], None, None, None, 0x00000000),
|
||||
p(9, 2, &[100.0], Some((10.0, 500.0)), Some(10000.0), Some(5.0), 0x00010000),
|
||||
p(10, 2, &[0.0], Some((0.0, 100.0)), Some(500.0), Some(0.0), 0x00010001),
|
||||
p(13, 2, &[100.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(14, 7, &[1.0], None, None, None, 0x00000016),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Change Color", "Change Color", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(2, 2, &[0.0], Some((-180.0, 180.0)), Some(1800.0), Some(-1800.0), 0x00010000),
|
||||
p(3, 2, &[0.0], Some((-100.0, 100.0)), Some(100.0), Some(-100.0), 0x00010000),
|
||||
p(4, 2, &[0.0], Some((-100.0, 100.0)), Some(100.0), Some(-100.0), 0x00010000),
|
||||
p(5, 5, &[255.0, 0.0, 0.0, 255.0], None, None, None, 0x00000000),
|
||||
p(6, 2, &[15.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(7, 2, &[0.0], Some((0.0, 100.0)), Some(100.0), Some(0.0), 0x00010001),
|
||||
p(8, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(9, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Extract", "Extract", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 9, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000005),
|
||||
p(3, 1, &[0.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(4, 1, &[255.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(5, 1, &[0.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(6, 1, &[0.0], Some((0.0, 255.0)), Some(255.0), Some(0.0), 0x00000000),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Noise HLS2", "Noise HLS", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(2, 10, &[0.0], Some((0.0, 100.0)), Some(1000.0), Some(0.0), 0x00010001),
|
||||
p(3, 10, &[0.0], Some((0.0, 100.0)), Some(1000.0), Some(0.0), 0x00010001),
|
||||
p(4, 10, &[0.0], Some((0.0, 100.0)), Some(1000.0), Some(0.0), 0x00010001),
|
||||
p(5, 2, &[1.0], Some((0.0, 4.0)), Some(10.0), Some(0.0), 0x00020000),
|
||||
p(6, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Scatter", "Scatter", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 127.0)), Some(1000.0), Some(0.0), 0x00010000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(3, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("CC Light Sweep", "CC Light Sweep", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.25], None, None, None, 0x00000000),
|
||||
p(2, 3, &[-30.0], None, None, None, 0x00000000),
|
||||
p(3, 7, &[3.0], None, None, None, 0x00000003),
|
||||
p(4, 2, &[50.0], Some((0.0, 200.0)), Some(4000.0), Some(0.0), 0x00010000),
|
||||
p(5, 2, &[25.0], Some((0.0, 100.0)), Some(500.0), Some(0.0), 0x00010000),
|
||||
p(6, 2, &[50.0], Some((0.0, 100.0)), Some(500.0), Some(0.0), 0x00010000),
|
||||
p(7, 2, &[4.0], Some((0.0, 10.0)), Some(20.0), Some(0.0), 0x00020000),
|
||||
p(8, 5, &[255.0, 255.0, 250.0, 240.0], None, None, None, 0x00000000),
|
||||
p(9, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Compound Blur", "Compound Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 2, &[20.0], Some((0.0, 100.0)), Some(4000.0), Some(0.0), 0x00010000),
|
||||
p(3, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(4, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Displacement Map", "Displacement Map", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x0000000b),
|
||||
p(3, 2, &[5.0], Some((-100.0, 100.0)), Some(32000.0), Some(-32000.0), 0x00010000),
|
||||
p(4, 7, &[2.0], None, None, None, 0x0000000b),
|
||||
p(5, 2, &[5.0], Some((-100.0, 100.0)), Some(32000.0), Some(-32000.0), 0x00010000),
|
||||
p(6, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(7, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(8, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Set Matte3", "Set Matte", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[4.0], None, None, None, 0x0000000a),
|
||||
p(3, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(4, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(5, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(6, 4, &[1.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Gaussian Blur", "Gaussian Blur (Legacy)", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 2, &[0.0], Some((0.0, 50.0)), Some(30000.0), Some(0.0), 0x00010000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000003),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE OFMotionBlur", "Pixel Motion Blur", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 7, &[1.0], None, None, None, 0x00000002),
|
||||
p(2, 10, &[180.0], Some((0.0, 360.0)), Some(5000.0), Some(0.0), 0x00020000),
|
||||
p(3, 1, &[5.0], Some((1.0, 20.0)), Some(256.0), Some(1.0), 0x00000000),
|
||||
p(4, 10, &[20.0], Some((5.0, 100.0)), Some(100.0), Some(5.0), 0x00010000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Layer Control", "Layer Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 0, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Slider Control", "Slider Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 10, &[0.0], Some((0.0, 100.0)), Some(1000000.0), Some(-1000000.0), 0x00020000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Angle Control", "Angle Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 3, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Checkbox Control", "Checkbox Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 4, &[0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Color Control", "Color Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 5, &[255.0, 255.0, 0.0, 0.0], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE Point Control", "Point Control", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 6, &[0.5, 0.5], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE CurvesCustom", "Curves", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(2, 7, &[1.0], None, None, None, 0x00000005),
|
||||
p(1, 11, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
fx("ADBE MESH WARP", "Mesh Warp", &[
|
||||
p(0, 0, &[], None, None, None, 0x00000000),
|
||||
p(1, 1, &[7.0], Some((1.0, 31.0)), Some(31.0), Some(1.0), 0x00000000),
|
||||
p(2, 1, &[7.0], Some((1.0, 31.0)), Some(31.0), Some(1.0), 0x00000000),
|
||||
p(5, 1, &[8.0], Some((1.0, 10.0)), Some(10.0), Some(1.0), 0x00000000),
|
||||
p(4, 11, &[], None, None, None, 0x00000000),
|
||||
p(BUILT_IN, 9, &[], None, None, None, 0x00000000),
|
||||
]),
|
||||
];
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Effect instances built in code from the effect table (`fx_defs`): the default `sspc` of every
|
||||
//! effect type the exporter writes, its project copy (`EfDf`) and the value records of each
|
||||
//! parameter type (aep-format.md 3.7).
|
||||
//!
|
||||
//! What After Effects needs (read-backs in `docs/aep-export.md`, "Built in code"): a `parT` entry
|
||||
//! per parameter (`tdmn` + a 148-byte `pard`) whose bytes may be zero, since After Effects takes
|
||||
//! the definitions from the effect itself (e1-zpardfull); no `sdat` (e2-nosdat). The `pard` fields
|
||||
//! the importer reads back (type, default, valid maximum, slider range, popup item count) are
|
||||
//! written from the table so that an exported file imports again.
|
||||
|
||||
use crate::Chunk;
|
||||
use crate::effect_map::{Conv, by_match_name};
|
||||
use crate::export::fx_defs::{BUILT_IN, FxDef, PDef};
|
||||
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdmn, tdsn};
|
||||
use crate::{CURVE_IDENTITY, CurvesData, MeshData};
|
||||
|
||||
/// The layer a prototype's layer parameters name when their default is the owning layer; the
|
||||
/// exporter replaces it with the owning layer's id (`effects::instance`).
|
||||
pub(crate) const PROTO_OWNER: u32 = 1;
|
||||
|
||||
/// Layer parameters whose default is None instead of the owning layer (Timewarp's Matte Layer and
|
||||
/// Warp Layer, 529).
|
||||
const LAYER_NONE: &[&str] = &["ADBE Timewarp-0029", "ADBE Timewarp-0031"];
|
||||
|
||||
/// The record of a parameter type (`pard` code): its components, key template and class word.
|
||||
pub(crate) fn value_spec(code: u8, range: Option<(f64, f64)>) -> Option<RecSpec> {
|
||||
let scalar = |class| RecSpec { dims: 1, template: KeyTemplate::Plain, class, limits: None, byte5: None };
|
||||
Some(match code {
|
||||
// Sliders carry their range as `tdum` / `tduM`.
|
||||
1 | 2 | 10 => RecSpec { limits: Some(range.unwrap_or((0.0, 0.0))), ..scalar(class::EFFECT_NUMBER) },
|
||||
3 => scalar(class::EFFECT_NUMBER),
|
||||
4 | 7 => scalar(class::EFFECT_CHOICE),
|
||||
5 => RecSpec { dims: 4, template: KeyTemplate::Spatial(1), class: class::COLOR, limits: None, byte5: None },
|
||||
6 => RecSpec { dims: 2, template: KeyTemplate::Spatial(3), class: class::SPATIAL, limits: None, byte5: None },
|
||||
0 | 13 | 14 | 15 => scalar(class::EFFECT_OTHER),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn put(b: &mut [u8], at: usize, bytes: &[u8]) {
|
||||
if let Some(dst) = b.get_mut(at..at + bytes.len()) {
|
||||
dst.copy_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
/// The 148-byte `pard` of a parameter: the type at 12, the default at 56 and the copies After
|
||||
/// Effects reads it from (s8-e1..s12-e2), a popup's item count at 60, the valid range, slider
|
||||
/// range and display word of a slider (float: `f32` at 104 / 108 / 112 / 116, default 120,
|
||||
/// display 124; fixed and integer: 124 / 128 / 132 / 136, default 140, display 144). Every other
|
||||
/// byte is zero (the switches at 48 are not needed: s12-e2-nofl).
|
||||
pub(crate) fn pard(p: &PDef, popup_items: Option<u16>) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 148];
|
||||
put(&mut b, 12, &u32::from(p.code).to_be_bytes());
|
||||
let d = |i: usize| p.default.get(i).copied().unwrap_or(0.0);
|
||||
// After Effects takes the default of a parameter without a value record from here (s8-e1:
|
||||
// the copies below are the ones it reads).
|
||||
match p.code {
|
||||
10 => {
|
||||
put(&mut b, 56, &d(0).to_be_bytes());
|
||||
// The default again as `f32` at 120, the display word (decimals) at 124.
|
||||
put(&mut b, 120, &(d(0) as f32).to_be_bytes());
|
||||
put(&mut b, 124, &p.display.to_be_bytes());
|
||||
}
|
||||
2 => put(&mut b, 56, &fixed(d(0))),
|
||||
// An angle: the default again at 60.
|
||||
3 => {
|
||||
put(&mut b, 56, &fixed(d(0)));
|
||||
put(&mut b, 60, &fixed(d(0)));
|
||||
}
|
||||
1 => put(&mut b, 56, &(d(0).round() as i32).to_be_bytes()),
|
||||
// A checkbox: the default again as a byte at 60.
|
||||
4 => {
|
||||
put(&mut b, 56, &(d(0).round().max(0.0) as u32).to_be_bytes());
|
||||
put(&mut b, 60, &[u8::from(d(0) != 0.0)]);
|
||||
}
|
||||
// A popup: the default again as `u16` at 62 (the item count is at 60).
|
||||
7 => {
|
||||
let sel = d(0).round().clamp(0.0, f64::from(u16::MAX));
|
||||
put(&mut b, 56, &(sel as u32).to_be_bytes());
|
||||
put(&mut b, 62, &(sel as u16).to_be_bytes());
|
||||
}
|
||||
6 => {
|
||||
// Fractions of the layer at 56 / 60 and the same in percent at 68 / 72.
|
||||
put(&mut b, 56, &fixed(d(0)));
|
||||
put(&mut b, 60, &fixed(d(1)));
|
||||
put(&mut b, 68, &fixed(d(0) * 100.0));
|
||||
put(&mut b, 72, &fixed(d(1) * 100.0));
|
||||
}
|
||||
5 => {
|
||||
// ARGB at 56 and the same at 60.
|
||||
let c: Vec<u8> = (0..4).map(|i| d(i).round().clamp(0.0, 255.0) as u8).collect();
|
||||
put(&mut b, 56, &c);
|
||||
put(&mut b, 60, &c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if p.code == 7 {
|
||||
let n = popup_items.unwrap_or_else(|| u16::try_from(p.display).unwrap_or(0));
|
||||
put(&mut b, 60, &n.to_be_bytes());
|
||||
}
|
||||
match p.code {
|
||||
10 => {
|
||||
if let Some(m) = p.valid_min {
|
||||
put(&mut b, 104, &(m as f32).to_be_bytes());
|
||||
}
|
||||
if let Some(m) = p.valid_max {
|
||||
put(&mut b, 108, &(m as f32).to_be_bytes());
|
||||
}
|
||||
if let Some((lo, hi)) = p.range {
|
||||
put(&mut b, 112, &(lo as f32).to_be_bytes());
|
||||
put(&mut b, 116, &(hi as f32).to_be_bytes());
|
||||
}
|
||||
}
|
||||
1 | 2 => {
|
||||
let enc = |v: f64| if p.code == 2 { fixed(v) } else { (v.round() as i32).to_be_bytes() };
|
||||
if let Some(m) = p.valid_min {
|
||||
put(&mut b, 124, &enc(m));
|
||||
}
|
||||
if let Some(m) = p.valid_max {
|
||||
put(&mut b, 128, &enc(m));
|
||||
}
|
||||
if let Some((lo, hi)) = p.range {
|
||||
put(&mut b, 132, &enc(lo));
|
||||
put(&mut b, 136, &enc(hi));
|
||||
}
|
||||
// The default again at 140, the display word (decimals, percent) at 144.
|
||||
put(&mut b, 140, &enc(d(0)));
|
||||
put(&mut b, 144, &p.display.to_be_bytes());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// The default arbitrary data of an effect (its type-11 parameter), from the decoded layouts
|
||||
/// (aep-format.md 3.7).
|
||||
pub(crate) fn default_data(match_name: &str) -> Option<Vec<u8>> {
|
||||
match match_name {
|
||||
// Master hue, saturation, lightness (0), then six colour ranges of four hue bounds and
|
||||
// their hue, saturation and lightness (0): Reds centred at 0 degrees, then every 60, each
|
||||
// range 30 degrees wide with 30 degree fall-off (443-446).
|
||||
"ADBE HUE SATURATION" => {
|
||||
let mut out = vec![0u8; 12];
|
||||
for c in 0..6i32 {
|
||||
let centre = 60 * c;
|
||||
for off in [-45, -15, 15, 45] {
|
||||
out.extend((centre + off).rem_euclid(360).to_be_bytes());
|
||||
}
|
||||
out.extend([0u8; 12]);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
// A header word `0x000f10a7` and the version 1, then five channels (RGB, red, green, blue,
|
||||
// alpha) of input black, input white, gamma, output black, output white as `f32`
|
||||
// (511-516).
|
||||
"ADBE Easy Levels2" | "ADBE Pro Levels2" => {
|
||||
let mut out = Vec::with_capacity(108);
|
||||
out.extend(0x000f_10a7_u32.to_be_bytes());
|
||||
out.extend(1u32.to_be_bytes());
|
||||
for _ in 0..5 {
|
||||
for v in [0.0f32, 1.0, 1.0, 0.0, 1.0] {
|
||||
out.extend(v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
"ADBE CurvesCustom" => CurvesData { curves: std::array::from_fn(|_| CURVE_IDENTITY.to_vec()) }.to_bytes(),
|
||||
// 7 x 7 cells, undistorted (Rows / Columns default 7, 985).
|
||||
"ADBE MESH WARP" => {
|
||||
let (columns, rows) = (7, 7);
|
||||
let vertices = (0..=rows).flat_map(|j| (0..=columns).map(move |i| MeshData::default_vertex(columns, rows, i, j))).collect();
|
||||
MeshData { columns, rows, tail: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0d, 0x0d], vertices }.to_bytes()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The popup item count of parameter position `index` from the shared effect table.
|
||||
fn popup_items(fx: &FxDef, index: usize) -> Option<u16> {
|
||||
let map = by_match_name(fx.match_name)?;
|
||||
let pm = map.params.iter().find(|p| p.index == index)?;
|
||||
match pm.conv {
|
||||
Conv::Popup { items, .. } => u16::try_from(items.len()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A record without a value of its own (layer parameters, groups, buttons): `tdsb` `state`, no
|
||||
/// user name, the header and a zero `cdat`.
|
||||
fn plain_record(state: u32, extra: Vec<Chunk<'static>>) -> Chunk<'static> {
|
||||
let spec = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::EFFECT_OTHER, limits: None, byte5: None };
|
||||
let mut kids = vec![
|
||||
Chunk::data(*b"tdsb", state.to_be_bytes().to_vec()),
|
||||
tdsn(NO_NAME),
|
||||
Chunk::data(*b"tdb4", crate::export::synth::tdb4(&spec, false, 25_600)),
|
||||
Chunk::data(*b"cdat", vec![0u8; spec.cdat_len()]),
|
||||
];
|
||||
kids.extend(extra);
|
||||
Chunk::list(*b"tdbs", kids)
|
||||
}
|
||||
|
||||
/// The arbitrary-data record (4-byte `cdat`, class `0x00060007`, colour-like template 1) and the
|
||||
/// `LIST aRbs` with the data after it (443, 961).
|
||||
fn data_record(data: Vec<u8>) -> [Chunk<'static>; 2] {
|
||||
let spec = RecSpec { dims: 1, template: KeyTemplate::Spatial(1), class: class::ARBITRARY, limits: None, byte5: None };
|
||||
let rec = Chunk::list(
|
||||
*b"tdbs",
|
||||
vec![
|
||||
Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()),
|
||||
tdsn(NO_NAME),
|
||||
Chunk::data(*b"tdb4", crate::export::synth::tdb4(&spec, false, 25_600)),
|
||||
Chunk::data(*b"cdat", vec![0u8; 4]),
|
||||
],
|
||||
);
|
||||
[rec, Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", data)])]
|
||||
}
|
||||
|
||||
/// The default `sspc` of an effect: its name (`fnam`), the parameter list (`parT`: the count,
|
||||
/// then `tdmn` + `pard` per parameter, the default arbitrary data after its `pard`), the value
|
||||
/// group (records only for layer parameters, groups, buttons and arbitrary data, the built-in
|
||||
/// group) and `pgui` (zero).
|
||||
pub(crate) fn default_sspc(fx: &FxDef) -> Chunk<'static> {
|
||||
let fnam = Chunk { id: *b"fnam", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", fx.name.as_bytes().to_vec())]), pad: None };
|
||||
let count = u32::try_from(fx.params.len()).unwrap_or(u32::MAX);
|
||||
let mut part = vec![Chunk::data(*b"parn", count.to_be_bytes().to_vec())];
|
||||
let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME)];
|
||||
for (i, p) in fx.params.iter().enumerate() {
|
||||
let record = fx.record(p);
|
||||
part.push(tdmn(&record));
|
||||
part.push(Chunk::data(*b"pard", pard(p, popup_items(fx, i))));
|
||||
if matches!(p.code, 4 | 7 | 15) {
|
||||
// Checkboxes, popups and buttons have a `pdnm` (their label or items); its text may
|
||||
// be empty.
|
||||
part.push(Chunk { id: *b"pdnm", body: crate::Body::Chunks(vec![Chunk::data(*b"Utf8", Vec::new())]), pad: None });
|
||||
}
|
||||
if p.id == BUILT_IN {
|
||||
values.push(tdmn(&record));
|
||||
values.push(Chunk::list(*b"tdgp", vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), tdmn("ADBE Group End")]));
|
||||
continue;
|
||||
}
|
||||
match p.code {
|
||||
0 => {
|
||||
let id = if LAYER_NONE.contains(&record.as_str()) { 0 } else { PROTO_OWNER };
|
||||
// The input layer (position 0) and every layer parameter name a layer (`tdpi`);
|
||||
// `tdps` 0 (320, 529).
|
||||
let state = if i == 0 { 3 } else { 1 };
|
||||
values.push(tdmn(&record));
|
||||
values.push(plain_record(state, vec![Chunk::data(*b"tdpi", id.to_be_bytes().to_vec()), Chunk::data(*b"tdps", 0u32.to_be_bytes().to_vec())]));
|
||||
}
|
||||
9 | 13..=15 => {
|
||||
values.push(tdmn(&record));
|
||||
values.push(plain_record(1, Vec::new()));
|
||||
}
|
||||
11 => {
|
||||
if let Some(data) = default_data(fx.match_name) {
|
||||
part.push(Chunk::data(*b"aRbp", data.clone()));
|
||||
values.push(tdmn(&record));
|
||||
values.extend(data_record(data));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
values.push(tdmn("ADBE Group End"));
|
||||
Chunk::list(*b"sspc", vec![fnam, Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values), Chunk::data(*b"pgui", vec![0u8; 16])])
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Colour profiles for the working spaces the exporter writes, authored here from the standards
|
||||
//! so that no profile of another party is copied: "Rec.709 Gamma 2.4" (ITU-R BT.709 primaries,
|
||||
//! a pure 2.4 power curve) and "sRGB IEC61966-2.1" (the same primaries, the IEC 61966-2-1
|
||||
//! curve), both D65, ICC.1:2010 v4 with a D50 PCS and a Bradford adaptation. After Effects keeps
|
||||
//! a working space as the profile's data (base64) and its profile ID (`cpid`, 624: the MD5
|
||||
//! profile ID of the v4 header), R49.
|
||||
|
||||
/// The profile's description and the name After Effects lists the working space under (624).
|
||||
pub(crate) const REC709_NAME: &str = "Rec.709 Gamma 2.4";
|
||||
/// The name After Effects lists the sRGB working space under (620).
|
||||
pub(crate) const SRGB_NAME: &str = "sRGB IEC61966-2.1";
|
||||
|
||||
fn s15(v: f64) -> [u8; 4] {
|
||||
let x = (v * 65536.0).round();
|
||||
let x = if x.is_finite() { x.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) } else { 0.0 };
|
||||
(x as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
type M3 = [[f64; 3]; 3];
|
||||
|
||||
fn mul(a: &M3, b: &M3) -> M3 {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for (i, row) in o.iter_mut().enumerate() {
|
||||
for (j, v) in row.iter_mut().enumerate() {
|
||||
*v = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn mulv(a: &M3, v: [f64; 3]) -> [f64; 3] {
|
||||
[0, 1, 2].map(|i| (0..3).map(|k| a[i][k] * v[k]).sum())
|
||||
}
|
||||
|
||||
fn inv(m: &M3) -> M3 {
|
||||
let [[a, b, c], [d, e, f], [g, h, i]] = *m;
|
||||
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||
let det = if det.abs() < 1e-12 { 1.0 } else { det };
|
||||
[
|
||||
[(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det],
|
||||
[(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det],
|
||||
[(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det],
|
||||
]
|
||||
}
|
||||
|
||||
fn xyz(x: f64, y: f64) -> [f64; 3] {
|
||||
[x / y, 1.0, (1.0 - x - y) / y]
|
||||
}
|
||||
|
||||
fn mluc(text: &str) -> Vec<u8> {
|
||||
let t: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let mut v = b"mluc\0\0\0\0".to_vec();
|
||||
v.extend(1u32.to_be_bytes());
|
||||
v.extend(12u32.to_be_bytes());
|
||||
v.extend(b"enUS");
|
||||
v.extend(u32::try_from(t.len()).unwrap_or(0).to_be_bytes());
|
||||
v.extend(28u32.to_be_bytes());
|
||||
v.extend(t);
|
||||
v
|
||||
}
|
||||
|
||||
fn xyz_tag(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut o = b"XYZ \0\0\0\0".to_vec();
|
||||
for c in v {
|
||||
o.extend(s15(c));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The Rec.709 Gamma 2.4 display profile (ICC v4.3) with its profile ID filled in.
|
||||
pub(crate) fn rec709_gamma24() -> Vec<u8> {
|
||||
// A pure power curve: `para` function type 0.
|
||||
let mut trc = b"para\0\0\0\0".to_vec();
|
||||
trc.extend([0, 0, 0, 0]);
|
||||
trc.extend(s15(2.4));
|
||||
profile("Rec.709 Gamma 2.4 (EffectCraft)", trc)
|
||||
}
|
||||
|
||||
/// The sRGB display profile (ICC v4.3): IEC 61966-2-1's curve as `para` function type 3, `Y =
|
||||
/// ((X + 0.055) / 1.055) ^ 2.4` from 0.04045 and `X / 12.92` below.
|
||||
pub(crate) fn srgb() -> Vec<u8> {
|
||||
let mut trc = b"para\0\0\0\0".to_vec();
|
||||
trc.extend([0, 3, 0, 0]);
|
||||
for v in [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045] {
|
||||
trc.extend(s15(v));
|
||||
}
|
||||
profile(SRGB_NAME, trc)
|
||||
}
|
||||
|
||||
/// A display profile with BT.709 primaries and D65 white, tone curve `trc` and description
|
||||
/// `desc`, its profile ID filled in.
|
||||
fn profile(desc: &str, trc: Vec<u8>) -> Vec<u8> {
|
||||
let prim = [(0.640, 0.330), (0.300, 0.600), (0.150, 0.060)];
|
||||
let w65 = xyz(0.3127, 0.3290);
|
||||
let w50 = [0.9642, 1.0, 0.8249];
|
||||
let cols = prim.map(|(x, y)| xyz(x, y));
|
||||
let p: M3 = [0, 1, 2].map(|r| [0, 1, 2].map(|c| cols[c][r]));
|
||||
let s = mulv(&inv(&p), w65);
|
||||
let m: M3 = [0, 1, 2].map(|r| [0, 1, 2].map(|c| p[r][c] * s[c]));
|
||||
let brad: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
|
||||
let (cs, cd) = (mulv(&brad, w65), mulv(&brad, w50));
|
||||
let d: M3 = [[cd[0] / cs[0], 0.0, 0.0], [0.0, cd[1] / cs[1], 0.0], [0.0, 0.0, cd[2] / cs[2]]];
|
||||
let chad = mul(&inv(&brad), &mul(&d, &brad));
|
||||
let m50 = mul(&chad, &m);
|
||||
let mut sf32 = b"sf32\0\0\0\0".to_vec();
|
||||
for row in chad {
|
||||
for v in row {
|
||||
sf32.extend(s15(v));
|
||||
}
|
||||
}
|
||||
let tags: [(&[u8; 4], Vec<u8>); 10] = [
|
||||
(b"desc", mluc(desc)),
|
||||
(b"cprt", mluc("No copyright, use freely (EffectCraft)")),
|
||||
(b"wtpt", xyz_tag(w50)),
|
||||
(b"chad", sf32),
|
||||
(b"rXYZ", xyz_tag([m50[0][0], m50[1][0], m50[2][0]])),
|
||||
(b"gXYZ", xyz_tag([m50[0][1], m50[1][1], m50[2][1]])),
|
||||
(b"bXYZ", xyz_tag([m50[0][2], m50[1][2], m50[2][2]])),
|
||||
(b"rTRC", trc.clone()),
|
||||
(b"gTRC", trc.clone()),
|
||||
(b"bTRC", trc),
|
||||
];
|
||||
let start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = Vec::new();
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
let mut placed: Vec<(Vec<u8>, usize)> = Vec::new();
|
||||
for (sig, data) in &tags {
|
||||
let off = match placed.iter().find(|(d, _)| d == data) {
|
||||
Some((_, off)) => *off,
|
||||
None => {
|
||||
let off = start + body.len();
|
||||
body.extend(data);
|
||||
body.resize(body.len().div_ceil(4) * 4, 0);
|
||||
placed.push((data.clone(), off));
|
||||
off
|
||||
}
|
||||
};
|
||||
table.extend(*sig);
|
||||
table.extend(u32::try_from(off).unwrap_or(0).to_be_bytes());
|
||||
table.extend(u32::try_from(data.len()).unwrap_or(0).to_be_bytes());
|
||||
}
|
||||
let size = start + body.len();
|
||||
let mut icc = vec![0u8; 128];
|
||||
icc[0..4].copy_from_slice(&u32::try_from(size).unwrap_or(0).to_be_bytes());
|
||||
icc[8..12].copy_from_slice(&[4, 0x30, 0, 0]);
|
||||
icc[12..16].copy_from_slice(b"mntr");
|
||||
icc[16..20].copy_from_slice(b"RGB ");
|
||||
icc[20..24].copy_from_slice(b"XYZ ");
|
||||
for (i, v) in [2026u16, 10, 19, 0, 0, 0].iter().enumerate() {
|
||||
icc[24 + 2 * i..26 + 2 * i].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
for (i, c) in w50.iter().enumerate() {
|
||||
icc[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15(*c));
|
||||
}
|
||||
icc.extend(u32::try_from(tags.len()).unwrap_or(0).to_be_bytes());
|
||||
icc.extend(table);
|
||||
icc.extend(body);
|
||||
// Profile ID (ICC.1:2010 7.2.18): the MD5 with flags, rendering intent and the ID zeroed (all
|
||||
// zero here already).
|
||||
let id = md5(&icc);
|
||||
icc[84..100].copy_from_slice(&id);
|
||||
icc
|
||||
}
|
||||
|
||||
/// The profile ID of a v4 profile (header bytes 84..100).
|
||||
pub(crate) fn profile_id(icc: &[u8]) -> [u8; 16] {
|
||||
let mut id = [0u8; 16];
|
||||
if let Some(src) = icc.get(84..100) {
|
||||
id.copy_from_slice(src);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// MD5 (RFC 1321).
|
||||
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4,
|
||||
11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
let k: [u32; 64] = std::array::from_fn(|i| ((i as f64 + 1.0).sin().abs() * 4_294_967_296.0) as u32);
|
||||
let mut msg = data.to_vec();
|
||||
let bits = (data.len() as u64).wrapping_mul(8);
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend(bits.to_le_bytes());
|
||||
let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32);
|
||||
let (blocks, _) = msg.as_chunks::<64>();
|
||||
for chunk in blocks {
|
||||
let m: [u32; 16] = std::array::from_fn(|i| u32::from_le_bytes([chunk[4 * i], chunk[4 * i + 1], chunk[4 * i + 2], chunk[4 * i + 3]]));
|
||||
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
|
||||
for i in 0..64 {
|
||||
let (f, g) = match i / 16 {
|
||||
0 => ((b & c) | (!b & d), i),
|
||||
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
|
||||
2 => (b ^ c ^ d, (3 * i + 5) % 16),
|
||||
_ => (c ^ (b | !d), (7 * i) % 16),
|
||||
};
|
||||
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = b.wrapping_add(f.rotate_left(S[i]));
|
||||
}
|
||||
a0 = a0.wrapping_add(a);
|
||||
b0 = b0.wrapping_add(b);
|
||||
c0 = c0.wrapping_add(c);
|
||||
d0 = d0.wrapping_add(d);
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, w) in [a0, b0, c0, d0].iter().enumerate() {
|
||||
out[4 * i..4 * i + 4].copy_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The colour profile JSON After Effects stores for a working space or a footage item (620, 624):
|
||||
/// the profile's data and its name.
|
||||
pub(crate) fn profile_json(icc: &[u8], name: &str) -> String {
|
||||
format!("{{\"baseColorProfile\":{{\"colorProfileData\":\"{}\",\"colorProfileName\":\"{}\"}},\"baseProfileType\":2}}", base64(icc), name)
|
||||
}
|
||||
|
||||
/// Standard base64 with padding.
|
||||
pub(crate) fn base64(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for c in data.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(c.get(1).copied().unwrap_or(0)) << 8) | u32::from(c.get(2).copied().unwrap_or(0));
|
||||
for (i, shift) in [18u32, 12, 6, 0].iter().enumerate() {
|
||||
if i <= c.len() {
|
||||
out.push(char::from(T[((n >> shift) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn md5_and_base64_match_their_specifications() {
|
||||
// RFC 1321 test suite and RFC 4648.
|
||||
assert_eq!(md5(b"").iter().map(|b| format!("{b:02x}")).collect::<String>(), "d41d8cd98f00b204e9800998ecf8427e");
|
||||
assert_eq!(md5(b"abc").iter().map(|b| format!("{b:02x}")).collect::<String>(), "900150983cd24fb0d6963f7d28e17f72");
|
||||
let long = b"12345678901234567890123456789012345678901234567890123456789012345678901234567890";
|
||||
assert_eq!(md5(long).iter().map(|b| format!("{b:02x}")).collect::<String>(), "57edf4a22be3c955ac49da2e2107b67a");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
assert_eq!(base64(b"fooba"), "Zm9vYmE=");
|
||||
assert_eq!(base64(b"foob"), "Zm9vYg==");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_srgb_profile_carries_the_iec_curve() {
|
||||
let icc = srgb();
|
||||
assert_eq!(u32::from_be_bytes(icc[0..4].try_into().unwrap()) as usize, icc.len());
|
||||
let mut z = icc.clone();
|
||||
z[84..100].fill(0);
|
||||
assert_eq!(profile_id(&icc), md5(&z));
|
||||
let at = icc.windows(8).position(|w| w == b"para\0\0\0\0").unwrap();
|
||||
assert_eq!(&icc[at + 8..at + 12], &[0, 3, 0, 0]);
|
||||
// AE shows its own name for the space only when the description is exactly this.
|
||||
let name: Vec<u8> = SRGB_NAME.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let at = icc.windows(name.len()).position(|w| w == name.as_slice()).unwrap();
|
||||
assert_ne!(&icc[at + name.len()..at + name.len() + 2], b"\0 ", "no suffix after the name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rec709_profile_is_a_valid_v4_display_profile() {
|
||||
let icc = rec709_gamma24();
|
||||
assert_eq!(u32::from_be_bytes(icc[0..4].try_into().unwrap()) as usize, icc.len());
|
||||
assert_eq!(&icc[36..40], b"acsp");
|
||||
// The ID is the MD5 of the profile with the ID zeroed.
|
||||
let mut z = icc.clone();
|
||||
z[84..100].fill(0);
|
||||
assert_eq!(profile_id(&icc), md5(&z));
|
||||
// D50 red primary of BT.709 (0.4361, 0.2225, 0.0139).
|
||||
let tags = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize;
|
||||
let r = (0..tags).find(|t| &icc[132 + 12 * t..136 + 12 * t] == b"rXYZ").unwrap();
|
||||
let off = u32::from_be_bytes(icc[136 + 12 * r..140 + 12 * r].try_into().unwrap()) as usize;
|
||||
let x = f64::from(i32::from_be_bytes(icc[off + 8..off + 12].try_into().unwrap())) / 65536.0;
|
||||
assert!((x - 0.4361).abs() < 2e-4, "{x}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Layer and composition markers (`docs/aep-format.md` 3.11; cases 122, 123, 249, 347-349, 509,
|
||||
//! 510). A marker group is `tdmn ADBE Marker` + `LIST mrst`: a `tdbs` whose key list holds one
|
||||
//! 16-byte record per marker (the time in the owner's time units, bytes 4 and 5 = 3, the rest
|
||||
//! written 0: After Effects leaves session bytes there) and a `LIST mrky` with one `LIST Nmrd` per
|
||||
//! marker (`NmHd`: cue type, number of cue parameters, duration in 1/600 s, 600, label; then the
|
||||
//! comment, chapter, URL, frame target and cue point name and the parameter pairs as `Utf8`).
|
||||
|
||||
use crate::Chunk;
|
||||
use effectcraft_project::Marker;
|
||||
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::timebase::TimeBase;
|
||||
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_u8, put_u32, tpl};
|
||||
|
||||
/// The units per second of a marker duration (`NmHd` @12, 249).
|
||||
const DURATION_UNITS: f64 = 600.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct MarkerPlan {
|
||||
pub time: i32,
|
||||
pub duration: u32,
|
||||
pub label: u8,
|
||||
/// 4 event cue (also without a cue point), 5 navigation cue (347, 348).
|
||||
pub cue: u32,
|
||||
/// Comment, chapter, URL, frame target, cue point name (249).
|
||||
pub strings: [String; 5],
|
||||
pub params: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The markers in the file's units (`tb` is the owner's time base: the composition's, or the
|
||||
/// layer's for layer markers); what cannot be written is reported.
|
||||
pub(crate) fn plan(markers: &[Marker], tb: &TimeBase, what: &str, col: &mut Collector) -> Vec<MarkerPlan> {
|
||||
let mut out: Vec<MarkerPlan> = Vec::with_capacity(markers.len());
|
||||
for m in markers {
|
||||
let (u, inexact) = tb.units(m.time);
|
||||
let Ok(time) = i32::try_from(u) else {
|
||||
col.lose(LossCategory::Markers, format!("{what}: a marker time out of range is not written"));
|
||||
continue;
|
||||
};
|
||||
if inexact {
|
||||
col.lose(LossCategory::RoundedTimes, format!("{what}: marker at {:.4} s moved to the time base", m.time.seconds()));
|
||||
}
|
||||
let d = m.duration.seconds() * DURATION_UNITS;
|
||||
let duration = if d.is_finite() && d > 0.0 { d.round().min(f64::from(u32::MAX)) as u32 } else { 0 };
|
||||
if d.is_finite() && d > 0.0 && (d - d.round()).abs() > 1e-6 {
|
||||
col.lose(LossCategory::RoundedTimes, format!("{what}: a marker duration is rounded to 1/600 s"));
|
||||
}
|
||||
if m.protected {
|
||||
col.lose(LossCategory::Markers, format!("{what}: the protected-region switch of a marker is not written"));
|
||||
}
|
||||
let (cue, cue_name, params) = match &m.cue_point {
|
||||
Some(c) => (if c.navigation { 5 } else { 4 }, c.name.clone(), c.params.clone()),
|
||||
None => (4, String::new(), Vec::new()),
|
||||
};
|
||||
out.push(MarkerPlan {
|
||||
time,
|
||||
duration,
|
||||
label: crate::export::plan::label_index(m.label),
|
||||
cue,
|
||||
strings: [m.comment.clone(), m.chapter.clone(), m.url.clone(), m.frame_target.clone(), cue_name],
|
||||
params,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|m| m.time);
|
||||
col.exported.markers = col.exported.markers.saturating_add(out.len());
|
||||
out
|
||||
}
|
||||
|
||||
/// The marker group (`tdmn` + `LIST mrst`) for `plans`, cloned from the prototype (249).
|
||||
pub(crate) fn build(proto: &[Chunk<'static>], plans: &[MarkerPlan]) -> R<Vec<Chunk<'static>>> {
|
||||
let mut group = proto.to_vec();
|
||||
let mrst = group.get_mut(1).filter(|c| c.is_list(b"mrst")).ok_or_else(|| tpl("marker prototype"))?;
|
||||
let n = u32::try_from(plans.len()).map_err(|_| tpl("too many markers"))?;
|
||||
{
|
||||
let tdbs = list_mut(mrst, b"tdbs")?;
|
||||
let list = list_mut(tdbs, b"list")?;
|
||||
let lhd3 = child_data(list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, 16)?;
|
||||
let ldat = child_data(list, b"ldat")?;
|
||||
ldat.clear();
|
||||
for p in plans {
|
||||
let mut rec = [0u8; 16];
|
||||
put(&mut rec, 0, &p.time.to_be_bytes())?;
|
||||
rec[4] = 3;
|
||||
rec[5] = 3;
|
||||
ldat.extend_from_slice(&rec);
|
||||
}
|
||||
}
|
||||
let mrky = list_mut(mrst, b"mrky")?;
|
||||
let nmrd_proto = kids_mut(mrky)?.first().cloned().ok_or_else(|| tpl("marker prototype without Nmrd"))?;
|
||||
let mut entries = Vec::with_capacity(plans.len());
|
||||
for p in plans {
|
||||
let mut nmrd = nmrd_proto.clone();
|
||||
{
|
||||
let h = child_data(&mut nmrd, b"NmHd")?;
|
||||
put_u32(h, 0, p.cue)?;
|
||||
put_u32(h, 4, u32::try_from(p.params.len()).unwrap_or(0))?;
|
||||
put_u32(h, 8, p.duration)?;
|
||||
put_u32(h, 12, 600)?;
|
||||
put_u8(h, 16, p.label)?;
|
||||
}
|
||||
let kids = kids_mut(&mut nmrd)?;
|
||||
kids.retain(|c| !c.is(b"Utf8"));
|
||||
for s in &p.strings {
|
||||
kids.push(Chunk::data(*b"Utf8", s.as_bytes().to_vec()));
|
||||
}
|
||||
for (k, v) in &p.params {
|
||||
kids.push(Chunk::data(*b"Utf8", k.as_bytes().to_vec()));
|
||||
kids.push(Chunk::data(*b"Utf8", v.as_bytes().to_vec()));
|
||||
}
|
||||
entries.push(nmrd);
|
||||
}
|
||||
*kids_mut(mrky)? = entries;
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
/// Inserts the marker group as the first property of a top property group (`tdgp`): right after
|
||||
/// its `tdsb` / `tdsn` header, before Time Remapping or the Transform group (123, 509).
|
||||
pub(crate) fn insert(tdgp: &mut Chunk<'static>, group: Vec<Chunk<'static>>) -> R<()> {
|
||||
let kids = kids_mut(tdgp)?;
|
||||
let at = kids.iter().position(|c| c.is(b"tdmn")).unwrap_or(kids.len());
|
||||
for (i, c) in group.into_iter().enumerate() {
|
||||
kids.insert(at.saturating_add(i), c);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
//! Layer masks: the plan (file units, losses reported) and the chunks, cloned from the mask
|
||||
//! prototypes built in code ([`MaskProtos::build`], with the layout of our fixtures 091, 093 and
|
||||
//! 208).
|
||||
//!
|
||||
//! What is written, each with its verified layout (`docs/aep-format.md` 3.4, 3.5 and 3.8):
|
||||
//! - per mask a `mkif` (inverted at byte 0, mode `u32` at 4, the layer's running mask counter at
|
||||
//! 8; the other bytes are zero, read-back t-zmkif) and an atom with the user name;
|
||||
//! - the path as `LIST shap` (`shph`: open bit `0x08` of byte 3, the box of vertices and handles
|
||||
//! normalised to the layer source size; `list`: three `f32` points per vertex, vertex `i`, out
|
||||
//! handle `i`, in handle `i + 1`, normalised inside the box);
|
||||
//! - path keys as 64-byte records (time, interpolation, label, the rest copied from the prototype)
|
||||
//! with one `shap` per key;
|
||||
//! - Feather (static or keyed, plain template with two components), Opacity (fraction) and
|
||||
//! Expansion (pixels), each only when it is not at its default.
|
||||
|
||||
use crate::Chunk;
|
||||
use effectcraft_keyframe::{Ease, Interp, ShapePath, Value};
|
||||
use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur, PropGroup};
|
||||
|
||||
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT, cut_name};
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::tree::{R, child_data, child_mut, kids_mut, list_mut, put_f32, put_f64, put_i32, put_u8, put_u16, put_u32, tpl};
|
||||
|
||||
/// The most vertices one mask path may have (as the importer).
|
||||
pub(crate) const MAX_VERTICES: usize = 65_536;
|
||||
|
||||
/// One path shape in the file's units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ShapePlan {
|
||||
/// Left, top, right, bottom as fractions of the layer source size.
|
||||
pub bbox: [f32; 4],
|
||||
pub open: bool,
|
||||
/// Three points per vertex, normalised inside the box.
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// One path keyframe.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct PathKeyPlan {
|
||||
pub time: i32,
|
||||
/// 1 linear, 2 bezier, 3 hold.
|
||||
pub in_interp: u8,
|
||||
pub out_interp: u8,
|
||||
/// The ease of a bezier side: speed (as stored) and influence (a fraction), at bytes 24..55
|
||||
/// as in the spatial template (599, 700).
|
||||
pub in_ease: (f64, f64),
|
||||
pub out_ease: (f64, f64),
|
||||
pub label: u8,
|
||||
pub shape: ShapePlan,
|
||||
}
|
||||
|
||||
/// A mask in the file's units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct MaskPlan {
|
||||
pub name: String,
|
||||
/// `mkif` mode id: None 0, Add 1, Subtract 2, Intersect 3, Lighten 4, Darken 5, Difference 6.
|
||||
pub mode: u32,
|
||||
pub inverted: bool,
|
||||
/// The static path (the first key's when the path is keyed).
|
||||
pub shape: ShapePlan,
|
||||
pub path_keys: Vec<PathKeyPlan>,
|
||||
/// Pixels; `None` at the default `[0, 0]`.
|
||||
pub feather: Option<[f64; 2]>,
|
||||
pub feather_keys: Vec<KeyPlan>,
|
||||
/// A fraction; `None` at the default 1.
|
||||
pub opacity: Option<f64>,
|
||||
/// Pixels; `None` at the default 0.
|
||||
pub expansion: Option<f64>,
|
||||
}
|
||||
|
||||
fn mode_id(m: MaskMode) -> u32 {
|
||||
match m {
|
||||
MaskMode::None => 0,
|
||||
MaskMode::Add => 1,
|
||||
MaskMode::Subtract => 2,
|
||||
MaskMode::Intersect => 3,
|
||||
MaskMode::Lighten => 4,
|
||||
MaskMode::Darken => 5,
|
||||
MaskMode::Difference => 6,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: f64) -> Option<f64> {
|
||||
(v.is_finite() && v.abs() <= LIMIT).then_some(v)
|
||||
}
|
||||
|
||||
/// A project path (layer pixels) in the file's units for a layer source of `size` pixels.
|
||||
pub(crate) fn shape_plan(p: &ShapePath, size: (u32, u32)) -> Result<ShapePlan, &'static str> {
|
||||
let n = p.vertices.len();
|
||||
if n == 0 {
|
||||
return Err("its path has no vertices");
|
||||
}
|
||||
if n > MAX_VERTICES {
|
||||
return Err("its path has more than 65 536 vertices");
|
||||
}
|
||||
let (w, h) = (f64::from(size.0.max(1)), f64::from(size.1.max(1)));
|
||||
let at = |i: usize| -> Option<([f64; 2], [f64; 2], [f64; 2])> {
|
||||
let v = *p.vertices.get(i)?;
|
||||
let t_in = p.in_tangents.get(i).copied().unwrap_or([0.0; 2]);
|
||||
let t_out = p.out_tangents.get(i).copied().unwrap_or([0.0; 2]);
|
||||
let add = |a: [f64; 2], b: [f64; 2]| [a[0] + b[0], a[1] + b[1]];
|
||||
Some((v, add(v, t_in), add(v, t_out)))
|
||||
};
|
||||
// Absolute points in layer pixels: vertex i, out handle i, in handle i + 1.
|
||||
let mut abs: Vec<[f64; 2]> = Vec::with_capacity(n.saturating_mul(3));
|
||||
for i in 0..n {
|
||||
let (v, _, out) = at(i).ok_or("its path is incomplete")?;
|
||||
let (_, next_in, _) = at((i + 1) % n).ok_or("its path is incomplete")?;
|
||||
abs.extend([v, out, next_in]);
|
||||
}
|
||||
if !abs.iter().flatten().all(|c| finite(*c).is_some()) {
|
||||
return Err("its path holds a number that is not valid");
|
||||
}
|
||||
// Normalised to the layer size, then the box of all points.
|
||||
let norm: Vec<[f64; 2]> = abs.iter().map(|[x, y]| [x / w, y / h]).collect();
|
||||
let (mut left, mut top, mut right, mut bottom) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for [x, y] in &norm {
|
||||
left = left.min(*x);
|
||||
right = right.max(*x);
|
||||
top = top.min(*y);
|
||||
bottom = bottom.max(*y);
|
||||
}
|
||||
let bbox = [left as f32, top as f32, right as f32, bottom as f32];
|
||||
let [l, t, r, b] = bbox.map(f64::from);
|
||||
// A box without width or height (all points on one line) stores 0 for that coordinate.
|
||||
let unit = |v: f64, lo: f64, hi: f64| if hi - lo > 0.0 { ((v - lo) / (hi - lo)) as f32 } else { 0.0 };
|
||||
let points = norm.iter().map(|[x, y]| [unit(*x, l, r), unit(*y, t, b)]).collect();
|
||||
Ok(ShapePlan { bbox, open: !p.closed, points })
|
||||
}
|
||||
|
||||
/// Plans the masks of a layer whose source is `size` pixels.
|
||||
pub(crate) fn plan_masks(layer: &Layer, size: (u32, u32), cp: &CompPlan, comp_name: &str, col: &mut Collector) -> Vec<MaskPlan> {
|
||||
let Some(masks) = layer.masks() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for g in masks.groups() {
|
||||
let what = format!("{comp_name}: '{}' {}", layer.name, g.name);
|
||||
if let Some(m) = plan_mask(g, size, cp, &what, col) {
|
||||
col.exported.masks = col.exported.masks.saturating_add(1);
|
||||
out.push(m);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn plan_mask(g: &PropGroup, size: (u32, u32), cp: &CompPlan, what: &str, col: &mut Collector) -> Option<MaskPlan> {
|
||||
let GroupKind::Mask { mode, inverted, locked, motion_blur, feather_falloff, roto_bezier, .. } = g.kind else {
|
||||
col.lose(LossCategory::Masks, format!("{what}: not a mask, not exported"));
|
||||
return None;
|
||||
};
|
||||
if !g.enabled {
|
||||
col.lose(LossCategory::Masks, format!("{what}: a disabled mask is not exported"));
|
||||
return None;
|
||||
}
|
||||
let Some(path_prop) = g.get("path") else {
|
||||
col.lose(LossCategory::Masks, format!("{what}: no path, not exported"));
|
||||
return None;
|
||||
};
|
||||
let Value::Path(static_path) = &path_prop.value else {
|
||||
col.lose(LossCategory::Masks, format!("{what}: the path is not a path, not exported"));
|
||||
return None;
|
||||
};
|
||||
// Switches the file layout does not hold (not decoded).
|
||||
if locked {
|
||||
col.lose(LossCategory::Masks, format!("{what}: the lock is not written"));
|
||||
}
|
||||
if motion_blur != MaskMotionBlur::SameAsLayer {
|
||||
col.lose(LossCategory::Masks, format!("{what}: mask motion blur is written as Same As Layer"));
|
||||
}
|
||||
if feather_falloff != FeatherFalloff::Smooth {
|
||||
col.lose(LossCategory::Masks, format!("{what}: Linear feather falloff is written as Smooth"));
|
||||
}
|
||||
if roto_bezier {
|
||||
col.lose(LossCategory::Masks, format!("{what}: RotoBezier is not written (the tangents are)"));
|
||||
}
|
||||
for id in ["path", "feather", "opacity", "expansion"] {
|
||||
if g.get(id).is_some_and(|p| p.has_expression()) {
|
||||
col.lose(LossCategory::Masks, format!("{what}: the expression on Mask {id} is not written"));
|
||||
}
|
||||
}
|
||||
// The path and its keys.
|
||||
let mut path_keys: Vec<PathKeyPlan> = Vec::new();
|
||||
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::new();
|
||||
for k in &path_prop.keys {
|
||||
let (u, inexact) = cp.tb.units(k.time);
|
||||
if inexact {
|
||||
col.lose(LossCategory::RoundedTimes, format!("{what}: path key at {:.4} s moved to the time base", k.time.seconds()));
|
||||
}
|
||||
keys.push((u, k));
|
||||
}
|
||||
keys.sort_by_key(|(u, _)| *u);
|
||||
let before = keys.len();
|
||||
keys.dedup_by_key(|(u, _)| *u);
|
||||
if keys.len() != before {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: path keys at the same time were merged"));
|
||||
}
|
||||
let side = |i: Interp| -> u8 {
|
||||
match i {
|
||||
Interp::Linear => 1,
|
||||
Interp::Hold => 3,
|
||||
Interp::Bezier => 2,
|
||||
}
|
||||
};
|
||||
let ease = |e: &[effectcraft_keyframe::Ease], on: bool| -> (f64, f64) {
|
||||
if !on {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let e = e.first().copied().unwrap_or(effectcraft_keyframe::Ease { speed: 0.0, influence: 1.0 / 3.0 });
|
||||
let speed = finite(e.speed).filter(|v| v.abs() <= 1e9).unwrap_or(0.0);
|
||||
let influence = if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 };
|
||||
(speed, influence)
|
||||
};
|
||||
let mut feather_points = !static_path.feather.is_empty();
|
||||
for (u, k) in &keys {
|
||||
let Ok(time) = i32::try_from(*u) else {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: path key time out of range, key dropped"));
|
||||
continue;
|
||||
};
|
||||
let Value::Path(p) = &k.value else {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: a path key without a path was dropped"));
|
||||
continue;
|
||||
};
|
||||
feather_points |= !p.feather.is_empty();
|
||||
let shape = match shape_plan(p, size) {
|
||||
Ok(s) => s,
|
||||
Err(why) => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: a path key was dropped, {why}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if k.roving {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving path key is written as a normal key"));
|
||||
}
|
||||
let (in_interp, out_interp) = (side(k.in_interp), side(k.out_interp));
|
||||
if (in_interp == 2 && k.in_ease.first().is_some_and(|e| e.speed != 0.0)) || (out_interp == 2 && k.out_ease.first().is_some_and(|e| e.speed != 0.0)) {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a path key ease speed other than 0 is written as stored (its unit is not sampled)"));
|
||||
}
|
||||
let (in_ease, out_ease) = (ease(&k.in_ease, in_interp == 2), ease(&k.out_ease, out_interp == 2));
|
||||
path_keys.push(PathKeyPlan { time, in_interp, out_interp, in_ease, out_ease, label: k.label.min(16), shape });
|
||||
col.exported.keyframes = col.exported.keyframes.saturating_add(1);
|
||||
}
|
||||
if feather_points {
|
||||
col.lose(LossCategory::Masks, format!("{what}: variable-width feather points are not written"));
|
||||
}
|
||||
let shape = match path_keys.first() {
|
||||
Some(k) => k.shape.clone(),
|
||||
None => match shape_plan(static_path, size) {
|
||||
Ok(s) => s,
|
||||
Err(why) => {
|
||||
col.lose(LossCategory::Masks, format!("{what}: not exported, {why}"));
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Feather, opacity, expansion.
|
||||
let feather_prop = g.get("feather");
|
||||
let feather_value = match feather_prop.map(|p| &p.value) {
|
||||
Some(Value::Vec2(v)) => *v,
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
let feather = match (finite(feather_value[0]), finite(feather_value[1])) {
|
||||
(Some(x), Some(y)) if (0.0..=MASK_LIMIT).contains(&x) && (0.0..=MASK_LIMIT).contains(&y) => ([x, y] != [0.0, 0.0]).then_some([x, y]),
|
||||
_ => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: feather {feather_value:?} is not valid, the default was written"));
|
||||
None
|
||||
}
|
||||
};
|
||||
let feather_keys = feather_prop.map_or_else(Vec::new, |p| plan_feather_keys(&p.keys, cp, what, col));
|
||||
let scalar = |id: &str, default: f64, lo: f64, hi: f64, col: &mut Collector| -> Option<f64> {
|
||||
let p = g.get(id)?;
|
||||
if !p.keys.is_empty() {
|
||||
col.lose(LossCategory::Masks, format!("{what}: keys on Mask {id} are not written (no keyed prototype), the static value is"));
|
||||
}
|
||||
let Value::Scalar(v) = p.value else { return None };
|
||||
match finite(v) {
|
||||
Some(v) if (lo..=hi).contains(&v) => ((v - default).abs() > 1e-12).then_some(v),
|
||||
_ => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: Mask {id} {v} is not valid, the default was written"));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let opacity = scalar("opacity", 100.0, 0.0, 100.0, col).map(|v| v / 100.0);
|
||||
// The record holds -32 000 to 32 000, as for the feather (`tduM`, 091).
|
||||
let expansion = scalar("expansion", 0.0, -MASK_LIMIT, MASK_LIMIT, col);
|
||||
let name = cut_name(&g.name, 255, what, col);
|
||||
Some(MaskPlan { name, mode: mode_id(mode), inverted, shape, path_keys, feather, feather_keys, opacity, expansion })
|
||||
}
|
||||
|
||||
/// Feather keys (plain template, two components, speed in pixels per second).
|
||||
fn plan_feather_keys(src: &[effectcraft_keyframe::Keyframe], cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
|
||||
let mut keys: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(src.len());
|
||||
for k in src {
|
||||
let (u, inexact) = cp.tb.units(k.time);
|
||||
if inexact {
|
||||
col.lose(LossCategory::RoundedTimes, format!("{what}: feather key at {:.4} s moved to the time base", k.time.seconds()));
|
||||
}
|
||||
keys.push((u, k));
|
||||
}
|
||||
keys.sort_by_key(|(u, _)| *u);
|
||||
let before = keys.len();
|
||||
keys.dedup_by_key(|(u, _)| *u);
|
||||
if keys.len() != before {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: feather keys at the same time were merged"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(keys.len());
|
||||
let beyond = std::cell::Cell::new(false);
|
||||
for (u, k) in &keys {
|
||||
let Ok(time) = i32::try_from(*u) else {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: feather key time out of range, key dropped"));
|
||||
continue;
|
||||
};
|
||||
let value = match &k.value {
|
||||
Value::Vec2([x, y]) => match (finite(*x), finite(*y)) {
|
||||
(Some(x), Some(y)) if (0.0..=MASK_LIMIT).contains(&x) && (0.0..=MASK_LIMIT).contains(&y) => vec![x, y],
|
||||
_ => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: a feather key that is not valid was dropped"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
col.lose(LossCategory::InvalidValues, format!("{what}: a feather key that is not a pair was dropped"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let code = |i: Interp| match i {
|
||||
Interp::Linear => 1u8,
|
||||
Interp::Bezier => 2,
|
||||
Interp::Hold => 3,
|
||||
};
|
||||
let (in_interp, out_interp) = (code(k.in_interp), code(k.out_interp));
|
||||
let ease = |eases: &[Ease], i: usize, on: bool| -> EaseSlot {
|
||||
if !on {
|
||||
return EaseSlot::default();
|
||||
}
|
||||
let e = crate::export::plan::key_ease(eases, i);
|
||||
let speed = finite(e.speed).filter(|v| v.abs() <= 1e9).unwrap_or_else(|| {
|
||||
beyond.set(true);
|
||||
0.0
|
||||
});
|
||||
EaseSlot { speed, influence: if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 } }
|
||||
};
|
||||
out.push(KeyPlan {
|
||||
time,
|
||||
value,
|
||||
in_interp,
|
||||
out_interp,
|
||||
in_ease: (0..2).map(|i| ease(&k.in_ease, i, in_interp == 2)).collect(),
|
||||
out_ease: (0..2).map(|i| ease(&k.out_ease, i, out_interp == 2)).collect(),
|
||||
spatial_in: [0.0; 3],
|
||||
spatial_out: [0.0; 3],
|
||||
spatial_auto: false,
|
||||
roving: false,
|
||||
label: k.label.min(16),
|
||||
});
|
||||
if k.roving {
|
||||
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving feather key is written as a normal key"));
|
||||
}
|
||||
col.exported.keyframes = col.exported.keyframes.saturating_add(1);
|
||||
}
|
||||
crate::export::plan::report_beyond(beyond.get(), what, col);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chunks
|
||||
|
||||
/// The mask prototypes.
|
||||
pub(crate) struct MaskProtos {
|
||||
/// `tdmn ADBE Mask Parade`.
|
||||
pub parade_name: Chunk<'static>,
|
||||
/// The parade group children before its first match name (`tdsb`, `tdsn`).
|
||||
pub parade_header: Vec<Chunk<'static>>,
|
||||
/// `tdmn ADBE Mask Atom`, `mkif`, the atom `tdgp` (with static Feather, Opacity, Offset).
|
||||
pub atom_name: Chunk<'static>,
|
||||
pub mkif: Chunk<'static>,
|
||||
pub atom: Chunk<'static>,
|
||||
/// `tdmn ADBE Group End`.
|
||||
pub group_end: Chunk<'static>,
|
||||
/// The keyed path property (`tdbs`, two 64-byte records).
|
||||
pub keyed_path: Chunk<'static>,
|
||||
/// The keyed Mask Feather property (`tdbs`, two 88-byte records).
|
||||
pub keyed_feather: Chunk<'static>,
|
||||
}
|
||||
|
||||
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let d = c.bytes()?;
|
||||
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
|
||||
}
|
||||
|
||||
/// The limit of a mask's feather and expansion (`tduM` 32 000, 091).
|
||||
const MASK_LIMIT: f64 = 32_000.0;
|
||||
|
||||
/// A shape header (`shph`, 24 bytes, aep-format.md 3.8): bytes 0..2 `b3 de 02` (in every
|
||||
/// sample; meaning not decoded, and zero there is "an internally damaged shape" for After Effects,
|
||||
/// s13-m1), byte 3 `0x01` (`0x09` once the path is open), the box at 4..19 (written per shape),
|
||||
/// byte 20 1.
|
||||
pub(crate) fn shph() -> Vec<u8> {
|
||||
let mut b = vec![0u8; 24];
|
||||
let _ = crate::export::tree::put(&mut b, 0, &[0xb3, 0xde, 0x02, 0x01]);
|
||||
let _ = put_u8(&mut b, 20, 1);
|
||||
b
|
||||
}
|
||||
|
||||
impl MaskProtos {
|
||||
/// The prototypes built in code (aep-format.md 3.8): the parade and atom groups, `mkif` with
|
||||
/// only its decoded fields (bytes 12..47 zero, read-back t-zmkif), the path, feather, opacity
|
||||
/// and expansion records, and the keyed path and feather records.
|
||||
pub(crate) fn build() -> MaskProtos {
|
||||
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, key_list, tdb4, tdbs, tdmn, tdsn};
|
||||
let state = || Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec());
|
||||
// The path property: one component of the path template (`tdb4` byte 7 = 1), a 4-byte
|
||||
// `cdat`; the shapes follow in `omks`.
|
||||
let path = RecSpec { dims: 1, template: KeyTemplate::Spatial(1), class: class::PATH, limits: None, byte5: None };
|
||||
let path_rec = |keyed: bool| {
|
||||
let mut kids = vec![state(), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(&path, keyed, 25_600))];
|
||||
if keyed {
|
||||
// Path keys are 64-byte records: flags `0x0001`, the `f64` at 16 1 on the first
|
||||
// key (093; the rest of the record is zero: its last bytes were uninitialised).
|
||||
let mut first = vec![0u8; 64];
|
||||
let mut later = vec![0u8; 64];
|
||||
for r in [&mut first, &mut later] {
|
||||
let _ = put_u16(r, 6, 0x0001);
|
||||
}
|
||||
let _ = put_f64(&mut first, 16, 1.0);
|
||||
kids.push(key_list(64, 2, [first, later].concat()));
|
||||
} else {
|
||||
kids.push(Chunk::data(*b"cdat", vec![0u8; 4]));
|
||||
}
|
||||
Chunk::list(*b"tdbs", kids)
|
||||
};
|
||||
let shap = Chunk::list(*b"shap", vec![Chunk::data(*b"shph", shph()), key_list(8, 0, Vec::new()), Chunk::data(*b"omtn", Vec::new())]);
|
||||
let feather = RecSpec { dims: 2, template: KeyTemplate::Plain, class: class::EFFECT_NUMBER, limits: Some((0.0, MASK_LIMIT)), byte5: None };
|
||||
let opacity = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::EFFECT_NUMBER, limits: Some((0.0, 100.0)), byte5: None };
|
||||
let offset = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::EFFECT_NUMBER, limits: Some((-MASK_LIMIT, MASK_LIMIT)), byte5: None };
|
||||
let atom = Chunk::list(
|
||||
*b"tdgp",
|
||||
vec![
|
||||
state(),
|
||||
tdsn("Mask 1"),
|
||||
tdmn("ADBE Mask Shape"),
|
||||
Chunk::list(*b"om-s", vec![path_rec(false), Chunk::list(*b"omks", vec![shap])]),
|
||||
tdmn("ADBE Mask Feather"),
|
||||
tdbs(&feather, false, 25_600),
|
||||
tdmn("ADBE Mask Opacity"),
|
||||
tdbs(&opacity, false, 25_600),
|
||||
tdmn("ADBE Mask Offset"),
|
||||
tdbs(&offset, false, 25_600),
|
||||
tdmn("ADBE Group End"),
|
||||
],
|
||||
);
|
||||
MaskProtos {
|
||||
parade_name: tdmn("ADBE Mask Parade"),
|
||||
parade_header: vec![state(), tdsn(NO_NAME)],
|
||||
atom_name: tdmn("ADBE Mask Atom"),
|
||||
mkif: Chunk::data(*b"mkif", vec![0u8; 48]),
|
||||
atom,
|
||||
group_end: tdmn("ADBE Group End"),
|
||||
keyed_path: path_rec(true),
|
||||
keyed_feather: tdbs(&feather, true, 25_600),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `tdmn` + `ADBE Mask Parade` group for the masks of a layer (source `size` pixels).
|
||||
pub(crate) fn build_parade(t: &MaskProtos, masks: &[MaskPlan], size: (u32, u32)) -> R<[Chunk<'static>; 2]> {
|
||||
let mut kids = t.parade_header.clone();
|
||||
for (i, m) in masks.iter().enumerate() {
|
||||
let mut mkif = t.mkif.clone();
|
||||
{
|
||||
let d = crate::export::tree::data_mut(&mut mkif)?;
|
||||
put_u8(d, 0, u8::from(m.inverted))?;
|
||||
put_u32(d, 4, m.mode)?;
|
||||
put_u32(d, 8, u32::try_from(i.saturating_add(1)).map_err(|_| tpl("too many masks"))?)?;
|
||||
}
|
||||
kids.push(t.atom_name.clone());
|
||||
kids.push(mkif);
|
||||
kids.push(build_atom(t, m, size)?);
|
||||
}
|
||||
kids.push(t.group_end.clone());
|
||||
Ok([t.parade_name.clone(), Chunk::list(*b"tdgp", kids)])
|
||||
}
|
||||
|
||||
fn build_atom(t: &MaskProtos, m: &MaskPlan, size: (u32, u32)) -> R<Chunk<'static>> {
|
||||
let mut atom = t.atom.clone();
|
||||
{
|
||||
let tdsn = child_mut(&mut atom, b"tdsn")?;
|
||||
crate::export::tree::set_utf8(tdsn, &m.name)?;
|
||||
}
|
||||
let kids = kids_mut(&mut atom)?;
|
||||
let first = kids.iter().position(|c| c.is(b"tdmn")).ok_or_else(|| tpl("empty mask atom"))?;
|
||||
let header: Vec<Chunk<'static>> = kids.drain(..first).collect();
|
||||
let mut pairs: Vec<(String, [Chunk<'static>; 2])> = Vec::new();
|
||||
let mut end: Option<Chunk<'static>> = None;
|
||||
let mut it = std::mem::take(kids).into_iter();
|
||||
while let Some(c) = it.next() {
|
||||
let name = tdmn_name(&c).unwrap_or_default();
|
||||
if name == "ADBE Group End" {
|
||||
end = Some(c);
|
||||
continue;
|
||||
}
|
||||
let node = it.next().ok_or_else(|| tpl("mask match name without a node"))?;
|
||||
pairs.push((name, [c, node]));
|
||||
}
|
||||
let end = end.ok_or_else(|| tpl("mask atom without its end"))?;
|
||||
let mut out = header;
|
||||
for (name, [tdmn, mut node]) in pairs {
|
||||
let keep = match name.as_str() {
|
||||
"ADBE Mask Shape" => {
|
||||
build_shape(t, &mut node, m, size)?;
|
||||
true
|
||||
}
|
||||
"ADBE Mask Feather" => {
|
||||
if !m.feather_keys.is_empty() {
|
||||
node = keyed_feather(t, &m.feather_keys)?;
|
||||
true
|
||||
} else if let Some([x, y]) = m.feather {
|
||||
set_cdat(&mut node, &[x, y])?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"ADBE Mask Opacity" => match m.opacity {
|
||||
Some(v) => {
|
||||
set_cdat(&mut node, &[v])?;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"ADBE Mask Offset" => match m.expansion {
|
||||
Some(v) => {
|
||||
set_cdat(&mut node, &[v])?;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
_ => true,
|
||||
};
|
||||
if keep {
|
||||
out.push(tdmn);
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
out.push(end);
|
||||
*kids_mut(&mut atom)? = out;
|
||||
Ok(atom)
|
||||
}
|
||||
|
||||
fn set_cdat(tdbs: &mut Chunk<'static>, values: &[f64]) -> R<()> {
|
||||
let cdat = child_data(tdbs, b"cdat")?;
|
||||
cdat.iter_mut().for_each(|b| *b = 0);
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
put_f64(cdat, i * 8, *v)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrites a key list (`lhd3` count words and `ldat`).
|
||||
fn set_key_list(tdbs: &mut Chunk<'static>, records: Vec<u8>, count: usize, size: usize) -> R<()> {
|
||||
let list = list_mut(tdbs, b"list")?;
|
||||
let n = u32::try_from(count).map_err(|_| tpl("too many keys"))?;
|
||||
{
|
||||
let lhd3 = child_data(list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
|
||||
}
|
||||
let ld = kids_mut(list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", records),
|
||||
None => ld.push(Chunk::data(*b"ldat", records)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn keyed_feather(t: &MaskProtos, keys: &[KeyPlan]) -> R<Chunk<'static>> {
|
||||
let mut tdbs = t.keyed_feather.clone();
|
||||
// The plain template with two components (208): 8 + 40 * 2 bytes.
|
||||
let size = 88;
|
||||
let mut ldat = Vec::with_capacity(size * keys.len());
|
||||
for k in keys {
|
||||
ldat.extend(crate::export::emit::plain_record(2, k, size)?);
|
||||
}
|
||||
set_key_list(&mut tdbs, ldat, keys.len(), size)?;
|
||||
Ok(tdbs)
|
||||
}
|
||||
|
||||
/// The `om-s` of the path: the property (static or keyed) and one `shap` per shape.
|
||||
fn build_shape(t: &MaskProtos, oms: &mut Chunk<'static>, m: &MaskPlan, size: (u32, u32)) -> R<()> {
|
||||
let aspect = f64::from(size.0.max(1)) / f64::from(size.1.max(1));
|
||||
let shapes: Vec<&ShapePlan> = if m.path_keys.is_empty() { vec![&m.shape] } else { m.path_keys.iter().map(|k| &k.shape).collect() };
|
||||
let kids = kids_mut(oms)?;
|
||||
// The shape prototype (one static `shap`).
|
||||
let omks = kids.iter_mut().find(|c| c.is_list(b"omks")).ok_or_else(|| tpl("mask path without omks"))?;
|
||||
let proto = kids_mut(omks)?.iter().find(|c| c.is_list(b"shap")).cloned().ok_or_else(|| tpl("mask path without a shape"))?;
|
||||
let mut list = Vec::with_capacity(shapes.len());
|
||||
for s in &shapes {
|
||||
list.push(build_shap(&proto, s)?);
|
||||
}
|
||||
*kids_mut(omks)? = list;
|
||||
let tdbs = kids.iter_mut().find(|c| c.is_list(b"tdbs")).ok_or_else(|| tpl("mask path without its property"))?;
|
||||
if !m.path_keys.is_empty() {
|
||||
let mut keyed = t.keyed_path.clone();
|
||||
// The two prototype records (093). Only the common fields are decoded; the rest of the 64
|
||||
// bytes is copied: the first key from the first record, every later key from the second
|
||||
// (093 differs there: an `f64` 1 at byte 16 of the first record, 0 in the second).
|
||||
let protos: Vec<Vec<u8>> = {
|
||||
let list = keyed.child_list(b"list").ok_or_else(|| tpl("keyed path without keys"))?;
|
||||
let ldat = list.child(b"ldat").and_then(|c| c.bytes()).ok_or_else(|| tpl("keyed path without records"))?;
|
||||
ldat.as_chunks::<64>().0.iter().map(|r| r.to_vec()).collect()
|
||||
};
|
||||
let (first_rec, later_rec) = match protos.as_slice() {
|
||||
[a, b, ..] => (a.clone(), b.clone()),
|
||||
_ => return Err(tpl("keyed path prototype without two records")),
|
||||
};
|
||||
let mut ldat = Vec::with_capacity(64 * m.path_keys.len());
|
||||
for (i, k) in m.path_keys.iter().enumerate() {
|
||||
let mut r = if i == 0 { first_rec.clone() } else { later_rec.clone() };
|
||||
let flags = u16::from_be_bytes([r.get(6).copied().unwrap_or(0), r.get(7).copied().unwrap_or(1)]) & !0x03e0;
|
||||
put_i32(&mut r, 0, k.time)?;
|
||||
put_u8(&mut r, 4, k.in_interp)?;
|
||||
put_u8(&mut r, 5, k.out_interp)?;
|
||||
put_u16(&mut r, 6, flags | (u16::from(k.label) << 6))?;
|
||||
// The ease of bezier sides at 24..55 (599, 700); linear and hold sides keep the
|
||||
// prototype's zeros.
|
||||
if k.in_interp == 2 {
|
||||
put_f64(&mut r, 24, k.in_ease.0)?;
|
||||
put_f64(&mut r, 32, k.in_ease.1)?;
|
||||
}
|
||||
if k.out_interp == 2 {
|
||||
put_f64(&mut r, 40, k.out_ease.0)?;
|
||||
put_f64(&mut r, 48, k.out_ease.1)?;
|
||||
}
|
||||
ldat.extend(r);
|
||||
}
|
||||
set_key_list(&mut keyed, ldat, m.path_keys.len(), 64)?;
|
||||
*tdbs = keyed;
|
||||
}
|
||||
// The width / height of the layer source, as for the Anchor Point (090, 093: 16 / 9 on a
|
||||
// 1920 x 1080 solid; 309: 1 on a 100 x 100 solid).
|
||||
put_f64(child_data(tdbs, b"tdb4")?, 24, aspect)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn build_shap(proto: &Chunk<'static>, s: &ShapePlan) -> R<Chunk<'static>> {
|
||||
let mut shap = proto.clone();
|
||||
{
|
||||
let shph = child_data(&mut shap, b"shph")?;
|
||||
crate::export::tree::put_bits(shph, 3, 0x08, s.open)?;
|
||||
for (i, v) in s.bbox.iter().enumerate() {
|
||||
put_f32(shph, 4 + 4 * i, *v)?;
|
||||
}
|
||||
}
|
||||
let list = list_mut(&mut shap, b"list")?;
|
||||
let records = s.points.len();
|
||||
let vertices = u32::try_from(records / 3).map_err(|_| tpl("too many vertices"))?;
|
||||
{
|
||||
// Point lists: word 2 the record count, words 3 and 7 the vertex count and four times it
|
||||
// (090: 12 records, 4 and 16; 233: 9 records, 3 and 12), word 4 the record size 8.
|
||||
let lhd3 = child_data(list, b"lhd3")?;
|
||||
crate::export::synth::put_counts(lhd3, u32::try_from(records).map_err(|_| tpl("too many points"))?, vertices, 8, vertices, vertices.saturating_mul(4))?;
|
||||
}
|
||||
let mut ldat = vec![0u8; records.saturating_mul(8)];
|
||||
for (i, [u, v]) in s.points.iter().enumerate() {
|
||||
put_f32(&mut ldat, i * 8, *u)?;
|
||||
put_f32(&mut ldat, i * 8 + 4, *v)?;
|
||||
}
|
||||
let ld = kids_mut(list)?;
|
||||
match ld.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => ld.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
Ok(shap)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,268 @@
|
||||
//! The export report: what was written (counts) and everything that was not, per category.
|
||||
//!
|
||||
//! Plain data (`serde`), so it round-trips through JSON: the engine command returns it and agents
|
||||
//! read it. Nothing the exporter leaves out is missing from it.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most example lines kept per loss category (the count is always exact).
|
||||
pub const MAX_DETAILS: usize = 20;
|
||||
|
||||
/// Why a piece of the project did not arrive in the `.aep`, or arrived changed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LossCategory {
|
||||
/// Effects on layers that are not written (not in the table, a parameter
|
||||
/// without a counterpart, an expression on a parameter).
|
||||
Effects,
|
||||
/// Mask data that is not written: a disabled mask or one without a usable path, keys on Mask
|
||||
/// Opacity or Expansion, expressions on mask properties, the lock, mask motion blur other than
|
||||
/// Same As Layer, Linear feather falloff, RotoBezier and variable-width feather points.
|
||||
Masks,
|
||||
/// Layer and composition markers.
|
||||
Markers,
|
||||
/// Text layer features that are not written (animators, Source Text keys, strokes, ...), and
|
||||
/// text layers without a text document.
|
||||
TextLayers,
|
||||
/// Shape layer contents that are not written.
|
||||
ShapeLayers,
|
||||
/// Camera settings that are not written, Auto-Orient Along Path or Towards Camera included.
|
||||
Cameras,
|
||||
/// Light settings that are not written, a spot light not oriented towards its point of
|
||||
/// interest included.
|
||||
Lights,
|
||||
/// 3D model and primitive layers (skipped as a whole).
|
||||
ModelLayers,
|
||||
/// Footage items that are not written (a file type without a prototype, a layered file's
|
||||
/// layer, a sequence with gaps or at a fractional rate), and items in a folder loop.
|
||||
FootageItems,
|
||||
/// Layers whose source is not exported (footage that is not written, a precomposition that is
|
||||
/// missing or closes a loop, other).
|
||||
UnsupportedLayers,
|
||||
/// Blend modes After Effects has no code for (written as Normal).
|
||||
BlendModes,
|
||||
/// Track mattes.
|
||||
TrackMattes,
|
||||
/// 3D features that are not written (per-character 3D of a text layer, Material Options other
|
||||
/// than the defaults; the layer's 3D switch itself is written).
|
||||
ThreeD,
|
||||
/// A time stretch that is not an exact small ratio (the layer is written unstretched).
|
||||
TimeStretch,
|
||||
/// Transform properties other than Anchor Point, Position, Scale, Rotation and Opacity.
|
||||
TransformProperties,
|
||||
/// Expression on a property that is not exported.
|
||||
Expressions,
|
||||
/// Keyframes written in a simpler form (see the detail lines).
|
||||
ApproximatedKeyframes,
|
||||
/// A value that is not a finite number or beyond the range of the format (default written).
|
||||
InvalidValues,
|
||||
/// Frame rate that the format cannot hold exactly, composition size or duration out of range.
|
||||
CompositionSettings,
|
||||
/// Parent links to a layer that was not exported.
|
||||
ParentLinks,
|
||||
/// Times rounded to the composition's time base.
|
||||
RoundedTimes,
|
||||
/// Composition options that are not exported (guides, essential graphics, advanced renderer),
|
||||
/// and settings that change rendered frames but are written at After Effects' defaults:
|
||||
/// Preserve Frame Rate or Preserve Resolution switched on, motion blur samples per frame and
|
||||
/// adaptive sample limit other than 16 and 128.
|
||||
CompositionOptions,
|
||||
/// Names longer than the format's name field (the name is kept in full where verified), and
|
||||
/// item comments (not written).
|
||||
Names,
|
||||
/// Layer timing After Effects does not keep: a layer of a clip, sequence or sound that runs
|
||||
/// past the end of its source (After Effects ends it there).
|
||||
LayerTiming,
|
||||
/// Project settings that are not written: a working space other than None, sRGB and Rec.709
|
||||
/// Gamma 2.4, the OCIO
|
||||
/// colour engine, HDR handling and output space, a frame count start other than 0 and 1.
|
||||
ProjectSettings,
|
||||
/// Time remapping on a layer After Effects cannot remap (a solid, a text): not written.
|
||||
TimeRemapping,
|
||||
/// Layer-style data that is not written: a style or Layer Styles switched off, edited
|
||||
/// gradients, keys on colours, points, popups and checkboxes, Knockout.
|
||||
LayerStyles,
|
||||
/// Layer switches and options that are not written: Wireframe quality, Bicubic sampling,
|
||||
/// frame blending, Preserve Transparency, Auto-Orient on a layer other than a camera or light
|
||||
/// (theirs is reported as [`Cameras`](Self::Cameras) / [`Lights`](Self::Lights)), an
|
||||
/// environment layer, locked markers.
|
||||
LayerSwitches,
|
||||
/// Render queue items (the render queue is not written).
|
||||
RenderQueue,
|
||||
}
|
||||
|
||||
impl LossCategory {
|
||||
/// A short readable name.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LossCategory::Effects => "effects",
|
||||
LossCategory::Masks => "masks",
|
||||
LossCategory::Markers => "markers",
|
||||
LossCategory::TextLayers => "text layers",
|
||||
LossCategory::ShapeLayers => "shape layers",
|
||||
LossCategory::Cameras => "cameras",
|
||||
LossCategory::Lights => "lights",
|
||||
LossCategory::ModelLayers => "3D model layers",
|
||||
LossCategory::FootageItems => "footage items",
|
||||
LossCategory::UnsupportedLayers => "unsupported layers",
|
||||
LossCategory::BlendModes => "blend modes",
|
||||
LossCategory::TrackMattes => "track mattes",
|
||||
LossCategory::ThreeD => "3D layers",
|
||||
LossCategory::TimeStretch => "time stretch",
|
||||
LossCategory::TransformProperties => "transform properties",
|
||||
LossCategory::Expressions => "expressions",
|
||||
LossCategory::ApproximatedKeyframes => "approximated keyframes",
|
||||
LossCategory::InvalidValues => "invalid values",
|
||||
LossCategory::CompositionSettings => "composition settings",
|
||||
LossCategory::ParentLinks => "parent links",
|
||||
LossCategory::RoundedTimes => "rounded times",
|
||||
LossCategory::CompositionOptions => "composition options",
|
||||
LossCategory::Names => "names",
|
||||
LossCategory::LayerTiming => "layer timing",
|
||||
LossCategory::ProjectSettings => "project settings",
|
||||
LossCategory::TimeRemapping => "time remapping",
|
||||
LossCategory::LayerStyles => "layer styles",
|
||||
LossCategory::LayerSwitches => "layer switches",
|
||||
LossCategory::RenderQueue => "render queue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One category of things that were not exported.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Loss {
|
||||
pub category: LossCategory,
|
||||
/// How many occurrences (exact).
|
||||
pub count: usize,
|
||||
/// Up to [`MAX_DETAILS`] example lines.
|
||||
pub details: Vec<String>,
|
||||
}
|
||||
|
||||
/// What was written to the file.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Exported {
|
||||
pub folders: usize,
|
||||
pub compositions: usize,
|
||||
pub solids: usize,
|
||||
/// All layers.
|
||||
pub layers: usize,
|
||||
pub solid_layers: usize,
|
||||
pub null_layers: usize,
|
||||
pub adjustment_layers: usize,
|
||||
/// Transform properties written with a value that differs from the default.
|
||||
pub properties: usize,
|
||||
/// Keyframes written.
|
||||
pub keyframes: usize,
|
||||
/// Expressions written (source text and enable switch).
|
||||
pub expressions: usize,
|
||||
/// Layer masks written.
|
||||
#[serde(default)]
|
||||
pub masks: usize,
|
||||
/// Precomposition layers (a composition shown as a layer).
|
||||
#[serde(default)]
|
||||
pub precomp_layers: usize,
|
||||
/// Effect instances written.
|
||||
#[serde(default)]
|
||||
pub effects: usize,
|
||||
/// Footage items (files) written.
|
||||
#[serde(default)]
|
||||
pub footage: usize,
|
||||
/// Footage layers written.
|
||||
#[serde(default)]
|
||||
pub footage_layers: usize,
|
||||
/// Text layers written.
|
||||
#[serde(default)]
|
||||
pub text_layers: usize,
|
||||
/// Layer and composition markers written.
|
||||
#[serde(default)]
|
||||
pub markers: usize,
|
||||
/// Camera layers written.
|
||||
#[serde(default)]
|
||||
pub cameras: usize,
|
||||
/// Light layers written.
|
||||
#[serde(default)]
|
||||
pub lights: usize,
|
||||
/// Shape layers written.
|
||||
#[serde(default)]
|
||||
pub shape_layers: usize,
|
||||
/// Layers written with an enabled Time Remap.
|
||||
#[serde(default)]
|
||||
pub time_remaps: usize,
|
||||
/// Text animators written.
|
||||
#[serde(default)]
|
||||
pub text_animators: usize,
|
||||
/// Layer styles written (one per style on a layer).
|
||||
#[serde(default)]
|
||||
pub layer_styles: usize,
|
||||
}
|
||||
|
||||
/// Standing limitations of the exporter (what it cannot write at all), listed in every report.
|
||||
pub const LIMITATIONS: &[&str] = &[
|
||||
"The render queue (its items are reported), render templates and project settings other than colour depth, working space (None, sRGB or Rec.709 Gamma 2.4), working gamma, the two linear-light switches, time display and frame count start are not exported.",
|
||||
"Composition options other than size, frame rate, duration, work area, background, pixel aspect, shutter, motion blur switch and display start are written at After Effects' defaults: other values of preserve frame rate, preserve resolution, motion blur samples and adaptive sample limit are reported; draft 3D, hide shy, the viewer resolution, the poster time and the composition's frame blending switch (layer frame blending is reported, and no layer blends without it) are not exported. The background is stored with 8 bits per channel.",
|
||||
"Footage is linked, not embedded: png, jpeg, OpenEXR, TIFF, Targa, Photoshop, Illustrator and EPS stills, png, OpenEXR and jpeg sequences, mp4 and mov clips and WAV audio are written with their path, size, duration and frame rate (a conformed clip rate too); the rest of their interpretation (alpha, colour management, fields, loops) is the default.",
|
||||
"Layer comments, a 3D layer's X / Y rotation, orientation and material options (each reported when it is not at its default), parallel and ambient lights, and shape items outside groups, rectangles, ellipses, polystars, paths, fills, strokes, gradients, trim paths, round corners, merge and offset paths and repeaters (wiggle, twist, zig-zag, pucker and bloat, ...) are not written.",
|
||||
"Mask colours are not written: After Effects shows its own colour for every exported mask.",
|
||||
];
|
||||
|
||||
/// The result of planning/writing an `.aep`: counts of what was written, the losses per category
|
||||
/// and the standing limitations of the exporter.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportReport {
|
||||
pub exported: Exported,
|
||||
/// Only categories that occurred, in category order.
|
||||
pub losses: Vec<Loss>,
|
||||
pub limitations: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExportReport {
|
||||
/// The sum of all loss counts.
|
||||
pub fn total_losses(&self) -> usize {
|
||||
self.losses.iter().map(|l| l.count).sum()
|
||||
}
|
||||
|
||||
/// True when nothing was lost.
|
||||
pub fn is_lossless(&self) -> bool {
|
||||
self.losses.is_empty()
|
||||
}
|
||||
|
||||
/// The loss count of a category.
|
||||
pub fn count(&self, category: LossCategory) -> usize {
|
||||
self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count)
|
||||
}
|
||||
|
||||
/// `effects: 3, masks: 1` (empty when lossless).
|
||||
pub fn summary(&self) -> String {
|
||||
self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects losses while planning.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Collector {
|
||||
pub exported: Exported,
|
||||
losses: BTreeMap<LossCategory, Loss>,
|
||||
}
|
||||
|
||||
impl Collector {
|
||||
/// Records one loss.
|
||||
pub fn lose(&mut self, category: LossCategory, detail: impl Into<String>) {
|
||||
let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() });
|
||||
loss.count = loss.count.saturating_add(1);
|
||||
if loss.details.len() < MAX_DETAILS {
|
||||
loss.details.push(detail.into());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(self) -> ExportReport {
|
||||
ExportReport {
|
||||
exported: self.exported,
|
||||
losses: self.losses.into_values().collect(),
|
||||
limitations: LIMITATIONS.iter().map(|s| (*s).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Project settings (`docs/aep-format.md` 3.12a, cases 620-634): colour depth, working space
|
||||
//! (None, sRGB or Rec.709 Gamma 2.4, with profiles authored here), working gamma, the two linear-light switches, time display and
|
||||
//! frame count start, written into the template's top-level chunks.
|
||||
|
||||
use crate::{AepFile, Chunk};
|
||||
use effectcraft_color::ColorSpace;
|
||||
use effectcraft_project::{BitDepth, ColorEngine, HdrMode, ProjectSettings, TimeDisplayStyle};
|
||||
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::tree::{R, data_mut, put_u8, tpl};
|
||||
|
||||
/// The settings in the file's terms.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SettingsPlan {
|
||||
/// `nhed` byte 15: 0 / 1 / 2 for 8 / 16 / 32 bits per channel.
|
||||
pub depth: u8,
|
||||
/// No working space (623); otherwise sRGB unless `rec709`.
|
||||
pub no_working_space: bool,
|
||||
/// Rec.709 Gamma 2.4, written with a profile authored here (`icc.rs`, R49).
|
||||
pub rec709: bool,
|
||||
/// `dwga` byte 0: 1 for a working gamma of 2.4.
|
||||
pub gamma_24: bool,
|
||||
pub linearize: bool,
|
||||
pub blend_linear: bool,
|
||||
/// `nhed` bytes 8, 11, 13: frames, feet + frames, frames per foot.
|
||||
pub frames: bool,
|
||||
pub feet: Option<u8>,
|
||||
/// `nhed` byte 14: 2 for a count from 0, 1 for a count from 1.
|
||||
pub frame_start: u8,
|
||||
}
|
||||
|
||||
pub(crate) fn plan(s: &ProjectSettings, col: &mut Collector) -> SettingsPlan {
|
||||
let depth = match s.bit_depth {
|
||||
BitDepth::Bpc8 => 0,
|
||||
BitDepth::Bpc16 => 1,
|
||||
BitDepth::Bpc32 => 2,
|
||||
};
|
||||
let (no_working_space, rec709) = match s.working_space {
|
||||
None => (true, false),
|
||||
Some(ColorSpace::Srgb) => (false, false),
|
||||
Some(ColorSpace::Rec709) => (false, true),
|
||||
Some(other) => {
|
||||
col.lose(
|
||||
LossCategory::ProjectSettings,
|
||||
format!("the working space {other:?} is written as sRGB (only None, sRGB and Rec.709 Gamma 2.4 are written)"),
|
||||
);
|
||||
(false, false)
|
||||
}
|
||||
};
|
||||
if s.color_engine != ColorEngine::Adobe {
|
||||
col.lose(LossCategory::ProjectSettings, "the OCIO colour engine is not written".to_string());
|
||||
}
|
||||
if s.hdr != HdrMode::Clip {
|
||||
col.lose(LossCategory::ProjectSettings, "the HDR handling is not written".to_string());
|
||||
}
|
||||
// After Effects renders in the working space (902): an output space equal to the working
|
||||
// space is its normal case (the importer sets it so for Rec.709 Gamma 2.4); another one has
|
||||
// no place in the file.
|
||||
if s.output_space.is_some_and(|o| Some(o) != s.working_space) {
|
||||
col.lose(LossCategory::ProjectSettings, "an output space other than the working space is not written".to_string());
|
||||
}
|
||||
let (frames, feet) = match s.time_display {
|
||||
TimeDisplayStyle::Timecode => (false, None),
|
||||
TimeDisplayStyle::Frames => (true, None),
|
||||
TimeDisplayStyle::Feet35 => (true, Some(16)),
|
||||
TimeDisplayStyle::Feet16 => (true, Some(40)),
|
||||
};
|
||||
let frame_start = match s.frame_start {
|
||||
0 => 2,
|
||||
1 => 1,
|
||||
other => {
|
||||
col.lose(LossCategory::ProjectSettings, format!("a frame count start of {other} is written as 0 (only 0 and 1 exist)"));
|
||||
2
|
||||
}
|
||||
};
|
||||
SettingsPlan {
|
||||
depth,
|
||||
no_working_space,
|
||||
rec709,
|
||||
gamma_24: s.working_gamma_24(),
|
||||
linearize: s.linearize,
|
||||
blend_linear: s.blend_linear,
|
||||
frames,
|
||||
feet,
|
||||
frame_start,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `p` into the template's top-level chunks.
|
||||
pub(crate) fn emit(file: &mut AepFile<'static>, p: &SettingsPlan) -> R<()> {
|
||||
let top = &mut file.chunks;
|
||||
let header =
|
||||
|top: &mut Vec<Chunk<'static>>, id: &[u8; 4]| -> R<usize> { top.iter().position(|c| c.is(id)).ok_or_else(|| tpl("template header chunk missing")) };
|
||||
// nhed and its copy nnhd (byte offsets per field: 620-634).
|
||||
for (id, at_depth, at_display, at_feet, at_fpf, at_start) in [(b"nhed", 15, 8, 11, 13, 14), (b"nnhd", 24, 8, 11, 19, 20)] {
|
||||
let i = header(top, id)?;
|
||||
let d = data_mut(top.get_mut(i).ok_or_else(|| tpl("header"))?)?;
|
||||
put_u8(d, at_depth, p.depth)?;
|
||||
put_u8(d, at_display, u8::from(p.frames))?;
|
||||
put_u8(d, at_feet, u8::from(p.feet.is_some()))?;
|
||||
put_u8(d, at_fpf, p.feet.unwrap_or(16))?;
|
||||
put_u8(d, at_start, p.frame_start)?;
|
||||
}
|
||||
{
|
||||
let i = header(top, b"dwga")?;
|
||||
put_u8(data_mut(top.get_mut(i).ok_or_else(|| tpl("dwga"))?)?, 0, u8::from(p.gamma_24))?;
|
||||
}
|
||||
// Blend Colors Using 1.0 Gamma (lnrb) and Linearize Working Space (lnrp): one-byte chunks
|
||||
// right after cpid, in that order (626, 627, 632).
|
||||
top.retain(|c| !c.is(b"lnrb") && !c.is(b"lnrp"));
|
||||
let cpid = header(top, b"cpid")?;
|
||||
let mut at = cpid.saturating_add(1);
|
||||
if p.blend_linear {
|
||||
top.insert(at, Chunk::data(*b"lnrb", vec![1u8]));
|
||||
at = at.saturating_add(1);
|
||||
}
|
||||
if p.linearize {
|
||||
top.insert(at, Chunk::data(*b"lnrp", vec![1u8]));
|
||||
}
|
||||
if !p.no_working_space {
|
||||
// 620, 624: `cpid` is the profile ID, the JSON after PwCs holds the profile's data and the
|
||||
// working space's name; the profiles are our own (R49).
|
||||
let (icc, name) = if p.rec709 {
|
||||
(crate::export::icc::rec709_gamma24(), crate::export::icc::REC709_NAME)
|
||||
} else {
|
||||
(crate::export::icc::srgb(), crate::export::icc::SRGB_NAME)
|
||||
};
|
||||
*top.get_mut(cpid).ok_or_else(|| tpl("cpid"))? = Chunk::data(*b"cpid", crate::export::icc::profile_id(&icc).to_vec());
|
||||
let pw = header(top, b"PwCs")?;
|
||||
let json = top.get_mut(pw.saturating_add(1)).filter(|c| c.is(b"Utf8")).ok_or_else(|| tpl("template without the working-space JSON"))?;
|
||||
let text = crate::export::icc::profile_json(&icc, name);
|
||||
*json = Chunk::data(*b"Utf8", text.into_bytes());
|
||||
}
|
||||
if p.no_working_space {
|
||||
// 623: cpid all 0xff, the working-space JSON after PwCs is `{}`.
|
||||
*top.get_mut(cpid).ok_or_else(|| tpl("cpid"))? = Chunk::data(*b"cpid", vec![0xffu8; 16]);
|
||||
let pw = header(top, b"PwCs")?;
|
||||
let json = top.get_mut(pw.saturating_add(1)).filter(|c| c.is(b"Utf8")).ok_or_else(|| tpl("template without the working-space JSON"))?;
|
||||
*json = Chunk::data(*b"Utf8", b"{}".to_vec());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
//! Shape layers (`docs/aep-format.md` 3.9; cases 238-242, 451-471, 519, 707-710, 731, 851-857):
|
||||
//! groups (nested), rectangle, ellipse, polystar and freeform paths, fills, strokes, gradient
|
||||
//! fills and strokes, trim paths, round corners, merge paths, offset paths and repeaters, the
|
||||
//! group transform, with keys and expressions on their properties.
|
||||
//!
|
||||
//! The layer block is the shape layer of 519 with its contents removed; every item is the item
|
||||
//! of 519 or 731 (all at After Effects' defaults, no value records) or the path item of 240, and
|
||||
//! every property that differs from After Effects' default (or carries keys or an expression)
|
||||
//! gets a value record, in After Effects' order (a record only for a non-default value, P.5):
|
||||
//! sizes and positions in pixels relative to the layer, colours `[A, R, G, B]` 0..255,
|
||||
//! opacities, percentages and the group scale in percent, popups 1-based (fill rule 455, line
|
||||
//! cap / join 239, trim type 469, polystar type 331, merge type 707, offset line join 710),
|
||||
//! angles in degrees. A record is the record After Effects wrote for the property in a
|
||||
//! one-change case, else the record of its property's type (R46): over the corpus every shape
|
||||
//! value record of a type has the same bytes but for its match name, the time scale and, for
|
||||
//! sliders, the slider range in `tdum` / `tduM` (0..100 for every slider but Skew -85..85 and
|
||||
//! Highlight Length -100..100; sizes and scales -32 000..32 000). A keyed record clears `tdb4`
|
||||
//! bit 0 of byte 5, sets byte 68 and holds the key list (851-855, 857, 209): one-component and
|
||||
//! two-component sizes plain records (48 / 88 bytes), points spatial records (104 bytes),
|
||||
//! colours the colour records of effects (152 bytes). An expression is stored as on the
|
||||
//! Transform properties (856, R15).
|
||||
|
||||
use crate::Chunk;
|
||||
use effectcraft_keyframe::{Gradient, Interp, Keyframe, Value};
|
||||
use effectcraft_project::{Node, PropGroup, Property};
|
||||
|
||||
use crate::export::masks::ShapePlan;
|
||||
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan};
|
||||
use crate::export::report::{Collector, LossCategory};
|
||||
use crate::export::tree::{R, child_data, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, set_utf8, tpl};
|
||||
|
||||
/// The deepest group nesting written.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// A property's value in the file's units: the static value, the keys (layer time) and the
|
||||
/// expression with its enable switch.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct RecVal {
|
||||
pub value: Vec<f64>,
|
||||
pub keys: Vec<KeyPlan>,
|
||||
pub expr: Option<(String, bool)>,
|
||||
}
|
||||
|
||||
type Records = Vec<(&'static str, RecVal)>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ShItem {
|
||||
Group {
|
||||
name: String,
|
||||
items: Vec<ShItem>,
|
||||
transform: Records,
|
||||
},
|
||||
/// Any other item but a path: the item's match name, name, value records and the records of
|
||||
/// its sub-groups (a repeater's transform).
|
||||
Simple {
|
||||
kind: &'static str,
|
||||
name: String,
|
||||
records: Records,
|
||||
subs: Vec<(&'static str, Records)>,
|
||||
},
|
||||
Path {
|
||||
name: String,
|
||||
shape: ShapePlan,
|
||||
},
|
||||
/// An item switched off (its eye): written with bit 0 of its group's `tdsb` cleared.
|
||||
Off(Box<ShItem>),
|
||||
}
|
||||
|
||||
/// The prototypes, by match name.
|
||||
pub(crate) struct ShapeProtos {
|
||||
/// The shape layer's block (519) with an empty `ADBE Root Vectors Group`.
|
||||
pub block: Vec<Chunk<'static>>,
|
||||
/// Item nodes (`tdmn` + node) at their defaults.
|
||||
pub items: Vec<(String, [Chunk<'static>; 2])>,
|
||||
/// Value records (`tdmn` + `tdbs`) by match name.
|
||||
pub records: Vec<(String, [Chunk<'static>; 2])>,
|
||||
/// The key list (`LIST list`) of a keyed property, whose header is rewritten.
|
||||
pub key_list: Chunk<'static>,
|
||||
}
|
||||
|
||||
/// How a shape property is stored.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Layout {
|
||||
/// One component (sliders, angles, popups): plain keys of 48 bytes.
|
||||
One,
|
||||
/// A size or scale of two or three components: plain keys of 88 / 128 bytes.
|
||||
Size,
|
||||
/// A point: spatial keys of 104 bytes (853; 128 bytes for a 3D point).
|
||||
Point,
|
||||
/// A colour: the 152-byte colour keys (854).
|
||||
Color,
|
||||
/// A popup (keys not sampled).
|
||||
Popup,
|
||||
}
|
||||
|
||||
/// The record whose layout a property takes, the slider range its record repeats in `tdum` /
|
||||
/// `tduM` (R46), and the key layout.
|
||||
#[derive(Clone, Copy)]
|
||||
struct RecKind {
|
||||
proto: &'static str,
|
||||
range: Option<(f64, f64)>,
|
||||
layout: Layout,
|
||||
}
|
||||
|
||||
const SLIDER: RecKind = RecKind { proto: "ADBE Vector Fill Opacity", range: Some((0.0, 100.0)), layout: Layout::One };
|
||||
const ANGLE: RecKind = RecKind { proto: "ADBE Vector Rotation", range: None, layout: Layout::One };
|
||||
const POPUP: RecKind = RecKind { proto: "ADBE Vector Fill Rule", range: None, layout: Layout::Popup };
|
||||
const POINT: RecKind = RecKind { proto: "ADBE Vector Ellipse Position", range: None, layout: Layout::Point };
|
||||
const SIZE: RecKind = RecKind { proto: "ADBE Vector Scale", range: Some((-32_000.0, 32_000.0)), layout: Layout::Size };
|
||||
const COLOR: RecKind = RecKind { proto: "ADBE Vector Fill Color", range: None, layout: Layout::Color };
|
||||
|
||||
/// Every property the exporter writes, in After Effects' order within an item (455, 464, 239,
|
||||
/// 331, 332, 828, 709), with its record type (sampled: 451-471, 238-242, 331, 332, 468, 469,
|
||||
/// 473-477, 707-710, 828; Offset Paths' Miter Limit, the Repeater's Offset, Composite, Anchor and
|
||||
/// Start Opacity are not sampled and take the type of their kind, E).
|
||||
const KINDS: &[(&str, RecKind)] = &[
|
||||
("ADBE Vector Rect Size", SIZE),
|
||||
("ADBE Vector Rect Position", POINT),
|
||||
("ADBE Vector Rect Roundness", SLIDER),
|
||||
("ADBE Vector Ellipse Size", SIZE),
|
||||
("ADBE Vector Ellipse Position", POINT),
|
||||
("ADBE Vector Star Type", POPUP),
|
||||
("ADBE Vector Star Points", SLIDER),
|
||||
("ADBE Vector Star Position", POINT),
|
||||
("ADBE Vector Star Rotation", ANGLE),
|
||||
("ADBE Vector Star Inner Radius", SLIDER),
|
||||
("ADBE Vector Star Outer Radius", SLIDER),
|
||||
("ADBE Vector Star Inner Roundess", SLIDER),
|
||||
("ADBE Vector Star Outer Roundess", SLIDER),
|
||||
("ADBE Vector Fill Rule", POPUP),
|
||||
("ADBE Vector Grad Type", POPUP),
|
||||
("ADBE Vector Grad Start Pt", POINT),
|
||||
("ADBE Vector Grad End Pt", POINT),
|
||||
("ADBE Vector Grad HiLite Length", RecKind { proto: "ADBE Vector Fill Opacity", range: Some((-100.0, 100.0)), layout: Layout::One }),
|
||||
("ADBE Vector Grad HiLite Angle", ANGLE),
|
||||
("ADBE Vector Fill Color", COLOR),
|
||||
("ADBE Vector Fill Opacity", SLIDER),
|
||||
("ADBE Vector Stroke Color", COLOR),
|
||||
("ADBE Vector Stroke Opacity", SLIDER),
|
||||
("ADBE Vector Stroke Width", SLIDER),
|
||||
("ADBE Vector Stroke Line Cap", POPUP),
|
||||
("ADBE Vector Stroke Line Join", POPUP),
|
||||
("ADBE Vector Stroke Miter Limit", SLIDER),
|
||||
("ADBE Vector Trim Start", SLIDER),
|
||||
("ADBE Vector Trim End", SLIDER),
|
||||
("ADBE Vector Trim Offset", ANGLE),
|
||||
("ADBE Vector Trim Type", POPUP),
|
||||
("ADBE Vector RoundCorner Radius", SLIDER),
|
||||
("ADBE Vector Merge Type", POPUP),
|
||||
("ADBE Vector Offset Amount", SLIDER),
|
||||
("ADBE Vector Offset Line Join", POPUP),
|
||||
("ADBE Vector Offset Miter Limit", SLIDER),
|
||||
("ADBE Vector Offset Copies", SLIDER),
|
||||
("ADBE Vector Offset Copy Offset", SLIDER),
|
||||
("ADBE Vector Repeater Copies", SLIDER),
|
||||
("ADBE Vector Repeater Offset", SLIDER),
|
||||
("ADBE Vector Repeater Order", POPUP),
|
||||
("ADBE Vector Repeater Anchor", POINT),
|
||||
("ADBE Vector Repeater Position", POINT),
|
||||
("ADBE Vector Repeater Scale", SIZE),
|
||||
("ADBE Vector Repeater Rotation", ANGLE),
|
||||
("ADBE Vector Repeater Opacity 1", SLIDER),
|
||||
("ADBE Vector Repeater Opacity 2", SLIDER),
|
||||
("ADBE Vector Anchor", POINT),
|
||||
("ADBE Vector Position", POINT),
|
||||
("ADBE Vector Scale", SIZE),
|
||||
// Skew has its own record (460: a slider of the angle layout, -85..85).
|
||||
("ADBE Vector Skew", RecKind { proto: "ADBE Vector Skew", range: Some((-85.0, 85.0)), layout: Layout::One }),
|
||||
("ADBE Vector Skew Axis", ANGLE),
|
||||
("ADBE Vector Rotation", ANGLE),
|
||||
("ADBE Vector Group Opacity", SLIDER),
|
||||
];
|
||||
|
||||
fn kind_of(name: &str) -> Option<RecKind> {
|
||||
KINDS.iter().find(|(n, _)| *n == name).map(|(_, k)| *k)
|
||||
}
|
||||
|
||||
impl ShapeProtos {
|
||||
fn item(&self, name: &str) -> R<[Chunk<'static>; 2]> {
|
||||
self.items.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).ok_or_else(|| tpl("shape item prototype missing"))
|
||||
}
|
||||
|
||||
/// The static value record of `name`: the one After Effects wrote for it in a one-change
|
||||
/// case, else the record of its type (R46).
|
||||
fn record(&self, name: &'static str) -> R<[Chunk<'static>; 2]> {
|
||||
if let Some((_, c)) = self.records.iter().find(|(n, _)| n == name) {
|
||||
return Ok(c.clone());
|
||||
}
|
||||
let kind = kind_of(name).ok_or_else(|| tpl("shape property without a record type"))?;
|
||||
let [_, mut node] = self.records.iter().find(|(n, _)| n == kind.proto).map(|(_, c)| c.clone()).ok_or_else(|| tpl("shape record prototype missing"))?;
|
||||
if let Some((lo, hi)) = kind.range {
|
||||
put_f64(child_data(&mut node, b"tdum")?, 0, lo)?;
|
||||
put_f64(child_data(&mut node, b"tduM")?, 0, hi)?;
|
||||
}
|
||||
Ok([crate::export::camlight::tdmn_chunk(name), node])
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: &[f64]) -> bool {
|
||||
v.iter().all(|x| x.is_finite() && x.abs() <= 1e9)
|
||||
}
|
||||
|
||||
/// The stored components of a value.
|
||||
pub(crate) type Conv = fn(&Value) -> Option<Vec<f64>>;
|
||||
|
||||
pub(crate) fn c_scalar(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Scalar(s) => Some(vec![*s]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset Paths' Copy Offset: EffectCraft's value + 1 (826-828).
|
||||
fn c_copy_offset(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Scalar(s) => Some(vec![*s + 1.0]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn c_point(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Vec2(p) => Some(p.to_vec()),
|
||||
Value::Vec3(p) => Some(vec![p[0], p[1]]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour as the file stores it (alpha 255, then red, green and blue from 0 to 255). A channel
|
||||
/// outside 0 to 1 is written as the nearest limit, as for lights (`rec_value` reports it); a
|
||||
/// value that is not finite stays so and is reported as invalid.
|
||||
pub(crate) fn c_color(v: &Value) -> Option<Vec<f64>> {
|
||||
let ch = |x: f64| if x.is_finite() { x.clamp(0.0, 1.0) * 255.0 } else { x };
|
||||
match v {
|
||||
Value::Color(c) => Some(vec![255.0, ch(c[0]), ch(c[1]), ch(c[2])]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a colour that is written has a channel outside 0 to 1: the keys of an animated
|
||||
/// property (its static value is not written then), else the static value.
|
||||
fn colour_beyond(p: &Property, keyed: bool) -> bool {
|
||||
let beyond = |v: &Value| matches!(v, Value::Color(c) if c.iter().take(3).any(|x| x.is_finite() && !(0.0..=1.0).contains(x)));
|
||||
if keyed { p.keys.iter().any(|k| beyond(&k.value)) } else { beyond(&p.value) }
|
||||
}
|
||||
|
||||
/// Whether a colour that is written has an alpha other than 1 (a colour property of After Effects
|
||||
/// has no alpha): the keys of an animated property, else the static value.
|
||||
fn colour_alpha(p: &Property, keyed: bool) -> bool {
|
||||
let alpha = |v: &Value| matches!(v, Value::Color(c) if c[3].is_finite() && (c[3] - 1.0).abs() > 1e-9);
|
||||
if keyed { p.keys.iter().any(|k| alpha(&k.value)) } else { alpha(&p.value) }
|
||||
}
|
||||
|
||||
/// A popup stored 1-based (EffectCraft's items in After Effects' order).
|
||||
pub(crate) fn c_popup(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Enum(e) => Some(vec![f64::from(*e) + 1.0]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What planning a shape layer needs: the layer's time base and the report.
|
||||
pub(crate) struct ShCx<'a> {
|
||||
pub cp: &'a CompPlan,
|
||||
pub col: &'a mut Collector,
|
||||
}
|
||||
|
||||
/// The keys of `p` in the file's units.
|
||||
fn plan_keys(p: &Property, conv: Conv, layout: Layout, what: &str, cx: &mut ShCx<'_>) -> Vec<KeyPlan> {
|
||||
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len());
|
||||
for k in &p.keys {
|
||||
let (u, inexact) = cx.cp.tb.units(k.time);
|
||||
if inexact {
|
||||
cx.col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
|
||||
}
|
||||
timed.push((u, k));
|
||||
}
|
||||
timed.sort_by_key(|(u, _)| *u);
|
||||
let before = timed.len();
|
||||
timed.dedup_by_key(|(u, _)| *u);
|
||||
if timed.len() != before {
|
||||
cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
|
||||
}
|
||||
let all: Vec<Keyframe> = timed.iter().map(|(_, k)| (*k).clone()).collect();
|
||||
let mut out = Vec::with_capacity(timed.len());
|
||||
let beyond = std::cell::Cell::new(false);
|
||||
for (i, (u, k)) in timed.iter().enumerate() {
|
||||
let Ok(time) = i32::try_from(*u) else {
|
||||
cx.col.lose(LossCategory::InvalidValues, format!("{what}: key time out of range, key dropped"));
|
||||
continue;
|
||||
};
|
||||
let Some(value) = conv(&k.value).filter(|v| finite(v)) else {
|
||||
cx.col.lose(LossCategory::InvalidValues, format!("{what}: a key value that cannot be stored, key dropped"));
|
||||
continue;
|
||||
};
|
||||
let mut side = |interp: Interp| -> u8 {
|
||||
match interp {
|
||||
Interp::Linear => 1,
|
||||
Interp::Hold => 3,
|
||||
// The ease of colour keys is sampled only as speed 0 / influence 0 (598).
|
||||
Interp::Bezier if layout == Layout::Color => {
|
||||
cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: bezier ease of a colour key is written as linear (not sampled)"));
|
||||
1
|
||||
}
|
||||
Interp::Bezier => 2,
|
||||
}
|
||||
};
|
||||
let (in_i, out_i) = (side(k.in_interp), side(k.out_interp));
|
||||
let slots = if layout == Layout::Size { value.len().max(1) } else { 1 };
|
||||
let ease = |eases: &[effectcraft_keyframe::Ease], on: bool| -> Vec<EaseSlot> {
|
||||
(0..slots)
|
||||
.map(|s| {
|
||||
if !on {
|
||||
return EaseSlot::default();
|
||||
}
|
||||
let e = crate::export::plan::key_ease(eases, s);
|
||||
let speed = if e.speed.is_finite() && e.speed.abs() <= 1e9 {
|
||||
e.speed
|
||||
} else {
|
||||
beyond.set(true);
|
||||
0.0
|
||||
};
|
||||
let influence = if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 };
|
||||
EaseSlot { speed, influence }
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (mut tin, mut tout) = if layout == Layout::Point { effectcraft_keyframe::spatial_tangents(&all, i) } else { ([0.0; 3], [0.0; 3]) };
|
||||
for t in tin.iter_mut().chain(tout.iter_mut()) {
|
||||
if !t.is_finite() || t.abs() > 1e9 {
|
||||
beyond.set(true);
|
||||
*t = 0.0;
|
||||
}
|
||||
}
|
||||
if k.roving {
|
||||
cx.col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving key is written as a normal key"));
|
||||
}
|
||||
out.push(KeyPlan {
|
||||
time,
|
||||
value,
|
||||
in_interp: in_i,
|
||||
out_interp: out_i,
|
||||
in_ease: ease(&k.in_ease, in_i == 2),
|
||||
out_ease: ease(&k.out_ease, out_i == 2),
|
||||
spatial_in: tin,
|
||||
spatial_out: tout,
|
||||
spatial_auto: k.spatial_auto,
|
||||
roving: false,
|
||||
label: k.label.min(16),
|
||||
});
|
||||
}
|
||||
cx.col.exported.keyframes = cx.col.exported.keyframes.saturating_add(out.len());
|
||||
crate::export::plan::report_beyond(beyond.get(), what, cx.col);
|
||||
out
|
||||
}
|
||||
|
||||
/// A property's value, keys and expression in the file's units; `None` when it needs no record:
|
||||
/// at `default` (when given) with no keys and no expression. Keys on a popup are not sampled:
|
||||
/// the first key's value is written (reported in `category`).
|
||||
pub(crate) fn rec_value(
|
||||
p: &Property,
|
||||
conv: Conv,
|
||||
layout: Layout,
|
||||
default: Option<&[f64]>,
|
||||
w: &str,
|
||||
category: LossCategory,
|
||||
cx: &mut ShCx<'_>,
|
||||
) -> Option<RecVal> {
|
||||
let expr = p.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled));
|
||||
if expr.is_some() {
|
||||
cx.col.exported.expressions = cx.col.exported.expressions.saturating_add(1);
|
||||
}
|
||||
let Some(value) = conv(&p.value) else {
|
||||
cx.col.lose(category, format!("{w} holds a value of another type, not written"));
|
||||
return None;
|
||||
};
|
||||
if !finite(&value) {
|
||||
cx.col.lose(LossCategory::InvalidValues, format!("{w} is not a valid number (the default is written)"));
|
||||
return None;
|
||||
}
|
||||
let differs = |v: &[f64]| default.is_none_or(|d| v.iter().zip(d).any(|(a, b)| (a - b).abs() > 1e-9));
|
||||
let mut keys = Vec::new();
|
||||
if !p.keys.is_empty() {
|
||||
if layout == Layout::Popup {
|
||||
cx.col.lose(category, format!("{w}: keys on a popup are not written (not sampled), the first key's value is written"));
|
||||
let first = p.keys.first().and_then(|k| conv(&k.value)).unwrap_or(value);
|
||||
return (differs(&first) || expr.is_some()).then_some(RecVal { value: first, keys: Vec::new(), expr });
|
||||
}
|
||||
keys = plan_keys(p, conv, layout, w, cx);
|
||||
}
|
||||
// Shape and text animator colours alike (both are planned here).
|
||||
if layout == Layout::Color && colour_beyond(p, !keys.is_empty()) {
|
||||
cx.col.lose(category, format!("{w}: a colour channel outside 0 to 1 is written as the nearest limit"));
|
||||
}
|
||||
if layout == Layout::Color && colour_alpha(p, !keys.is_empty()) {
|
||||
cx.col.lose(category, format!("{w}: the colour's alpha is not written (After Effects colours have none; written opaque)"));
|
||||
}
|
||||
if keys.is_empty() && expr.is_none() && !differs(&value) {
|
||||
return None;
|
||||
}
|
||||
let value = keys.first().map(|k| k.value.clone()).unwrap_or(value);
|
||||
Some(RecVal { value, keys, expr })
|
||||
}
|
||||
|
||||
/// Plans property `id` of `g` as the record `name` (`names`: `(name, id)`) unless it is at After
|
||||
/// Effects' default with no keys and no expression.
|
||||
fn rec(out: &mut Records, g: &PropGroup, names: (&'static str, &str), conv: Conv, default: &[f64], what: &str, cx: &mut ShCx<'_>) {
|
||||
let (name, id) = names;
|
||||
let Some(p) = g.get(id) else { return };
|
||||
let Some(kind) = kind_of(name) else { return };
|
||||
let w = format!("{what}: {name}");
|
||||
if let Some(v) = rec_value(p, conv, kind.layout, Some(default), &w, LossCategory::ShapeLayers, cx) {
|
||||
out.push((name, v));
|
||||
}
|
||||
}
|
||||
|
||||
fn en(g: &PropGroup, id: &str) -> Option<u32> {
|
||||
match g.get(id).map(|p| &p.value) {
|
||||
Some(Value::Enum(e)) => Some(*e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity, width, cap, join and miter limit of a stroke or gradient stroke; dashes, taper and
|
||||
/// wave are reported when they hold values.
|
||||
fn stroke_common(r: &mut Records, g: &PropGroup, w: &str, cx: &mut ShCx<'_>) {
|
||||
rec(r, g, ("ADBE Vector Stroke Opacity", "opacity"), c_scalar, &[100.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Stroke Width", "width"), c_scalar, &[2.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Stroke Line Cap", "cap"), c_popup, &[1.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Stroke Line Join", "join"), c_popup, &[1.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Stroke Miter Limit", "miter"), c_scalar, &[4.0], w, cx);
|
||||
// Dashes, a taper or a wave change the stroke (After Effects' defaults: no dash, no taper
|
||||
// length, no wave amount).
|
||||
let nonzero = |sub: &str, ids: &[&str]| {
|
||||
g.sub(sub).is_some_and(|s| s.props().any(|p| ids.iter().any(|i| p.match_id.starts_with(i)) && matches!(p.value, Value::Scalar(v) if v != 0.0)))
|
||||
};
|
||||
if nonzero("dashes", &["dash", "gap"]) || nonzero("taper", &["startLength", "endLength"]) || nonzero("wave", &["amount"]) {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: dashes, taper and wave of a stroke are not written"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient part of a gradient fill or stroke: type, start, end, highlight; the colour
|
||||
/// stops are not decoded (only the default white to black is written).
|
||||
fn gradient(r: &mut Records, g: &PropGroup, w: &str, cx: &mut ShCx<'_>) {
|
||||
rec(r, g, ("ADBE Vector Grad Type", "type"), c_popup, &[1.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Grad Start Pt", "start"), c_point, &[0.0, 0.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Grad End Pt", "end"), c_point, &[100.0, 0.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Grad HiLite Length", "highlightLength"), c_scalar, &[0.0], w, cx);
|
||||
rec(r, g, ("ADBE Vector Grad HiLite Angle", "highlightAngle"), c_scalar, &[0.0], w, cx);
|
||||
if let Some(p) = g.get("colors")
|
||||
&& (matches!(&p.value, Value::Gradient(gr) if *gr != Gradient::default()) || !p.keys.is_empty() || p.has_expression())
|
||||
{
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: the colour stops of a gradient are not written (not decoded), white to black"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The items of a `contents` group, top first, in the file's terms.
|
||||
pub(crate) fn plan_contents(contents: &PropGroup, what: &str, depth: usize, cx: &mut ShCx<'_>) -> Vec<ShItem> {
|
||||
let mut out = Vec::new();
|
||||
if depth > MAX_DEPTH {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{what}: shape groups nested deeper than {MAX_DEPTH} levels are not written"));
|
||||
return out;
|
||||
}
|
||||
for node in &contents.children {
|
||||
let Node::Group(g) = node else { continue };
|
||||
let w = format!("{what} / {}", g.name);
|
||||
// A switched-off item keeps its node with bit 0 of its group flags cleared (986, 987).
|
||||
let wrap = |item: ShItem| if g.enabled { item } else { ShItem::Off(Box::new(item)) };
|
||||
if en(g, "direction").is_some_and(|d| d != 0) {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: the path direction is not written"));
|
||||
}
|
||||
// A repeater writes its `composite` as Repeater Order.
|
||||
let composite_lost = g.match_id != "repeater" && en(g, "composite").is_some_and(|d| d != 0);
|
||||
if en(g, "blend").is_some_and(|d| d != 0) || composite_lost {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: blend mode and composite order are not written"));
|
||||
}
|
||||
let mut r = Vec::new();
|
||||
let mut subs = Vec::new();
|
||||
let kind = match g.match_id.as_str() {
|
||||
"group" => {
|
||||
let items = g.sub("contents").map(|c| plan_contents(c, &w, depth + 1, cx)).unwrap_or_default();
|
||||
let mut transform = Vec::new();
|
||||
if let Some(t) = g.sub("transform") {
|
||||
rec(&mut transform, t, ("ADBE Vector Anchor", "anchor"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Position", "position"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Scale", "scale"), c_point, &[100.0, 100.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Skew", "skew"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Skew Axis", "skewAxis"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Rotation", "rotation"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut transform, t, ("ADBE Vector Group Opacity", "opacity"), c_scalar, &[100.0], &w, cx);
|
||||
}
|
||||
out.push(wrap(ShItem::Group { name: g.name.clone(), items, transform }));
|
||||
continue;
|
||||
}
|
||||
"path" => {
|
||||
let p = g.get("path");
|
||||
if p.is_some_and(|p| !p.keys.is_empty() || p.has_expression()) {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: keys or an expression on the path (the static path is written)"));
|
||||
}
|
||||
match p.map(|p| &p.value) {
|
||||
Some(Value::Path(sp)) => match crate::export::masks::shape_plan(sp, (1, 1)) {
|
||||
Ok(shape) => out.push(wrap(ShItem::Path { name: g.name.clone(), shape })),
|
||||
Err(why) => cx.col.lose(LossCategory::ShapeLayers, format!("{w}: the path is not written: {why}")),
|
||||
},
|
||||
_ => cx.col.lose(LossCategory::ShapeLayers, format!("{w}: a path item without a path is not written")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"rect" => {
|
||||
rec(&mut r, g, ("ADBE Vector Rect Size", "size"), c_point, &[100.0, 100.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Rect Position", "position"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Rect Roundness", "roundness"), c_scalar, &[0.0], &w, cx);
|
||||
if g.get("perCorner").is_some_and(|p| p.value == Value::Bool(true) || !p.keys.is_empty() || p.has_expression()) {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: per-corner roundness is not written (the uniform Roundness is)"));
|
||||
}
|
||||
"ADBE Vector Shape - Rect"
|
||||
}
|
||||
"ellipse" => {
|
||||
rec(&mut r, g, ("ADBE Vector Ellipse Size", "size"), c_point, &[100.0, 100.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Ellipse Position", "position"), c_point, &[0.0, 0.0], &w, cx);
|
||||
"ADBE Vector Shape - Ellipse"
|
||||
}
|
||||
// Polystar (104, 331, 473-477): After Effects' defaults are a 5-point star with radii
|
||||
// 100 / 50 (519).
|
||||
"star" => {
|
||||
rec(&mut r, g, ("ADBE Vector Star Type", "type"), c_popup, &[1.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Points", "points"), c_scalar, &[5.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Position", "position"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Rotation", "rotation"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Inner Radius", "innerRadius"), c_scalar, &[50.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Outer Radius", "outerRadius"), c_scalar, &[100.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Inner Roundess", "innerRoundness"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Star Outer Roundess", "outerRoundness"), c_scalar, &[0.0], &w, cx);
|
||||
"ADBE Vector Shape - Star"
|
||||
}
|
||||
"fill" => {
|
||||
rec(&mut r, g, ("ADBE Vector Fill Rule", "rule"), c_popup, &[1.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Fill Color", "color"), c_color, &[255.0, 255.0, 0.0, 0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Fill Opacity", "opacity"), c_scalar, &[100.0], &w, cx);
|
||||
"ADBE Vector Graphic - Fill"
|
||||
}
|
||||
"stroke" => {
|
||||
rec(&mut r, g, ("ADBE Vector Stroke Color", "color"), c_color, &[255.0, 255.0, 255.0, 255.0], &w, cx);
|
||||
stroke_common(&mut r, g, &w, cx);
|
||||
"ADBE Vector Graphic - Stroke"
|
||||
}
|
||||
"gfill" => {
|
||||
rec(&mut r, g, ("ADBE Vector Fill Rule", "rule"), c_popup, &[1.0], &w, cx);
|
||||
gradient(&mut r, g, &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Fill Opacity", "opacity"), c_scalar, &[100.0], &w, cx);
|
||||
"ADBE Vector Graphic - G-Fill"
|
||||
}
|
||||
"gstroke" => {
|
||||
gradient(&mut r, g, &w, cx);
|
||||
stroke_common(&mut r, g, &w, cx);
|
||||
"ADBE Vector Graphic - G-Stroke"
|
||||
}
|
||||
// Trim Paths (101, 465-469, 851-856).
|
||||
"trim" => {
|
||||
rec(&mut r, g, ("ADBE Vector Trim Start", "start"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Trim End", "end"), c_scalar, &[100.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Trim Offset", "offset"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Trim Type", "mode"), c_popup, &[1.0], &w, cx);
|
||||
"ADBE Vector Filter - Trim"
|
||||
}
|
||||
// Round Corners (238; default radius 10, 519).
|
||||
"round" => {
|
||||
rec(&mut r, g, ("ADBE Vector RoundCorner Radius", "radius"), c_scalar, &[10.0], &w, cx);
|
||||
"ADBE Vector Filter - RC"
|
||||
}
|
||||
// Merge Paths (707, 708, 731).
|
||||
"merge" => {
|
||||
rec(&mut r, g, ("ADBE Vector Merge Type", "mode"), c_popup, &[1.0], &w, cx);
|
||||
"ADBE Vector Filter - Merge"
|
||||
}
|
||||
// Offset Paths (710, 828; defaults 731: amount 10, miter 4, one copy, copy offset 1).
|
||||
"offset" => {
|
||||
rec(&mut r, g, ("ADBE Vector Offset Amount", "amount"), c_scalar, &[10.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Offset Line Join", "join"), c_popup, &[1.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Offset Miter Limit", "miter"), c_scalar, &[4.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Offset Copies", "copies"), c_scalar, &[1.0], &w, cx);
|
||||
// EffectCraft offsets copy i by Amount x (i + 1 + copyOffset), After Effects by
|
||||
// Amount x (i + Copy Offset) (826-828): Copy Offset = copyOffset + 1, the
|
||||
// importer's inverse.
|
||||
rec(&mut r, g, ("ADBE Vector Offset Copy Offset", "copyOffset"), c_copy_offset, &[1.0], &w, cx);
|
||||
if g.get("copyOffset").is_some_and(|p| p.has_expression()) {
|
||||
cx.col.lose(
|
||||
LossCategory::ShapeLayers,
|
||||
format!("{w}: the expression on Offset Paths' Copy Offset is written as it is (After Effects' value is 1 more than EffectCraft's)"),
|
||||
);
|
||||
}
|
||||
"ADBE Vector Filter - Offset"
|
||||
}
|
||||
// Repeater (102, 709; defaults 731: one copy, offset 0, below, identity transform).
|
||||
"repeater" => {
|
||||
rec(&mut r, g, ("ADBE Vector Repeater Copies", "copies"), c_scalar, &[1.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Repeater Offset", "offset"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut r, g, ("ADBE Vector Repeater Order", "composite"), c_popup, &[1.0], &w, cx);
|
||||
let mut t = Vec::new();
|
||||
if let Some(tr) = g.sub("transform") {
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Anchor", "anchor"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Position", "position"), c_point, &[0.0, 0.0], &w, cx);
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Scale", "scale"), c_point, &[100.0, 100.0], &w, cx);
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Rotation", "rotation"), c_scalar, &[0.0], &w, cx);
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Opacity 1", "startOpacity"), c_scalar, &[100.0], &w, cx);
|
||||
rec(&mut t, tr, ("ADBE Vector Repeater Opacity 2", "endOpacity"), c_scalar, &[100.0], &w, cx);
|
||||
}
|
||||
subs.push(("ADBE Vector Repeater Transform", t));
|
||||
"ADBE Vector Filter - Repeater"
|
||||
}
|
||||
other => {
|
||||
cx.col.lose(LossCategory::ShapeLayers, format!("{w}: the shape item '{other}' is not written"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push(wrap(ShItem::Simple { kind, name: g.name.clone(), records: r, subs }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
|
||||
let b = c.bytes()?;
|
||||
let end = b.iter().position(|x| *x == 0).unwrap_or(b.len());
|
||||
std::str::from_utf8(b.get(..end)?).ok().map(str::to_string)
|
||||
}
|
||||
|
||||
/// A key of a point (the spatial layout of a 2D Position, 853) or a colour (854).
|
||||
fn spatial_key(k: &KeyPlan, n: usize, point: bool, size: usize) -> R<Vec<u8>> {
|
||||
let mut r = vec![0u8; size];
|
||||
put(&mut r, 0, &k.time.to_be_bytes())?;
|
||||
put_u8(&mut r, 4, k.in_interp)?;
|
||||
put_u8(&mut r, 5, k.out_interp)?;
|
||||
let base: u16 = if point { 0x0007 } else { 0x0001 };
|
||||
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
|
||||
if point {
|
||||
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
|
||||
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
|
||||
put_f64(&mut r, 24, i.speed)?;
|
||||
put_f64(&mut r, 32, i.influence)?;
|
||||
put_f64(&mut r, 40, o.speed)?;
|
||||
put_f64(&mut r, 48, o.influence)?;
|
||||
}
|
||||
for j in 0..n {
|
||||
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
|
||||
if point {
|
||||
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
|
||||
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The value record of `name` holding `v`.
|
||||
fn value_record(t: &ShapeProtos, name: &'static str, v: &RecVal) -> R<[Chunk<'static>; 2]> {
|
||||
let [m, node] = t.record(name)?;
|
||||
let layout = kind_of(name).map_or(Layout::One, |k| k.layout);
|
||||
build_record(m, node, layout, v, &t.key_list)
|
||||
}
|
||||
|
||||
/// A static value record (`node`, after its `tdmn` `m`) turned into the record of `v`: its value,
|
||||
/// its keys (`key_list` the list prototype) and its expression.
|
||||
pub(crate) fn build_record(m: Chunk<'static>, mut node: Chunk<'static>, layout: Layout, v: &RecVal, key_list: &Chunk<'static>) -> R<[Chunk<'static>; 2]> {
|
||||
if v.keys.is_empty() {
|
||||
let d = child_data(&mut node, b"cdat")?;
|
||||
for (i, x) in v.value.iter().enumerate() {
|
||||
put_f64(d, i * 8, *x)?;
|
||||
}
|
||||
} else {
|
||||
let n = v.value.len().max(1);
|
||||
let size = match layout {
|
||||
Layout::Point | Layout::Color => 56 + 24 * n,
|
||||
_ => 8 + 40 * n,
|
||||
};
|
||||
{
|
||||
let tdb4 = child_data(&mut node, b"tdb4")?;
|
||||
let b5 = tdb4.get(5).copied().ok_or_else(|| tpl("short tdb4"))?;
|
||||
put_u8(tdb4, 5, b5 & !0x01)?;
|
||||
put_u8(tdb4, 68, 1)?;
|
||||
// A keyed point or colour clears byte 79 too (853, 854; as on effects, R25).
|
||||
if matches!(layout, Layout::Point | Layout::Color) {
|
||||
put_u8(tdb4, 79, 0)?;
|
||||
}
|
||||
}
|
||||
let mut list = key_list.clone();
|
||||
let count = u32::try_from(v.keys.len()).map_err(|_| tpl("too many keys"))?;
|
||||
{
|
||||
let lhd3 = child_data(&mut list, b"lhd3")?;
|
||||
crate::export::synth::put_key_counts(lhd3, count, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
|
||||
}
|
||||
let mut ldat = Vec::with_capacity(size * v.keys.len());
|
||||
for k in &v.keys {
|
||||
ldat.extend(match layout {
|
||||
Layout::Point => spatial_key(k, n, true, size)?,
|
||||
Layout::Color => spatial_key(k, n, false, size)?,
|
||||
_ => crate::export::emit::plain_record(n, k, size)?,
|
||||
});
|
||||
}
|
||||
{
|
||||
let lk = kids_mut(&mut list)?;
|
||||
match lk.iter_mut().find(|c| c.is(b"ldat")) {
|
||||
Some(c) => *c = Chunk::data(*b"ldat", ldat),
|
||||
None => lk.push(Chunk::data(*b"ldat", ldat)),
|
||||
}
|
||||
}
|
||||
let kids = kids_mut(&mut node)?;
|
||||
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("shape record without cdat"))?;
|
||||
if let Some(slot) = kids.get_mut(at) {
|
||||
*slot = list;
|
||||
}
|
||||
}
|
||||
if let Some((text, enabled)) = &v.expr {
|
||||
// As on the Transform properties (R15; 856).
|
||||
{
|
||||
let tdb4 = child_data(&mut node, b"tdb4")?;
|
||||
put_u8(tdb4, 76, 1)?;
|
||||
put_u8(tdb4, 77, 1)?;
|
||||
put_u8(tdb4, 120, 1)?;
|
||||
put_u8(tdb4, 119, u8::from(!*enabled))?;
|
||||
}
|
||||
let kids = kids_mut(&mut node)?;
|
||||
let at = kids.iter().position(|c| c.is(b"tdum")).unwrap_or(kids.len());
|
||||
kids.insert(at, Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
|
||||
}
|
||||
Ok([m, node])
|
||||
}
|
||||
|
||||
/// Inserts value records into an item's `tdgp` (between its header and its sub-groups / end),
|
||||
/// in the order of [`KINDS`].
|
||||
fn put_records(t: &ShapeProtos, tdgp: &mut Chunk<'static>, records: &[(&'static str, RecVal)]) -> R<()> {
|
||||
let kids = kids_mut(tdgp)?;
|
||||
let mut at = kids.iter().position(|c| c.is(b"tdmn")).unwrap_or(kids.len());
|
||||
let mut sorted: Vec<&(&'static str, RecVal)> = records.iter().collect();
|
||||
sorted.sort_by_key(|(n, _)| KINDS.iter().position(|(o, _)| o == n).unwrap_or(KINDS.len()));
|
||||
for (name, v) in sorted {
|
||||
let [m, node] = value_record(t, name, v)?;
|
||||
kids.insert(at, m);
|
||||
kids.insert(at.saturating_add(1), node);
|
||||
at = at.saturating_add(2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The item nodes (`tdmn` + `tdgp` pairs) for `items`, top first.
|
||||
pub(crate) fn build_items(t: &ShapeProtos, items: &[ShItem], depth: usize) -> R<Vec<Chunk<'static>>> {
|
||||
let mut out = Vec::new();
|
||||
if depth > MAX_DEPTH {
|
||||
return Ok(out);
|
||||
}
|
||||
let pos = |kids: &[Chunk<'static>], n: &str| kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(n));
|
||||
for item in items {
|
||||
match item {
|
||||
ShItem::Off(inner) => {
|
||||
let mut built = build_items(t, std::slice::from_ref(inner.as_ref()), depth)?;
|
||||
if let Some(node) = built.get_mut(1) {
|
||||
let tdsb = child_data(node, b"tdsb")?;
|
||||
let flags = tdsb.get(3).copied().ok_or_else(|| tpl("short tdsb"))?;
|
||||
put_u8(tdsb, 3, flags & !1)?;
|
||||
}
|
||||
out.extend(built);
|
||||
}
|
||||
ShItem::Group { name, items, transform } => {
|
||||
let [m, mut node] = t.item("ADBE Vector Group")?;
|
||||
set_name(&mut node, name)?;
|
||||
let children = build_items(t, items, depth + 1)?;
|
||||
let kids = kids_mut(&mut node)?;
|
||||
let vg = pos(kids, "ADBE Vectors Group").ok_or_else(|| tpl("group prototype without contents"))?;
|
||||
let contents = kids.get_mut(vg + 1).ok_or_else(|| tpl("group prototype without contents"))?;
|
||||
let ck = kids_mut(contents)?;
|
||||
let end = ck.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(ck.len());
|
||||
ck.splice(end..end, children);
|
||||
let tg = pos(kids, "ADBE Vector Transform Group").ok_or_else(|| tpl("group prototype without transform"))?;
|
||||
let tnode = kids.get_mut(tg + 1).ok_or_else(|| tpl("group prototype without transform"))?;
|
||||
put_records(t, tnode, transform)?;
|
||||
out.extend([m, node]);
|
||||
}
|
||||
ShItem::Simple { kind, name, records, subs } => {
|
||||
let [m, mut node] = t.item(kind)?;
|
||||
set_name(&mut node, name)?;
|
||||
for (sub, recs) in subs {
|
||||
if recs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let kids = kids_mut(&mut node)?;
|
||||
let at = pos(kids, sub).ok_or_else(|| tpl("shape item prototype without its sub-group"))?;
|
||||
let group = kids.get_mut(at + 1).ok_or_else(|| tpl("shape item prototype without its sub-group"))?;
|
||||
put_records(t, group, recs)?;
|
||||
}
|
||||
put_records(t, &mut node, records)?;
|
||||
out.extend([m, node]);
|
||||
}
|
||||
ShItem::Path { name, shape } => {
|
||||
let [m, mut node] = t.item("ADBE Vector Shape - Group")?;
|
||||
set_name(&mut node, name)?;
|
||||
let kids = kids_mut(&mut node)?;
|
||||
let oms = kids.iter_mut().find(|c| c.is_list(b"om-s")).ok_or_else(|| tpl("path prototype without its shape"))?;
|
||||
let omks = list_mut(oms, b"omks")?;
|
||||
let proto = kids_mut(omks)?.iter().find(|c| c.is_list(b"shap")).cloned().ok_or_else(|| tpl("path prototype without a shape"))?;
|
||||
*kids_mut(omks)? = vec![crate::export::masks::build_shap(&proto, shape)?];
|
||||
out.extend([m, node]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The item's user name (`tdsn/Utf8`).
|
||||
fn set_name(node: &mut Chunk<'static>, name: &str) -> R<()> {
|
||||
let kids = kids_mut(node)?;
|
||||
let tdsn = kids.iter_mut().find(|c| c.is(b"tdsn")).ok_or_else(|| tpl("shape item without a name"))?;
|
||||
set_utf8(tdsn, name)
|
||||
}
|
||||
|
||||
/// Fills the layer's `ADBE Root Vectors Group` with `items`.
|
||||
pub(crate) fn put_contents(layr: &mut Chunk<'static>, t: &ShapeProtos, items: &[ShItem]) -> R<()> {
|
||||
let built = build_items(t, items, 0)?;
|
||||
let root = list_mut(layr, b"tdgp")?;
|
||||
let kids = kids_mut(root)?;
|
||||
let at = kids
|
||||
.iter()
|
||||
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Root Vectors Group"))
|
||||
.ok_or_else(|| tpl("shape layer without contents"))?;
|
||||
let rv = kids.get_mut(at + 1).ok_or_else(|| tpl("shape layer without contents"))?;
|
||||
let rk = kids_mut(rv)?;
|
||||
let end = rk.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some("ADBE Group End")).unwrap_or(rk.len());
|
||||
rk.splice(end..end, built);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The layer, item and project skeletons the exporter fills, built in code (aep-format.md 3.1-3.4):
|
||||
//! only the property groups the exporter writes into, the default records it edits, and the small
|
||||
//! fixed records each layer carries. After Effects adds the groups that are left out (read-back
|
||||
//! e1-sparse).
|
||||
|
||||
use crate::Chunk;
|
||||
use crate::export::synth::{KeyTemplate, NO_NAME, RecSpec, class, tdbs, tdmn, tdsn};
|
||||
|
||||
/// A group: its match name and a `tdgp` with the state `state` (`tdsb`), no user name, `kids` and
|
||||
/// the closing `ADBE Group End`.
|
||||
pub(crate) fn group(name: &str, state: u32, kids: Vec<Chunk<'static>>) -> [Chunk<'static>; 2] {
|
||||
let mut body = vec![Chunk::data(*b"tdsb", state.to_be_bytes().to_vec()), tdsn(NO_NAME)];
|
||||
body.extend(kids);
|
||||
body.push(tdmn("ADBE Group End"));
|
||||
[tdmn(name), Chunk::list(*b"tdgp", body)]
|
||||
}
|
||||
|
||||
/// The seven records that follow a layer or item block (`fvdv` 3, the others zero; the same in
|
||||
/// every sample, meaning not decoded).
|
||||
pub(crate) fn f_records() -> Vec<Chunk<'static>> {
|
||||
vec![
|
||||
Chunk::data(*b"fvdv", 3u32.to_be_bytes().to_vec()),
|
||||
Chunk::data(*b"fiop", vec![0]),
|
||||
Chunk::data(*b"ftts", vec![0; 4]),
|
||||
Chunk::data(*b"foac", vec![0]),
|
||||
Chunk::data(*b"fiac", vec![0]),
|
||||
Chunk::data(*b"fipc", vec![0; 2]),
|
||||
Chunk::data(*b"fifl", vec![0; 4]),
|
||||
]
|
||||
}
|
||||
|
||||
/// The layer styles in the order After Effects keeps their switch groups (950).
|
||||
const STYLE_ORDER: [&str; 10] =
|
||||
["dropShadow", "innerShadow", "outerGlow", "innerGlow", "bevelEmboss", "chromeFX", "solidFill", "gradientFill", "patternFill", "frameFX"];
|
||||
|
||||
/// The empty `ADBE Layer Styles` group (state 3), Blending Options with the advanced group, and
|
||||
/// the switch group of every style (state 2: not applied), for `styles::put_styles` to fill.
|
||||
pub(crate) fn layer_styles() -> [Chunk<'static>; 2] {
|
||||
let mut kids: Vec<Chunk<'static>> = group("ADBE Blend Options Group", 3, group("ADBE Adv Blend Group", 1, Vec::new()).into()).into();
|
||||
for s in STYLE_ORDER {
|
||||
kids.extend(group(&format!("{s}/enabled"), 2, Vec::new()));
|
||||
}
|
||||
group("ADBE Layer Styles", 3, kids)
|
||||
}
|
||||
|
||||
/// The inert Time Remapping record every layer has (`tdsb` 3, value 0, the maximum written per
|
||||
/// layer: aep-format.md 3.4).
|
||||
fn time_remap() -> Chunk<'static> {
|
||||
let spec = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::LAYER_SCALAR, limits: Some((0.0, 0.0)), byte5: None };
|
||||
let mut rec = tdbs(&spec, false, 25_600);
|
||||
if let Some(state) = rec.children_mut().iter_mut().find(|c| c.is(b"tdsb")) {
|
||||
*state = Chunk::data(*b"tdsb", 3u32.to_be_bytes().to_vec());
|
||||
}
|
||||
rec
|
||||
}
|
||||
|
||||
/// The block of an AV layer (solid, footage, precomposition): `Layr` (a zero `ldta` the exporter
|
||||
/// fills with the decoded fields, read-back t-zldta; the name; the property tree with Time
|
||||
/// Remapping, an empty Transform group and the layer styles), `Ewst` and the f-records.
|
||||
pub(crate) fn av_layer() -> Vec<Chunk<'static>> {
|
||||
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(""), tdmn("ADBE Time Remapping"), time_remap()];
|
||||
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
|
||||
root.extend(layer_styles());
|
||||
root.push(tdmn("ADBE Group End"));
|
||||
let layr = Chunk::list(
|
||||
*b"Layr",
|
||||
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
|
||||
);
|
||||
let mut out = vec![layr, Chunk::list(*b"Ewst", Vec::new())];
|
||||
// Two sets of f-records follow every layer (000).
|
||||
out.extend(f_records());
|
||||
out.extend(f_records());
|
||||
out
|
||||
}
|
||||
|
||||
/// The block of a layer whose property tree is `nodes` (cameras, lights, shapes: `tree_defs`),
|
||||
/// with a zero `ldta`, the guides, `Ewst` and the f-records.
|
||||
pub(crate) fn layer_block(nodes: &[crate::export::tree_spec::Node]) -> Vec<Chunk<'static>> {
|
||||
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn("")];
|
||||
root.extend(crate::export::tree_spec::build(nodes));
|
||||
root.push(tdmn("ADBE Group End"));
|
||||
let layr = Chunk::list(
|
||||
*b"Layr",
|
||||
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
|
||||
);
|
||||
let mut out = vec![layr, Chunk::list(*b"Ewst", Vec::new())];
|
||||
// Two sets of f-records follow every layer (000).
|
||||
out.extend(f_records());
|
||||
out.extend(f_records());
|
||||
out
|
||||
}
|
||||
|
||||
/// The Source Text record (`btds/tdbs`, 110): one component, class `0x00010004`, a 4-byte zero
|
||||
/// `cdat`.
|
||||
const SOURCE_TEXT: crate::export::tree_spec::Rec = crate::export::tree_spec::Rec {
|
||||
state: 1,
|
||||
label: NO_NAME,
|
||||
dims: 1,
|
||||
template: 0,
|
||||
class: class::EFFECT_CHOICE,
|
||||
byte5: 0x01,
|
||||
header: &[],
|
||||
cdat_len: 4,
|
||||
cdat: &[],
|
||||
key_size: 0,
|
||||
limits: None,
|
||||
tdpi: None,
|
||||
};
|
||||
|
||||
/// The block of a text layer (110, 245): `ADBE Text Properties` with the text document (the
|
||||
/// Source Text record, the document `doc` as `LIST btdk`, and `btgu` with the document's id and
|
||||
/// a zero one), the empty Path and More Options groups; the inert Transform records and the layer
|
||||
/// styles; a zero `ldta`, the guides, `Ewst` and the f-records.
|
||||
pub(crate) fn text_layer(doc: &[u8]) -> Vec<Chunk<'static>> {
|
||||
use crate::export::tree_spec::{Node, build};
|
||||
let btdk = Chunk { id: *b"LIST", body: crate::Body::OpaqueList { list_type: *b"btdk", data: doc.to_vec().into() }, pad: None };
|
||||
// The record without its match name (`build` gives the pair).
|
||||
let mut btds_kids: Vec<Chunk<'static>> = build(&[Node::R("ADBE Text Document", SOURCE_TEXT)]).into_iter().filter(|c| c.is_list(b"tdbs")).collect();
|
||||
btds_kids.push(btdk);
|
||||
let btgu = Chunk::list(*b"btgu", vec![Chunk::data(*b"pgui", vec![0; 16]), Chunk::data(*b"pgui", vec![0; 16])]);
|
||||
let mut props = vec![tdmn("ADBE Text Document"), Chunk::list(*b"btds", btds_kids), btgu];
|
||||
props.extend(group("ADBE Text Path Options", 1, Vec::new()));
|
||||
props.extend(group("ADBE Text More Options", 1, Vec::new()));
|
||||
let mut root = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn("")];
|
||||
root.extend(group("ADBE Text Properties", 1, props));
|
||||
root.extend(group("ADBE Transform Group", 1, crate::export::tree_spec::build(crate::export::tree_defs::AV_TRANSFORM)));
|
||||
root.extend(layer_styles());
|
||||
root.push(tdmn("ADBE Group End"));
|
||||
let layr = Chunk::list(
|
||||
*b"Layr",
|
||||
vec![Chunk::data(*b"ldta", vec![0; 164]), Chunk::data(*b"Utf8", Vec::new()), Chunk::list(*b"tdgp", root), crate::export::base::guides()],
|
||||
);
|
||||
let mut out = vec![layr, Chunk::list(*b"Ewst", Vec::new())];
|
||||
out.extend(f_records());
|
||||
out.extend(f_records());
|
||||
out
|
||||
}
|
||||
|
||||
/// `tdmn` + node pairs of `nodes`, by match name.
|
||||
pub(crate) fn pairs(nodes: &[crate::export::tree_spec::Node]) -> Vec<(String, [Chunk<'static>; 2])> {
|
||||
let mut out = Vec::new();
|
||||
let mut it = crate::export::tree_spec::build(nodes).into_iter();
|
||||
while let (Some(m), Some(n)) = (it.next(), it.next()) {
|
||||
let name = String::from_utf8_lossy(m.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned();
|
||||
out.push((name, [m, n]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The marker group (`tdmn ADBE Marker` + `LIST mrst`, aep-format.md 3.11) for
|
||||
/// `markers::build`: the marker property as a keyed one-component record (class `0x00010004`,
|
||||
/// 16-byte keys, 249) and `mrky` with one `Nmrd` whose header (`NmHd`) and strings are written
|
||||
/// per marker.
|
||||
pub(crate) fn marker_group() -> Vec<Chunk<'static>> {
|
||||
let spec = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::EFFECT_CHOICE, limits: None, byte5: None };
|
||||
let record = tdbs(&spec, true, 25_600);
|
||||
let nmrd = Chunk::list(*b"Nmrd", vec![Chunk::data(*b"NmHd", vec![0; 20])]);
|
||||
vec![tdmn("ADBE Marker"), Chunk::list(*b"mrst", vec![record, Chunk::list(*b"mrky", vec![nmrd])])]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The layer-style records the exporter writes, one per parameter (aep-format.md 3.12b): the
|
||||
//! match name, the components, the key template (`tdb4` byte 7), the class word (`tdb4` 8..11)
|
||||
//! and the limits (`tdum` / `tduM`), as After Effects shows them for each style parameter. The
|
||||
//! rows were listed from our style cases (950-980) through the reader.
|
||||
|
||||
/// One record: match name, components, key template, class word, limits.
|
||||
pub(crate) type StyleRec = (&'static str, u8, u8, u32, Option<(f64, f64)>);
|
||||
|
||||
const fn s(name: &'static str, dims: u8, template: u8, class: u32, limits: Option<(f64, f64)>) -> StyleRec {
|
||||
(name, dims, template, class, limits)
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(crate) static STYLE_RECORDS: &[StyleRec] = &[
|
||||
s("ADBE B Channel Blend", 1, 0, 0xffff0004, None),
|
||||
s("ADBE Blend Interior", 1, 0, 0xffff0004, None),
|
||||
s("ADBE Blend Ranges", 1, 0, 0xffff0004, None),
|
||||
s("ADBE G Channel Blend", 1, 0, 0xffff0004, None),
|
||||
s("ADBE Global Altitude2", 1, 0, 0x0002ffff, None),
|
||||
s("ADBE Global Angle2", 1, 0, 0x0002ffff, None),
|
||||
s("ADBE Layer Fill Opacity2", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("ADBE R Channel Blend", 1, 0, 0xffff0004, None),
|
||||
s("bevelEmboss/bevelDirection", 1, 0, 0x00010004, None),
|
||||
s("bevelEmboss/bevelStyle", 1, 0, 0x00010004, None),
|
||||
s("bevelEmboss/bevelTechnique", 1, 0, 0x00010004, None),
|
||||
s("bevelEmboss/blur", 1, 0, 0xffffffff, Some((0.0, 250.0))),
|
||||
s("bevelEmboss/highlightColor", 4, 1, 0x0002ffff, None),
|
||||
s("bevelEmboss/highlightMode", 1, 0, 0x00010004, None),
|
||||
s("bevelEmboss/highlightOpacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("bevelEmboss/localLightingAltitude", 1, 0, 0xffffffff, None),
|
||||
s("bevelEmboss/localLightingAngle", 1, 0, 0xffffffff, None),
|
||||
s("bevelEmboss/shadowColor", 4, 1, 0x0002ffff, None),
|
||||
s("bevelEmboss/shadowMode", 1, 0, 0x00010004, None),
|
||||
s("bevelEmboss/shadowOpacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("bevelEmboss/softness", 1, 0, 0xffffffff, Some((0.0, 16.0))),
|
||||
s("bevelEmboss/strengthRatio", 1, 0, 0xffffffff, Some((1.0, 1000.0))),
|
||||
s("bevelEmboss/useGlobalAngle", 1, 0, 0x00010004, None),
|
||||
s("chromeFX/blur", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("chromeFX/color", 4, 1, 0x0002ffff, None),
|
||||
s("chromeFX/distance", 1, 0, 0xffffffff, Some((1.0, 100.0))),
|
||||
s("chromeFX/invert", 1, 0, 0x00010004, None),
|
||||
s("chromeFX/localLightingAngle", 1, 0, 0xffffffff, None),
|
||||
s("chromeFX/mode2", 1, 0, 0x00010004, None),
|
||||
s("chromeFX/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/blur", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/chokeMatte", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/color", 4, 1, 0x0002ffff, None),
|
||||
s("dropShadow/distance", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/layerConceals", 1, 0, 0x00010004, None),
|
||||
s("dropShadow/localLightingAngle", 1, 0, 0xffffffff, None),
|
||||
s("dropShadow/mode2", 1, 0, 0x00010004, None),
|
||||
s("dropShadow/noise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("dropShadow/useGlobalAngle", 1, 0, 0x00010004, None),
|
||||
s("frameFX/color", 4, 1, 0x0002ffff, None),
|
||||
s("frameFX/mode2", 1, 0, 0x00010004, None),
|
||||
s("frameFX/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("frameFX/size", 1, 0, 0xffffffff, Some((1.0, 100.0))),
|
||||
s("frameFX/style", 1, 0, 0x00010004, None),
|
||||
s("gradientFill/align", 1, 0, 0x00010004, None),
|
||||
s("gradientFill/angle", 1, 0, 0xffffffff, None),
|
||||
s("gradientFill/gradientSmoothness", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("gradientFill/mode2", 1, 0, 0x00010004, None),
|
||||
s("gradientFill/offset", 2, 3, 0xffffffff, None),
|
||||
s("gradientFill/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("gradientFill/reverse", 1, 0, 0x00010004, None),
|
||||
s("gradientFill/scale", 1, 0, 0xffffffff, Some((10.0, 150.0))),
|
||||
s("gradientFill/type", 1, 0, 0x00010004, None),
|
||||
s("innerGlow/AEColorChoice", 1, 0, 0x00010004, None),
|
||||
s("innerGlow/blur", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerGlow/chokeMatte", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerGlow/color", 4, 1, 0x0002ffff, None),
|
||||
s("innerGlow/glowTechnique", 1, 0, 0x00010004, None),
|
||||
s("innerGlow/gradientSmoothness", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerGlow/innerGlowSource", 1, 0, 0x00010004, None),
|
||||
s("innerGlow/inputRange", 1, 0, 0xffffffff, Some((1.0, 100.0))),
|
||||
s("innerGlow/mode2", 1, 0, 0x00010004, None),
|
||||
s("innerGlow/noise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerGlow/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerGlow/shadingNoise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/blur", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/chokeMatte", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/color", 4, 1, 0x0002ffff, None),
|
||||
s("innerShadow/distance", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/localLightingAngle", 1, 0, 0xffffffff, None),
|
||||
s("innerShadow/mode2", 1, 0, 0x00010004, None),
|
||||
s("innerShadow/noise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("innerShadow/useGlobalAngle", 1, 0, 0x00010004, None),
|
||||
s("outerGlow/AEColorChoice", 1, 0, 0x00010004, None),
|
||||
s("outerGlow/blur", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("outerGlow/chokeMatte", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("outerGlow/color", 4, 1, 0x0002ffff, None),
|
||||
s("outerGlow/glowTechnique", 1, 0, 0x00010004, None),
|
||||
s("outerGlow/gradientSmoothness", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("outerGlow/inputRange", 1, 0, 0xffffffff, Some((1.0, 100.0))),
|
||||
s("outerGlow/mode2", 1, 0, 0x00010004, None),
|
||||
s("outerGlow/noise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("outerGlow/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("outerGlow/shadingNoise", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
s("solidFill/color", 4, 1, 0x0002ffff, None),
|
||||
s("solidFill/mode2", 1, 0, 0x00010004, None),
|
||||
s("solidFill/opacity", 1, 0, 0xffffffff, Some((0.0, 100.0))),
|
||||
];
|
||||
Loaded 100 of 1420 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user