diff --git a/.github/workflows/house-rules.yml b/.github/workflows/house-rules.yml new file mode 100644 index 000000000..de64ad3ee --- /dev/null +++ b/.github/workflows/house-rules.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 97c35c2b9..ba48c1a97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,56 @@ + +## 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. + + +## 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. + + # EffectCraft — rules for agents and contributors These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this @@ -102,10 +155,20 @@ 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. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). diff --git a/CLAUDE.md b/CLAUDE.md index 640b9800e..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,37 +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. -- **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**. -- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **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** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. -- **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 `); 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-`; keep every `Cargo.toml` valid (the `crates/*` glob). - -## 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. +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 7d58b050d..5b426c8f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1970,6 +1970,7 @@ dependencies = [ name = "effectcraft-ui-egui" version = "0.21.0" dependencies = [ + "bytemuck", "chrono", "craft-quit", "craft-ui", @@ -1992,6 +1993,7 @@ dependencies = [ "egui_extras", "egui_kittest", "exr", + "half", "image", "kurbo", "log", @@ -2000,6 +2002,7 @@ dependencies = [ "serde", "serde_json", "sys-locale", + "tempfile", "toml", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 8ffe7b988..abb2256c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "d5ead4 wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } bytemuck = "1" pollster = "1" +half = "2.7" # ui # Shared frontend components; keep this dependency above the engine boundary. diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 98bcbb598..2981519e9 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -17,6 +17,7 @@ //! [--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 ` @@ -114,9 +115,13 @@ const USAGE: &str = "usage: effectcraft-cli ) @@ -190,6 +195,8 @@ const VALUED: &[&str] = &[ "--webm-codec", "--audio-bitrate", "--opus-app", + "--exr-compression", + "--exr-depth", // mcp "--automation-read-root", "--automation-write-root", @@ -881,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); diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index 8eda44603..363eb8e52 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -1171,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); +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a70e03fcd..07d327a21 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -8,7 +8,9 @@ //! `--demo` opens the demo project instead (also Help ▸ Open Demo Project). //! //! `--control ` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server; -//! see `effectcraft_ui_egui::control` for the methods. +//! 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")] @@ -112,6 +114,13 @@ fn parse_args(args: impl IntoIterator, env_port: Result) -> 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 { @@ -267,6 +276,9 @@ fn main() -> eframe::Result { // written on background threads (M13.14). session.check_footage_on_open = true; session.autosave.background = true; + // The disk cache's footage stamps too: an imported or opened project with thousands of + // sequence frames on a network share took half a minute to stamp. + session.disk_salt_background = true; // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = effectcraft_host::config_dir() { @@ -310,7 +322,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(true); + 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 { @@ -666,6 +678,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] diff --git a/contributors/people.toml b/contributors/people.toml index defb83972..e9144f9ea 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -62,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 diff --git a/crates/aep/Cargo.toml b/crates/aep/Cargo.toml index c538dd4f5..b1bf3f9fe 100644 --- a/crates/aep/Cargo.toml +++ b/crates/aep/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-aep" -description = "After Effects projects: the .aep (RIFX 'Egg!') / .aepx container as a lossless chunk tree with a typed view, and its import into an EffectCraft project, which reports everything it does not carry over" +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 diff --git a/crates/aep/README.md b/crates/aep/README.md index a9b3b807d..965bea317 100644 --- a/crates/aep/README.md +++ b/crates/aep/README.md @@ -5,9 +5,9 @@ After Effects projects (`.aep`, `.aepx`) in one crate, the way `effectcraft-lott - `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)). - -Writing an EffectCraft project as an `.aep` (export) is not part of this crate yet. + 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. @@ -46,7 +46,11 @@ 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. +- 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 diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 0a4746f68..531d74843 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -33,10 +33,13 @@ 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, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type}; +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}; @@ -54,4 +57,5 @@ pub use textdoc::{ }; 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}; diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index cbcc56c53..72e7db28a 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -381,3 +381,173 @@ pub fn identifier_channel_type(data: &[u8]) -> Option<&str> { 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>, `, 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 { + if data.get(..4)? != b"cry1" { + return None; + } + let text = |b: &[u8]| -> Option { + 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 { + 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" , ", noise)); + assert_eq!(d, Some(CryptomatteData { layer: "Layer_A".into(), selection: "<0000002a>, ".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>, ", 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", "", 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}"); + } + } +} diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 7f426bcd1..e33fce833 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -1,7 +1,8 @@ -//! The effect table of the importer (`crate::import`): an After Effects effect (its match name) and its parameters (by +//! The effect table shared by the importer (`crate::import`) and the exporter +//! (`crate::export`): an After Effects effect (its match name) and its parameters (by //! their index in `parT`) map to an EffectCraft effect, a parameter id and a unit conversion. //! Plain data; the evidence for each row is in `docs/aep-format.md` 3.7 and the case numbers in -//! the comments. +//! the comments. The importer reads a file through it, the exporter writes the inverse. use crate::container::effects::ParamKind; use ParamKind as K; @@ -109,6 +110,8 @@ pub const HUE_SAT_CHANNEL: &[&str] = &["Master", "Reds", "Yellows", "Greens", "C pub const LEVELS_CHANNEL: &[&str] = &["RGB", "Red", "Green", "Blue", "Alpha"]; /// 511. pub const LEVELS_CLIP: &[&str] = &["On", "Off", "Off for 32 bpc Color"]; +/// The Cryptomatte plug-in's Output (`Cryptomatte-0002`). +pub const CRYPTOMATTE_OUTPUT: &[&str] = &["Colors", "Matted Colors", "Matted RGBA", "Matte Only"]; /// One mapped parameter. #[derive(Clone, Copy, Debug)] @@ -142,6 +145,10 @@ pub enum Extra { Extractor, /// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name. Identifier, + /// The Cryptomatte plug-in: the layer and selection in its Settings data + /// (`CryptomatteData`), Output split over EffectCraft's Display and Matte Only; every + /// parameter is handled by the importer's Cryptomatte step, none by the table. + Cryptomatte, } /// One mapped effect. @@ -531,6 +538,9 @@ pub const EFFECTS: &[EffectMap] = &[ ], extra: Extra::MeshWarp, }, + // The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection + // Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`). + EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte }, // Expression controls (070-077, 217-221, 310). // Directional Blur (711): Direction in degrees, Blur Length in pixels (valid max 1 000). fx( @@ -1131,6 +1141,16 @@ pub fn popup_key(item: &str) -> String { s.strip_suffix(" channel").map(str::to_string).unwrap_or(s) } +/// The table row of an After Effects match name. +pub fn by_match_name(match_name: &str) -> Option<&'static EffectMap> { + EFFECTS.iter().find(|m| m.match_name == match_name) +} + +/// The table row of an EffectCraft effect id. +pub fn by_effect(effect: &str) -> Option<&'static EffectMap> { + EFFECTS.iter().find(|m| m.effect == effect) +} + /// The EffectCraft parameters Hue/Saturation's master values (`Extra::HueSaturation`) go to, /// in the order of the data (hue, saturation, lightness: 444-446). pub const HUE_SATURATION_MASTER: [&str; 3] = ["hue", "saturation", "lightness"]; diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index 8fe084f0f..e336d84e5 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -256,6 +256,9 @@ impl<'f, 'a> Effect<'f, 'a> { && 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()); } diff --git a/crates/aep/src/container/style_map.rs b/crates/aep/src/container/style_map.rs index 80f69e69a..fdb28c875 100644 --- a/crates/aep/src/container/style_map.rs +++ b/crates/aep/src/container/style_map.rs @@ -1,4 +1,5 @@ -//! The layer-style table of the importer (`crate::import`): the After Effects layer styles (`ADBE Layer Styles`, docs +//! 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. //! @@ -72,6 +73,10 @@ pub struct StyleMap { } 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) @@ -321,6 +326,11 @@ 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::*; diff --git a/crates/aep/src/container/version.rs b/crates/aep/src/container/version.rs new file mode 100644 index 000000000..bf3fbf4c4 --- /dev/null +++ b/crates/aep/src/container/version.rs @@ -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 { + 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::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::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 { + 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)); + } +} diff --git a/crates/aep/src/export.rs b/crates/aep/src/export.rs new file mode 100644 index 000000000..12f10aa0c --- /dev/null +++ b/crates/aep/src/export.rs @@ -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), + /// 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, + 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"\n\n \n \n application/vnd.adobe.aftereffects.project\n \n \n\n"; + +/// 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 { + 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) -> Result { + 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() +} diff --git a/crates/aep/src/export/base.rs b/crates/aep/src/export/base.rs new file mode 100644 index 000000000..d483cefb7 --- /dev/null +++ b/crates/aep/src/export/base.rs @@ -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, 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 { + 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> { + 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 = "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> { + 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); + } +} diff --git a/crates/aep/src/export/camlight.rs b/crates/aep/src/export/camlight.rs new file mode 100644 index 000000000..597bf800a --- /dev/null +++ b/crates/aep/src/export/camlight.rs @@ -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 { + 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 { + 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> { + 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 { + 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> = gk.drain(..first).collect(); + let mut pairs: Vec<(String, Vec>)> = 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> { + 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) +} diff --git a/crates/aep/src/export/effects.rs b/crates/aep/src/export/effects.rs new file mode 100644 index 000000000..4f42128cc --- /dev/null +++ b/crates/aep/src/export/effects.rs @@ -0,0 +1,1221 @@ +//! Effects: the inverse of the importer, through the same table (`crate::effect_map`). +//! An EffectCraft effect the table maps is written as the After Effects effect it came from: +//! +//! * the instance is the effect's default instance, built in code from the effect table +//! (`fx_defs`, `fxsynth`: `tdmn` + `sspc` with its full `parT` and the records After Effects +//! always keeps: the input layer, buttons, group markers, hidden entries, arbitrary data), with +//! its instance name and the owning layer's id in the input-layer record; +//! * a parameter whose value differs from its `pard` default gets a value record (absent = +//! default, `docs/aep-format.md` 3.7). The record is built from the **record of its parameter +//! type**: over the whole corpus every static value record of a type has the same `tdsb` and +//! `tdb4` apart from the time scale (`@12`) and, for points, the layer aspect (`@24`), and a +//! slider's `tdum` / `tduM` repeat its `pard` slider range (float sliders `f32` @112 / @116, +//! fixed sliders 16.16 and integer sliders `i32` @132 / @136); its `tdsn` is the parameter's +//! name (a checkbox's `pdnm` label); `tests/effects.rs` rebuilds every value record of every +//! effect fixture and compares it with After Effects' bytes; +//! * a keyed parameter turns its record into the keyed form of its kind (fixed slider 363, colour +//! 364, point 382, float slider 397: `tdb4@5` bit 0 cleared, `cdat` replaced by the key list); +//! * an expression is stored as on any property (534, R15); +//! * Hue/Saturation's master values and Levels' RGB values are patched into the effect's +//! arbitrary data (444-446, 512-516); +//! * the project lists each effect type in `Pefl` and keeps one definition copy per type in +//! `EfdG`; the first instance of a type in the file holds the full parameter definitions +//! (`parT`), every later one an empty `parT` (383). +//! +//! Units are the importer's inverse (`Conv`): colours straight RGBA 0..1 to ARGB 0..255 (alpha +//! 255), points layer pixels to fractions of the layer size, percent sliders by their valid +//! maximum, popups by item name to the file's 1-based index, checkboxes 1.0 when on. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::effect_map::{Conv, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, by_effect, by_match_name, popup_key}; +use crate::{AepFile, Body, CURVE_IDENTITY, Chunk, CurvesData, MeshData, ParamKind}; +use effectcraft_keyframe::{Interp, Keyframe, Value}; +use effectcraft_project::{GroupKind, Layer, LayerId, Node, ParamUi, PropGroup, Property}; + +use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT}; +use crate::export::report::{Collector, LossCategory}; +use crate::export::tree::{R, child_data, child_mut, data_mut, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, tpl}; + +/// How a parameter is stored in its value record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PKind { + /// ARGB 0..255 (4 components). + Color, + /// Two fractions of the layer size. + Point, + /// A fixed slider (pard type 2): the number as it is. + Fixed, + /// An angle in degrees (pard type 3). + Angle, + /// An integer slider (pard type 1). + Int, + /// A float slider, stored as the float32 After Effects keeps (widened to f64). + Float, + /// A popup, stored 1-based. + Popup, + /// A checkbox, stored 1.0 when on. + Checkbox, +} + +impl PKind { + fn of(k: ParamKind) -> Option { + Some(match k { + ParamKind::Color => PKind::Color, + ParamKind::Point => PKind::Point, + ParamKind::FixedSlider => PKind::Fixed, + ParamKind::Angle => PKind::Angle, + ParamKind::IntSlider => PKind::Int, + ParamKind::FloatSlider => PKind::Float, + ParamKind::Popup => PKind::Popup, + ParamKind::Checkbox => PKind::Checkbox, + _ => return None, + }) + } + + /// The `pard` type code (`docs/aep-format.md` 3.7). + fn code(self) -> u32 { + match self { + PKind::Int => 1, + PKind::Fixed => 2, + PKind::Angle => 3, + PKind::Checkbox => 4, + PKind::Color => 5, + PKind::Point => 6, + PKind::Popup => 7, + PKind::Float => 10, + } + } + + fn dims(self) -> usize { + match self { + PKind::Color => 4, + PKind::Point => 2, + _ => 1, + } + } +} + +/// A parameter value in the file's units. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ParamValue { + Static(Vec), + Keys(Vec), +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ParamPlan { + pub index: u16, + pub kind: PKind, + pub value: ParamValue, + /// The expression text and its enable switch. + pub expr: Option<(String, bool)>, +} + +/// An effect instance in the file's units. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct EffectPlan { + pub match_name: &'static str, + pub name: String, + pub params: Vec, + /// New arbitrary data for the parameter with this index (Hue/Saturation, Levels). + pub data: Option<(u16, Vec)>, + /// The layer each layer parameter names (by its index); absent = the owning layer. + pub layers: Vec<(u16, LayerPick)>, + /// The fx switch (the value group's `tdsb`: 1 on, 0 off). + pub enabled: bool, +} + +/// What a layer parameter names (`docs/aep-format.md` 3.7: its record always exists, its `tdpi` +/// is the chosen layer's id, the owning layer's own id by default, 0 for None; 320, 321, 904). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LayerPick { + /// The layer that owns the effect (After Effects' default). + Owner, + /// None. + Nothing, + /// Another layer of the composition. + Layer(LayerId), +} + +fn sane(v: f64) -> Option { + (v.is_finite() && v.abs() <= LIMIT).then_some(v) +} + +// --------------------------------------------------------------------------------------------- +// The parameter definitions of the prototypes (read once from the embedded effect template). + +/// One parameter definition of a prototype instance (`pard`, `pdnm`). +#[derive(Clone, Debug, Default)] +pub(crate) struct ParDef { + /// The parameter's match name (`-NNNN`), the name of its value record. The number in + /// it is not the position in `parT` (Fill: position 2 is `ADBE Fill-0007`, 215). + pub record: String, + /// `pard@12`. + pub code: u32, + /// `pard@16`, or a checkbox's `pdnm` label. + pub name: String, + /// The popup items (`pdnm`, separated by `|`). + pub items: Vec, + /// The default in stored units (`pard@56`). + pub default: Vec, + /// The slider range a value record repeats as `tdum` / `tduM`. + pub range: Option<(f64, f64)>, + /// A slider's valid maximum (the table's scale check). + pub valid_max: Option, + /// The default arbitrary data (the `aRbp` after the `pard`). + pub data: Option>, +} + +fn be_i32(d: &[u8], at: usize) -> Option { + Some(i32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} +fn be_u32(d: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} +fn be_f32(d: &[u8], at: usize) -> Option { + Some(f64::from(f32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))) +} +fn be_f64(d: &[u8], at: usize) -> Option { + Some(f64::from_be_bytes(d.get(at..at.checked_add(8)?)?.try_into().ok()?)) +} + +fn cname(d: &[u8]) -> String { + d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect() +} + +impl ParDef { + fn from_pard(pard: &[u8], pdnm: Option<&str>, data: Option<&[u8]>) -> ParDef { + let code = be_u32(pard, 12).unwrap_or(u32::MAX); + let fx = |at: usize| be_i32(pard, at).map(|v| f64::from(v) / 65536.0); + let default: Vec = match code { + 10 => be_f64(pard, 56).into_iter().collect(), + 2 | 3 => fx(56).into_iter().collect(), + 1 => be_i32(pard, 56).map(f64::from).into_iter().collect(), + 4 | 7 => be_u32(pard, 56).map(f64::from).into_iter().collect(), + // Fractions of the layer, or percent after an After Effects re-save (see the reader). + 6 => { + let p: Vec = [fx(56), fx(60)].into_iter().flatten().collect(); + if p.iter().any(|c| c.abs() > 1.0 + 1e-9) { p.iter().map(|c| c / 100.0).collect() } else { p } + } + 5 => pard.get(56..60).map(|c| c.iter().map(|b| f64::from(*b)).collect()).unwrap_or_default(), + _ => Vec::new(), + }; + let range = match code { + 10 => be_f32(pard, 112).zip(be_f32(pard, 116)), + 2 => fx(132).zip(fx(136)), + 1 => be_i32(pard, 132).map(f64::from).zip(be_i32(pard, 136).map(f64::from)), + _ => None, + }; + let valid_max = match code { + 10 => be_f32(pard, 108), + 2 => fx(128), + 1 => be_i32(pard, 128).map(f64::from), + _ => None, + }; + let pname = pard.get(16..48).map(cname).unwrap_or_default(); + let (name, items) = match (code, pdnm) { + (7, Some(p)) if !p.is_empty() => (pname, p.split('|').map(str::to_string).collect()), + (4, Some(p)) if pname.trim().is_empty() => (p.to_string(), Vec::new()), + _ => (pname, Vec::new()), + }; + ParDef { record: String::new(), code, name, items, default, range, valid_max, data: data.map(<[u8]>::to_vec) } + } +} + +/// The parameter definitions of an instance's `parT`, by position (the table's index). +fn par_defs(sspc: &Chunk<'_>) -> HashMap { + let mut out = HashMap::new(); + let Some(part) = sspc.child_list(b"parT") else { return out }; + let k = part.children(); + let mut i = 0usize; + let mut position = 0u16; + while i < k.len() { + let (Some(m), Some(p)) = (k.get(i), k.get(i + 1)) else { break }; + if !(m.is(b"tdmn") && p.is(b"pard")) { + i += 1; + continue; + } + let name = tdmn_name(m).unwrap_or_default(); + let mut j = i + 2; + let mut pdnm = None; + let mut data = None; + while let Some(c) = k.get(j) { + if c.is(b"tdmn") { + break; + } + if c.is(b"pdnm") { + pdnm = c.children().iter().find(|u| u.is(b"Utf8")).and_then(|u| u.bytes()).map(|b| String::from_utf8_lossy(b).to_string()); + } + if c.is(b"aRbp") { + data = c.bytes(); + } + j += 1; + } + if let Some(bytes) = p.bytes() { + out.insert(position, ParDef { record: name, ..ParDef::from_pard(bytes, pdnm.as_deref(), data) }); + } + position = position.saturating_add(1); + i = j; + } + out +} + +/// The parameter definitions of every effect type, by match name (read from the default +/// instances built from the effect table). +fn defs() -> &'static HashMap> { + static DEFS: OnceLock>> = OnceLock::new(); + DEFS.get_or_init(|| { + crate::export::fx_defs::FX_DEFS.iter().map(|fx| (fx.match_name.to_string(), par_defs(&crate::export::fxsynth::default_sspc(fx)))).collect() + }) +} + +// --------------------------------------------------------------------------------------------- +// Planning. + +/// The stored components of an EffectCraft value, `None` when it does not fit. +fn stored(conv: Conv, kind: PKind, v: &Value, size: (f64, f64), ui: &ParamUi, def: &ParDef) -> Option> { + let c = v.components(); + let g = |i: usize| c.get(i).copied().and_then(sane); + let one = |x: f64| -> Vec { + match kind { + // The float32 After Effects keeps (393, 394). + PKind::Float => vec![f64::from(x as f32)], + PKind::Int => vec![x.round()], + _ => vec![x], + } + }; + Some(match conv { + Conv::Color => { + let ch = |i: usize| g(i).map(|x| x.clamp(0.0, 1.0) * 255.0); + vec![255.0, ch(0)?, ch(1)?, ch(2)?] + } + Conv::Point => vec![g(0)? / size.0.max(1.0), g(1)? / size.1.max(1.0)], + Conv::Percent { valid_max } => one(g(0)? * valid_max / 100.0), + Conv::Plain { .. } | Conv::Angle => one(g(0)?), + Conv::Factor { factor, .. } => one(g(0)? / factor), + Conv::Checkbox => match v { + Value::Bool(b) => vec![if *b { 1.0 } else { 0.0 }], + _ => vec![if g(0)? != 0.0 { 1.0 } else { 0.0 }], + }, + Conv::Popup { items, .. } => { + let i = match v { + Value::Enum(i) => usize::try_from(*i).ok()?, + _ => usize::try_from(g(0)?.round() as i64).ok()?, + }; + let ParamUi::Popup { options } = ui else { return None }; + let name = options.get(i)?; + let file: Vec<&str> = if def.items.is_empty() { items.to_vec() } else { def.items.iter().map(String::as_str).collect() }; + let at = file.iter().position(|f| f.trim() != "(-" && popup_key(f) == popup_key(name))?; + vec![f64::from(u32::try_from(at).ok()?.checked_add(1)?)] + } + // Not a value record (layer parameters, histograms). + Conv::LayerRef { .. } | Conv::Skip => return None, + }) +} + +/// Whether a popup's items are After Effects' items in the same order (separators included, as +/// After Effects counts them), so an expression's 1-based result picks the same item in both. +fn same_popup_order(items: &[&str], ui: &ParamUi, def: &ParDef) -> bool { + let ParamUi::Popup { options } = ui else { return false }; + let file: Vec<&str> = if def.items.is_empty() { items.to_vec() } else { def.items.iter().map(String::as_str).collect() }; + file.len() == options.len() && file.iter().zip(options).all(|(f, o)| f.trim() != "(-" && popup_key(f) == popup_key(o)) +} + +/// [`stored`], reporting what it changes in a value it can store: a colour channel outside 0 to 1 +/// (written as the nearest limit) and a fraction on an integer parameter (rounded). +fn stored_reported(conv: Conv, kind: PKind, v: &Value, (size, ui, def): ((f64, f64), &ParamUi, &ParDef), what: &str, col: &mut Collector) -> Option> { + let s = stored(conv, kind, v, size, ui, def)?; + if matches!(conv, Conv::Color) { + let c = v.components(); + if c.iter().take(3).any(|x| !(0.0..=1.0).contains(x)) { + col.lose(LossCategory::InvalidValues, format!("{what}: a colour channel outside 0 to 1 is written as the nearest limit")); + } + // After Effects' colour parameters have no opacity (written as 255); Drop Shadow, for + // one, uses EffectCraft's. + if c.get(3).is_some_and(|a| a.to_bits() != 1f64.to_bits()) { + col.lose(LossCategory::Effects, format!("{what}: the colour's opacity is not written")); + } + } + if kind == PKind::Int + && let Some(raw) = stored(conv, PKind::Fixed, v, size, ui, def) + && raw.iter().zip(&s).any(|(a, b)| a.to_bits() != b.to_bits()) + { + col.lose(LossCategory::Effects, format!("{what}: {raw:?} is written as the whole number {s:?}")); + } + Some(s) +} + +fn is_default(kind: PKind, s: &[f64], def: &ParDef) -> bool { + let tol = |a: f64, b: f64| { + let t = match kind { + // 16.16 defaults. + PKind::Fixed | PKind::Angle | PKind::Point => 1.0 / 65536.0, + _ => 1e-9 * a.abs().max(b.abs()).max(1.0), + }; + (a - b).abs() <= t + }; + match kind { + // Alpha is not editable in After Effects; the RGB channels decide. + PKind::Color => s.get(1..4).zip(def.default.get(1..4)).is_some_and(|(a, b)| a.iter().zip(b).all(|(x, y)| tol(*x, *y))), + _ => s.len() == def.default.len() && s.iter().zip(&def.default).all(|(a, b)| tol(*a, *b)), + } +} + +/// Plans the effects of `layer` (its source is `size` pixels) and reports what cannot be written. +pub(crate) fn plan_effects(layer: &Layer, size: (u32, u32), cp: &CompPlan, comp_name: &str, col: &mut Collector) -> Vec { + let Some(fx) = layer.effects() else { return Vec::new() }; + let size = (f64::from(size.0.max(1)), f64::from(size.1.max(1))); + let mut out = Vec::new(); + for node in &fx.children { + let Node::Group(g) = node else { continue }; + let here = format!("{comp_name}: '{}' effect '{}'", layer.name, g.name); + let GroupKind::Effect { effect } = &g.kind else { + col.lose(LossCategory::Effects, format!("{here}: not an effect group")); + continue; + }; + let (Some(spec), Some(pdefs)) = (by_effect(effect), by_effect(effect).and_then(|s| defs().get(s.match_name))) else { + col.lose(LossCategory::Effects, format!("{comp_name}: '{}' has the effect '{}' ({effect})", layer.name, g.name)); + continue; + }; + if g.label.is_some() { + col.lose(LossCategory::Effects, format!("{here}: the effect's label colour is not written")); + } + let mut params = Vec::new(); + let mut layers = Vec::new(); + for pm in spec.params { + let Some(prop) = g.prop(pm.target) else { continue }; + let Ok(index) = u16::try_from(pm.index) else { continue }; + if let Conv::LayerRef { own } = pm.conv { + if let Some(pick) = plan_layer_param(layer, g, pm.target, own, prop, &here, col) { + layers.push((index, pick)); + } + continue; + } + let (Some(kind), Some(def)) = (PKind::of(pm.kind), pdefs.get(&index)) else { + col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no prototype definition", prop.name)); + continue; + }; + if def.code != kind.code() { + col.lose(LossCategory::Effects, format!("{here}: parameter '{}': the prototype has another type", prop.name)); + continue; + } + if let Some(pp) = plan_param(ParamIn { conv: pm.conv, kind, def, prop, size }, index, cp, &here, col) { + params.push(pp); + } + } + let data = match spec.extra { + Extra::Curves => plan_curves(g, pdefs, &here, col), + Extra::MeshWarp => plan_mesh(g, pdefs, size, &here, col), + _ => plan_extra(spec, g, pdefs, &here, col), + }; + report_unmapped(g, (effect, spec), size, &here, col); + col.exported.effects = col.exported.effects.saturating_add(1); + out.push(EffectPlan { + match_name: spec.match_name, + name: crate::export::plan::cut_name(&g.name, 255, &here, col), + params, + data, + layers, + enabled: g.enabled, + }); + } + out +} + +/// A layer parameter (the importer's inverse, `Conv::LayerRef`): an unset layer is None where the +/// owning layer is EffectCraft's own default (`own`, Layer Control) and the owning layer +/// elsewhere (Compound Blur, Displacement Map, Set Matte: unset means "this layer" in both +/// programs); `None` = the default, the owning layer. +fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop: &Property, here: &str, col: &mut Collector) -> Option { + let what = format!("{here}: parameter '{}'", prop.name); + if !prop.keys.is_empty() || prop.has_expression() { + col.lose(LossCategory::Effects, format!("{what}: keys or an expression on a layer parameter are not written, its value is")); + } + let pick = match prop.value { + Value::Layer(None) if own => LayerPick::Nothing, + Value::Layer(None) => LayerPick::Owner, + Value::Layer(Some(effectcraft_project::NO_LAYER)) => LayerPick::Nothing, + Value::Layer(Some(id)) if id == layer.id.0 => LayerPick::Owner, + Value::Layer(Some(id)) => LayerPick::Layer(LayerId(id)), + _ => { + col.lose(LossCategory::InvalidValues, format!("{what}: not a layer, the default was written")); + LayerPick::Owner + } + }; + // After Effects hands the named layer's source to the effect (904 E24, the importer's + // companion `Source` = 0); another choice is not sampled. Layer Control (`own`) has no + // source choice in After Effects: it only names the layer. + if let LayerPick::Layer(_) = pick + && !own + && g.prop(&format!("{target}Source")).is_some_and(|s| !matches!(s.value, Value::Enum(0))) + { + col.lose(LossCategory::Effects, format!("{what}: the named layer's masks and effects are not applied in After Effects (it takes the layer's source)")); + } + (pick != LayerPick::Owner).then_some(pick) +} + +/// Hue/Saturation's master values or Levels' RGB values, as new arbitrary data (the prototype's +/// default data with the values patched in); `None` when they are at the default. +fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap, here: &str, col: &mut Collector) -> Option<(u16, Vec)> { + let ids: &[&str] = match spec.extra { + // (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below) + Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None, + Extra::HueSaturation => &HUE_SATURATION_MASTER, + Extra::Levels => &LEVELS_RGB, + }; + let mut values = Vec::with_capacity(ids.len()); + for id in ids { + let v = g.prop(id).and_then(|p| { + if !p.keys.is_empty() || p.has_expression() { + col.lose( + LossCategory::Effects, + format!("{here}: keys or an expression on '{}' are not written (arbitrary data, the static value is written)", p.name), + ); + } + let v = p.value.components().first().copied(); + if v.is_some_and(|v| sane(v).is_none()) { + col.lose(LossCategory::InvalidValues, format!("{here}: '{}' is beyond the format's range, the default is written", p.name)); + } + v.and_then(sane) + }); + values.push(v); + } + let (index, mut data) = pdefs.iter().find(|(_, d)| d.code == 11).and_then(|(i, d)| d.data.clone().map(|data| (*i, data)))?; + let before = data.clone(); + for (k, (id, v)) in ids.iter().zip(values).enumerate() { + let Some(v) = v else { continue }; + if spec.extra == Extra::HueSaturation { + // Three i32 at 0, 4, 8 (444-446): whole degrees and percents. + let r = v.round().clamp(-1e6, 1e6); + if (r - v).abs() > 1e-9 { + col.lose(LossCategory::Effects, format!("{here}: '{id}' {v} is written as {r} (After Effects keeps whole numbers)")); + } + put(&mut data, k * 4, &(r as i32).to_be_bytes()).ok()?; + } else { + // The RGB channel's five f32 at 8.. (512-516). + put(&mut data, 8 + k * 4, &(v as f32).to_be_bytes()).ok()?; + } + } + (data != before).then_some((index, data)) +} + +/// `x,y` pairs (EffectCraft's curve points and mesh offsets; whitespace or `;` between pairs). +fn pairs_of(s: &str) -> Vec<(f64, f64)> { + s.split(|c: char| c.is_whitespace() || c == ';') + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let (x, y) = t.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().split(',').next()?.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some((x, y)) + }) + .collect() +} + +/// The arbitrary-data parameter's index and default data. +fn data_param(pdefs: &HashMap) -> Option<(u16, Vec)> { + pdefs.iter().find(|(_, d)| d.code == 11).and_then(|(i, d)| d.data.clone().map(|data| (*i, data))) +} + +fn static_str<'p>(g: &'p PropGroup, id: &str, here: &str, col: &mut Collector) -> Option<&'p str> { + let p = g.prop(id)?; + if !p.keys.is_empty() || p.has_expression() { + col.lose(LossCategory::Effects, format!("{here}: keys or an expression on '{}' are not written (arbitrary data, the static value is written)", p.name)); + } + match &p.value { + Value::Str(s) => Some(s.as_str()), + _ => { + col.lose(LossCategory::InvalidValues, format!("{here}: '{}' is not text, the default is written", p.name)); + None + } + } +} + +/// Curves (FORMAT S.1): EffectCraft's points (0..1) as After Effects' whole 0..255 points, with +/// the lookup tables After Effects stores for them (the natural spline); `None` at the default. +fn plan_curves(g: &PropGroup, pdefs: &HashMap, here: &str, col: &mut Collector) -> Option<(u16, Vec)> { + let mut curves: [Vec<(u8, u8)>; 5] = Default::default(); + for (slot, id) in curves.iter_mut().zip(["rgb", "red", "green", "blue", "alpha"]) { + let pts = static_str(g, id, here, col).map(pairs_of).unwrap_or_default(); + let mut ints: Vec<(u8, u8)> = Vec::with_capacity(pts.len()); + let (mut rounded, mut clipped) = (false, false); + for (x, y) in pts { + let (x, y) = (x * 255.0, y * 255.0); + let (xs, ys) = (x.clamp(0.0, 255.0), y.clamp(0.0, 255.0)); + clipped |= xs.to_bits() != x.to_bits() || ys.to_bits() != y.to_bits(); + rounded |= (xs - xs.round()).abs() > 1e-3 || (ys - ys.round()).abs() > 1e-3; + ints.push((xs.round() as u8, ys.round() as u8)); + } + // Points with the same input are merged as EffectCraft's own curve merges them (the + // first is kept), and fewer than two points are the identity there too. + ints.sort_by_key(|p| p.0); + ints.dedup_by_key(|p| p.0); + if clipped { + col.lose(LossCategory::Effects, format!("{here}: curve '{id}' points outside 0 to 1 are written as the nearest limit")); + } + if rounded { + col.lose(LossCategory::Effects, format!("{here}: curve '{id}' points rounded to whole levels (After Effects keeps 0..255)")); + } + if ints.len() > 16 { + col.lose(LossCategory::Effects, format!("{here}: curve '{id}' has {} points, After Effects keeps 16; the first 16 are written", ints.len())); + ints.truncate(16); + } + *slot = if ints.len() < 2 { CURVE_IDENTITY.to_vec() } else { ints }; + } + let d = CurvesData { curves }; + if d.is_default() { + return None; + } + let (index, _) = data_param(pdefs)?; + match d.to_bytes() { + Some(bytes) => Some((index, bytes)), + None => { + col.lose(LossCategory::Effects, format!("{here}: the curves cannot be written, the default is")); + None + } + } +} + +/// Mesh Warp (FORMAT S.2): EffectCraft's per-vertex pixel offsets as After Effects' vertices +/// (fractions of the layer) with their default handles moved along; `None` at the default. +fn plan_mesh(g: &PropGroup, pdefs: &HashMap, size: (f64, f64), here: &str, col: &mut Collector) -> Option<(u16, Vec)> { + let int = |id: &str| g.prop(id).map(|p| p.value.as_f64().round().clamp(1.0, 31.0) as u32).unwrap_or(7); + let (rows, columns) = (int("rows"), int("columns")); + // Per vertex `dx,dy` (pixels), optionally followed by the eight pixel moves of its handles + // (top, right, bottom, left) that the importer keeps. + let offs: Vec> = static_str(g, "mesh", here, col) + .map(|s| { + s.split(|c: char| c.is_whitespace() || c == ';') + .filter(|t| !t.is_empty()) + .filter_map(|t| { + let v: Vec = t.split(',').map(|x| x.trim().parse::().ok().filter(|x| x.is_finite())).collect::>()?; + (v.len() >= 2).then_some(v) + }) + .collect() + }) + .unwrap_or_default(); + let count = usize::try_from((rows + 1) * (columns + 1)).ok()?; + let moved = offs.iter().any(|v| v.iter().any(|x| *x != 0.0)); + if !moved && rows == 7 && columns == 7 { + return None; + } + if moved && offs.len() != count { + col.lose( + LossCategory::Effects, + format!("{here}: the Distortion Mesh has {} vertices for {columns} x {rows} cells, an undistorted mesh is written", offs.len()), + ); + } + let (index, default) = data_param(pdefs)?; + let tail = MeshData::parse(&default).map(|m| m.tail).unwrap_or([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0d, 0x0d]); + let mut vertices = Vec::with_capacity(count); + for k in 0..count { + let k32 = u32::try_from(k).ok()?; + let (i, j) = (k32 % (columns + 1), k32 / (columns + 1)); + let mut v = MeshData::default_vertex(columns, rows, i, j); + if offs.len() == count + && let Some(o) = offs.get(k) + { + // A moved vertex carries its handles along (985), then each handle its own move. + let at = |n: usize| o.get(n).copied().unwrap_or(0.0); + let (fx, fy) = ((at(0) / size.0) as f32, (at(1) / size.1) as f32); + for (n, p) in v.iter_mut().enumerate() { + if n == 0 || *p != [0.0, 0.0] { + let (hx, hy) = if n == 0 { (0.0, 0.0) } else { ((at(2 * n) / size.0) as f32, (at(2 * n + 1) / size.1) as f32) }; + *p = [p[0] + fx + hx, p[1] + fy + hy]; + } + } + } + vertices.push(v); + } + MeshData { columns, rows, tail, vertices }.to_bytes().map(|b| (index, b)) +} + +/// EffectCraft parameters the table does not map are lost when they carry anything. +/// Reports the parameters of `g` the table has no record for: with keys or an expression, or at +/// a value other than the one a new instance of the effect has. +fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64, f64), here: &str, col: &mut Collector) { + let mut next = 1; + let fresh = |size: [f64; 2], next: &mut u64| { + effectcraft_effects::find(effect).map(|s| effectcraft_effects::instantiate(s, &mut effectcraft_project::build::Ids(next), &g.name, size)) + }; + // A default that depends on the layer size (a centre point) is not compared: the size an + // effect was added at is not known here. + let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next)); + let extra: &[&str] = match spec.extra { + // EXtractoR and IDentifier have no prototype to write from, so they never get this far + // (they are reported). + Extra::None | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[], + Extra::HueSaturation => &HUE_SATURATION_MASTER, + Extra::Levels => &LEVELS_RGB, + Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"], + Extra::MeshWarp => &["mesh"], + }; + let mut stack: Vec<(&PropGroup, String)> = vec![(g, String::new())]; + let mut steps = 0usize; + while let Some((grp, prefix)) = stack.pop() { + steps += 1; + if steps > 10_000 { + break; + } + for n in &grp.children { + match n { + Node::Group(sub) => stack.push((sub, format!("{prefix}{}/", sub.match_id))), + Node::Prop(p) => { + let path = format!("{prefix}{}", p.match_id); + // A layer parameter's source companion is checked with the parameter (`plan_layer_param`). + let companion = spec.params.iter().any(|s| matches!(s.conv, Conv::LayerRef { .. }) && path == format!("{}Source", s.target)); + if spec.params.iter().any(|s| s.target == path) || extra.contains(&path.as_str()) || companion { + continue; + } + if !p.keys.is_empty() || p.has_expression() { + col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no After Effects counterpart", p.name)); + } else if let Some(d) = fresh.as_ref().and_then(|f| f.prop(&path)) + && other.as_ref().and_then(|f| f.prop(&path)).is_some_and(|o| o.value == d.value) + && d.value != p.value + { + col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no After Effects counterpart, its value is not written", p.name)); + } + } + } + } + } +} + +/// One effect parameter to plan: its table conversion, stored kind, prototype definition and +/// property, and the layer's source size. +#[derive(Clone, Copy)] +struct ParamIn<'a> { + conv: Conv, + kind: PKind, + def: &'a ParDef, + prop: &'a Property, + size: (f64, f64), +} + +fn plan_param(pin: ParamIn<'_>, index: u16, cp: &CompPlan, here: &str, col: &mut Collector) -> Option { + let ParamIn { conv, kind, def, prop, size } = pin; + let what = format!("{here}: parameter '{}'", prop.name); + // The table's scale was verified with this valid maximum (the importer checks the same). + let vm = match conv { + Conv::Percent { valid_max } | Conv::Plain { valid_max } | Conv::Factor { valid_max, .. } => Some(valid_max), + _ => None, + }; + if let (Some(want), Some(found)) = (vm, def.valid_max) + && (want - found).abs() > want.abs() * 1e-6 + { + col.lose(LossCategory::Effects, format!("{what}: the prototype's scale differs from the table's, not written")); + return None; + } + let expr = prop.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled)); + if expr.is_some() { + col.exported.expressions = col.exported.expressions.saturating_add(1); + // The expression is written as it is; where the stored value is scaled (a percent or + // factor parameter has other units) or a popup's items are in another order, its result + // means something else in After Effects. Popups count from 1 in both programs' expressions. + let other_units = match conv { + Conv::Popup { items, .. } => !same_popup_order(items, &prop.ui, def), + Conv::Percent { valid_max } => (valid_max - 100.0).abs() > 1e-9, + Conv::Factor { factor, .. } => (factor - 1.0).abs() > 1e-9, + _ => false, + }; + if other_units { + col.lose(LossCategory::Expressions, format!("{what}: the expression is written, but After Effects reads its result in other units")); + } + } + if prop.keys.is_empty() { + let Some(s) = stored_reported(conv, kind, &prop.value, (size, &prop.ui, def), &what, col) else { + if matches!(conv, Conv::Popup { .. }) { + col.lose(LossCategory::Effects, format!("{what}: the popup item has no After Effects counterpart, the default was written")); + } else { + col.lose(LossCategory::InvalidValues, format!("{what}: a value that cannot be stored, the default was written")); + } + return None; + }; + // A property with an expression keeps its record, also at the default (R15). + if is_default(kind, &s, def) && expr.is_none() { + return None; + } + return Some(ParamPlan { index, kind, value: ParamValue::Static(s), expr }); + } + if !matches!(kind, PKind::Fixed | PKind::Float | PKind::Color | PKind::Point) { + col.lose( + LossCategory::Effects, + format!("{what}: keys on a popup, checkbox, angle or integer slider are not written (not sampled), the first key's value was written"), + ); + let first = prop.keys.first().map(|k| &k.value).unwrap_or(&prop.value); + let s = stored_reported(conv, kind, first, (size, &prop.ui, def), &what, col)?; + return (!is_default(kind, &s, def) || expr.is_some()).then_some(ParamPlan { index, kind, value: ParamValue::Static(s), expr }); + } + let keys = plan_keys(pin, cp, &what, col); + if keys.is_empty() { + return None; + } + col.exported.keyframes = col.exported.keyframes.saturating_add(keys.len()); + Some(ParamPlan { index, kind, value: ParamValue::Keys(keys), expr }) +} + +fn plan_keys(pin: ParamIn<'_>, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec { + let ParamIn { conv, kind, def, prop, size } = pin; + let keys: &[Keyframe] = &prop.keys; + let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(keys.len()); + for k in keys { + let (u, inexact) = cp.tb.units(k.time); + if inexact { + 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 { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged")); + } + // The ease of colour keys is sampled only as speed 0 / influence 0, which After Effects + // interpolates linearly (598): a colour key is written linear. + let ease_ok = matches!(kind, PKind::Fixed | PKind::Float | PKind::Point); + // Ease speeds are in stored units per second; a point's in pixels per second over the layer + // height (597, 598, 730). + let factor = match conv { + Conv::Percent { valid_max } => 100.0 / valid_max, + Conv::Factor { factor, .. } => factor, + Conv::Point => size.1.max(1.0), + _ => 1.0, + }; + let mut out = Vec::with_capacity(timed.len()); + let beyond = std::cell::Cell::new(false); + for (u, k) in timed { + let Ok(time) = i32::try_from(u) else { + col.lose(LossCategory::InvalidValues, format!("{what}: key time out of range, key dropped")); + continue; + }; + let Some(value) = stored_reported(conv, kind, &k.value, (size, &prop.ui, def), what, col) else { + col.lose(LossCategory::InvalidValues, format!("{what}: a key value that cannot be stored, key dropped")); + continue; + }; + let mut side = |i: Interp| -> u8 { + match i { + Interp::Linear => 1, + Interp::Hold => 3, + Interp::Bezier if ease_ok => 2, + Interp::Bezier => { + col.lose( + LossCategory::ApproximatedKeyframes, + format!("{what}: bezier ease of a colour key is written as linear (only speed 0 / influence 0 is sampled)"), + ); + 1 + } + } + }; + let (in_i, out_i) = (side(k.in_interp), side(k.out_interp)); + let ease = |eases: &[effectcraft_keyframe::Ease], on: bool| -> Vec { + if !on { + return vec![EaseSlot::default()]; + } + let e = crate::export::plan::key_ease(eases, 0); + let speed = sane(e.speed / factor).unwrap_or_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 }; + vec![EaseSlot { speed, influence }] + }; + let (mut tin, mut tout) = ([0.0; 3], [0.0; 3]); + if kind == PKind::Point { + for (t, s) in [(&mut tin, k.spatial_in), (&mut tout, k.spatial_out)] { + for (o, v) in t.iter_mut().zip([s[0] / size.0, s[1] / size.1]) { + *o = sane(v).unwrap_or_else(|| { + beyond.set(true); + 0.0 + }); + } + } + } + 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: kind != PKind::Point || k.spatial_auto, + roving: false, + label: k.label.min(16), + }); + if k.roving { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving key is written as a normal key")); + } + } + crate::export::plan::report_beyond(beyond.get(), what, col); + out +} + +// --------------------------------------------------------------------------------------------- +// Emit. + +/// The effect prototypes, built in code ([`FxProtos::build`]). +pub(crate) struct FxProtos { + /// match name -> (`tdmn`, default `sspc`, `EfDf` definition copy). + defaults: HashMap, Chunk<'static>, Chunk<'static>)>, + /// `pard` type code -> the static value record of that type. + kinds: HashMap>, + /// Keyed value records by kind. + keyed: Vec<(KeyedKind, Chunk<'static>)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyedKind { + Fixed, + Color, + Point, + Float, +} + +fn keyed_kind(k: PKind) -> Option { + match k { + PKind::Fixed => Some(KeyedKind::Fixed), + PKind::Color => Some(KeyedKind::Color), + PKind::Point => Some(KeyedKind::Point), + PKind::Float => Some(KeyedKind::Float), + _ => None, + } +} + +pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option { + let d = c.bytes()?; + Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()) +} + +impl FxProtos { + /// The prototypes built in code from the effect table (`fx_defs`, `fxsynth`). + pub(crate) fn build() -> FxProtos { + use crate::export::fxsynth::value_spec; + let mut defaults = HashMap::new(); + for fx in crate::export::fx_defs::FX_DEFS { + let sspc = crate::export::fxsynth::default_sspc(fx); + let m = crate::export::synth::tdmn(fx.match_name); + let efdf = Chunk::list(*b"EfDf", vec![m.clone(), sspc.clone()]); + defaults.insert(fx.match_name.to_string(), (m, sspc, efdf)); + } + let kinds = [1u8, 2, 3, 4, 5, 6, 7, 10] + .iter() + .filter_map(|c| value_spec(*c, None).map(|s| (u32::from(*c), crate::export::synth::tdbs(&s, false, 25_600)))) + .collect(); + let keyed = [(KeyedKind::Fixed, 2u8), (KeyedKind::Color, 5), (KeyedKind::Point, 6), (KeyedKind::Float, 10)] + .iter() + .filter_map(|(k, c)| value_spec(*c, None).map(|s| (*k, crate::export::synth::tdbs(&s, true, 25_600)))) + .collect(); + FxProtos { defaults, kinds, keyed } + } +} + +/// Which effect types already have an instance with full definitions in the file, in the order +/// they first appear. +#[derive(Default)] +pub(crate) struct FxUse { + pub types: Vec<&'static str>, +} + +/// A `tdsn` chunk holding `text`. +fn tdsn(text: &str) -> Chunk<'static> { + nested(*b"tdsn", text) +} + +/// A chunk that holds one `Utf8` chunk (`tdsn`, `fnam`). +fn nested(id: [u8; 4], text: &str) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None } +} + +/// The value record of `p`, built from the record of its parameter type. +pub(crate) fn value_record(protos: &FxProtos, p: &ParamPlan, def: &ParDef, source_size: (u32, u32)) -> R<(Chunk<'static>, Chunk<'static>)> { + let mut rec = protos.kinds.get(&p.kind.code()).cloned().ok_or_else(|| tpl("effect value prototype missing"))?; + { + let kids = kids_mut(&mut rec)?; + if let Some(slot) = kids.iter_mut().find(|c| c.is(b"tdsn")) + && !def.name.is_empty() + { + *slot = tdsn(&def.name); + } + } + if p.kind == PKind::Point { + // The layer's aspect, as on the Anchor Point (369: 16 / 9, 383: 1 on 100 x 100). + put_f64(child_data(&mut rec, b"tdb4")?, 24, f64::from(source_size.0) / f64::from(source_size.1.max(1)))?; + } + if let Some((lo, hi)) = def.range { + put_f64(child_data(&mut rec, b"tdum")?, 0, lo)?; + put_f64(child_data(&mut rec, b"tduM")?, 0, hi)?; + } + match &p.value { + ParamValue::Static(v) => { + let cdat = child_data(&mut rec, b"cdat")?; + cdat.iter_mut().for_each(|b| *b = 0); + for (i, x) in v.iter().enumerate() { + put_f64(cdat, i * 8, *x)?; + } + } + ParamValue::Keys(keys) => { + let kk = keyed_kind(p.kind).ok_or_else(|| tpl("keys on a parameter kind without a keyed prototype"))?; + let proto = protos.keyed.iter().find(|(k, _)| *k == kk).map(|(_, c)| c).ok_or_else(|| tpl("keyed effect prototype missing"))?; + let mut list = proto.child_list(b"list").cloned().ok_or_else(|| tpl("keyed effect prototype without keys"))?; + { + // Static and keyed records of the same parameter differ in `tdb4` bytes 5 (the + // static bit), 68 (has keys) and 79 (spatial kinds only): taken from the keyed + // record of the kind (362 / 363, 359 / 364, 369 / 382, 391 / 397). + let ktdb4 = proto.child(b"tdb4").and_then(|c| c.bytes()).ok_or_else(|| tpl("keyed effect prototype without tdb4"))?; + let tdb4 = child_data(&mut rec, b"tdb4")?; + for at in [5usize, 68, 79] { + put_u8(tdb4, at, *ktdb4.get(at).ok_or_else(|| tpl("short tdb4"))?)?; + } + } + let n = u32::try_from(keys.len()).map_err(|_| tpl("too many keys"))?; + let dims = p.kind.dims(); + let size = match kk { + KeyedKind::Fixed | KeyedKind::Float => 8 + 40 * dims, + KeyedKind::Color | KeyedKind::Point => 56 + 24 * dims, + }; + { + let lhd3 = child_data(&mut 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 * keys.len()); + for k in keys { + ldat.extend(match kk { + KeyedKind::Fixed | KeyedKind::Float => crate::export::emit::plain_record(1, k, size)?, + KeyedKind::Color | KeyedKind::Point => spatial_record(kk, k, dims, 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 rec)?; + let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("value record without cdat"))?; + if let Some(slot) = kids.get_mut(at) { + *slot = list; + } + } + } + if let Some((text, enabled)) = &p.expr { + // `tdb4@120` = 1 and the text before `tdum` (534; unlike the Transform properties, + // bytes 76 and 77 stay 0); `@119` the disabled switch as on the Transform properties (R15). + { + let tdb4 = child_data(&mut rec, b"tdb4")?; + put_u8(tdb4, 120, 1)?; + put_u8(tdb4, 119, u8::from(!*enabled))?; + } + let kids = kids_mut(&mut rec)?; + 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((Chunk::data(*b"tdmn", tdmn_bytes(&def.record)), rec)) +} + +/// A key of a colour (flags `0x0001`, 210, 364) or point (flags `0x0007`, `u32@8` the automatic +/// tangent switch, 382) parameter: the spatial layout with the value at 56. +fn spatial_record(kk: KeyedKind, k: &KeyPlan, n: usize, size: usize) -> R> { + 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 kk == KeyedKind::Point { 0x0007 } else { 0x0001 }; + put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?; + if kk == KeyedKind::Point { + put_u32(&mut r, 8, u32::from(k.spatial_auto))?; + // The temporal ease (598): speed in layer heights per second, influence a fraction. + 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 kk == KeyedKind::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) +} + +/// A match name padded to the 40 bytes of `tdmn`. +fn tdmn_bytes(name: &str) -> Vec { + let mut v = vec![0u8; 40]; + for (i, b) in name.bytes().take(39).enumerate() { + if let Some(slot) = v.get_mut(i) { + *slot = b; + } + } + v +} + +/// The `tdmn` + `sspc` of one instance on the layer with id `layer_id`. +fn instance( + protos: &FxProtos, + fx: &EffectPlan, + layer_id: u32, + layer_ids: &HashMap, + source_size: (u32, u32), + uses: &mut FxUse, +) -> R>> { + let (tdmn, sspc, _) = protos.defaults.get(fx.match_name).ok_or_else(|| tpl("effect prototype missing"))?; + let mut sspc = sspc.clone(); + let pdefs = par_defs(&sspc); + // `fnam` keeps the effect's own name; a name of the user's goes into the value group's + // `tdsn`, which holds the placeholder `-_0_/-` otherwise (610 against 609). + let own = child_mut(&mut sspc, b"fnam")?.children().iter().find(|c| c.is(b"Utf8")).and_then(|c| c.bytes()).map(|b| String::from_utf8_lossy(b).to_string()); + if own.as_deref() != Some(fx.name.as_str()) && !fx.name.is_empty() { + let values = list_mut(&mut sspc, b"tdgp")?; + let slot = child_mut(values, b"tdsn")?; + *slot = nested(*b"tdsn", &fx.name); + } + // The fx switch: the value group's `tdsb` (`fx-switch` cases). + put_u32(child_data(list_mut(&mut sspc, b"tdgp")?, b"tdsb")?, 0, u32::from(fx.enabled))?; + let mut records = Vec::with_capacity(fx.params.len()); + for p in &fx.params { + let def = pdefs.get(&p.index).ok_or_else(|| tpl("parameter definition missing"))?; + records.push((p.index, value_record(protos, p, def, source_size)?)); + } + { + let values = list_mut(&mut sspc, b"tdgp")?; + let kids = kids_mut(values)?; + // The input layer (`-0000`) names the layer that owns the effect (320). + let tdpi_of = |kids: &mut Vec>, record: &str| -> Option { + kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(record)).map(|i| i + 1) + }; + let input = pdefs.get(&0).map(|d| d.record.clone()).unwrap_or_default(); + // The prototype's own layer id (its input layer), to tell "the owning layer" from None. + let mut proto_owner = None; + if let Some(at) = tdpi_of(kids, &input) + && let Some(node) = kids.get_mut(at) + { + let tdpi = child_data(node, b"tdpi")?; + proto_owner = tdpi.get(0..4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + put_u32(tdpi, 0, layer_id)?; + } + // Every layer parameter's record exists in the prototype (its `tdpi` the prototype's + // default): a mapped one names what the plan says (the owning layer by default; a layer + // that is not exported was reported and left out of the plan in + // `plan::resolve_effect_layers`); an unmapped one keeps its default, the prototype's own + // layer becoming the owning layer and None (0) staying None (Timewarp's Matte / Warp + // Layer, 529). + let mapped: Vec = by_match_name(fx.match_name) + .map(|s| s.params.iter().filter(|p| matches!(p.conv, Conv::LayerRef { .. })).filter_map(|p| u16::try_from(p.index).ok()).collect()) + .unwrap_or_default(); + for (index, def) in &pdefs { + if *index == 0 || def.code != 0 { + continue; + } + let Some(at) = tdpi_of(kids, &def.record) else { continue }; + let Some(node) = kids.get_mut(at) else { continue }; + let tdpi = child_data(node, b"tdpi")?; + let id = if mapped.contains(index) { + match fx.layers.iter().find(|(i, _)| i == index).map(|(_, p)| *p) { + None | Some(LayerPick::Owner) => layer_id, + Some(LayerPick::Nothing) => 0, + Some(LayerPick::Layer(l)) => layer_ids.get(&l).copied().unwrap_or(layer_id), + } + } else { + let stored = tdpi.get(0..4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + if stored.is_some() && stored == proto_owner { layer_id } else { stored.unwrap_or(0) } + }; + put_u32(tdpi, 0, id)?; + } + // Arbitrary data: the `aRbp` of the `LIST aRbs` after the parameter's record. + if let Some((index, data)) = &fx.data { + let want = pdefs.get(index).map(|d| d.record.clone()).unwrap_or_default(); + let i = kids + .iter() + .position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(want.as_str())) + .ok_or_else(|| tpl("arbitrary data record missing"))?; + let list = kids.iter_mut().skip(i + 1).take(3).find(|c| c.is_list(b"aRbs")).ok_or_else(|| tpl("arbitrary data missing"))?; + let arbp = child_mut(list, b"aRbp")?; + *data_mut(arbp)? = data.clone(); + } + let position: HashMap = pdefs.iter().map(|(i, d)| (d.record.clone(), *i)).collect(); + for (index, (m, r)) in records { + // Before the first record of a later parameter (or the built-in group, or the end). + let at = kids + .iter() + .enumerate() + .filter(|(_, c)| c.is(b"tdmn")) + .find(|(_, c)| { + let n = tdmn_name(c).unwrap_or_default(); + position.get(&n).is_some_and(|i| *i > index) || n == "ADBE Effect Built In Params" || n == "ADBE Group End" + }) + .map(|(i, _)| i) + .unwrap_or(kids.len()); + kids.splice(at..at, [m, r]); + } + } + // A popup selection is also written into its `pard` default slot (@56, u32; 377, 390, 324). + if let Some(part) = sspc.children_mut().iter_mut().find(|c| c.is_list(b"parT")) { + for p in &fx.params { + let (PKind::Popup, ParamValue::Static(v)) = (p.kind, &p.value) else { continue }; + let Some(sel) = v.first().map(|x| x.round()).filter(|x| *x >= 1.0 && *x <= 1000.0) else { continue }; + let want = pdefs.get(&p.index).map(|d| d.record.clone()).unwrap_or_default(); + let kids = kids_mut(part)?; + if let Some(i) = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(want.as_str())) + && let Some(pard) = kids.get_mut(i + 1) + { + put_u32(data_mut(pard)?, 56, sel as u32)?; + } + } + } + if uses.types.contains(&fx.match_name) { + // A later instance of a type: its definitions come from the project's copy (383). + let part = list_mut(&mut sspc, b"parT")?; + *part = Chunk::list(*b"parT", Vec::new()); + } else { + uses.types.push(fx.match_name); + } + Ok(vec![tdmn.clone(), sspc]) +} + +/// The layer's `ADBE Effect Parade` (`tdmn` + `tdgp`), the effects in stack order. +pub(crate) fn build_parade( + protos: &FxProtos, + fxs: &[EffectPlan], + layer_id: u32, + layer_ids: &HashMap, + source_size: (u32, u32), + uses: &mut FxUse, +) -> R>> { + let mut kids = vec![Chunk::data(*b"tdsb", vec![0, 0, 0, 1]), tdsn("-_0_/-")]; + for fx in fxs { + kids.extend(instance(protos, fx, layer_id, layer_ids, source_size, uses)?); + } + kids.push(Chunk::data(*b"tdmn", tdmn_bytes("ADBE Group End"))); + Ok(vec![Chunk::data(*b"tdmn", tdmn_bytes("ADBE Effect Parade")), Chunk::list(*b"tdgp", kids)]) +} + +/// Writes the project-level effect lists: `Pefl` (one `pjef` per type, sorted by match name, 365) +/// and `EfdG` (the `EfDC` count, little-endian, and one `EfDf` per type in order of first use) +/// right after `Pefl`. +pub(crate) fn put_project_lists(protos: &FxProtos, file: &mut AepFile<'static>, uses: &FxUse) -> R<()> { + if uses.types.is_empty() { + return Ok(()); + } + let at = file.chunks.iter().position(|c| c.is_list(b"Pefl")).ok_or_else(|| tpl("template without Pefl"))?; + let mut sorted: Vec<&str> = uses.types.clone(); + sorted.sort_unstable(); + if let Some(pefl) = file.chunks.get_mut(at) { + let kids = kids_mut(pefl)?; + kids.clear(); + kids.extend(sorted.iter().map(|n| Chunk::data(*b"pjef", n.as_bytes().to_vec()))); + } + let n = u32::try_from(uses.types.len()).map_err(|_| tpl("too many effect types"))?; + let mut efdg = vec![Chunk::data(*b"EfDC", n.to_le_bytes().to_vec())]; + for t in &uses.types { + let (_, _, def) = protos.defaults.get(*t).ok_or_else(|| tpl("effect prototype missing"))?; + efdg.push(def.clone()); + } + file.chunks.insert(at + 1, Chunk::list(*b"EfdG", efdg)); + Ok(()) +} diff --git a/crates/aep/src/export/emit.rs b/crates/aep/src/export/emit.rs new file mode 100644 index 000000000..e7423efe9 --- /dev/null +++ b/crates/aep/src/export/emit.rs @@ -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 { + 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, + /// Item id and duration in seconds of every exported composition (precomposition layers). + comp_ids: HashMap, + /// Item id and duration in seconds of every exported footage item (footage layers). + footage_ids: HashMap, + /// 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, + special_slot: Option<(Chunk<'static>, Vec)>, + /// 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>); + +pub(crate) fn emit(plan: &ProjectPlan, extra: &[u8]) -> R> { + 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 = HashMap::new(); + assign_ids(&plan.root, &mut cx, &mut ids_by_entry)?; + + let mut root: Vec = 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 = 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) -> 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>); + +/// 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, sink: Out<'_>) -> R<()> { + build_level_depth(entries, cx, ids, sink, true, 0) +} + +fn build_level_depth(entries: &[Entry], cx: &mut Cx<'_>, ids: &HashMap, 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 = 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> { + 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> { + 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> { + 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 = HashMap::new(); + for lp in &cp.layers { + layer_ids.insert(lp.ec_id, cx.ids.alloc()?); + } + let mut blocks: Vec> = 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 = 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> = 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) -> R>> { + 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) -> 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 { + 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> = gk.drain(..first).collect(); + let mut pairs: Vec<(String, Vec>)> = 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> { + 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> { + 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> { + 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) +} diff --git a/crates/aep/src/export/footage.rs b/crates/aep/src/export/footage.rs new file mode 100644 index 000000000..3ff732db3 --- /dev/null +++ b/crates/aep/src/export/footage.rs @@ -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, + /// 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, +} + +/// 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) { + 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 { + 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 { + 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 { + 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>)>, +} + +/// 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>| { + 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> { + use crate::export::base::{bytes, utf8}; + let s = spec(kind); + let mut fields: Vec<(usize, Vec)> = 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>> { + 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}"); + } + } +} diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs new file mode 100644 index 000000000..e8464bada --- /dev/null +++ b/crates/aep/src/export/fx_defs.rs @@ -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, + /// The valid minimum of a slider. + pub valid_min: Option, + /// 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, valid_min: Option, 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), + ]), +]; diff --git a/crates/aep/src/export/fxsynth.rs b/crates/aep/src/export/fxsynth.rs new file mode 100644 index 000000000..4fa02f0c5 --- /dev/null +++ b/crates/aep/src/export/fxsynth.rs @@ -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 { + 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) -> Vec { + 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 = (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> { + 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 { + 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> { + 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) -> [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])]) +} diff --git a/crates/aep/src/export/icc.rs b/crates/aep/src/export/icc.rs new file mode 100644 index 000000000..ae6db7893 --- /dev/null +++ b/crates/aep/src/export/icc.rs @@ -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 { + let t: Vec = 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 { + 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 { + // 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 { + 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) -> Vec { + 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); 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 = Vec::new(); + let mut placed: Vec<(Vec, 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::(), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc").iter().map(|b| format!("{b:02x}")).collect::(), "900150983cd24fb0d6963f7d28e17f72"); + let long = b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"; + assert_eq!(md5(long).iter().map(|b| format!("{b:02x}")).collect::(), "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 = 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}"); + } +} diff --git a/crates/aep/src/export/markers.rs b/crates/aep/src/export/markers.rs new file mode 100644 index 000000000..fc3fedd59 --- /dev/null +++ b/crates/aep/src/export/markers.rs @@ -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 { + let mut out: Vec = 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>> { + 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>) -> 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(()) +} diff --git a/crates/aep/src/export/masks.rs b/crates/aep/src/export/masks.rs new file mode 100644 index 000000000..61d8634c4 --- /dev/null +++ b/crates/aep/src/export/masks.rs @@ -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, + /// Pixels; `None` at the default `[0, 0]`. + pub feather: Option<[f64; 2]>, + pub feather_keys: Vec, + /// A fraction; `None` at the default 1. + pub opacity: Option, + /// Pixels; `None` at the default 0. + pub expansion: Option, +} + +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 { + (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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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>, + /// `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 { + 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 { + 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> { + 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> = kids.drain(..first).collect(); + let mut pairs: Vec<(String, [Chunk<'static>; 2])> = Vec::new(); + let mut end: Option> = 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, 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> { + 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> = { + 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> { + 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) +} diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs new file mode 100644 index 000000000..9f45c50cd --- /dev/null +++ b/crates/aep/src/export/plan.rs @@ -0,0 +1,1342 @@ +//! The plan: the EffectCraft project reduced to exactly the values the `.aep` can hold, in the +//! file's units, with everything that cannot be written already reported. Pure data; the chunks +//! are built from it in `emit`. + +use std::collections::{BTreeMap, HashSet}; + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Ease, Interp, Keyframe}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, Property, Quality, Renderer}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::export::report::{Collector, LossCategory}; +use crate::export::timebase::{TimeBase, time_base}; + +/// No sensible coordinate, angle or percentage comes near this; larger numbers (and NaN +/// or infinity) are written as the default. +pub(crate) const LIMIT: f64 = 1e9; +/// The largest composition or solid side After Effects accepts. +pub(crate) const MAX_SIDE: u32 = 30_000; +/// The longest composition After Effects accepts (3 hours). +const MAX_SECONDS: i64 = 3 * 3600; +/// Deepest folder nesting that is exported. +const MAX_FOLDER_DEPTH: usize = 64; + +/// The five Transform properties that are exported, in the file's canonical order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PropKind { + Anchor, + Position, + Scale, + Rotation, + Opacity, +} + +impl PropKind { + pub const ALL: [PropKind; 5] = [PropKind::Anchor, PropKind::Position, PropKind::Scale, PropKind::Rotation, PropKind::Opacity]; + + /// The match name in the file. + pub fn match_name(self) -> &'static str { + match self { + PropKind::Anchor => "ADBE Anchor Point", + PropKind::Position => "ADBE Position", + PropKind::Scale => "ADBE Scale", + PropKind::Rotation => "ADBE Rotate Z", + PropKind::Opacity => "ADBE Opacity", + } + } + + /// The property id in the project. + pub fn id(self) -> &'static str { + match self { + PropKind::Anchor => "anchor", + PropKind::Position => "position", + PropKind::Scale => "scale", + PropKind::Rotation => "rotation", + PropKind::Opacity => "opacity", + } + } + + /// Components stored in a value. + pub fn dims(self) -> usize { + match self { + PropKind::Anchor | PropKind::Position | PropKind::Scale => 3, + PropKind::Rotation | PropKind::Opacity => 1, + } + } + + /// Anchor and Position have motion paths (the spatial key template). + pub fn spatial(self) -> bool { + matches!(self, PropKind::Anchor | PropKind::Position) + } + + /// The factor from the project's unit to the stored one (percent to fraction). + fn unit(self) -> f64 { + match self { + PropKind::Scale | PropKind::Opacity => 100.0, + _ => 1.0, + } + } +} + +/// One side of a key's temporal ease: speed in stored units per second, influence 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct EaseSlot { + pub speed: f64, + pub influence: f64, +} + +impl Default for EaseSlot { + fn default() -> Self { + EaseSlot { speed: 0.0, influence: 0.0 } + } +} + +/// A keyframe in the file's units. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct KeyPlan { + /// In the composition's time units (layer time). + pub time: i32, + pub value: Vec, + /// 1 linear, 2 bezier, 3 hold. + pub in_interp: u8, + pub out_interp: u8, + /// One slot per component (plain template) or one slot (spatial template). + pub in_ease: Vec, + pub out_ease: Vec, + pub spatial_in: [f64; 3], + pub spatial_out: [f64; 3], + pub spatial_auto: bool, + pub roving: bool, + pub label: u8, +} + +/// A property that differs from its default: a static value, keys, or an expression. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PropPlan { + pub kind: PropKind, + pub value: Vec, + pub keys: Vec, + pub expr: Option<(String, bool)>, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum LayerSrc { + Solid(ItemId), + Null, + /// A precomposition layer: the composition item it shows. + Comp(ItemId), + /// A footage layer: the footage item it shows. + Footage(ItemId), + /// A text layer and its text document. + Text(Box), + /// A camera (256) and its options. + Camera(Box), + /// A spot or point light (254, 252) and its options. + Light(Box), + /// A shape layer and its items (519). + Shape(Vec), +} + +/// A layer in the file's units. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LayerPlan { + pub name: String, + pub src: LayerSrc, + pub adjustment: bool, + pub label: u8, + /// 1 draft, 2 best. + pub quality: u8, + pub start: i32, + pub in_rel: i32, + pub out_rel: i32, + pub blend: u8, + pub parent: Option, + /// Track matte: the matte layer and the mode (`ldta@107`: 1 alpha, 2 alpha inverted, 3 luma, + /// 4 luma inverted; 403-406). + pub matte: Option<(LayerId, u8)>, + pub markers: Vec, + pub ec_id: LayerId, + pub video: bool, + pub audio: bool, + pub solo: bool, + pub locked: bool, + pub shy: bool, + pub collapse: bool, + pub effects: bool, + pub motion_blur: bool, + pub guide: bool, + pub three_d: bool, + /// Time stretch as `numerator / denominator` (1 / 1 = 100 %, -1 / 1 = -100 %). + pub stretch: (i32, u32), + /// The time scale the layer's properties count in. + pub time_scale: u32, + pub props: Vec, + /// The layer's masks, in stack order. + pub masks: Vec, + /// The layer's effects, in stack order. + pub fx: Vec, + /// Size of the layer's source in pixels (the Anchor Point divisor). + pub source_size: (u32, u32), + /// An enabled Time Remap (source seconds; keys in layer time), written into the layer's + /// `ADBE Time Remapping` record (319, 354). + pub remap: Option, + /// The layer styles (None: none applied). + pub styles: Option, +} + +/// `cp` without its layers. +fn comp_header(cp: &CompPlan) -> CompPlan { + CompPlan { + width: cp.width, + height: cp.height, + tb: cp.tb, + rate: cp.rate, + duration_units: cp.duration_units, + work_start: cp.work_start, + work_end: cp.work_end, + background: cp.background, + pixel_aspect: cp.pixel_aspect, + motion_blur: cp.motion_blur, + shutter_angle: cp.shutter_angle, + shutter_phase: cp.shutter_phase, + display_start: cp.display_start, + markers: Vec::new(), + layers: Vec::new(), + } +} + +/// A composition in the file's units. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CompPlan { + pub width: u16, + pub height: u16, + pub tb: TimeBase, + pub rate: FrameRate, + pub duration_units: u32, + pub work_start: u32, + /// `None` = the whole composition (`0xffffffff`). + pub work_end: Option, + pub background: [u8; 3], + pub pixel_aspect: (u32, u32), + pub motion_blur: bool, + pub shutter_angle: u32, + pub shutter_phase: i32, + pub display_start: u32, + /// Composition markers (written into the `Markers` view layer). + pub markers: Vec, + pub layers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SolidPlan { + pub color: [f32; 3], + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ItemPlan { + Folder(Vec), + Comp(Box), + Solid(SolidPlan), + Footage(Box), +} + +/// A project item with its name and label. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Entry { + pub ec: ItemId, + pub name: String, + pub label: u8, + pub plan: ItemPlan, +} + +/// The whole project: the items at the root. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ProjectPlan { + pub root: Vec, + pub settings: crate::export::settings::SettingsPlan, +} + +/// `name` cut to `max` bytes at a character boundary (after Effects stores a name of up to 255 +/// characters; a solid's name lives in a 256 byte field). +pub(crate) fn cut_name(name: &str, max: usize, what: &str, col: &mut Collector) -> String { + if name.len() <= max { + return name.to_string(); + } + let mut end = max; + while end > 0 && !name.is_char_boundary(end) { + end -= 1; + } + col.lose(LossCategory::Names, format!("{what}: name of {} bytes cut to {end}", name.len())); + name.get(..end).unwrap_or_default().to_string() +} + +/// A number that can be written, or `None`. +fn sane(v: f64) -> Option { + (v.is_finite() && v.abs() <= LIMIT).then_some(v) +} + +pub(crate) fn label_index(l: Label) -> u8 { + Label::ALL.iter().position(|x| *x == l).and_then(|i| u8::try_from(i).ok()).unwrap_or(0) +} + +fn blend_code(mode: BlendMode) -> Option { + Some(match mode { + BlendMode::Normal => 2, + BlendMode::Add => 4, + BlendMode::Multiply => 5, + BlendMode::Screen => 6, + BlendMode::Overlay => 7, + BlendMode::Difference => 26, + // The codes After Effects writes for the other modes (906, 907; the importer's table). + BlendMode::SoftLight => 8, + BlendMode::HardLight => 9, + BlendMode::Darken => 10, + BlendMode::Lighten => 11, + BlendMode::ClassicDifference => 12, + BlendMode::Hue => 13, + BlendMode::Saturation => 14, + BlendMode::Color => 15, + BlendMode::Luminosity => 16, + BlendMode::StencilAlpha => 17, + BlendMode::StencilLuma => 18, + BlendMode::SilhouetteAlpha => 19, + BlendMode::SilhouetteLuma => 20, + BlendMode::ClassicColorDodge => 23, + BlendMode::ClassicColorBurn => 24, + BlendMode::Exclusion => 25, + BlendMode::ColorDodge => 27, + BlendMode::ColorBurn => 28, + BlendMode::LinearDodge => 29, + BlendMode::LinearBurn => 30, + BlendMode::LinearLight => 31, + BlendMode::VividLight => 32, + BlendMode::PinLight => 33, + BlendMode::HardMix => 34, + _ => return None, + }) +} + +/// Natural, case-insensitive order of names (digit runs compare as numbers), as After Effects +/// sorts the project panel and stores the items. +pub(crate) fn natural_key(name: &str) -> Vec<(bool, String, u128)> { + let mut out: Vec<(bool, String, u128)> = Vec::new(); + let mut chars = name.chars().peekable(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + let mut n: u128 = 0; + let mut s = String::new(); + while let Some(&d) = chars.peek() { + if !d.is_ascii_digit() { + break; + } + n = n.saturating_mul(10).saturating_add(u128::from(d as u8 - b'0')); + s.push(d); + chars.next(); + } + out.push((true, s, n)); + } else { + let mut s = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_digit() { + break; + } + s.extend(d.to_lowercase()); + chars.next(); + } + out.push((false, s, 0)); + } + } + out +} + +fn sort_entries(v: &mut [Entry]) { + v.sort_by(|a, b| { + let ka = natural_key(&a.name); + let kb = natural_key(&b.name); + let cmp = |x: &(bool, String, u128), y: &(bool, String, u128)| 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, + }; + for (x, y) in ka.iter().zip(kb.iter()) { + let c = cmp(x, y); + if c != std::cmp::Ordering::Equal { + return c; + } + } + ka.len().cmp(&kb.len()).then_with(|| a.ec.0.cmp(&b.ec.0)) + }); +} + +/// Plans the whole project. +pub(crate) fn plan(project: &Project, col: &mut Collector) -> ProjectPlan { + // Items whose chain of folders reaches the root (no loop, not nested absurdly deep). + let mut ok: HashSet = HashSet::new(); + for (id, item) in &project.items { + let mut at = item.parent.filter(|p| project.items.contains_key(p)); + let mut steps = 0usize; + let reachable = loop { + match at { + None => break true, + Some(p) if p == *id => break false, + Some(p) => { + steps += 1; + if steps > MAX_FOLDER_DEPTH { + break false; + } + at = project.items.get(&p).and_then(|i| i.parent).filter(|q| project.items.contains_key(q)); + } + } + }; + if reachable { + ok.insert(*id); + } else { + col.lose( + LossCategory::FootageItems, + format!("'{}' is in a folder loop or nested deeper than {MAX_FOLDER_DEPTH} levels and was not exported", item.name), + ); + } + } + // Children per folder, project order (creation order = id order). + let mut by_parent: BTreeMap, Vec> = BTreeMap::new(); + for (id, item) in &project.items { + if ok.contains(id) { + by_parent.entry(item.parent.filter(|p| project.items.contains_key(p))).or_default().push(*id); + } + } + let cx = PlanCx { project, by_parent: &by_parent, ok: &ok }; + let root = plan_children(&cx, None, col); + // The base writes an empty render queue. + for job in &project.render_queue { + let comp = project.items.get(&job.comp).map_or("a missing composition", |i| i.name.as_str()); + col.lose(LossCategory::RenderQueue, format!("the render queue item of '{comp}' is not written")); + } + ProjectPlan { root, settings: crate::export::settings::plan(&project.settings, col) } +} + +struct PlanCx<'a> { + project: &'a Project, + by_parent: &'a BTreeMap, Vec>, + ok: &'a HashSet, +} + +fn plan_children(cx: &PlanCx<'_>, parent: Option, col: &mut Collector) -> Vec { + let project = cx.project; + let mut out = Vec::new(); + for id in cx.by_parent.get(&parent).map(Vec::as_slice).unwrap_or(&[]) { + let Some(item) = project.item(*id) else { continue }; + let label = label_index(item.label); + let item_name = cut_name(&item.name, 255, "item", col); + if !item.comment.is_empty() { + col.lose(LossCategory::Names, format!("'{}': the item comment is not written", item.name)); + } + if item.proxy.is_some() { + col.lose(LossCategory::FootageItems, format!("'{}': the proxy is not written", item.name)); + } + match &item.kind { + ItemKind::Folder => { + col.exported.folders += 1; + let kids = plan_children(cx, Some(*id), col); + out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Folder(kids) }); + } + ItemKind::Comp(comp) => { + let cp = plan_comp(cx, *id, &item.name, comp, col); + col.exported.compositions += 1; + out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Comp(Box::new(cp)) }); + } + ItemKind::Solid(s) => { + let width = clamp_side(s.width, &item.name, col); + let height = clamp_side(s.height, &item.name, col); + let mut color = [0f32; 3]; + for (c, v) in color.iter_mut().zip(s.color) { + *c = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if c.to_bits() != v.to_bits() { + col.lose(LossCategory::InvalidValues, format!("solid '{}': colour channel {v} written as {c}", item.name)); + } + } + if s.pixel_aspect.to_bits() != 1f64.to_bits() { + col.lose(LossCategory::FootageItems, format!("solid '{}': pixel aspect {} is not written (written as 1)", item.name, s.pixel_aspect)); + } + col.exported.solids += 1; + out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Solid(SolidPlan { color, width, height }) }); + } + ItemKind::Footage(f) => { + if !f.markers.is_empty() { + col.lose(LossCategory::Markers, format!("footage '{}': {} footage marker(s) are not written", item.name, f.markers.len())); + } + if let Some(fp) = crate::export::footage::plan_footage(&item.name, f, col) { + out.push(Entry { ec: *id, name: item_name, label, plan: ItemPlan::Footage(Box::new(fp)) }); + } + } + } + } + sort_entries(&mut out); + out +} + +fn clamp_side(v: u32, what: &str, col: &mut Collector) -> u32 { + if (1..=MAX_SIDE).contains(&v) { + v + } else { + col.lose(LossCategory::CompositionSettings, format!("'{what}': size {v} is outside 1 to {MAX_SIDE}, written as {}", v.clamp(1, MAX_SIDE))); + v.clamp(1, MAX_SIDE) + } +} + +/// The best fraction `n / d` of `v` with a denominator up to 1000 (continued fractions). +fn rational(v: f64) -> (u32, u32) { + if !(v.is_finite() && v > 0.0) { + return (1, 1); + } + let (mut h0, mut h1, mut k0, mut k1) = (0i64, 1i64, 1i64, 0i64); + let mut x = v; + for _ in 0..24 { + let a = x.floor(); + if a > 1e6 { + break; + } + let a = a as i64; + let (h2, k2) = (a * h1 + h0, a * k1 + k0); + if k2 > 1000 || h2 > 1_000_000 { + break; + } + (h0, h1, k0, k1) = (h1, h2, k1, k2); + if (h1 as f64 / k1 as f64 - v).abs() < 1e-9 { + break; + } + let frac = x - a as f64; + if frac < 1e-12 { + break; + } + x = 1.0 / frac; + } + match (u32::try_from(h1), u32::try_from(k1)) { + (Ok(n), Ok(d)) if n > 0 && d > 0 => (n, d), + _ => (1, 1), + } +} + +fn plan_comp(cx: &PlanCx<'_>, comp_id: ItemId, name: &str, comp: &Comp, col: &mut Collector) -> CompPlan { + let rate = comp.frame_rate; + let tb = time_base(rate).unwrap_or_else(|| { + col.lose(LossCategory::CompositionSettings, format!("'{name}': frame rate {} cannot be stored, 25 fps written", rate.label())); + TimeBase { frame_duration: 1024, timescale: 25600 } + }); + let rate = if time_base(rate).is_some() { rate } else { FrameRate::new(25, 1) }; + let width = clamp_side(comp.width, name, col); + let height = clamp_side(comp.height, name, col); + let max_frames = MAX_SECONDS.saturating_mul(rate.num) / rate.den.max(1); + let mut frames = tb.frames(comp.duration, rate); + if (1..=max_frames).contains(&frames) && rate.tick_of(frames) != comp.duration { + col.lose(LossCategory::RoundedTimes, format!("'{name}': duration moved to whole frames ({frames})")); + } + if frames < 1 || frames > max_frames { + let fixed = frames.clamp(1, max_frames.max(1)); + col.lose(LossCategory::CompositionSettings, format!("'{name}': duration of {frames} frames is outside 1 frame to 3 hours, written as {fixed} frames")); + frames = fixed; + } + let fd = i64::from(tb.frame_duration); + let duration_units = u32::try_from(frames.saturating_mul(fd)).unwrap_or_else(|_| { + col.lose(LossCategory::CompositionSettings, format!("'{name}': the duration does not fit the time base of {} fps, written shorter", rate.label())); + u32::MAX + }); + let ws = tb.frames(comp.work_area.0, rate).clamp(0, frames - 1); + let we = tb.frames(comp.work_area.1, rate).clamp(ws + 1, frames); + let whole = ws == 0 && we == frames; + if (comp.work_area.0 != rate.tick_of(ws) && comp.work_area.0 != Tick::ZERO) || (comp.work_area.1 != rate.tick_of(we) && comp.work_area.1 != comp.duration) { + col.lose(LossCategory::RoundedTimes, format!("'{name}': work area moved to whole frames ({ws} to {we})")); + } + let mut background = [0u8; 3]; + for (b, v) in background.iter_mut().zip(comp.background) { + *b = if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + // Eight bits per channel is what the format holds (the nearest level is written); a + // channel outside 0 to 1 is reported. + if !(0.0..=1.0).contains(&v) { + col.lose(LossCategory::CompositionSettings, format!("'{name}': background channel {v} written as {b}/255")); + } + } + let pa = sane(comp.pixel_aspect).filter(|v| *v > 0.0).unwrap_or(1.0); + let (pn, pd) = rational(pa); + if (f64::from(pn) / f64::from(pd) - pa).abs() > 1e-9 || pa.to_bits() != comp.pixel_aspect.to_bits() { + col.lose(LossCategory::CompositionSettings, format!("'{name}': pixel aspect {pa} written as {pn}/{pd}")); + } + if matches!(comp.renderer, Renderer::Advanced3D) { + col.lose(LossCategory::CompositionOptions, format!("'{name}': the Advanced 3D renderer is written as Classic 3D")); + } + let comp_markers = crate::export::markers::plan(&comp.markers, &tb, &format!("'{name}'"), col); + if !comp.guides.is_empty() { + col.lose(LossCategory::CompositionOptions, format!("'{name}': {} guides", comp.guides.len())); + } + if comp.essential.is_some() { + col.lose(LossCategory::CompositionOptions, format!("'{name}': Essential Graphics")); + } + // Advanced settings that change rendered frames, which the base does not decode: written at + // After Effects' defaults (the samples per frame and adaptive limit are the 16 and 128 every + // sample holds at cdta 200 and 196, base.rs), so any other value is reported. Viewer state + // (Hide Shy, Draft 3D, the viewer resolution, the poster time) is a listed limitation. + let unwritten = [ + (comp.preserve_frame_rate, "Preserve Frame Rate switched on"), + (comp.preserve_resolution, "Preserve Resolution switched on"), + (comp.motion_blur_samples != 16, "motion blur samples per frame other than 16"), + (comp.motion_blur_adaptive_limit != 128, "adaptive sample limit other than 128"), + ]; + for (_, what) in unwritten.iter().filter(|(lost, _)| *lost) { + col.lose(LossCategory::CompositionOptions, format!("'{name}': {what} (written as the default)")); + } + let (ds, inexact) = tb.units(comp.display_start); + let display_start = u32::try_from(ds).unwrap_or(0); + if inexact || i64::from(display_start) != ds { + col.lose(LossCategory::CompositionSettings, format!("'{name}': start timecode written as {} frames", i64::from(display_start) / fd.max(1))); + } + let shutter = |v: f64, (lo, hi): (f64, f64), default: f64, what: &str, col: &mut Collector| { + let out = sane(v).map_or(default, |v| v.round().clamp(lo, hi)); + if out.to_bits() != v.to_bits() { + col.lose(LossCategory::CompositionSettings, format!("'{name}': shutter {what} {v} written as {out}")); + } + out + }; + let shutter_angle = shutter(comp.shutter_angle, (0.0, 720.0), 180.0, "angle", col) as u32; + let shutter_phase = shutter(comp.shutter_phase, (-720.0, 720.0), 0.0, "phase", col) as i32; + + let mut cp = CompPlan { + width: u16::try_from(width).unwrap_or(u16::MAX), + height: u16::try_from(height).unwrap_or(u16::MAX), + tb, + rate, + duration_units, + work_start: u32::try_from(ws.saturating_mul(fd)).unwrap_or(0), + // Both fit when the duration does (`ws < we <= frames`); a duration that does not is reported above. + work_end: if whole { None } else { u32::try_from(we.saturating_mul(fd)).ok() }, + background, + pixel_aspect: (pn, pd), + motion_blur: comp.enable_motion_blur, + shutter_angle, + shutter_phase, + display_start, + markers: comp_markers, + layers: Vec::new(), + }; + let ids: HashSet = comp.layers.iter().map(|l| l.id).collect(); + let at = CompAt { id: comp_id, name, comp, ids: &ids }; + for layer in &comp.layers { + if let Some(lp) = plan_layer(cx, at, &cp, layer, col) { + cp.layers.push(lp); + } + } + resolve_parents(&mut cp, name, col); + resolve_mattes(&mut cp, name, col); + resolve_effect_layers(&mut cp, name, col); + cp +} + +/// An effect's layer parameter that names a layer that is not exported falls back to the owning +/// layer (After Effects' default) and is reported. +fn resolve_effect_layers(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) { + let exported: HashSet = cp.layers.iter().map(|l| l.ec_id).collect(); + for l in &mut cp.layers { + for fx in &mut l.fx { + let before = fx.layers.len(); + fx.layers.retain(|(_, p)| !matches!(p, crate::export::effects::LayerPick::Layer(id) if !exported.contains(id))); + if fx.layers.len() != before { + col.lose( + LossCategory::Effects, + format!( + "{comp_name}: '{}' effect '{}': a layer parameter names a layer that is not exported, the owning layer was written", + l.name, fx.name + ), + ); + } + } + } +} + +/// A track matte whose layer is not exported, or is the layer itself, is dropped and reported. +fn resolve_mattes(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) { + let exported: HashSet = cp.layers.iter().map(|l| l.ec_id).collect(); + for l in &mut cp.layers { + if let Some((m, _)) = l.matte + && (m == l.ec_id || !exported.contains(&m)) + { + col.lose(LossCategory::TrackMattes, format!("{comp_name}: '{}': the track matte layer is not exported", l.name)); + l.matte = None; + } + } +} + +/// Parent links to a layer that was not exported, and links that lead into a loop, are cut. +fn resolve_parents(cp: &mut CompPlan, comp_name: &str, col: &mut Collector) { + let exported: HashSet = cp.layers.iter().map(|l| l.ec_id).collect(); + let mut links: std::collections::HashMap = cp.layers.iter().filter_map(|l| l.parent.map(|p| (l.ec_id, p))).collect(); + for l in &mut cp.layers { + let Some(p) = l.parent else { continue }; + let mut cut = !exported.contains(&p); + if !cut { + // Follow the chain; a chain longer than the number of links is a loop. + let mut at = p; + for _ in 0..=links.len() { + if at == l.ec_id { + cut = true; + break; + } + match links.get(&at) { + Some(next) => at = *next, + None => break, + } + } + if !cut && at == l.ec_id { + cut = true; + } + } + if cut { + col.lose(LossCategory::ParentLinks, format!("{comp_name}: '{}' has a parent that was not exported or forms a loop", l.name)); + links.remove(&l.ec_id); + l.parent = None; + } + } +} + +/// The source of a layer and its size; `None` (with a loss) for a layer that is not written. +fn plan_source( + cx: &PlanCx<'_>, + comp_id: ItemId, + comp_name: &str, + comp: &Comp, + layer: &Layer, + remapped: bool, + col: &mut Collector, +) -> Option<(LayerSrc, (u32, u32))> { + let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name); + Some(match &layer.source { + LayerSource::Solid { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| &i.kind) { + Some(ItemKind::Solid(s)) => (LayerSrc::Solid(*item), (s.width.clamp(1, MAX_SIDE), s.height.clamp(1, MAX_SIDE))), + _ => { + col.lose(LossCategory::UnsupportedLayers, what("solid layer without a solid:")); + return None; + } + }, + LayerSource::Null => (LayerSrc::Null, (100, 100)), + LayerSource::Text => { + // A text layer has no source; it spans the composition (the importer's convention). + let tp = crate::export::text::plan_text(layer, comp_name, col)?; + (LayerSrc::Text(Box::new(tp)), (comp.width.clamp(1, MAX_SIDE), comp.height.clamp(1, MAX_SIDE))) + } + // A shape layer has no source; its anchor is in pixels (462), its items are planned from + // the `contents` group. + // (planned below, once the layer's time base is known: their keys count in it) + LayerSource::Shape => (LayerSrc::Shape(Vec::new()), (comp.width.clamp(1, MAX_SIDE), comp.height.clamp(1, MAX_SIDE))), + // Cameras and lights have no source; their point of interest is the Anchor Point in + // pixels (3.12). + LayerSource::Camera => (LayerSrc::Camera(Box::new(crate::export::camlight::plan_camera(layer, comp.width, &what("camera"), col))), (1, 1)), + LayerSource::Light { kind } => (LayerSrc::Light(Box::new(crate::export::camlight::plan_light(layer, *kind, &what("light"), col)?)), (1, 1)), + LayerSource::Model { .. } | LayerSource::Primitive { .. } => { + col.lose(LossCategory::ModelLayers, what("3D layer")); + return None; + } + LayerSource::Footage { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| (&i.name, &i.kind)) { + Some((name, ItemKind::Footage(f))) if let Some(fp) = crate::export::footage::plan_footage(name, f, &mut Collector::default()) => { + // After Effects ends a layer of timed footage at the source's end (f1: an mp4, + // two sequences and a WAV written 5 s long read back 1, 0.1, 0.1 and 1 s long). + // Source time the layer reaches: its span divided by the stretch (a reversed layer reaches + // it at its in point). + let span = (layer.out_point.seconds() - layer.start_time.seconds()).abs().max((layer.in_point.seconds() - layer.start_time.seconds()).abs()) + / (layer.stretch.abs() / 100.0).max(1e-9); + // A remapped layer may run past its source (the remap holds a frame); less than a + // composition frame past is the rounding of a clip's duration to frames. + let frame = comp.frame_rate.frame_duration().seconds(); + if fp.seconds > 0.0 && span > fp.seconds + frame.max(1e-6) - 1e-9 && !remapped { + col.lose( + LossCategory::LayerTiming, + what(&format!("footage layer runs {span:.3} s, past its source's {:.3} s; After Effects ends it there:", fp.seconds)), + ); + } + (LayerSrc::Footage(*item), (f.width.clamp(1, MAX_SIDE), f.height.clamp(1, MAX_SIDE))) + } + _ => { + col.lose(LossCategory::UnsupportedLayers, what("footage layer whose footage is not exported:")); + return None; + } + }, + LayerSource::Comp { item } => match cx.project.item(*item).filter(|_| cx.ok.contains(item)).map(|i| &i.kind) { + Some(ItemKind::Comp(c)) if !precomp_loop(cx.project, comp_id, *item) => { + (LayerSrc::Comp(*item), (c.width.clamp(1, MAX_SIDE), c.height.clamp(1, MAX_SIDE))) + } + Some(ItemKind::Comp(_)) => { + col.lose(LossCategory::UnsupportedLayers, what("precomposition layer that closes a loop:")); + return None; + } + _ => { + col.lose(LossCategory::UnsupportedLayers, what("precomposition layer without its composition:")); + return None; + } + }, + }) +} + +/// The exact time stretch ratio of a layer and the time scale its properties count in. +fn plan_stretch(layer: &Layer, comp_timescale: u32, comp_name: &str, col: &mut Collector) -> ((i32, u32), u32) { + let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name); + let mut stretch = (1i32, 1u32); + let mut time_scale = comp_timescale; + if layer.stretch.is_nan() || (layer.stretch - 100.0).abs() >= 1e-9 { + // A negative stretch is a time-reversed layer: the numerator is negative (981-983) and the + // properties count in the composition's time scale, as below 100 % (982, 983). + let r = layer.stretch / 100.0; + let reversed = r < 0.0; + let (n, d) = rational(r.abs()); + let exact = r.is_finite() && r != 0.0 && (f64::from(n) / f64::from(d) - r.abs()).abs() <= 1e-9 * r.abs(); + let ts = if n >= d && !reversed { u64::from(comp_timescale) * u64::from(n) / u64::from(d) } else { u64::from(comp_timescale) }; + let ts_exact = n < d || reversed || (u64::from(comp_timescale) * u64::from(n)) % u64::from(d) == 0; + let num = i32::try_from(n).ok().map(|n| if reversed { -n } else { n }); + match num { + Some(num) if exact && ts_exact && ts <= u64::from(u32::MAX) => { + if has_bezier_keys(&layer.props, 0) { + col.lose( + LossCategory::ApproximatedKeyframes, + what(&format!( + "bezier ease on a {:.2} % stretched layer (its speed unit there is not sampled; written as stored) on layer", + layer.stretch + )), + ); + } + stretch = (num, d); + time_scale = u32::try_from(ts).unwrap_or(comp_timescale); + } + _ => col.lose(LossCategory::TimeStretch, what(&format!("stretch {:.2} % (not an exact small ratio) on layer", layer.stretch))), + } + } + (stretch, time_scale) +} + +/// The Transform properties of a layer that are written. +fn plan_transform(layer: &Layer, src: &LayerSrc, comp: &Comp, source_size: (u32, u32), cp: &CompPlan, comp_name: &str, col: &mut Collector) -> Vec { + let is_text = matches!(src, LayerSrc::Text(_)); + let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_)); + let is_shape = matches!(src, LayerSrc::Shape(_)); + let mut props = Vec::new(); + let at = PropAt { comp, comp_name, layer: &layer.name, source: source_size, text: is_text || cam_light || is_shape }; + if let Some(tr) = layer.transform() { + for kind in PropKind::ALL { + // A camera's or light's point of interest is After Effects' Anchor Point. + let id = if cam_light && kind == PropKind::Anchor { "poi" } else { kind.id() }; + let Some(p) = tr.get(id) else { continue }; + if let Some(pp) = plan_prop(kind, None, p, at, cp, col) { + col.exported.properties += 1; + props.push(pp); + } + } + // A camera's or light's block holds After Effects' own Position and point of interest: + // a value at EffectCraft's default is written too. + if cam_light { + for (kind, id) in [(PropKind::Position, "position"), (PropKind::Anchor, "poi")] { + if props.iter().any(|p: &PropPlan| p.kind == kind) { + continue; + } + if let Some(effectcraft_keyframe::Value::Vec3(v)) = tr.get(id).map(|p| &p.value) { + let value: Vec = v.iter().map(|c| if c.is_finite() { *c } else { 0.0 }).collect(); + props.push(PropPlan { kind, value, keys: Vec::new(), expr: None }); + col.exported.properties += 1; + } + } + } + for node in &tr.children { + let id = node.match_id(); + if PropKind::ALL.iter().any(|k| k.id() == id) || (cam_light && id == "poi") { + continue; + } + if let Some(p) = node.as_prop() + && is_non_default_extra(id, p) + { + col.lose(LossCategory::TransformProperties, format!("{comp_name}: '{}' {}", layer.name, p.name)); + } + } + } + props +} + +/// The composition a layer is planned in. +#[derive(Clone, Copy)] +struct CompAt<'a> { + id: ItemId, + name: &'a str, + comp: &'a Comp, + /// The ids of its layers. + ids: &'a HashSet, +} + +/// A time relative to the layer start (composition units) in source time: divided by the +/// stretch `n / d`. A result between two units is rounded, one beyond the format's range is +/// written as the nearest limit; both are reported. +fn unstretch(rel: i64, stretch: (i32, u32), what: &str, col: &mut Collector) -> i32 { + let v = if stretch == (1, 1) { rel as f64 } else { (rel as f64) * f64::from(stretch.1) / f64::from(stretch.0) }; + let r = v.round(); + if r != v { + col.lose(LossCategory::RoundedTimes, format!("{what}: rounded to the time base after the time stretch")); + } + let lim = r.clamp(f64::from(i32::MIN), f64::from(i32::MAX)); + if lim != r { + col.lose(LossCategory::LayerTiming, format!("{what}: beyond the range the format holds, written as the nearest limit")); + } + lim as i32 +} + +/// The ease written for slot `i` of a key side: the slot's, else the last one. A side without an +/// ease is the first key's incoming or the last key's outgoing side, which no interpolation uses +/// (the importer clears them); After Effects' 33.33 % is written there. +pub(crate) fn key_ease(eases: &[Ease], i: usize) -> Ease { + eases.get(i).or(eases.last()).copied().unwrap_or(Ease { speed: 0.0, influence: 1.0 / 3.0 }) +} + +/// Reports a key speed or spatial tangent beyond the format's range (written as 0). +pub(crate) fn report_beyond(beyond: bool, what: &str, col: &mut Collector) { + if beyond { + col.lose(LossCategory::InvalidValues, format!("{what}: a key speed or spatial tangent beyond {LIMIT} is written as 0")); + } +} + +/// Reports keys whose evaluation the format does not keep: muted keys (written as active keys) and +/// auto-Bezier keys (written with their stored ease, not the slope EffectCraft computes). +fn report_key_flags(layer: &Layer, what: &str, col: &mut Collector) { + layer.props.walk("", &mut |_, p| { + if p.keys.iter().any(|k| k.muted) { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: '{}': a muted key is written as an active key", p.name)); + } + let n = p.keys.len(); + let bezier = |k: &Keyframe| k.in_interp == Interp::Bezier || k.out_interp == Interp::Bezier; + if p.keys.iter().enumerate().any(|(i, k)| k.auto_bezier && bezier(k) && i > 0 && i + 1 < n) { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: '{}': an auto-Bezier key is written with its stored ease", p.name)); + } + }); +} + +fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col: &mut Collector) -> Option { + let CompAt { id: comp_id, name: comp_name, comp, ids } = at; + let what = |s: &str| format!("{comp_name}: {s} '{}'", layer.name); + // An enabled Time Remap (the importer's `timeRemap` property) is written on precomposition + // and footage layers, the layers After Effects can remap (319, 354; a solid cannot). + let remap_prop = layer.props.prop("timeRemap"); + let remappable = matches!(layer.source, LayerSource::Comp { .. } | LayerSource::Footage { .. }); + if remap_prop.is_some() && !remappable { + col.lose(LossCategory::TimeRemapping, what("time remapping on a layer After Effects cannot remap is not written; layer")); + } + let remapped = remap_prop.is_some() && remappable; + let (mut src, source_size) = plan_source(cx, comp_id, comp_name, comp, layer, remapped, col)?; + report_key_flags(layer, &format!("{comp_name}: '{}'", layer.name), col); + let tb = cp.tb; + let mut rounded = false; + let mut units = |t: Tick, col: &mut Collector, what: &str| -> i32 { + let (u, inexact) = tb.units(t); + rounded |= inexact; + i32::try_from(u).unwrap_or_else(|_| { + col.lose(LossCategory::InvalidValues, format!("{comp_name}: '{}' {what} is out of range, written as 0", layer.name)); + 0 + }) + }; + let start = units(layer.start_time, col, "start time"); + let in_abs = units(layer.in_point, col, "in point"); + let out_abs = units(layer.out_point, col, "out point"); + if rounded { + col.lose(LossCategory::RoundedTimes, what("start, in or out point moved to the time base; layer")); + } + // Time stretch: the exact ratio `ldta@8 / @108` (018 200 %: 2 / 1, 316 50 %: 1 / 2, 317 150 %: + // 3 / 2); in and out points relative to the start are in source time (absolute = start + + // relative x stretch), and the layer's properties count in the composition's time scale x + // stretch for a stretch of 100 % or more, in the composition's below (018, 317: 51 200 and + // 38 400; 316: 25 600). Keys count source time in that time scale (641: 200 %, a key at comp + // 1 s = source 0.5 s stored as 25 600 / 51 200; 642: 50 %, comp 1 s = source 2 s stored as + // 51 200 / 25 600; 643: the same with the layer starting at 1 s), which is EffectCraft's layer + // time in the layer's time scale. + let (stretch, time_scale) = plan_stretch(layer, cp.tb.timescale, comp_name, col); + // In and out points relative to the start, in source time (reversed, 981: After Effects' in + // point is the later end, source 0 at the start). + let (first, last) = if stretch.0 < 0 { (out_abs, in_abs) } else { (in_abs, out_abs) }; + let in_rel = unstretch(i64::from(first) - i64::from(start), stretch, &what("in point; layer"), col); + let out_rel = unstretch(i64::from(last) - i64::from(start), stretch, &what("out point; layer"), col); + + // The layer's properties count in its own time scale (keys in source time units). + let layer_cp; + let cp: &CompPlan = if time_scale == cp.tb.timescale { + cp + } else { + layer_cp = CompPlan { tb: TimeBase { timescale: time_scale, ..cp.tb }, layers: Vec::new(), ..comp_header(cp) }; + &layer_cp + }; + + if let LayerSrc::Shape(items) = &mut src + && let Some(c) = layer.props.sub("contents") + { + let mut sx = crate::export::shapes::ShCx { cp, col }; + *items = crate::export::shapes::plan_contents(c, &what("shape layer"), 0, &mut sx); + } + // Text animators count their keys in the layer's time base too. + if let LayerSrc::Text(tp) = &mut src { + tp.animators = crate::export::textanim::plan(layer, cp, &format!("{comp_name}: '{}'", layer.name), col); + } + + // Switches and features that are not written. + let sw = &layer.switches; + let unwritten = [ + (sw.quality == Quality::Wireframe, "Wireframe quality (written as Best)"), + (sw.sampling != effectcraft_project::Sampling::Bilinear, "Bicubic sampling (written as Bilinear)"), + (sw.frame_blend != effectcraft_project::FrameBlend::Off, "frame blending"), + (layer.preserve_transparency, "Preserve Transparency"), + (layer.auto_orient != effectcraft_project::AutoOrient::Off && !matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_)), "Auto-Orient"), + (layer.environment || layer.environment_background, "the environment layer setting"), + (layer.markers_locked, "Lock Markers"), + ]; + for (_, what_lost) in unwritten.iter().filter(|(on, _)| *on) { + col.lose(LossCategory::LayerSwitches, what(&format!("{what_lost} is not written; layer"))); + } + // The 3D switch is written (`ldta@38` bit 0x04, 015); a 3D layer's material options are not + // (reported below when changed), and its X / Y rotation and orientation are reported too. + let per_char_3d = matches!(layer.props.prop("text/perChar3d").map(|p| &p.value), Some(effectcraft_keyframe::Value::Bool(true))); + if layer.switches.three_d && matches!(src, LayerSrc::Text(_)) && per_char_3d { + col.lose(LossCategory::ThreeD, what("per-character 3D of the text layer is not written; 3D layer")); + } + // Material Options change how a 3D layer takes lights and shadows; they are not written, so a + // value other than After Effects' default, a key or an expression is reported (review + // finding). On a 2D layer they have no effect. + if layer.switches.three_d { + for name in changed_material_options(layer) { + col.lose(LossCategory::ThreeD, what(&format!("the material option {name} is not written; 3D layer"))); + } + } + // Track matte (403-408): the mode and the matte layer's id; a matte layer that is not + // exported (or the layer itself) is reported in `resolve_mattes`. + let matte = layer.track_matte.map(|tm| { + let mode = match tm.kind { + effectcraft_project::MatteKind::Alpha => 1, + effectcraft_project::MatteKind::AlphaInverted => 2, + effectcraft_project::MatteKind::Luma => 3, + effectcraft_project::MatteKind::LumaInverted => 4, + }; + (tm.layer, mode) + }); + // Layer markers count layer time in the layer's units, as its keys do. + let markers = crate::export::markers::plan(&layer.markers, &cp.tb, &what("layer"), col); + let fx = crate::export::effects::plan_effects(layer, source_size, cp, comp_name, col); + let styles = crate::export::styles::plan_styles(layer, comp, cp, &format!("{comp_name}: '{}'", layer.name), col); + let is_text = matches!(src, LayerSrc::Text(_)); + let cam_light = matches!(src, LayerSrc::Camera(_) | LayerSrc::Light(_)); + let is_shape = matches!(src, LayerSrc::Shape(_)); + let masks = if is_text || is_shape { + let n = layer.masks().map_or(0, |m| m.groups().count()); + if n > 0 { + let kind = if is_text { "text" } else { "shape" }; + col.lose(LossCategory::Masks, what(&format!("{n} mask(s) on a {kind} layer (the path unit on {kind} layers is not verified) on"))); + } + Vec::new() + } else { + crate::export::masks::plan_masks(layer, source_size, cp, comp_name, col) + }; + let blend = match blend_code(layer.blend_mode) { + Some(c) => c, + None => { + col.lose(LossCategory::BlendModes, what(&format!("blend mode {} on", layer.blend_mode.label()))); + 2 + } + }; + let parent = layer.parent.filter(|p| ids.contains(p)); + if layer.parent.is_some() && parent.is_none() { + col.lose(LossCategory::ParentLinks, what("a parent that is not a layer of this composition, written without it, on")); + } + + let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col); + let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col)); + let mut name = cut_name(&layer.name, 255, &what("layer"), col); + // A text layer named after its text stores no name (110, 113: After Effects shows the text). + if let LayerSrc::Text(tp) = &src + && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name) + { + name.clear(); + } + col.exported.layers += 1; + match (&src, layer.switches.adjustment) { + (LayerSrc::Null, _) => col.exported.null_layers += 1, + (LayerSrc::Comp(_), _) => col.exported.precomp_layers += 1, + (LayerSrc::Footage(_), _) => col.exported.footage_layers += 1, + (LayerSrc::Text(_), _) => col.exported.text_layers += 1, + (LayerSrc::Camera(_), _) => col.exported.cameras += 1, + (LayerSrc::Light(_), _) => col.exported.lights += 1, + (LayerSrc::Shape(_), _) => col.exported.shape_layers += 1, + (_, true) => col.exported.adjustment_layers += 1, + _ => col.exported.solid_layers += 1, + } + Some(LayerPlan { + name, + src, + adjustment: layer.switches.adjustment, + label: label_index(layer.label), + quality: if layer.switches.quality == Quality::Draft { 1 } else { 2 }, + start, + in_rel, + out_rel, + stretch, + time_scale, + blend, + parent, + matte, + markers, + ec_id: layer.id, + video: layer.switches.video, + audio: layer.switches.audio, + solo: layer.switches.solo, + locked: layer.switches.locked, + shy: layer.switches.shy, + collapse: layer.switches.collapse || is_shape, + effects: layer.switches.effects, + motion_blur: layer.switches.motion_blur, + guide: layer.switches.guide, + three_d: layer.switches.three_d || cam_light, + props, + masks, + fx, + source_size, + remap, + styles, + }) +} + +/// Whether any property below `g` has keyframes. +/// Whether a property of the group has a key with a bezier side. +fn has_bezier_keys(g: &effectcraft_project::PropGroup, depth: usize) -> bool { + if depth > 64 { + return true; + } + g.children.iter().any(|n| match n { + effectcraft_project::Node::Group(s) => has_bezier_keys(s, depth + 1), + effectcraft_project::Node::Prop(p) => { + p.keys.iter().any(|k| k.in_interp == effectcraft_keyframe::Interp::Bezier || k.out_interp == effectcraft_keyframe::Interp::Bezier) + } + }) +} + +/// Whether showing the composition `to` inside `from` closes a loop (`to` is `from`, or uses it +/// through its own precomposition layers). The walk is iterative and visits each item once. +fn precomp_loop(project: &Project, from: ItemId, to: ItemId) -> bool { + let mut seen: HashSet = HashSet::new(); + let mut stack = vec![to]; + while let Some(at) = stack.pop() { + if at == from { + return true; + } + if !seen.insert(at) { + continue; + } + if let Some(ItemKind::Comp(c)) = project.item(at).map(|i| &i.kind) { + for l in &c.layers { + if let LayerSource::Comp { item } = &l.source { + stack.push(*item); + } + } + } + } + false +} + +/// The names of a layer's Material Options that differ from a new layer's (value, keys or an +/// expression); a property EffectCraft's defaults do not list counts as changed. +fn changed_material_options(layer: &Layer) -> Vec { + let Some(g) = layer.props.sub("materialOptions") else { return Vec::new() }; + let mut next = 0; + let defaults = effectcraft_project::build::material_options(&mut effectcraft_project::build::Ids(&mut next)); + g.props() + .filter(|p| !p.keys.is_empty() || p.has_expression() || defaults.props().find(|d| d.match_id == p.match_id).is_none_or(|d| d.value != p.value)) + .map(|p| p.name.clone()) + .collect() +} + +/// Whether a transform property outside the five carries something (a keyed or changed value). +fn is_non_default_extra(id: &str, p: &Property) -> bool { + if !p.keys.is_empty() || p.has_expression() { + return true; + } + let comps = p.value.components(); + match id { + "orientation" | "rotation_x" | "rotation_y" | "x_rotation" | "y_rotation" | "orient" => comps.iter().any(|c| c.abs() > 1e-9), + _ => false, + } +} + +/// The layer a Transform property belongs to. +#[derive(Clone, Copy)] +struct PropAt<'a> { + comp: &'a Comp, + comp_name: &'a str, + layer: &'a str, + /// The layer's source size. + source: (u32, u32), + /// The anchor is stored in pixels (text, camera, light and shape layers). + text: bool, +} + +fn plan_prop(kind: PropKind, label: Option<&str>, p: &Property, at: PropAt<'_>, cp: &CompPlan, col: &mut Collector) -> Option { + let PropAt { comp, comp_name, layer, source, text } = at; + // A text layer's anchor is stored in pixels, with the origin as its default (385). + let (sw, sh) = if text { (1.0, 1.0) } else { (f64::from(source.0.max(1)), f64::from(source.1.max(1))) }; + let what = format!("{comp_name}: '{layer}' {}", label.unwrap_or(kind.match_name())); + let dims = kind.dims(); + let default: Vec = match kind { + PropKind::Anchor if text => vec![0.0, 0.0, 0.0], + PropKind::Anchor => vec![0.5, 0.5, 0.0], + PropKind::Position => vec![f64::from(comp.width) / 2.0, f64::from(comp.height) / 2.0, 0.0], + PropKind::Scale => vec![1.0, 1.0, 1.0], + PropKind::Rotation => vec![0.0], + PropKind::Opacity => vec![1.0], + }; + // The project's value in the stored unit. + let to_file = |c: &[f64], what: &str, col: &mut Collector| -> Vec { + let mut out = Vec::with_capacity(dims); + for i in 0..dims { + let raw = c.get(i).copied(); + let fallback = if kind == PropKind::Scale { 100.0 } else { 0.0 }; + let v = raw.and_then(sane).or(if raw.is_none() { Some(fallback) } else { None }); + let v = match v { + Some(v) => v, + None => { + col.lose(LossCategory::InvalidValues, format!("{what}: a value that is not a valid number, the default was written")); + default.get(i).copied().map(|d| d * kind.unit()).unwrap_or(0.0) + } + }; + let stored = match kind { + PropKind::Anchor => match i { + 0 => v / sw, + 1 => v / sh, + _ => v, + }, + PropKind::Scale | PropKind::Opacity => v / 100.0, + _ => v, + }; + out.push(stored); + } + out + }; + let static_value = to_file(&p.value.components(), &what, col); + let expr = p.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled)); + if expr.is_some() { + col.exported.expressions += 1; + } + let is_default = static_value.iter().zip(&default).all(|(a, b)| (a - b).abs() < 1e-12); + if p.keys.is_empty() { + if is_default && expr.is_none() { + return None; + } + return Some(PropPlan { kind, value: static_value, keys: Vec::new(), expr }); + } + // Keys, sorted and unique in time units. + let mut keys: Vec<(i64, &Keyframe)> = Vec::with_capacity(p.keys.len()); + for k in &p.keys { + let (u, inexact) = cp.tb.units(k.time); + if inexact { + col.lose(LossCategory::RoundedTimes, format!("{what}: 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}: keys at the same time were merged")); + } + let anchor = kind == PropKind::Anchor; + let mut plans = Vec::with_capacity(keys.len()); + let all: Vec = keys.iter().map(|(_, k)| (*k).clone()).collect(); + let beyond = std::cell::Cell::new(false); + for (i, (u, k)) in keys.iter().enumerate() { + let Ok(time) = i32::try_from(*u) else { + col.lose(LossCategory::InvalidValues, format!("{what}: key time out of range, key dropped")); + continue; + }; + let value = to_file(&k.value.components(), &what, col); + let side = |interp: Interp| -> u8 { + match interp { + Interp::Linear => 1, + Interp::Hold => 3, + Interp::Bezier => 2, + } + }; + let in_i = side(k.in_interp); + let out_i = side(k.out_interp); + let slots = if kind.spatial() || dims == 1 { 1 } else { dims }; + let ease_of = |eases: &[Ease], i: usize| -> EaseSlot { + let e = key_ease(eases, i); + // An Anchor Point stored as a fraction of the source has its spatial speed in pixels + // per second over the source height (597, 730), as the importer reads it. + let per = if anchor { sh } else { 1.0 }; + let speed = sane(e.speed / kind.unit() / per).unwrap_or_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 } + }; + let in_ease: Vec = (0..slots).map(|s| if in_i == 2 { ease_of(&k.in_ease, s) } else { EaseSlot::default() }).collect(); + let out_ease: Vec = (0..slots).map(|s| if out_i == 2 { ease_of(&k.out_ease, s) } else { EaseSlot::default() }).collect(); + let (mut tin, mut tout) = if kind.spatial() { effectcraft_keyframe::spatial_tangents(&all, i) } else { ([0.0; 3], [0.0; 3]) }; + for t in tin.iter_mut().chain(tout.iter_mut()) { + if sane(*t).is_none() { + beyond.set(true); + *t = 0.0; + } + } + if anchor { + for t in [&mut tin, &mut tout] { + t[0] /= sw; + t[1] /= sh; + } + } + plans.push(KeyPlan { + time, + value, + in_interp: in_i, + out_interp: out_i, + in_ease, + out_ease, + spatial_in: tin, + spatial_out: tout, + spatial_auto: k.spatial_auto, + roving: k.roving, + label: k.label.min(16), + }); + col.exported.keyframes += 1; + } + report_beyond(beyond.get(), &what, col); + if plans.is_empty() { + return None; + } + let value = plans.first().map(|k| k.value.clone()).unwrap_or(static_value); + Some(PropPlan { kind, value, keys: plans, expr }) +} + +/// The Time Remap in the file's terms: a one-component value in seconds, keys in layer time +/// with the plain 48-byte records of a one-dimensional property (319, 354). An enabled remap +/// without keys is written as one key at layer time 0 holding its value: After Effects reads a +/// remap record without keys as switched off (r1, "Freeze": `tdsb` 1 with a static value came +/// back disabled). +fn plan_remap(p: &Property, comp: &Comp, cp: &CompPlan, layer: &str, comp_name: &str, col: &mut Collector) -> PropPlan { + // Rotation has the same layout (one component, stored as is, not spatial). + let at = PropAt { comp, comp_name, layer, source: (1, 1), text: false }; + let planned = plan_prop(PropKind::Rotation, Some("Time Remap"), p, at, cp, col); + col.exported.time_remaps += 1; + let mut plan = planned.unwrap_or_else(|| PropPlan { kind: PropKind::Rotation, value: vec![0.0], keys: Vec::new(), expr: None }); + if plan.keys.is_empty() { + plan.keys.push(KeyPlan { + time: 0, + value: plan.value.clone(), + in_interp: 1, + out_interp: 1, + in_ease: vec![EaseSlot::default()], + out_ease: vec![EaseSlot::default()], + spatial_in: [0.0; 3], + spatial_out: [0.0; 3], + spatial_auto: false, + roving: false, + label: 0, + }); + col.exported.keyframes += 1; + } + plan +} diff --git a/crates/aep/src/export/report.rs b/crates/aep/src/export/report.rs new file mode 100644 index 000000000..7caba0e7d --- /dev/null +++ b/crates/aep/src/export/report.rs @@ -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, +} + +/// 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, + pub limitations: Vec, +} + +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::>().join(", ") + } +} + +/// Collects losses while planning. +#[derive(Default)] +pub(crate) struct Collector { + pub exported: Exported, + losses: BTreeMap, +} + +impl Collector { + /// Records one loss. + pub fn lose(&mut self, category: LossCategory, detail: impl Into) { + 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(), + } + } +} diff --git a/crates/aep/src/export/settings.rs b/crates/aep/src/export/settings.rs new file mode 100644 index 000000000..d3d7c7a1a --- /dev/null +++ b/crates/aep/src/export/settings.rs @@ -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, + /// `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>, id: &[u8; 4]| -> R { 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(()) +} diff --git a/crates/aep/src/export/shapes.rs b/crates/aep/src/export/shapes.rs new file mode 100644 index 000000000..74cd49d5b --- /dev/null +++ b/crates/aep/src/export/shapes.rs @@ -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, + pub keys: Vec, + pub expr: Option<(String, bool)>, +} + +type Records = Vec<(&'static str, RecVal)>; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ShItem { + Group { + name: String, + items: Vec, + 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), +} + +/// 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>, + /// 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 { + 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>; + +pub(crate) fn c_scalar(v: &Value) -> Option> { + 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> { + match v { + Value::Scalar(s) => Some(vec![*s + 1.0]), + _ => None, + } +} + +pub(crate) fn c_point(v: &Value) -> Option> { + 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> { + 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> { + 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 { + 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 = 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 { + (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 { + 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 { + 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 { + 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 { + 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> { + 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>> { + 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(()) +} diff --git a/crates/aep/src/export/skeleton.rs b/crates/aep/src/export/skeleton.rs new file mode 100644 index 000000000..037b4d865 --- /dev/null +++ b/crates/aep/src/export/skeleton.rs @@ -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>; 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> { + 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> = 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> { + 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> { + 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> { + 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> = 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> { + 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])])] +} diff --git a/crates/aep/src/export/style_defs.rs b/crates/aep/src/export/style_defs.rs new file mode 100644 index 000000000..e99f847a9 --- /dev/null +++ b/crates/aep/src/export/style_defs.rs @@ -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))), +]; diff --git a/crates/aep/src/export/styles.rs b/crates/aep/src/export/styles.rs new file mode 100644 index 000000000..4b8cb51f1 --- /dev/null +++ b/crates/aep/src/export/styles.rs @@ -0,0 +1,403 @@ +//! Layer styles: the inverse of the importer, through the shared table +//! [`crate::style_map`]. +//! +//! Every layer block already holds the `ADBE Layer Styles` group with Blending Options +//! and the ten switch groups at rest (950). A style is applied by setting its switch group's +//! `tdsb` to 1 and Layer Styles and Blending Options to 1 (951-960); a parameter that differs +//! from After Effects' default gets a record, built from the table of the record +//! After Effects writes for that parameter (`style_defs`, from 977) with the value, in After Effects' +//! property order (970). Keys on a scalar take the keyed form of 976 (`tdb4` bytes 5, 68 and 79 +//! from it, plain 48-byte key records); an expression is stored as on an effect parameter +//! (`tdb4@120` 1, `@119` the disabled switch, the text before `tdum`). +//! +//! A style switched off keeps its records with its switch group's `tdsb` 0 (988). Not written, +//! reported: the whole Layer Styles group switched off (not sampled), gradients that differ from the default (`GCst` is not decoded), keys on +//! colours, points, popups and checkboxes (not sampled), Knockout (After Effects has none). + +use std::collections::HashMap; + +use crate::Chunk; +use crate::style_map::{self as sm, StyleConv, StyleParam}; +use effectcraft_keyframe::{Interp, Value}; +use effectcraft_project::build::Ids; +use effectcraft_project::styles::{self as st, STYLE_BLEND_MODES}; +use effectcraft_project::{Comp, Layer, PropGroup, Property}; + +use crate::export::plan::{CompPlan, EaseSlot, KeyPlan}; +use crate::export::report::{Collector, LossCategory}; +use crate::export::tree::{R, child_data, kids_mut, put_f64, put_u8, put_u32, tpl}; + +/// A record's value. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum RecValue { + Static(Vec), + Keys(Vec), +} + +/// One parameter record to write. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Record { + /// The full match name (`dropShadow/opacity`, `ADBE Global Angle2`). + pub name: String, + pub value: RecValue, + /// Expression text and its enabled switch. + pub expr: Option<(String, bool)>, +} + +/// The layer styles of one layer. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct StylesPlan { + /// Applied styles (switch-group prefix) with their records and their switch, in menu order. + pub styles: Vec<(&'static str, Vec, bool)>, + /// Global Light Angle / Altitude records (Blending Options). + pub global: Vec, + /// Advanced Blending records. + pub adv: Vec, +} + +fn sane(v: f64) -> Option { + (v.is_finite() && v.abs() <= crate::export::plan::LIMIT).then_some(v) +} + +/// An EffectCraft value in the file's units. +pub(crate) fn stored(conv: StyleConv, v: &Value) -> Option> { + let out = match (conv, v) { + (StyleConv::Number, Value::Scalar(x)) => vec![sane(*x)?], + (StyleConv::Color, Value::Color(c)) => vec![255.0, sane(c[0] * 255.0)?, sane(c[1] * 255.0)?, sane(c[2] * 255.0)?], + (StyleConv::Mode, Value::Enum(i)) => { + let name = STYLE_BLEND_MODES.get(usize::try_from(*i).ok()?)?.0; + vec![f64::from(sm::mode_code(name)?)] + } + (StyleConv::Popup(map), Value::Enum(i)) => vec![f64::from(sm::popup_code(map, *i)?)], + (StyleConv::Checkbox, Value::Bool(b)) => vec![if *b { 1.0 } else { 0.0 }], + (StyleConv::Vec2, Value::Vec2(p)) => vec![sane(p[0])?, sane(p[1])?], + _ => return None, + }; + Some(out) +} + +/// Whether an expression on a style popup would mean something else in After Effects. Both +/// programs count popup items from 1 in expressions, so only a popup whose stored item numbers are +/// not EffectCraft's items in the same order (Inner Glow Source is stored reversed) or a blend +/// mode whose After Effects item number differs from its place in EffectCraft's list is reported. +fn popup_expression_differs(conv: StyleConv) -> bool { + match conv { + StyleConv::Popup(map) => map.iter().enumerate().any(|(i, m)| usize::try_from(*m).ok() != Some(i)), + StyleConv::Mode => STYLE_BLEND_MODES.iter().enumerate().any(|(i, (name, _))| sm::mode_code(name).and_then(|c| usize::try_from(c).ok()) != Some(i + 1)), + _ => false, + } +} + +fn same(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-9 * x.abs().max(1.0)) +} + +/// The record of one parameter, `None` when it stays at After Effects' default (no record). +fn plan_record(spec: &StyleParam, full: String, prop: &Property, cp: &CompPlan, what: &str, col: &mut Collector) -> Option { + let here = format!("{what}: {full}"); + let expr = prop.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled)); + if expr.is_some() { + col.exported.expressions = col.exported.expressions.saturating_add(1); + if popup_expression_differs(spec.conv) { + col.lose( + LossCategory::LayerStyles, + format!("{here}: an expression on a popup whose items are in another order in After Effects means something else there (written unchanged)"), + ); + } + } + if !prop.keys.is_empty() { + if spec.conv == StyleConv::Number { + let keys = plan_number_keys(prop, cp, &here, col); + if !keys.is_empty() { + return Some(Record { name: full, value: RecValue::Keys(keys), expr }); + } + } else { + col.lose(LossCategory::LayerStyles, format!("{here}: keys are not written (not sampled for this kind), the current value is")); + } + } + let Some(value) = stored(spec.conv, &prop.value) else { + col.lose(LossCategory::InvalidValues, format!("{here}: a value that cannot be stored, After Effects' default written")); + return None; + }; + if same(&value, spec.default) && expr.is_none() { + return None; + } + Some(Record { name: full, value: RecValue::Static(value), expr }) +} + +/// The keys of a scalar in the layer's time base: linear, hold and bezier sides with the ease in +/// stored units per second (the plain template of 976). +fn plan_number_keys(prop: &Property, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec { + let mut timed: Vec<(i64, &effectcraft_keyframe::Keyframe)> = Vec::with_capacity(prop.keys.len()); + for k in &prop.keys { + let (u, inexact) = cp.tb.units(k.time); + if inexact { + 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 { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged")); + } + let mut out = Vec::with_capacity(timed.len()); + let beyond = std::cell::Cell::new(false); + for (u, k) in timed { + let (Ok(time), Some(v)) = ( + i32::try_from(u), + match &k.value { + Value::Scalar(x) => sane(*x), + _ => None, + }, + ) else { + col.lose(LossCategory::InvalidValues, format!("{what}: a key that cannot be stored, key dropped")); + continue; + }; + let side = |i: Interp| -> u8 { + match i { + Interp::Linear => 1, + Interp::Bezier => 2, + Interp::Hold => 3, + } + }; + let (in_i, out_i) = (side(k.in_interp), side(k.out_interp)); + let ease = |eases: &[effectcraft_keyframe::Ease], on: bool| -> Vec { + if !on { + return vec![EaseSlot::default()]; + } + let e = crate::export::plan::key_ease(eases, 0); + let influence = if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 }; + let speed = sane(e.speed).unwrap_or_else(|| { + beyond.set(true); + 0.0 + }); + vec![EaseSlot { speed, influence }] + }; + out.push(KeyPlan { + time, + value: vec![v], + 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: [0.0; 3], + spatial_out: [0.0; 3], + spatial_auto: true, + roving: false, + label: k.label.min(16), + }); + if k.roving { + col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving key is written as a normal key")); + } + } + crate::export::plan::report_beyond(beyond.get(), what, col); + out +} + +/// EffectCraft's default of a style parameter (to tell an edited gradient). +fn model_default(style: &str, id: &str) -> Option { + let mut next = 1; + let g = st::style(&mut Ids(&mut next), style)?; + g.get(id).map(|p| p.value.clone()) +} + +/// The layer styles of `layer`; `None` when it has none applied. +pub(crate) fn plan_styles(layer: &Layer, comp: &Comp, cp: &CompPlan, what: &str, col: &mut Collector) -> Option { + let ls = layer.layer_styles()?; + let mut plan = StylesPlan::default(); + let groups: Vec<&PropGroup> = ls.groups().filter(|g| g.match_id != st::BLENDING).collect(); + if groups.is_empty() { + return None; + } + if !layer.can_have_styles() { + col.lose(LossCategory::LayerStyles, format!("{what}: layer styles on a layer that takes none, not written")); + return None; + } + if !ls.enabled { + col.lose(LossCategory::LayerStyles, format!("{what}: Layer Styles switched off (the stored switch is not sampled), not written")); + return None; + } + for g in groups { + let Some(spec) = sm::by_ec(&g.match_id) else { + col.lose(LossCategory::LayerStyles, format!("{what}: style {} has no After Effects counterpart", g.name)); + continue; + }; + let mut recs = Vec::new(); + for prm in spec.params { + let Some(prop) = g.get(prm.ec) else { continue }; + let full = spec.param_name(prm); + if prm.conv == StyleConv::Gradient { + let edited = !prop.keys.is_empty() || prop.expr.is_some() || model_default(spec.ec, prm.ec).is_some_and(|d| d != prop.value); + if edited { + col.lose(LossCategory::LayerStyles, format!("{what}: {full} (gradient colours are not decoded; After Effects' default gradient)")); + } + continue; + } + if let Some(r) = plan_record(prm, full, prop, cp, what, col) { + recs.push(r); + } + } + plan.styles.push((spec.ae, recs, g.enabled)); + } + if plan.styles.is_empty() { + return None; + } + // Global Light: the composition's, on every styled layer. + const ANGLE: &[f64] = &[st::DEFAULT_ANGLE]; + const ALTITUDE: &[f64] = &[st::DEFAULT_ALTITUDE]; + for (name, p, default) in [(sm::GLOBAL_ANGLE, &comp.global_light.angle, ANGLE), (sm::GLOBAL_ALTITUDE, &comp.global_light.altitude, ALTITUDE)] { + let spec = StyleParam { ae: name, ec: name, conv: StyleConv::Number, default }; + if let Some(r) = plan_record(&spec, name.to_string(), p, cp, what, col) { + plan.global.push(r); + } + } + if let Some(adv) = ls.sub(st::BLENDING).and_then(|b| b.sub("advancedBlending")) { + for prm in sm::ADVANCED_BLENDING { + if let Some(p) = adv.get(prm.ec) + && let Some(r) = plan_record(prm, prm.ae.to_string(), p, cp, what, col) + { + plan.adv.push(r); + } + } + if adv.get("knockout").is_some_and(|p| p.value != Value::Enum(0) || !p.keys.is_empty()) { + col.lose(LossCategory::LayerStyles, format!("{what}: Knockout (After Effects has none)")); + } + } + col.exported.layer_styles += plan.styles.len(); + Some(plan) +} + +// --------------------------------------------------------------------------------------------- +// Emit. + +/// The style record prototypes, built in code: full match name -> (`tdmn`, `tdbs`), and +/// the keyed scalar record. +pub(crate) struct StyleProtos { + records: HashMap, Chunk<'static>)>, + keyed: Chunk<'static>, +} + +fn tdmn_name(c: &Chunk<'_>) -> Option { + let d = c.bytes()?; + Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()) +} + +impl StyleProtos { + /// The records built in code from the style table (`style_defs`); the keyed form of a scalar + /// takes `tdb4` bytes 5, 68 and 79 and the key list of a keyed one-component record. + pub(crate) fn from_table() -> StyleProtos { + use crate::export::synth::{KeyTemplate, RecSpec, class, tdbs, tdmn}; + let records = crate::export::style_defs::STYLE_RECORDS + .iter() + .map(|(name, dims, template, cls, limits)| { + let template = if *template == 0 { KeyTemplate::Plain } else { KeyTemplate::Spatial(*template) }; + let spec = RecSpec { dims: *dims, template, class: *cls, limits: *limits, byte5: None }; + (name.to_string(), (tdmn(name), tdbs(&spec, false, 25_600))) + }) + .collect(); + let scalar = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::EFFECT_NUMBER, limits: Some((0.0, 100.0)), byte5: None }; + StyleProtos { records, keyed: tdbs(&scalar, true, 25_600) } + } + + /// The `tdmn` + `tdbs` of one record. + fn build(&self, r: &Record) -> R<[Chunk<'static>; 2]> { + let (m, proto) = self.records.get(&r.name).ok_or_else(|| tpl("style record prototype missing"))?; + let mut rec = proto.clone(); + match &r.value { + RecValue::Static(v) => { + let cdat = child_data(&mut rec, b"cdat")?; + cdat.iter_mut().for_each(|b| *b = 0); + for (i, x) in v.iter().enumerate() { + put_f64(cdat, i * 8, *x)?; + } + } + RecValue::Keys(keys) => { + let ktdb4 = self.keyed.child(b"tdb4").and_then(|c| c.bytes()).ok_or_else(|| tpl("keyed style prototype without tdb4"))?; + { + let tdb4 = child_data(&mut rec, b"tdb4")?; + for at in [5usize, 68, 79] { + put_u8(tdb4, at, *ktdb4.get(at).ok_or_else(|| tpl("short tdb4"))?)?; + } + } + let mut list = self.keyed.child_list(b"list").cloned().ok_or_else(|| tpl("keyed style prototype without keys"))?; + let n = u32::try_from(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 * keys.len()); + for k in keys { + ldat.extend(crate::export::emit::plain_record(1, 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 rec)?; + let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("style record without cdat"))?; + if let Some(slot) = kids.get_mut(at) { + *slot = list; + } + } + } + if let Some((text, enabled)) = &r.expr { + { + let tdb4 = child_data(&mut rec, b"tdb4")?; + put_u8(tdb4, 120, 1)?; + put_u8(tdb4, 119, u8::from(!*enabled))?; + } + let kids = kids_mut(&mut rec)?; + 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.clone(), rec]) + } +} + +/// The node after the `tdmn` named `name` among `kids`. +fn named_mut<'a>(kids: &'a mut [Chunk<'static>], name: &str) -> R<&'a mut Chunk<'static>> { + let i = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(name)).ok_or_else(|| tpl("layer styles group incomplete"))?; + kids.get_mut(i + 1).ok_or_else(|| tpl("layer styles group incomplete")) +} + +fn set_flags(g: &mut Chunk<'static>, flags: u32) -> R<()> { + put_u32(child_data(g, b"tdsb")?, 0, flags) +} + +/// Inserts `recs` before the `tdmn` named `before` (or before `ADBE Group End`). +fn insert_records(protos: &StyleProtos, g: &mut Chunk<'static>, recs: &[Record], before: &str) -> R<()> { + let mut built = Vec::with_capacity(recs.len() * 2); + for r in recs { + built.extend(protos.build(r)?); + } + let kids = kids_mut(g)?; + let at = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(before)).ok_or_else(|| tpl("layer styles group incomplete"))?; + kids.splice(at..at, built); + Ok(()) +} + +/// Applies `plan` to the layer's `ADBE Layer Styles` group. +pub(crate) fn put_styles(protos: &StyleProtos, layr: &mut Chunk<'static>, plan: &StylesPlan) -> R<()> { + let root = crate::export::tree::list_mut(layr, b"tdgp")?; + let ls = named_mut(kids_mut(root)?, sm::GROUP)?; + set_flags(ls, sm::FLAGS_ON)?; + { + let bo = named_mut(kids_mut(ls)?, sm::BLEND_OPTIONS)?; + set_flags(bo, sm::FLAGS_ON)?; + insert_records(protos, bo, &plan.global, sm::ADV_BLEND)?; + let adv = named_mut(kids_mut(bo)?, sm::ADV_BLEND)?; + insert_records(protos, adv, &plan.adv, "ADBE Group End")?; + } + for (prefix, recs, on) in &plan.styles { + let g = named_mut(kids_mut(ls)?, &format!("{prefix}/enabled"))?; + set_flags(g, if *on { sm::FLAGS_ON } else { sm::FLAGS_OFF })?; + insert_records(protos, g, recs, "ADBE Group End")?; + } + Ok(()) +} diff --git a/crates/aep/src/export/synth.rs b/crates/aep/src/export/synth.rs new file mode 100644 index 000000000..07d75c194 --- /dev/null +++ b/crates/aep/src/export/synth.rs @@ -0,0 +1,215 @@ +//! Chunks the exporter builds in code instead of copying them from projects After Effects saved. +//! +//! Every byte written here is a decoded field (`docs/aep-format.md`), a named constant with its +//! evidence, or zero where After Effects was shown to accept zero (the read-backs listed in +//! `docs/aep-export.md`, "Built in code"). + +use crate::{Body, Chunk}; + +/// `tdmn`: a match name padded to 40 bytes. +pub(crate) fn tdmn(name: &str) -> Chunk<'static> { + let mut b = vec![0u8; 40]; + for (slot, c) in b.iter_mut().zip(name.bytes().take(39)) { + *slot = c; + } + Chunk::data(*b"tdmn", b) +} + +/// The user name of a property that has none (`-_0_/-`, aep-format.md 3.4). +pub(crate) const NO_NAME: &str = "-_0_/-"; + +/// `tdsn`: the user name, a nested chunk holding one `Utf8`. +pub(crate) fn tdsn(name: &str) -> Chunk<'static> { + Chunk { id: *b"tdsn", body: Body::Chunks(vec![Chunk::data(*b"Utf8", name.as_bytes().to_vec())]), pad: None } +} + +/// How a property's keys are stored (`tdb4` byte 7, aep-format.md 3.5). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum KeyTemplate { + /// Value and type-major ease doubles: Opacity, Rotation, Scale, sliders. + Plain, + /// Ease, value and tangents; byte 7 is 3 on positions and points, 1 on colours and paths. + Spatial(u8), +} + +/// The u32 at `tdb4` bytes 8..11. Its two u16 halves follow the kind of property (aep-format.md +/// 3.4, "class word"); without it After Effects drops a property's keys (read-back e1-bz5). +pub(crate) mod class { + /// Transform Scale, Rotation and Opacity; the other one-dimensional layer properties. + pub const LAYER_SCALAR: u32 = 0x0001_ffff; + /// Positions and points (spatial, no limits); effect sliders and angles. + pub const SPATIAL: u32 = 0xffff_ffff; + /// Effect sliders (integer, fixed, float) and angles. + pub const EFFECT_NUMBER: u32 = 0xffff_ffff; + /// Effect checkboxes and popups. + pub const EFFECT_CHOICE: u32 = 0x0001_0004; + /// Colours. + pub const COLOR: u32 = 0x0002_ffff; + /// Effect layer parameters, groups and buttons. + pub const EFFECT_OTHER: u32 = 0x0001_0000; + /// Arbitrary data (Hue/Saturation, Levels, Curves, Mesh Warp). + pub const ARBITRARY: u32 = 0x0006_0007; + /// Mask and shape paths. + pub const PATH: u32 = 0x0002_0007; +} + +/// Byte 5 bits 1..3 of a spatial property, next to the static bit 0: `0x0e` on positions and +/// points (`0x0f` static, `0x0e` keyed: Anchor Point, Position, Gradient Ramp's points), `0x06` on +/// colours (`0x07` / `0x06`: Tint's colours). Meaning not decoded. +fn spatial_byte5(template: u8) -> u8 { + if template == 3 { 0x0e } else { 0x06 } +} + +/// What a property record needs to know about its property. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RecSpec { + /// Components of a value (`tdb4` byte 3). + pub dims: u8, + pub template: KeyTemplate, + /// `tdb4` bytes 8..11 (see [`class`]). + pub class: u32, + /// `tdum` / `tduM`, written when the property has them (Scale `0, 0`, Opacity `0, 100`). + pub limits: Option<(f64, f64)>, + /// `tdb4` byte 5 of the static record when it is not the one [`tdb4`] derives (bits 1..3 are + /// not decoded; the keyed record clears bit 0). + pub byte5: Option, +} + +impl RecSpec { + /// Bytes of a static value (`cdat`): the key record without its 8-byte header, value first, + /// the rest zero (aep-format.md 3.4 / 3.5: `40 n` plain, `24 n` spatial). + pub fn cdat_len(&self) -> usize { + let n = usize::from(self.dims); + match self.template { + KeyTemplate::Plain => 40 * n, + KeyTemplate::Spatial(_) => 24 * n, + } + } + + /// Bytes of one key record (aep-format.md 3.5). + pub fn key_len(&self) -> usize { + let n = usize::from(self.dims); + match self.template { + KeyTemplate::Plain => 8 + 40 * n, + KeyTemplate::Spatial(_) => 56 + 24 * n, + } + } +} + +/// The 124-byte `tdb4` header of a property (aep-format.md 3.4). The decoded bytes, the class +/// word and the constant doubles at 16..56 are written; After Effects reads the other bytes as +/// zero (read-back e1-ztdb4d). +pub(crate) fn tdb4(spec: &RecSpec, keyed: bool, time_scale: u32) -> Vec { + let mut b = vec![0u8; 124]; + let set = |b: &mut Vec, at: usize, bytes: &[u8]| { + if let Some(dst) = b.get_mut(at..at + bytes.len()) { + dst.copy_from_slice(bytes); + } + }; + set(&mut b, 0, &[0xdb, 0x99, 0x00, spec.dims]); + let spatial = match spec.template { + KeyTemplate::Plain => 0, + KeyTemplate::Spatial(t) => t, + }; + let byte5 = match spec.byte5 { + Some(b) => (b & !0x01) | u8::from(!keyed), + None => (if spatial == 0 { 0 } else { spatial_byte5(spatial) }) | u8::from(!keyed), + }; + set(&mut b, 5, &[byte5]); + set(&mut b, 7, &[spatial]); + set(&mut b, 8, &spec.class.to_be_bytes()); + set(&mut b, 12, &time_scale.to_be_bytes()); + // Every record After Effects writes carries 0.0001 and four 1.0 here; without them a roving + // key reads back at time zero instead of its retimed place (read-back s14-re-keyframes). + set(&mut b, 16, &1e-4f64.to_be_bytes()); + for at in [24, 32, 40, 48] { + set(&mut b, at, &1.0f64.to_be_bytes()); + } + set(&mut b, 68, &[u8::from(keyed)]); + // Set on a static spatial property, cleared once it is keyed (aep-format.md 3.4, byte 79). + set(&mut b, 79, &[u8::from(spatial != 0 && !keyed)]); + b +} + +/// A key list (`LIST list`: `lhd3` + `ldat`) of `count` records of `size` bytes (aep-format.md +/// 3.5): word 0 `0x00d00bee`, word 2 the count, word 3 the blocks of four (at least one), word 4 +/// the size, words 5 and 7 the capacity (the count rounded up to four, at least two), word 6 one. +pub(crate) fn key_list(size: u32, count: u32, records: Vec) -> Chunk<'static> { + let mut lhd3 = vec![0u8; 52]; + // Cannot fail: the header is 52 bytes. + let _ = crate::export::tree::put_u32(&mut lhd3, 0, 0x00d0_0bee); + let _ = crate::export::tree::put_u32(&mut lhd3, 24, 1); + let _ = put_key_counts(&mut lhd3, count, size); + Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", records)]) +} + +/// Sets the count words of a key list header for `count` keys of `size` bytes. +pub(crate) fn put_key_counts(lhd3: &mut [u8], count: u32, size: u32) -> crate::export::tree::R<()> { + let capacity = count.div_ceil(4).saturating_mul(4).max(2); + put_counts(lhd3, count, count.div_ceil(4).max(1), size, capacity, capacity) +} + +/// Sets the count words of a list header: word 2 the record count, word 3 `blocks`, word 4 the +/// record size, word 5 `w5`, word 7 the capacity. A key list has `w5` = the capacity; a path +/// list counts vertices: `blocks` = `w5` = the vertex count, the capacity four times it (090: +/// 12 points, 4, 4 and 16; aep-format.md 3.5). +pub(crate) fn put_counts(lhd3: &mut [u8], count: u32, blocks: u32, size: u32, w5: u32, capacity: u32) -> crate::export::tree::R<()> { + use crate::export::tree::put_u32; + put_u32(lhd3, 8, count)?; + put_u32(lhd3, 12, blocks)?; + put_u32(lhd3, 16, size)?; + put_u32(lhd3, 20, w5)?; + put_u32(lhd3, 28, capacity) +} + +/// A property record (`LIST tdbs`) with a zero static value or an empty key list, for the +/// exporter to fill: `tdsb` 1 (written), no user name, the header, then `cdat` or the key list, +/// then the limits. +pub(crate) fn tdbs(spec: &RecSpec, keyed: bool, time_scale: u32) -> Chunk<'static> { + let mut kids = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec()), tdsn(NO_NAME), Chunk::data(*b"tdb4", tdb4(spec, keyed, time_scale))]; + if keyed { + kids.push(key_list(u32::try_from(spec.key_len()).unwrap_or(u32::MAX), 0, Vec::new())); + } else { + kids.push(Chunk::data(*b"cdat", vec![0u8; spec.cdat_len()])); + } + if let Some((lo, hi)) = spec.limits { + kids.push(Chunk::data(*b"tdum", lo.to_be_bytes().to_vec())); + kids.push(Chunk::data(*b"tduM", hi.to_be_bytes().to_vec())); + } + Chunk::list(*b"tdbs", kids) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_record_has_the_decoded_layout() { + let pos = RecSpec { dims: 3, template: KeyTemplate::Spatial(3), class: class::SPATIAL, limits: None, byte5: None }; + let h = tdb4(&pos, false, 25_600); + assert_eq!(&h[..8], &[0xdb, 0x99, 0, 3, 0, 0x0f, 0, 3]); + assert_eq!(&h[8..16], &[0xff, 0xff, 0xff, 0xff, 0, 0, 0x64, 0]); + assert_eq!((h[68], h[79]), (0, 1)); + // A roving key needs these to keep its time (read-back s14-re-keyframes). + assert_eq!(&h[16..24], &1e-4f64.to_be_bytes()); + assert!(h[24..56].chunks(8).all(|d| d == 1.0f64.to_be_bytes())); + assert_eq!(pos.cdat_len(), 72); + assert_eq!(pos.key_len(), 128); + let opacity = RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::LAYER_SCALAR, limits: Some((0.0, 100.0)), byte5: None }; + let k = tdb4(&opacity, true, 25_600); + assert_eq!((k[5], k[7], k[68]), (0, 0, 1)); + let rec = tdbs(&opacity, false, 25_600); + let ids: Vec<[u8; 4]> = rec.children().iter().map(|c| c.id).collect(); + assert_eq!(ids, [*b"tdsb", *b"tdsn", *b"tdb4", *b"cdat", *b"tdum", *b"tduM"]); + assert_eq!(rec.child(b"cdat").and_then(|c| c.bytes()).map(<[u8]>::len), Some(40)); + } + + #[test] + fn a_key_list_header_counts_and_rounds() { + let l = key_list(48, 5, vec![0; 240]); + let h = l.child(b"lhd3").and_then(|c| c.bytes()).unwrap(); + let w = |i: usize| u32::from_be_bytes(h[i * 4..i * 4 + 4].try_into().unwrap()); + assert_eq!((w(0), w(2), w(3), w(4), w(5), w(6), w(7)), (0x00d0_0bee, 5, 2, 48, 8, 1, 8)); + assert_eq!(h.len(), 52); + } +} diff --git a/crates/aep/src/export/template.rs b/crates/aep/src/export/template.rs new file mode 100644 index 000000000..91daba636 --- /dev/null +++ b/crates/aep/src/export/template.rs @@ -0,0 +1,162 @@ +//! The prototypes the exporter clones and edits, all built in code: the project skeleton +//! (`base`), the layer blocks (`skeleton`, `tree_defs`), and the records of Transform, masks, +//! effects, layer styles, footage, text and text animators (see `docs/aep-export.md`). Nothing is +//! read from a file After Effects wrote; each layout was checked by After Effects reading the +//! export back. + +use crate::{AepFile, Chunk}; + +use crate::export::tree::{R, tpl}; + +/// The chunks cloned by the exporter. +pub(crate) struct Template { + /// The whole base project; its `Fold` children are replaced by the export. + pub file: AepFile<'static>, + /// The base composition item without its layer blocks. + pub comp_shell: Chunk<'static>, + /// Index in the shell's children where the layer blocks go. + pub layer_at: usize, + /// `Layr`, `Ewst` and the f-records that follow the layer `L`. + pub layer_block: Vec>, + /// The footage item of the solid `L` (inside `Solids`). + pub solid_item: Chunk<'static>, + /// The `Solids` folder item. + pub folder_item: Chunk<'static>, + /// The f-records that follow a footage item inside a folder. + pub fset: Vec>, + /// The `FEE ` list that follows a composition item. + pub fee: Chunk<'static>, + /// `tdmn`, `tdbs` pairs of the five Transform properties with a static value. + pub stat: Vec>, + /// The same with two keys. + pub keyd: Vec>, + /// The layer block of a null layer. + pub null_block: Vec>, + /// The hidden solid item of a null layer. + pub null_item: Chunk<'static>, + /// The mask prototypes (fixtures 091, 093, 208). + pub masks: crate::export::masks::MaskProtos, + /// The effect prototypes (cases 356-397). + pub effects: crate::export::effects::FxProtos, + /// The footage item prototypes (cases 351, 353, 603-607). + pub footage: crate::export::footage::FtProtos, + /// The layer blocks of a point (110) and a box (245) text layer. + pub text_point: Vec>, + pub text_box: Vec>, + /// The text animator prototypes (579, 580-592, 728). + pub anims: crate::export::textanim::AnimProtos, + /// The marker group (`tdmn ADBE Marker` + `LIST mrst`) of fixture 249. + pub marker_group: Vec>, + /// The layer blocks of a two-node camera (256), a spot (254) and a point light (252). + pub camera_block: Vec>, + pub spot_block: Vec>, + pub point_block: Vec>, + /// Camera and light option records (342-345, 254). + pub options: crate::export::camlight::OptionProtos, + /// Shape layer, item and record prototypes (519, 240, 451-471, 239, 242). + pub shapes: crate::export::shapes::ShapeProtos, + /// Layer-style record prototypes (977, 976). + pub styles: crate::export::styles::StyleProtos, +} + +/// The `tdmn`, `tdbs` pairs of the five Transform properties, static or keyed, built in code +/// (aep-format.md 3.4): Anchor Point and Position spatial, Scale, Rotation and Opacity plain; +/// Scale and Opacity carry their limits. +fn transform_records(keyed: bool) -> Vec> { + use crate::export::plan::PropKind; + use crate::export::synth::{KeyTemplate, RecSpec, class}; + PropKind::ALL + .iter() + .flat_map(|k| { + let spec = match k { + PropKind::Anchor | PropKind::Position => { + RecSpec { dims: 3, template: KeyTemplate::Spatial(3), class: class::SPATIAL, limits: None, byte5: None } + } + PropKind::Scale => RecSpec { dims: 3, template: KeyTemplate::Plain, class: class::LAYER_SCALAR, limits: Some((0.0, 0.0)), byte5: None }, + PropKind::Rotation => RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::LAYER_SCALAR, limits: None, byte5: None }, + PropKind::Opacity => RecSpec { dims: 1, template: KeyTemplate::Plain, class: class::LAYER_SCALAR, limits: Some((0.0, 100.0)), byte5: None }, + }; + [crate::export::synth::tdmn(k.match_name()), crate::export::synth::tdbs(&spec, keyed, 25_600)] + }) + .collect() +} + +impl Template { + pub fn load() -> R