Compare commits
171
Commits
No files matched your search
@@ -70,6 +70,12 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,6 +96,8 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -120,6 +128,12 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -144,6 +158,8 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -170,6 +186,12 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -187,6 +209,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$FILMCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
@@ -215,6 +240,12 @@ jobs:
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -232,6 +263,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$FILMCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
|
||||
@@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
|
||||
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
|
||||
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox; later encoding and other OSes) and does nothing else. It
|
||||
video decoding through VideoToolbox and Media Foundation / Direct3D 11; later encoding and Linux) and does nothing else. It
|
||||
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
|
||||
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
|
||||
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
|
||||
@@ -92,6 +92,13 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
|
||||
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
|
||||
and other logos stay out; draw a generic icon instead).
|
||||
9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A
|
||||
font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input
|
||||
`CRAFT_FONTS_DIR=<absolute path of a craft-fonts checkout>` (`crates/text/build.rs` embeds the fonts in its
|
||||
`fonts/manifest.txt` as `filmcraft_text::fonts::CRAFT_FONTS`; unset, it is empty and the app uses its
|
||||
bundled and system fonts). Never add craft-fonts to a `Cargo.toml`. Code using `CRAFT_FONTS` must work
|
||||
when it is empty. Standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
## 2. Clean-room code
|
||||
|
||||
|
||||
@@ -106,6 +106,21 @@ is traced or derived from Adobe artwork.
|
||||
| Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) |
|
||||
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
|
||||
|
||||
## Optional build input: craft-fonts
|
||||
|
||||
These fonts are **not files in this repository**. Builds made with the optional build input
|
||||
`CRAFT_FONTS_DIR` (all official releases; see [docs/contributing.md](docs/contributing.md#building-with-craft-fonts))
|
||||
embed them from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), where each file,
|
||||
its author, pinned upstream source and licence text are listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). Release packages
|
||||
carry each font's licence as `OFL-<family>.txt`.
|
||||
|
||||
| Font | Use in FilmCraft | Licence |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
|
||||
@@ -124,6 +139,15 @@ remove them (AGENTS.md §1.8).
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
|
||||
## Third-party source code
|
||||
|
||||
Source files that are transcribed from third-party code rather than written from scratch. Each keeps
|
||||
the original copyright and permission notice in its header.
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures, function table and constants transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
|
||||
## Downloaded at runtime
|
||||
|
||||
These files are never in the repository or the app bundle. FilmCraft downloads them into the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FilmCraft — instructions for agents
|
||||
|
||||
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat) and `../drawcraft` (Illustrator), with the same conventions.
|
||||
FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor targeting Adobe Premiere Pro parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat) and `../vectorcraft` (Illustrator; formerly DrawCraft), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
`plan/` is maintainer-local (gitignored, not in the repo). Each step names the public equivalent to use if you don't have it.
|
||||
@@ -11,6 +11,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.**
|
||||
- **Never crash** ([`AGENTS.md`](AGENTS.md) §0, standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). People depend on FilmCraft; a crash loses their work, so this outranks feature work. No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` or `unsafe` outside tests: return `Result<T, E>` and propagate with `?` (`ok_or`, `let … else`, `if let`). Treat every number from media, project files, presets or CLI/MCP/control params as hostile: `get()` instead of `[i]`, checked/saturating math, no division by possibly-zero values, capped allocations, bounded recursion. `lock().unwrap_or_else(PoisonError::into_inner)`; background jobs run under `catch_unwind`. The panic hook + UI `catch_unwind` (`crates/ui-egui/src/crash.rs`) are the last-resort guard, not a licence. Every crash fix gets a regression test that panicked before. In particular (§1): never use Adobe iconography, images or any other Adobe asset. Every asset must be openly licensed (OSS / public domain / Creative Commons, or original work by a contributor) and must have a `<file>.attribution` sidecar plus an entry in `ATTRIBUTION.md`. `cargo xtask assets` enforces this.
|
||||
- **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo (no new font files here; [`AGENTS.md`](AGENTS.md) §1.9, standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Build with them: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run -p filmcraft` (`crates/text/build.rs` embeds them as `filmcraft_text::fonts::CRAFT_FONTS`, used for Japanese in the UI and titles). Unset, `CRAFT_FONTS` is empty and everything must still build, test and run; never add craft-fonts to a `Cargo.toml`. Tests on its glyphs skip when it is empty, so run the gates with and without `CRAFT_FONTS_DIR` when you touch fonts.
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, LUTs, fonts. Never copy GPL/LGPL/AGPL code (FFmpeg, x264, x265, MLT, Kdenlive, Shotcut, Olive, LAME…). ffmpeg/ffprobe run only as external test oracles / fixture generators — never linked or shipped.
|
||||
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
|
||||
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
|
||||
|
||||
Generated
+27
@@ -992,6 +992,16 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpu-time"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9e393a7668fe1fad3075085b86c781883000b4ede868f43627b34a87c8b7ded"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1693,6 +1703,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.2.1"
|
||||
@@ -1774,6 +1794,7 @@ version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-av1",
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-color",
|
||||
@@ -1866,6 +1887,7 @@ name = "filmcraft-export"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
@@ -2077,9 +2099,12 @@ version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
@@ -2087,6 +2112,7 @@ dependencies = [
|
||||
"objc2-core-media",
|
||||
"objc2-core-video",
|
||||
"objc2-video-toolbox",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2190,6 +2216,7 @@ dependencies = [
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
|
||||
@@ -29,6 +29,7 @@ filmcraft-hevc = { path = "crates/hevc" }
|
||||
filmcraft-vp9 = { path = "crates/vp9" }
|
||||
filmcraft-prores = { path = "crates/prores" }
|
||||
filmcraft-dnx = { path = "crates/dnx" }
|
||||
filmcraft-apv = { path = "crates/apv" }
|
||||
filmcraft-av1 = { path = "crates/av1" }
|
||||
filmcraft-aac = { path = "crates/aac" }
|
||||
filmcraft-opus = { path = "crates/opus" }
|
||||
@@ -101,6 +102,9 @@ opt-level = 3
|
||||
[profile.dev.package.filmcraft-dnx]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-apv]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-av1]
|
||||
opt-level = 3
|
||||
|
||||
|
||||
@@ -15,4 +15,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Inter font (assets/fonts/Inter-*.ttf) — OFL-1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
|
||||
BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
|
||||
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
|
||||
@@ -184,7 +184,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), with Linux and Windows to follow ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox) and Windows (Media Foundation), with Linux to follow; H.264 export can use NVIDIA's encoder on Windows (opt-in) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -196,7 +196,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
||||
|
||||
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
|
||||
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes cleanly in ffmpeg with error concealment switched off.
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Background jobs** with progress and cancel, so you keep editing while it renders.
|
||||
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
|
||||
@@ -220,7 +220,7 @@ Import merges the document's bins, media and sequences into your project as one
|
||||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are about 135 engine commands so far, with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
|
||||
```jsonc
|
||||
// over the control channel (JSON lines on TCP) or as MCP tool calls
|
||||
@@ -238,7 +238,7 @@ The trailer and the grades in these screenshots were built exactly this way, by
|
||||
## Everywhere
|
||||
|
||||
- **Native** on macOS, Windows and Linux, with a native macOS menu bar.
|
||||
- **The web:** every crate up to the engine compiles to `wasm32`; the browser front end is next.
|
||||
- **The web:** the same engine and UI run in the browser via WebAssembly (`apps/filmcraft-web`, see [docs/web.md](docs/web.md)); every release ships it as `filmcraft-web-<version>.zip`.
|
||||
- **Swappable UI.** The interface is one crate (`ui-egui`) over the engine, so a different front end can replace it without touching editing logic.
|
||||
|
||||
<br>
|
||||
@@ -252,6 +252,13 @@ cargo run --release -p filmcraft-cli -- commands # list every engine c
|
||||
cargo run --release -p filmcraft-cli -- mcp # MCP server (headless)
|
||||
```
|
||||
|
||||
Japanese text in the interface and in titles comes from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (release builds always include it; without it FilmCraft uses its own and the system's fonts):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
|
||||
```
|
||||
|
||||
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
@@ -279,7 +286,7 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS; Windows and Linux have no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys, export and encoding still run on the CPU ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows; Linux has no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only there ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only delivery-codec export; no HEVC or AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
@@ -315,7 +322,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | **Video editing, color and sound · you are here** | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -351,7 +358,9 @@ FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APAC
|
||||
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds made with
|
||||
[craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) also embed its fonts
|
||||
(OFL-1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
@@ -370,3 +379,7 @@ Forks and modified versions must remove them.
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Ffilmcraft&type=date&legend=top-left)
|
||||
+7
-7
@@ -36,7 +36,7 @@ before choosing work.
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoder. Linux has no hardware path. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
@@ -48,10 +48,10 @@ before choosing work.
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API) and Windows (Media Foundation);
|
||||
- hardware decode on Linux (VA-API);
|
||||
- zero-copy decoded frames into wgpu;
|
||||
- the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done);
|
||||
- GPU export, then hardware encode (H.264 / HEVC).
|
||||
- GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 on NVENC (Windows, opt-in); HEVC on Windows and Linux remain.
|
||||
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
|
||||
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
|
||||
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
|
||||
@@ -63,7 +63,7 @@ In priority order. Agents should prefer this work over adding more checklist ite
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC and AV1 export: hardware encoders first, and `rav1e` (BSD-2) for AV1.
|
||||
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder); still to do: HEVC elsewhere, 10-bit / HDR HEVC, and AV1 (`rav1e`, BSD-2).
|
||||
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
|
||||
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
|
||||
8. **AI features** with openly licensed local models: transcription on by default, text-based
|
||||
@@ -100,7 +100,7 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Windows/Linux (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Linux, and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
@@ -154,7 +154,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Running now
|
||||
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Next: VA-API (Linux) and Media Foundation (Windows) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in). Next: VA-API (Linux) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
|
||||
|
||||
## Log
|
||||
@@ -205,4 +205,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists in UI state. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
@@ -27,12 +27,16 @@ SUBCOMMANDS
|
||||
inspect [project|sequence] project tree or active sequence as JSON (default: both)
|
||||
import <file>... import media into the project
|
||||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
||||
[--scale f] [--quality 0-100] [--no-audio] [--queue]
|
||||
export the active sequence and wait for it to finish: with an
|
||||
export preset (`export --list-presets`; built-in or the user's), or
|
||||
a format (h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
from the extension); --range entire|inOut|workArea, or a custom
|
||||
range in seconds; --settings is ExportSettings JSON merged over the
|
||||
preset; --queue adds to the export queue and runs it instead
|
||||
preset; --scale renders at a fraction of the frame size (0.5 =
|
||||
half); --quality 0-100 for the formats that take one; --no-audio
|
||||
leaves the sound out; --queue adds to the export queue and runs it
|
||||
instead
|
||||
export --list-presets [query] list export presets (name, category, format) as JSON
|
||||
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
|
||||
probe <media> [--image-sequence]
|
||||
@@ -105,7 +109,8 @@ impl Backend {
|
||||
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
match self {
|
||||
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await.map_err(|e| e.to_string()),
|
||||
// a blocking export runs as an app job polled by the client (#91, #92)
|
||||
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +154,19 @@ fn format_for(path: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// OS hardware video decoders (VideoToolbox on macOS, Media Foundation on Windows) in front of our
|
||||
/// own, as in the desktop app. Also what `mcp` and the headless commands decode with.
|
||||
fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
filmcraft_platform::register()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own, as in the desktop app.
|
||||
let _ = filmcraft_platform::register();
|
||||
register_hardware_decoders();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
@@ -373,6 +383,14 @@ async fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
/// decoders at start-up.
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders() {
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
|
||||
@@ -170,11 +170,11 @@ pub fn install() -> Result<(), JsValue> {
|
||||
&obj,
|
||||
"readFile",
|
||||
Closure::<dyn Fn(String) -> js_sys::Promise>::new(|path: String| {
|
||||
future_to_promise(async move {
|
||||
future_to_promise(caught(async move {
|
||||
let blob = crate::fs::blob(&path).ok_or_else(|| JsValue::from_str(&format!("{path}: not found")))?;
|
||||
let buf = JsFuture::from(blob.array_buffer()).await?;
|
||||
Ok(js_sys::Uint8Array::new(&buf).into())
|
||||
})
|
||||
}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
@@ -191,10 +191,10 @@ pub fn install() -> Result<(), JsValue> {
|
||||
&obj,
|
||||
"importUrl",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|url: String, name: JsValue| {
|
||||
future_to_promise(async move {
|
||||
future_to_promise(caught(async move {
|
||||
let f = fetch_file(&url, name.as_string()).await?;
|
||||
Ok(to_js(&crate::import::import_files(vec![f]).await))
|
||||
})
|
||||
}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
@@ -202,13 +202,13 @@ pub fn install() -> Result<(), JsValue> {
|
||||
&obj,
|
||||
"openProject",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|src: JsValue| {
|
||||
future_to_promise(async move {
|
||||
future_to_promise(caught(async move {
|
||||
let file = match src.as_string() {
|
||||
Some(url) => fetch_file(&url, None).await?,
|
||||
None => files_of(&src).into_iter().next().ok_or("need a File or URL")?,
|
||||
};
|
||||
crate::import::open_project_file(file).await.map(|v| to_js(&v)).map_err(|e| JsValue::from(js_sys::Error::new(&e)))
|
||||
})
|
||||
}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
@@ -216,3 +216,19 @@ pub fn install() -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(&w, &"filmcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `f`, rejecting with an [`as_error`] `Error`.
|
||||
async fn caught(f: impl std::future::Future<Output = Result<JsValue, JsValue>>) -> Result<JsValue, JsValue> {
|
||||
f.await.map_err(as_error)
|
||||
}
|
||||
|
||||
/// Every member rejects with an `Error` (#90), as `execute` and `request` do, so `e.message` and
|
||||
/// `e instanceof Error` work: a plain string (a fetch status, "not found") becomes one, and an
|
||||
/// `Error` passes through unchanged.
|
||||
fn as_error(e: JsValue) -> JsValue {
|
||||
if e.is_instance_of::<js_sys::Error>() {
|
||||
return e;
|
||||
}
|
||||
let msg = e.as_string().unwrap_or_else(|| format!("{e:?}"));
|
||||
js_sys::Error::new(&msg).into()
|
||||
}
|
||||
@@ -502,7 +502,8 @@ impl MediaSource for WcSource {
|
||||
}
|
||||
let t = &self.file.tracks[self.track];
|
||||
let n = t.samples.len();
|
||||
let target = req.time.max(Tick::ZERO).to_rational_floor(1, t.timescale.max(1) as i64);
|
||||
// nearest, as `Mp4Source` looks frames up (sample times are rounded to the timescale)
|
||||
let target = req.time.max(Tick::ZERO).to_rational_round(1, t.timescale.max(1) as i64);
|
||||
let i = t.sample_at_presentation_time(target).unwrap_or(n - 1).min(n - 1);
|
||||
let want = t.samples[i].pts;
|
||||
self.collect(&mut c, want);
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
played: Arc<AtomicU64>,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
hw: AudioHardwarePrefs,
|
||||
/// Sequence sample rate for "Attempt to force hardware to document sample rate".
|
||||
document_rate: Option<u32>,
|
||||
@@ -44,7 +45,14 @@ impl CpalOut {
|
||||
let host = cpal::default_host();
|
||||
let dev = host.default_output_device()?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Self { stream: None, played: Arc::new(AtomicU64::new(0)), rate: cfg.sample_rate().0, hw: AudioHardwarePrefs::default(), document_rate: None })
|
||||
Some(Self {
|
||||
stream: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
rate: cfg.sample_rate().0,
|
||||
channels: cfg.channels(),
|
||||
hw: AudioHardwarePrefs::default(),
|
||||
document_rate: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The stream configuration the settings ask for, falling back to the device default.
|
||||
@@ -77,6 +85,7 @@ impl AudioOut for CpalOut {
|
||||
config.buffer_size = cpal::BufferSize::Fixed(self.hw.buffer_size);
|
||||
}
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.played.store(0, Ordering::SeqCst);
|
||||
let played = self.played.clone();
|
||||
let err = |e| eprintln!("filmcraft: audio stream error: {e}");
|
||||
@@ -103,6 +112,9 @@ impl AudioOut for CpalOut {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
fn channels(&self) -> usize {
|
||||
self.channels as usize
|
||||
}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.stream.as_ref().map(|_| self.played.load(Ordering::SeqCst))
|
||||
}
|
||||
@@ -122,6 +134,7 @@ impl AudioOut for CpalOut {
|
||||
&& let Ok(cfg) = self.config(&dev)
|
||||
{
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use filmcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -18,44 +32,257 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
|
||||
// the thread can't be started (the closure, and with it the guard, is dropped).
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
eprintln!("filmcraft: control server could not start a connection thread: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One counted connection; decrements the open-connection count when dropped.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST (issue #119): the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9876\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.openDemoProject\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,6 @@ mod tests {
|
||||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(target_os = "macos"), "{hardware:?}");
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,13 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
}
|
||||
});
|
||||
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
|
||||
let language = if items.iter().any(|it| it.id == "app.language.japanese" && it.checked == Some(true)) {
|
||||
let checked = |id: &str| items.iter().any(|it| it.id == id && it.checked == Some(true));
|
||||
let language = if checked("app.language.japanese") {
|
||||
filmcraft_ui_egui::i18n::Language::Ja
|
||||
} else if checked("app.language.spanish") {
|
||||
filmcraft_ui_egui::i18n::Language::Es
|
||||
} else if checked("app.language.portuguese") {
|
||||
filmcraft_ui_egui::i18n::Language::PtBr
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
|
||||
@@ -19,6 +19,10 @@ const ID_PCE: u32 = 5;
|
||||
const ID_FIL: u32 = 6;
|
||||
const ID_END: u32 = 7;
|
||||
|
||||
/// `extension_type` of a fill element carrying SBR data (ISO/IEC 14496-3 §4.5.2.8.2, Table 4.121).
|
||||
const EXT_SBR_DATA: u32 = 13;
|
||||
const EXT_SBR_DATA_CRC: u32 = 14;
|
||||
|
||||
/// One decoded `individual_channel_stream` before stereo processing and synthesis.
|
||||
struct Ics {
|
||||
info: IcsInfo,
|
||||
@@ -45,6 +49,8 @@ pub struct Decoder {
|
||||
offsets: Vec<usize>,
|
||||
channels: Vec<ChannelState>,
|
||||
noise_state: u32,
|
||||
/// A fill element carried SBR data (implicitly signalled HE-AAC).
|
||||
sbr_seen: bool,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
@@ -67,7 +73,7 @@ impl Decoder {
|
||||
if layout.is_empty() && asc.channel_config != 0 {
|
||||
return Err(Error::InvalidConfig("unsupported channel configuration"));
|
||||
}
|
||||
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491 };
|
||||
let mut d = Decoder { asc, layout: Vec::new(), offsets: Vec::new(), channels: Vec::new(), noise_state: 0x2545_F491, sbr_seen: false };
|
||||
d.set_layout(layout);
|
||||
Ok(d)
|
||||
}
|
||||
@@ -90,6 +96,12 @@ impl Decoder {
|
||||
pub fn sample_rate(&self) -> u32 {
|
||||
self.asc.sample_rate
|
||||
}
|
||||
/// Whether the stream is HE-AAC: SBR signalled in the `AudioSpecificConfig` (explicit) or SBR
|
||||
/// data seen in a fill element of a decoded access unit (implicit). Only the AAC-LC core is
|
||||
/// decoded, at [`Self::sample_rate`]; the SBR high band is not reconstructed.
|
||||
pub fn sbr(&self) -> bool {
|
||||
self.asc.sbr || self.sbr_seen
|
||||
}
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
@@ -174,7 +186,11 @@ impl Decoder {
|
||||
count += br.read_bits(8)? as usize;
|
||||
count -= 1;
|
||||
}
|
||||
br.skip(count * 8)?;
|
||||
if count > 0 {
|
||||
let ext = br.read_bits(4)?;
|
||||
self.sbr_seen |= matches!(ext, EXT_SBR_DATA | EXT_SBR_DATA_CRC);
|
||||
br.skip(count * 8 - 4)?;
|
||||
}
|
||||
}
|
||||
_ => break, // ID_END
|
||||
}
|
||||
@@ -542,3 +558,27 @@ fn synth(state: &mut ChannelState, info: &IcsInfo, spec: &[f32]) -> Vec<f32> {
|
||||
state.prev_shape = cur;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Implicitly signalled HE-AAC (an AAC-LC config) is recognised from the SBR fill element.
|
||||
#[test]
|
||||
fn sbr_fill_element_marks_implicit_he_aac() {
|
||||
// AAC-LC, 22.05 kHz, stereo
|
||||
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
|
||||
assert!(!dec.sbr());
|
||||
// ID_FIL, count 1, extension_type EXT_SBR_DATA (+ 4 bits of payload), ID_END
|
||||
let _ = dec.decode(&[0b1100_0011, 0b1010_0001, 0b1100_0000]);
|
||||
assert!(dec.sbr());
|
||||
// other fill payloads (EXT_FILL) don't
|
||||
let mut dec = Decoder::new(&[0x13, 0x90]).unwrap();
|
||||
let _ = dec.decode(&[0b1100_0010, 0b0000_0001, 0b1100_0000]);
|
||||
assert!(!dec.sbr());
|
||||
// explicit signalling: AOT 5, core 22.05 kHz, stereo, extension 44.1 kHz, core AOT 2
|
||||
let dec = Decoder::new(&[0x2B, 0x92, 0x08, 0x00]).unwrap();
|
||||
assert!(dec.sbr());
|
||||
assert_eq!(dec.sample_rate(), 22_050);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "filmcraft-apv"
|
||||
description = "Clean-room Advanced Professional Video (APV, IETF RFC 9924) decoder and encoder"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["threads"]
|
||||
threads = ["dep:rayon"]
|
||||
|
||||
[dependencies]
|
||||
filmcraft-bitstream.workspace = true
|
||||
rayon = { workspace = true, optional = true }
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,74 @@
|
||||
# filmcraft-apv
|
||||
|
||||
Clean-room Advanced Professional Video (APV) decoder and encoder in pure Rust, implemented strictly
|
||||
from the public **IETF RFC 9924** specification (*Advanced Professional Video*, February 2026,
|
||||
ISSN 2070-1721).
|
||||
|
||||
Layer L0: depends only on `filmcraft-bitstream`, `thiserror`, and (optional, feature `threads`,
|
||||
default on) `rayon`. Builds for `wasm32-unknown-unknown`; `unsafe_code = "forbid"`.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use filmcraft_apv::*;
|
||||
|
||||
// Decode one APV Access Unit (or raw bitstream AU prefixed with a 32-bit au_size).
|
||||
let frame: Frame = decode_frame(&au)?;
|
||||
let f8 = decode_frame_with(&au, &DecodeOptions { bit_depth: Some(8), threads: true })?;
|
||||
decode_frame_into(&au, &DecodeOptions::default(), &mut frame_buf)?;
|
||||
let hdr: FrameHeader = probe(&au)?;
|
||||
|
||||
// Split a multi-AU raw .apv bitstream (RFC 9924 Appendix A).
|
||||
let aus: Vec<&[u8]> = split_raw_bitstream(&raw_apv_bytes)?;
|
||||
|
||||
// Encode progressive intra frames.
|
||||
let mut enc = Encoder::new(Profile::P422_10, 1920, 1080)?;
|
||||
let au: Vec<u8> = enc.encode(&frame)?; // Access Unit starting with 'aPv1'
|
||||
let raw_au: Vec<u8> = enc.encode_raw_au(&frame)?; // Prefixed with 32-bit au_size (ISOBMFF apv1 / .apv)
|
||||
let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationRecord payload
|
||||
```
|
||||
|
||||
## Specification Coverage (IETF RFC 9924)
|
||||
|
||||
### Decoder
|
||||
- **Access Units & PBUs (§5.3.1–5.3.3, Appendix A)**: Accepts both unprefixed Access Units
|
||||
(`'aPv1'` signature) and length-prefixed `raw_bitstream_access_unit()` packets. Parses primary
|
||||
(`pbu_type == 1`), non-primary (`2`), preview (`25`), auxiliary alpha (`27`), AU info (`65`),
|
||||
metadata (`66`), and filler (`67`) PBUs. PBUs or frame headers with non-zero `reserved_zero_*`
|
||||
fields are ignored per §5.3.3 and §5.3.5.
|
||||
- **Profiles & Sampling (§9.3)**: All 7 standard profiles (`422-10` idc 33, `422-12` idc 44,
|
||||
`444-10` idc 55, `444-12` idc 66, `4444-10` idc 77, `4444-12` idc 88, `400-10` idc 99) at
|
||||
coded bit depths 10–16, with optional output depth rescaling (`8..=16`).
|
||||
- **Quantization Matrices & Tiles (§5.3.7–5.3.8, §5.3.12–5.3.14)**: Default flat (`16`) and
|
||||
custom per-component 8×8 quantization matrices (`use_q_matrix`), optional `tile_size_in_fh`
|
||||
verification, and independent per-tile, per-component byte-aligned entropy partitions. Tiles
|
||||
decode in parallel via `rayon` when `threads` is enabled.
|
||||
- **Entropy Decoding (§5.3.15–5.3.16, §7.1)**: Adaptive Golomb-Rice / Exp-Golomb VLC decoding for
|
||||
`abs_dc_coeff_diff`, `coeff_zero_run`, and `abs_ac_coeff_minus1` with state tracking (`PrevDC`,
|
||||
`PrevDcDiff`, `Prev1stAcLevel`, `PrevLevel`, `PrevRun`) across the 8×8 blocks of each macroblock.
|
||||
- **Scaling & Inverse Transform (§6.3)**: Exact integer dequantization (`levelScale = [40, 45, 51,
|
||||
57, 64, 71]`, `bdShift = BitDepth - 2`) and 2-D separable 8×8 integer inverse transform using the
|
||||
RFC 9924 Figure 25 `transMatrix`. Note: in RFC 9924 §6.3.2.2, `transMatrix[m][n]` has row `m` as
|
||||
the frequency index (row 0 is `[64; 8]`) and column `n` as the spatial sample index, so the 1-D
|
||||
inverse transform evaluates `sum_j transMatrix[j][i] * x[j]`.
|
||||
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
|
||||
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
|
||||
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
|
||||
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
|
||||
across thousands of mutated and random inputs.
|
||||
|
||||
### Encoder
|
||||
- Progressive intra encoding for all 7 profiles (`422-10`, `422-12`, `444-10`, `444-12`,
|
||||
`4444-10`, `4444-12`, `400-10`).
|
||||
- Supports fixed `qp` or binary-search frame-budget rate control over forward-transformed tiles.
|
||||
- Emits conforming tile grids (`tile_width_in_mbs >= 16`, `tile_height_in_mbs >= 8`,
|
||||
`TileCols, TileRows <= 20`), optional custom quantization matrices, optional `MDCV`/`CLL`
|
||||
metadata PBUs, and `APVDecoderConfigurationRecord` (`apvC`) metadata.
|
||||
|
||||
## Accuracy (vs FFmpeg as an External Oracle)
|
||||
|
||||
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
|
||||
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
|
||||
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
|
||||
(`max_diff = 0`) with FFmpeg's APV decoder across every profile and test case.**
|
||||
@@ -0,0 +1,217 @@
|
||||
//! RFC 9924 §6.3 scaling and 8x8 integer inverse transform, plus the matching forward transform.
|
||||
|
||||
use crate::tables::LEVEL_SCALE;
|
||||
|
||||
/// 1D 8-point inverse transform using the RFC 9924 §6.3.2.3 even/odd butterfly decomposition,
|
||||
/// shifting each output by `(sum + round) >> shift`.
|
||||
#[inline(always)]
|
||||
fn idct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
|
||||
if (x1 | x2 | x3 | x4 | x5 | x6 | x7) == 0 {
|
||||
let v = (x0 * 64 + round) >> shift;
|
||||
return [v; 8];
|
||||
}
|
||||
let e0 = 64 * (x0 + x4);
|
||||
let e1 = 64 * (x0 - x4);
|
||||
let e2 = 35 * x2 - 84 * x6;
|
||||
let e3 = 84 * x2 + 35 * x6;
|
||||
|
||||
let even0 = e0 + e3;
|
||||
let even1 = e1 + e2;
|
||||
let even2 = e1 - e2;
|
||||
let even3 = e0 - e3;
|
||||
|
||||
let odd0 = 89 * x1 + 75 * x3 + 50 * x5 + 18 * x7;
|
||||
let odd1 = 75 * x1 - 18 * x3 - 89 * x5 - 50 * x7;
|
||||
let odd2 = 50 * x1 - 89 * x3 + 18 * x5 + 75 * x7;
|
||||
let odd3 = 18 * x1 - 50 * x3 + 75 * x5 - 89 * x7;
|
||||
|
||||
[
|
||||
(even0 + odd0 + round) >> shift,
|
||||
(even1 + odd1 + round) >> shift,
|
||||
(even2 + odd2 + round) >> shift,
|
||||
(even3 + odd3 + round) >> shift,
|
||||
(even3 - odd3 + round) >> shift,
|
||||
(even2 - odd2 + round) >> shift,
|
||||
(even1 - odd1 + round) >> shift,
|
||||
(even0 - odd0 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Fast path when an 8x8 block has only a DC coefficient (`coeff[1..64]` are all zero).
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8_dc_only(dc_coeff: i16, q0: u8, qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let scaled = ((dc_coeff as i64 * q0 as i64 * level_scale) << qp_shift) + scale_round;
|
||||
let d0 = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
let g0 = (d0 * 64 + 64) >> 7;
|
||||
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
let sample = (((g0 * 64 + round) >> bd_shift) + mid).clamp(0, max_val) as u16;
|
||||
out.fill(sample);
|
||||
}
|
||||
|
||||
/// Scale and inverse-transform one 8x8 block of transform coefficients (indexed by `y * 8 + x`)
|
||||
/// into reconstructed samples at `bit_depth` (`10..=16`), per RFC 9924 §6.3.
|
||||
#[inline]
|
||||
pub fn scale_and_idct8x8(coeff: &[i16; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8, out: &mut [u16; 64]) {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let scale_round = if bd_shift_scale > 0 { 1i64 << (bd_shift_scale - 1) } else { 0 };
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut d = [0i32; 64];
|
||||
for idx in 0..64 {
|
||||
let c = coeff[idx] as i64;
|
||||
if c != 0 {
|
||||
let qm = q_matrix[idx] as i64;
|
||||
let scaled = ((c * qm * level_scale) << qp_shift) + scale_round;
|
||||
d[idx] = (scaled >> bd_shift_scale).clamp(-32768, 32767) as i32;
|
||||
}
|
||||
}
|
||||
|
||||
// 1D vertical transform on each column x = 0..8, followed by (e + 64) >> 7
|
||||
let mut g = [0i32; 64];
|
||||
for x in 0..8 {
|
||||
let col = idct_1d(d[x], d[8 + x], d[16 + x], d[24 + x], d[32 + x], d[40 + x], d[48 + x], d[56 + x], 64, 7);
|
||||
for i in 0..8 {
|
||||
g[i * 8 + x] = col[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 1D horizontal transform on each row y = 0..8, followed by final bit-depth shift and offset
|
||||
let bd_shift = 20u32.saturating_sub(bit_depth as u32);
|
||||
let round = 1i32 << (bd_shift - 1);
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let max_val = (1i32 << bit_depth) - 1;
|
||||
|
||||
for y in 0..8 {
|
||||
let r = &g[y * 8..y * 8 + 8];
|
||||
let row = idct_1d(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], round, bd_shift);
|
||||
for i in 0..8 {
|
||||
out[y * 8 + i] = (row[i] + mid).clamp(0, max_val) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1D 8-point forward butterfly transform matching `TRANS_MATRIX`.
|
||||
#[inline(always)]
|
||||
fn fdct_1d(x0: i32, x1: i32, x2: i32, x3: i32, x4: i32, x5: i32, x6: i32, x7: i32, round: i32, shift: u32) -> [i32; 8] {
|
||||
let s0 = x0 + x7;
|
||||
let d0 = x0 - x7;
|
||||
let s1 = x1 + x6;
|
||||
let d1 = x1 - x6;
|
||||
let s2 = x2 + x5;
|
||||
let d2 = x2 - x5;
|
||||
let s3 = x3 + x4;
|
||||
let d3 = x3 - x4;
|
||||
|
||||
let es0 = s0 + s3;
|
||||
let ed0 = s0 - s3;
|
||||
let es1 = s1 + s2;
|
||||
let ed1 = s1 - s2;
|
||||
|
||||
[
|
||||
(64 * (es0 + es1) + round) >> shift,
|
||||
(89 * d0 + 75 * d1 + 50 * d2 + 18 * d3 + round) >> shift,
|
||||
(84 * ed0 + 35 * ed1 + round) >> shift,
|
||||
(75 * d0 - 18 * d1 - 89 * d2 - 50 * d3 + round) >> shift,
|
||||
(64 * (es0 - es1) + round) >> shift,
|
||||
(50 * d0 - 89 * d1 + 18 * d2 + 75 * d3 + round) >> shift,
|
||||
(35 * ed0 - 84 * ed1 + round) >> shift,
|
||||
(18 * d0 - 50 * d1 + 75 * d2 - 89 * d3 + round) >> shift,
|
||||
]
|
||||
}
|
||||
|
||||
/// Forward 8x8 transform of level-shifted samples (`sample - (1 << (bit_depth - 1))`) into the
|
||||
/// scaled coefficient domain `d[y * 8 + x]` matching [`scale_and_idct8x8`].
|
||||
#[inline]
|
||||
pub fn fdct8x8(samples: &[u16; 64], bit_depth: u8) -> [i32; 64] {
|
||||
let mid = 1i32 << (bit_depth - 1);
|
||||
let shift1 = (bit_depth as u32).saturating_sub(5);
|
||||
let add1 = if shift1 > 0 { 1i32 << (shift1 - 1) } else { 0 };
|
||||
|
||||
// Horizontal forward pass
|
||||
let mut t = [0i32; 64];
|
||||
for y in 0..8 {
|
||||
let s = &samples[y * 8..y * 8 + 8];
|
||||
let row = fdct_1d(
|
||||
s[0] as i32 - mid,
|
||||
s[1] as i32 - mid,
|
||||
s[2] as i32 - mid,
|
||||
s[3] as i32 - mid,
|
||||
s[4] as i32 - mid,
|
||||
s[5] as i32 - mid,
|
||||
s[6] as i32 - mid,
|
||||
s[7] as i32 - mid,
|
||||
add1,
|
||||
shift1,
|
||||
);
|
||||
t[y * 8..y * 8 + 8].copy_from_slice(&row);
|
||||
}
|
||||
|
||||
// Vertical forward pass
|
||||
let mut f = [0i32; 64];
|
||||
for u in 0..8 {
|
||||
let col = fdct_1d(t[u], t[8 + u], t[16 + u], t[24 + u], t[32 + u], t[40 + u], t[48 + u], t[56 + u], 128, 8);
|
||||
for v in 0..8 {
|
||||
f[v * 8 + u] = col[v];
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// Quantize forward-transformed coefficients `f` (in the `d` domain) at `qp` with `q_matrix`.
|
||||
#[inline]
|
||||
pub fn quantize8x8(f: &[i32; 64], q_matrix: &[u8; 64], qp: u8, bit_depth: u8) -> [i16; 64] {
|
||||
let bd_shift_scale = (bit_depth as u32).saturating_sub(2);
|
||||
let level_scale = LEVEL_SCALE[(qp % 6) as usize] as i64;
|
||||
let qp_shift = (qp / 6) as u32;
|
||||
|
||||
let mut out = [0i16; 64];
|
||||
for idx in 0..64 {
|
||||
let val = f[idx] as i64;
|
||||
if val == 0 {
|
||||
continue;
|
||||
}
|
||||
let sign = if val < 0 { -1i64 } else { 1i64 };
|
||||
let abs_val = val.unsigned_abs() as i64;
|
||||
let qm = q_matrix[idx].max(1) as i64;
|
||||
let den = (qm * level_scale) << qp_shift;
|
||||
let num = abs_val << bd_shift_scale;
|
||||
// Deadzone/rounding offset: 1/2 for DC, 3/8 for AC
|
||||
let offset = if idx == 0 { den / 2 } else { (den * 3) / 8 };
|
||||
let level = ((num + offset) / den).min(32767);
|
||||
out[idx] = (level * sign) as i16;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fdct_idct_roundtrip_10bit() {
|
||||
let mut src = [0u16; 64];
|
||||
for (i, s) in src.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 13) % 850) as u16;
|
||||
}
|
||||
let qm = [16u8; 64];
|
||||
// At qp = 0 (very fine quantization), roundtrip is within 1 LSB
|
||||
let f = fdct8x8(&src, 10);
|
||||
let q = quantize8x8(&f, &qm, 0, 10);
|
||||
let mut rec = [0u16; 64];
|
||||
scale_and_idct8x8(&q, &qm, 0, 10, &mut rec);
|
||||
for i in 0..64 {
|
||||
let err = (src[i] as i32 - rec[i] as i32).abs();
|
||||
assert!(err <= 1, "idx {i}: src={} rec={} err={err}", src[i], rec[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! RFC 9924 APV frame and tile decoder.
|
||||
|
||||
use filmcraft_bitstream::BitReader;
|
||||
|
||||
use crate::dct::{scale_and_idct8x8, scale_and_idct8x8_dc_only};
|
||||
use crate::header::{
|
||||
FrameHeader, PBU_ALPHA_FRAME, PBU_METADATA, PBU_NON_PRIMARY_FRAME, PBU_PREVIEW_FRAME, PBU_PRIMARY_FRAME, parse_au_pbus, parse_frame_header,
|
||||
parse_metadata_pbu, read_byte_alignment,
|
||||
};
|
||||
use crate::tables::ZIGZAG_8X8;
|
||||
use crate::{ChromaFormat, ContentLightLevel, Error, Frame, MasteringDisplay, Result};
|
||||
|
||||
/// Options for [`decode_frame_with`] and [`decode_frame_into`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecodeOptions {
|
||||
/// Output sample bit depth (`8..=16`). `None` keeps the stream's coded bit depth (`10..=16`).
|
||||
pub bit_depth: Option<u8>,
|
||||
/// Decode tiles in parallel when the `threads` feature is enabled.
|
||||
pub threads: bool,
|
||||
}
|
||||
|
||||
impl Default for DecodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { bit_depth: None, threads: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse RFC 9924 §7.1.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn read_vlc(r: &mut BitReader, k_param: u32) -> Result<u32> {
|
||||
let mut symbol_value: u64 = 0;
|
||||
let mut k = k_param;
|
||||
let parse_exp_golomb = if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
false
|
||||
} else if !r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
symbol_value += 1u64 << k;
|
||||
false
|
||||
} else {
|
||||
symbol_value += 2u64 << k;
|
||||
true
|
||||
};
|
||||
if parse_exp_golomb {
|
||||
loop {
|
||||
if r.read_bit().map_err(|_| Error::Truncated)? {
|
||||
break;
|
||||
}
|
||||
symbol_value += 1u64 << k;
|
||||
k += 1;
|
||||
if k > 28 {
|
||||
return Err(Error::Invalid("VLC codeword exceeds maximum length"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if k > 0 {
|
||||
symbol_value += r.read_bits(k).map_err(|_| Error::Truncated)? as u64;
|
||||
}
|
||||
u32::try_from(symbol_value).map_err(|_| Error::Invalid("VLC symbol overflow"))
|
||||
}
|
||||
|
||||
/// Probe the primary frame header of an Access Unit without decoding its tiles.
|
||||
pub fn probe(data: &[u8]) -> Result<FrameHeader> {
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
for pbu in &pbus {
|
||||
if matches!(pbu.pbu_type, PBU_PRIMARY_FRAME | PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME)
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
return Ok(hdr);
|
||||
}
|
||||
}
|
||||
Err(Error::Invalid("no decodable frame PBU in access unit"))
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with default options.
|
||||
pub fn decode_frame(data: &[u8]) -> Result<Frame> {
|
||||
decode_frame_with(data, &DecodeOptions::default())
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit with custom [`DecodeOptions`].
|
||||
pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
|
||||
let mut out = Frame::new(2, 2, ChromaFormat::Yuv422, 10, false);
|
||||
decode_frame_into(data, opts, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode one APV Access Unit into `out`, reusing its plane allocations when possible.
|
||||
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<FrameHeader> {
|
||||
if let Some(d) = opts.bit_depth
|
||||
&& !(8..=16).contains(&d)
|
||||
{
|
||||
return Err(Error::Invalid("requested output bit_depth must be in 8..=16"));
|
||||
}
|
||||
|
||||
let pbus = parse_au_pbus(data)?;
|
||||
let mut primary: Option<(u16, FrameHeader, &[u8])> = None;
|
||||
let mut alpha_pbu: Option<(FrameHeader, &[u8])> = None;
|
||||
let mut mdcv: Option<MasteringDisplay> = None;
|
||||
let mut cll: Option<ContentLightLevel> = None;
|
||||
|
||||
for pbu in &pbus {
|
||||
match pbu.pbu_type {
|
||||
PBU_PRIMARY_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_NON_PRIMARY_FRAME | PBU_PREVIEW_FRAME => {
|
||||
if primary.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
primary = Some((pbu.group_id, hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_ALPHA_FRAME => {
|
||||
if alpha_pbu.is_none()
|
||||
&& let Some(hdr) = parse_frame_header(pbu.payload)?
|
||||
{
|
||||
alpha_pbu = Some((hdr, pbu.payload));
|
||||
}
|
||||
}
|
||||
PBU_METADATA => {
|
||||
let _ = parse_metadata_pbu(pbu.payload, &mut mdcv, &mut cll);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (_group_id, hdr, payload) = primary.ok_or(Error::Invalid("no primary frame PBU in access unit"))?;
|
||||
let out_depth = opts.bit_depth.unwrap_or(hdr.bit_depth);
|
||||
|
||||
decode_pbu_planes(&hdr, payload, out_depth, opts.threads, out)?;
|
||||
out.mastering_display = mdcv;
|
||||
out.content_light = cll;
|
||||
|
||||
// If the primary frame has no 4th component and an auxiliary alpha frame PBU (type 27) is present
|
||||
// with matching dimensions, decode its luma plane as the alpha plane.
|
||||
if out.alpha.is_none()
|
||||
&& let Some((ahdr, apayload)) = alpha_pbu
|
||||
&& ahdr.width == hdr.width
|
||||
&& ahdr.height == hdr.height
|
||||
{
|
||||
let mut aframe = Frame::new(ahdr.width, ahdr.height, ahdr.chroma, out_depth, false);
|
||||
if decode_pbu_planes(&ahdr, apayload, out_depth, opts.threads, &mut aframe).is_ok() {
|
||||
out.alpha = Some(aframe.y);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hdr)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DecodedTile {
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
w_luma: u32,
|
||||
h_luma: u32,
|
||||
/// Decoded pixels for each component `c` in `0..num_comps`, packed with stride `w_luma / sub_w`.
|
||||
comps: [Vec<u16>; 4],
|
||||
}
|
||||
|
||||
fn decode_pbu_planes(hdr: &FrameHeader, payload: &[u8], out_depth: u8, threads: bool, out: &mut Frame) -> Result<()> {
|
||||
let num_tiles = hdr.num_tiles();
|
||||
let mut pos = hdr.header_bytes;
|
||||
let mut tile_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_tiles);
|
||||
|
||||
for tile_idx in 0..num_tiles {
|
||||
let sz_bytes = payload.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let tile_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if tile_size == 0 {
|
||||
return Err(Error::Invalid("tile_size cannot be 0"));
|
||||
}
|
||||
if let Some(fh_sizes) = &hdr.tile_sizes_in_fh
|
||||
&& fh_sizes.get(tile_idx).copied() != Some(tile_size as u32)
|
||||
{
|
||||
return Err(Error::Invalid("tile_size does not match tile_size_in_fh"));
|
||||
}
|
||||
pos += 4;
|
||||
let tile_end = pos.checked_add(tile_size).ok_or(Error::Truncated)?;
|
||||
let tile_bytes = payload.get(pos..tile_end).ok_or(Error::Truncated)?;
|
||||
pos = tile_end;
|
||||
tile_slices.push((tile_idx, tile_bytes));
|
||||
}
|
||||
|
||||
// Verify any trailing filler bytes in the frame PBU are 0xFF (RFC 9924 §5.3.4 & §5.3.11)
|
||||
if payload.get(pos..).is_some_and(|rest| rest.iter().any(|&b| b != 0xFF)) {
|
||||
return Err(Error::Invalid("non-0xFF filler byte at end of frame PBU"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = if threads && tile_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
tile_slices.into_par_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
} else {
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, threads)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_tiles: Result<Vec<DecodedTile>> = {
|
||||
let _ = threads;
|
||||
tile_slices.into_iter().map(|(idx, bytes)| decode_single_tile(hdr, idx, bytes, out_depth, false)).collect()
|
||||
};
|
||||
|
||||
let mut decoded_tiles = decoded_tiles?;
|
||||
|
||||
// Prepare output frame metadata
|
||||
let w = hdr.width as usize;
|
||||
let h = hdr.height as usize;
|
||||
let cw = hdr.chroma.chroma_width(hdr.width) as usize;
|
||||
let ch = hdr.chroma.chroma_height(hdr.height) as usize;
|
||||
let n_luma = w.checked_mul(h).ok_or(Error::Invalid("frame luma size overflow"))?;
|
||||
let n_chroma = cw.checked_mul(ch).ok_or(Error::Invalid("frame chroma size overflow"))?;
|
||||
|
||||
out.width = hdr.width;
|
||||
out.height = hdr.height;
|
||||
out.chroma = hdr.chroma;
|
||||
out.bit_depth = out_depth;
|
||||
out.color = hdr.color;
|
||||
out.profile_idc = hdr.profile_idc;
|
||||
out.level_idc = hdr.level_idc;
|
||||
out.band_idc = hdr.band_idc;
|
||||
|
||||
// Zero-copy fast path when a single tile covers the exact frame dimensions without macroblock padding.
|
||||
if decoded_tiles.len() == 1 && decoded_tiles[0].w_luma == hdr.width && decoded_tiles[0].h_luma == hdr.height {
|
||||
let [y, cb, cr, a] = std::mem::take(&mut decoded_tiles[0].comps);
|
||||
out.y = y;
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
out.cb = cb;
|
||||
out.cr = cr;
|
||||
} else {
|
||||
out.cb.clear();
|
||||
out.cr.clear();
|
||||
}
|
||||
out.alpha = if hdr.chroma == ChromaFormat::Yuv4444 { Some(a) } else { None };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
out.y.resize(n_luma, 0);
|
||||
out.cb.resize(n_chroma, 0);
|
||||
out.cr.resize(n_chroma, 0);
|
||||
if hdr.chroma == ChromaFormat::Yuv4444 {
|
||||
let a = out.alpha.get_or_insert_with(Vec::new);
|
||||
a.resize(n_luma, 0);
|
||||
} else {
|
||||
out.alpha = None;
|
||||
}
|
||||
|
||||
// Copy each tile's reconstructed samples into the cropped output planes (RFC 9924 §6)
|
||||
let sub_w = hdr.chroma.sub_width_c() as usize;
|
||||
let sub_h = hdr.chroma.sub_height_c() as usize;
|
||||
|
||||
for tile in &decoded_tiles {
|
||||
copy_tile_comp(&tile.comps[0], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, &mut out.y, w, h);
|
||||
if hdr.chroma.num_comps() >= 3 {
|
||||
let tw_c = (tile.w_luma as usize) / sub_w;
|
||||
let th_c = (tile.h_luma as usize) / sub_h;
|
||||
let tx_c = (tile.x0 as usize) / sub_w;
|
||||
let ty_c = (tile.y0 as usize) / sub_h;
|
||||
copy_tile_comp(&tile.comps[1], tw_c, th_c, tx_c, ty_c, &mut out.cb, cw, ch);
|
||||
copy_tile_comp(&tile.comps[2], tw_c, th_c, tx_c, ty_c, &mut out.cr, cw, ch);
|
||||
}
|
||||
if hdr.chroma == ChromaFormat::Yuv4444
|
||||
&& let Some(alpha) = out.alpha.as_mut()
|
||||
{
|
||||
copy_tile_comp(&tile.comps[3], tile.w_luma as usize, tile.h_luma as usize, tile.x0 as usize, tile.y0 as usize, alpha, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_tile_comp(src: &[u16], tw: usize, th: usize, tx: usize, ty: usize, dst: &mut [u16], fw: usize, fh: usize) {
|
||||
if tx >= fw || ty >= fh {
|
||||
return;
|
||||
}
|
||||
let copy_w = tw.min(fw - tx);
|
||||
let copy_h = th.min(fh - ty);
|
||||
for r in 0..copy_h {
|
||||
let s_off = r * tw;
|
||||
let d_off = (ty + r) * fw + tx;
|
||||
dst[d_off..d_off + copy_w].copy_from_slice(&src[s_off..s_off + copy_w]);
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_single_tile(hdr: &FrameHeader, tile_idx: usize, tile_bytes: &[u8], out_depth: u8, par_comps: bool) -> Result<DecodedTile> {
|
||||
let num_comps = hdr.chroma.num_comps();
|
||||
let min_hdr_size = 5 + 5 * num_comps;
|
||||
if tile_bytes.len() < min_hdr_size {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
|
||||
// tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = u16::from_be_bytes([tile_bytes[0], tile_bytes[1]]) as usize;
|
||||
let tile_index = u16::from_be_bytes([tile_bytes[2], tile_bytes[3]]) as usize;
|
||||
if tile_index != tile_idx {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
if tile_header_size < min_hdr_size || tile_header_size > tile_bytes.len() {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let mut tile_data_size = [0usize; 4];
|
||||
for c in 0..num_comps {
|
||||
let off = 4 + c * 4;
|
||||
let sz = u32::from_be_bytes([tile_bytes[off], tile_bytes[off + 1], tile_bytes[off + 2], tile_bytes[off + 3]]) as usize;
|
||||
if sz == 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_data_size[c] = sz;
|
||||
}
|
||||
|
||||
let max_tile_qp = (51 + hdr.qp_bd_offset()) as u8;
|
||||
let mut tile_qp = [0u8; 4];
|
||||
for c in 0..num_comps {
|
||||
let qp = tile_bytes[4 + num_comps * 4 + c];
|
||||
if qp > max_tile_qp {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
tile_qp[c] = qp;
|
||||
}
|
||||
|
||||
let reserved_zero_8bits = tile_bytes[4 + num_comps * 5];
|
||||
if reserved_zero_8bits != 0 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
|
||||
let tx = tile_idx % (hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (hdr.tile_cols as usize);
|
||||
let x0 = hdr.col_starts[tx];
|
||||
let x1 = hdr.col_starts[tx + 1];
|
||||
let y0 = hdr.row_starts[ty];
|
||||
let y1 = hdr.row_starts[ty + 1];
|
||||
let w_luma = x1.saturating_sub(x0);
|
||||
let h_luma = y1.saturating_sub(y0);
|
||||
|
||||
let mut comp_off = tile_header_size;
|
||||
let mut comp_slices: Vec<(usize, &[u8])> = Vec::with_capacity(num_comps);
|
||||
for c in 0..num_comps {
|
||||
let comp_end = comp_off.checked_add(tile_data_size[c]).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
let comp_bytes = tile_bytes.get(comp_off..comp_end).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
comp_off = comp_end;
|
||||
comp_slices.push((c, comp_bytes));
|
||||
}
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = if par_comps && comp_slices.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
comp_slices.into_par_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
} else {
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let decoded_comps: Result<Vec<Vec<u16>>> = {
|
||||
let _ = par_comps;
|
||||
comp_slices.into_iter().map(|(c, comp_bytes)| decode_tile_comp(hdr, tile_idx, c, comp_bytes, tile_qp[c], w_luma, h_luma, out_depth)).collect()
|
||||
};
|
||||
|
||||
let mut comps: [Vec<u16>; 4] = Default::default();
|
||||
for (c, buf) in decoded_comps?.into_iter().enumerate() {
|
||||
comps[c] = buf;
|
||||
}
|
||||
|
||||
Ok(DecodedTile { x0, y0, w_luma, h_luma, comps })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[u8], qp: u8, w_luma: u32, h_luma: u32, out_depth: u8) -> Result<Vec<u16>> {
|
||||
let num_mb_cols = w_luma / 16;
|
||||
let num_mb_rows = h_luma / 16;
|
||||
let sub_w = if c == 0 { 1 } else { hdr.chroma.sub_width_c() };
|
||||
let sub_h = if c == 0 { 1 } else { hdr.chroma.sub_height_c() };
|
||||
let tw_c = (w_luma / sub_w) as usize;
|
||||
let th_c = (h_luma / sub_h) as usize;
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
|
||||
let mut comp_buf = vec![0u16; tw_c * th_c];
|
||||
let mut r = BitReader::new(comp_bytes);
|
||||
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
let q_mat = &hdr.q_matrix[c];
|
||||
let coded_depth = hdr.bit_depth;
|
||||
|
||||
for mb_y in 0..(num_mb_rows as usize) {
|
||||
for mb_x in 0..(num_mb_cols as usize) {
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
// abs_dc_coeff_diff (RFC 9924 §5.3.15 & §7.1.1)
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
let abs_dc_diff = read_vlc(&mut r, k_dc).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_dc = if abs_dc_diff > 0 { r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))? } else { false };
|
||||
let diff = if sign_dc { -(abs_dc_diff as i64) } else { abs_dc_diff as i64 };
|
||||
let dc = prev_dc as i64 + diff;
|
||||
if !(-32768..=32767).contains(&dc) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_dc = dc as i32;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut coeff = [0i16; 64];
|
||||
coeff[0] = dc as i16;
|
||||
|
||||
// ac_coeff_coding (RFC 9924 §5.3.16 & §7.1.2–7.1.3)
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
let run = read_vlc(&mut r, k_run).map_err(|_| Error::CorruptTile(tile_idx))? as usize;
|
||||
scan_pos = scan_pos.checked_add(run).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
if scan_pos > 64 {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
prev_run = run as u32;
|
||||
if scan_pos < 64 {
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
let abs_ac_minus1 = read_vlc(&mut r, k_lvl).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let sign_ac = r.read_bit().map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
let abs_level = (abs_ac_minus1 as u64) + 1;
|
||||
let level_i64 = if sign_ac { -(abs_level as i64) } else { abs_level as i64 };
|
||||
if !(-32768..=32767).contains(&level_i64) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
coeff[ZIGZAG_8X8[scan_pos] as usize] = level_i64 as i16;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level as u32;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rec = [0u16; 64];
|
||||
if first_ac {
|
||||
scale_and_idct8x8_dc_only(coeff[0], q_mat[0], qp, coded_depth, &mut rec);
|
||||
} else {
|
||||
scale_and_idct8x8(&coeff, q_mat, qp, coded_depth, &mut rec);
|
||||
}
|
||||
if out_depth != coded_depth {
|
||||
rescale_block(&mut rec, coded_depth, out_depth);
|
||||
}
|
||||
|
||||
let px0 = mb_x * blk_w + bx * 8;
|
||||
let py0 = mb_y * blk_h + by * 8;
|
||||
for ry in 0..8 {
|
||||
let dst_row = (py0 + ry) * tw_c + px0;
|
||||
comp_buf[dst_row..dst_row + 8].copy_from_slice(&rec[ry * 8..ry * 8 + 8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
read_byte_alignment(&mut r).map_err(|_| Error::CorruptTile(tile_idx))?;
|
||||
Ok(comp_buf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rescale_block(block: &mut [u16; 64], from_depth: u8, to_depth: u8) {
|
||||
if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
let fill_shift = from_depth as u32;
|
||||
for s in block.iter_mut() {
|
||||
let v = *s as u32;
|
||||
*s = ((v << shift) | (v >> (fill_shift - shift))) as u16;
|
||||
}
|
||||
} else if to_depth < from_depth {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_v = (1u32 << to_depth) - 1;
|
||||
for s in block.iter_mut() {
|
||||
*s = (((*s as u32) + round) >> shift).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
//! RFC 9924 APV intra frame encoder.
|
||||
|
||||
use filmcraft_bitstream::BitWriter;
|
||||
|
||||
use crate::dct::{fdct8x8, quantize8x8};
|
||||
use crate::header::{FrameHeader, MAX_DIMENSION, PBU_PRIMARY_FRAME, write_frame_header, write_metadata_pbu};
|
||||
use crate::tables::{DEFAULT_Q_MATRIX_VAL, ZIGZAG_8X8, select_level_and_band};
|
||||
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, Frame, MasteringDisplay, Profile, Result};
|
||||
|
||||
/// Generate RFC 9924 §7.2.4 variable-length codeword (`h(v)`).
|
||||
#[inline]
|
||||
pub(crate) fn write_vlc(w: &mut BitWriter, symbol_val: u32, k_param: u32) {
|
||||
const PREFIX_VLC: [[u32; 2]; 3] = [[1, 0], [0, 0], [0, 1]];
|
||||
let mut symbol_value = symbol_val;
|
||||
let val_prefix = ((symbol_val >> k_param) as usize).min(2);
|
||||
let mut bit_count = 0usize;
|
||||
let mut k = k_param;
|
||||
|
||||
while symbol_value >= (1u32 << k) {
|
||||
symbol_value -= 1u32 << k;
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(0, 1);
|
||||
}
|
||||
if bit_count >= 2 {
|
||||
k += 1;
|
||||
}
|
||||
bit_count += 1;
|
||||
}
|
||||
if bit_count < 2 {
|
||||
w.write_bits(PREFIX_VLC[val_prefix][bit_count], 1);
|
||||
} else {
|
||||
w.write_bits(1, 1);
|
||||
}
|
||||
if k > 0 {
|
||||
w.write_bits(symbol_value, k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact bit length of a VLC codeword generated by [`write_vlc`].
|
||||
#[inline]
|
||||
pub(crate) fn vlc_bit_len(symbol_val: u32, k_param: u32) -> u32 {
|
||||
if symbol_val < (1u32 << k_param) {
|
||||
1 + k_param
|
||||
} else if symbol_val < (2u32 << k_param) {
|
||||
2 + k_param
|
||||
} else {
|
||||
let rem = (symbol_val >> k_param) - 1;
|
||||
let zeros = 31 - rem.leading_zeros();
|
||||
3 + k_param + 2 * zeros
|
||||
}
|
||||
}
|
||||
|
||||
/// APV encoder configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EncoderConfig {
|
||||
pub profile: Profile,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub color: ColorInfo,
|
||||
pub color_description_present: bool,
|
||||
/// Fixed tile quantization parameter (`0..=51 + QpBdOffset`). When `None`, rate control
|
||||
/// chooses a uniform QP per frame to fit [`Self::target_frame_bytes`].
|
||||
pub qp: Option<u8>,
|
||||
/// Target encoded frame size in bytes when `qp` is `None`.
|
||||
pub target_frame_bytes: u32,
|
||||
/// Tile width in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 16`).
|
||||
pub tile_width_in_mbs: u32,
|
||||
/// Tile height in 16x16 macroblocks (RFC 9924 §9.4.1 requires `>= 8`).
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
/// Optional custom per-component 8x8 quantization matrices (`y * 8 + x`).
|
||||
pub q_matrix: Option<[[u8; 64]; 4]>,
|
||||
/// Write `tile_size_in_fh` array in `frame_header()` (`tile_size_present_in_fh_flag = 1`).
|
||||
pub write_tile_sizes_in_fh: bool,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl EncoderConfig {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Self {
|
||||
let chroma = profile.default_chroma();
|
||||
let bit_depth = profile.default_bit_depth();
|
||||
let w_mbs = width.div_ceil(16).max(1);
|
||||
let h_mbs = height.div_ceil(16).max(1);
|
||||
// Choose conforming tile dimensions (>= 16x8 MBs, <= 20x20 tiles) with ~4-8 tiles on HD/4K.
|
||||
let tile_width_in_mbs = w_mbs.div_ceil(4).max(16).max(w_mbs.div_ceil(20));
|
||||
let tile_height_in_mbs = h_mbs.div_ceil(4).max(8).max(h_mbs.div_ceil(20));
|
||||
let target_frame_bytes = profile.nominal_frame_bytes(width, height);
|
||||
let kbps = ((target_frame_bytes as u64) * 8 * 30 / 1000).max(1000) as u32;
|
||||
let (level_idc, band_idc) = select_level_and_band((width as u64) * (height as u64) * 30, kbps);
|
||||
Self {
|
||||
profile,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
color: ColorInfo::BT709,
|
||||
color_description_present: true,
|
||||
qp: None,
|
||||
target_frame_bytes,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
level_idc,
|
||||
band_idc,
|
||||
q_matrix: None,
|
||||
write_tile_sizes_in_fh: false,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APV intra encoder.
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
hdr: FrameHeader,
|
||||
}
|
||||
|
||||
struct PreparedTileComp {
|
||||
/// Forward-transformed 8x8 blocks in MB-then-subblock scan order.
|
||||
blocks: Vec<[i32; 64]>,
|
||||
}
|
||||
|
||||
struct PreparedTile {
|
||||
tile_idx: usize,
|
||||
comps: Vec<PreparedTileComp>,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Self> {
|
||||
Self::with_config(EncoderConfig::new(profile, width, height))
|
||||
}
|
||||
|
||||
pub fn with_config(cfg: EncoderConfig) -> Result<Self> {
|
||||
if cfg.width == 0 || cfg.height == 0 || cfg.width > MAX_DIMENSION || cfg.height > MAX_DIMENSION {
|
||||
return Err(Error::Input(format!("unsupported dimensions {}x{}", cfg.width, cfg.height)));
|
||||
}
|
||||
if cfg.chroma == ChromaFormat::Yuv422 && !cfg.width.is_multiple_of(2) {
|
||||
return Err(Error::Input("4:2:2 requires even frame width".into()));
|
||||
}
|
||||
if !(10..=16).contains(&cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported coded bit depth {}", cfg.bit_depth)));
|
||||
}
|
||||
if !cfg.profile.supports(cfg.chroma, cfg.bit_depth) {
|
||||
return Err(Error::Input(format!("profile {} does not support {:?} {}-bit", cfg.profile.name(), cfg.chroma, cfg.bit_depth)));
|
||||
}
|
||||
if cfg.tile_width_in_mbs == 0 || cfg.tile_height_in_mbs == 0 {
|
||||
return Err(Error::Input("tile dimensions in MBs must be non-zero".into()));
|
||||
}
|
||||
|
||||
let w_mbs = cfg.width.div_ceil(16);
|
||||
let h_mbs = cfg.height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(cfg.tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(cfg.tile_height_in_mbs);
|
||||
if tile_cols > 20 || tile_rows > 20 {
|
||||
return Err(Error::Input(format!("tile grid {tile_cols}x{tile_rows} exceeds RFC 9924 limit of 20x20")));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < w_mbs {
|
||||
col_starts.push(s * 16);
|
||||
s += cfg.tile_width_in_mbs;
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut s = 0u32;
|
||||
while s < h_mbs {
|
||||
row_starts.push(s * 16);
|
||||
s += cfg.tile_height_in_mbs;
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
if let Some(qm) = &cfg.q_matrix {
|
||||
for c in 0..cfg.chroma.num_comps() {
|
||||
if qm[c].contains(&0) {
|
||||
return Err(Error::Input("q_matrix entries must be non-zero (1..=255)".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hdr = FrameHeader {
|
||||
profile_idc: cfg.profile.idc(),
|
||||
level_idc: cfg.level_idc,
|
||||
band_idc: cfg.band_idc.min(3),
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
chroma: cfg.chroma,
|
||||
bit_depth: cfg.bit_depth,
|
||||
capture_time_distance: 0,
|
||||
color_description_present: cfg.color_description_present,
|
||||
color: cfg.color,
|
||||
use_q_matrix: cfg.q_matrix.is_some(),
|
||||
q_matrix: cfg.q_matrix.unwrap_or([[DEFAULT_Q_MATRIX_VAL; 64]; 4]),
|
||||
tile_width_in_mbs: cfg.tile_width_in_mbs,
|
||||
tile_height_in_mbs: cfg.tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh: None,
|
||||
header_bytes: 0,
|
||||
};
|
||||
|
||||
Ok(Self { cfg, hdr })
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &EncoderConfig {
|
||||
&self.cfg
|
||||
}
|
||||
|
||||
pub fn target_frame_bytes(&self) -> u32 {
|
||||
self.cfg.target_frame_bytes
|
||||
}
|
||||
|
||||
/// Encode one frame into an APV Access Unit (`b"aPv1"...`), suitable for MP4/MOV (`apv1`) or MKV (`V_APV`).
|
||||
pub fn encode(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
if frame.width != self.cfg.width || frame.height != self.cfg.height {
|
||||
return Err(Error::Input(format!("frame is {}x{}, encoder is {}x{}", frame.width, frame.height, self.cfg.width, self.cfg.height)));
|
||||
}
|
||||
if frame.chroma != self.cfg.chroma {
|
||||
return Err(Error::Input(format!("frame chroma {:?} does not match encoder {:?}", frame.chroma, self.cfg.chroma)));
|
||||
}
|
||||
if !(8..=16).contains(&frame.bit_depth) {
|
||||
return Err(Error::Input(format!("unsupported input bit depth {}", frame.bit_depth)));
|
||||
}
|
||||
let n_luma = (frame.width as usize) * (frame.height as usize);
|
||||
let n_chroma = (frame.chroma_width() as usize) * (frame.chroma_height() as usize);
|
||||
if frame.y.len() != n_luma || frame.cb.len() != n_chroma || frame.cr.len() != n_chroma {
|
||||
return Err(Error::Input("plane buffer lengths do not match frame geometry".into()));
|
||||
}
|
||||
if self.cfg.chroma == ChromaFormat::Yuv4444 && frame.alpha.as_ref().is_none_or(|a| a.len() != n_luma) {
|
||||
return Err(Error::Input("4:4:4:4 profile requires an alpha plane of width * height samples".into()));
|
||||
}
|
||||
|
||||
let prepared = self.prepare_tiles(frame);
|
||||
let max_qp = (51 + self.hdr.qp_bd_offset()) as u8;
|
||||
let qp = match self.cfg.qp {
|
||||
Some(q) => q.min(max_qp),
|
||||
None => self.select_qp(&prepared, max_qp),
|
||||
};
|
||||
|
||||
let encoded_tiles: Vec<Vec<u8>> = {
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
prepared.par_iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
prepared.iter().map(|t| self.encode_tile(t, qp)).collect()
|
||||
}
|
||||
};
|
||||
|
||||
let mut hdr = self.hdr.clone();
|
||||
hdr.color = if !self.cfg.color_description_present {
|
||||
ColorInfo::default()
|
||||
} else if frame.color != ColorInfo::BT709 {
|
||||
frame.color
|
||||
} else {
|
||||
self.cfg.color
|
||||
};
|
||||
if self.cfg.write_tile_sizes_in_fh {
|
||||
hdr.tile_sizes_in_fh = Some(encoded_tiles.iter().map(|t| t.len() as u32).collect());
|
||||
}
|
||||
let fh_bytes = write_frame_header(&hdr);
|
||||
|
||||
let tiles_total_bytes: usize = encoded_tiles.iter().map(|t| 4 + t.len()).sum();
|
||||
let pbu_size = 4 + fh_bytes.len() + tiles_total_bytes;
|
||||
|
||||
let mdcv = frame.mastering_display.as_ref().or(self.cfg.mastering_display.as_ref());
|
||||
let cll = frame.content_light.as_ref().or(self.cfg.content_light.as_ref());
|
||||
let meta_pbu = write_metadata_pbu(1, mdcv, cll);
|
||||
let meta_total = meta_pbu.as_ref().map_or(0, |m| 4 + m.len());
|
||||
|
||||
let mut au = Vec::with_capacity(4 + 4 + pbu_size + meta_total);
|
||||
au.extend_from_slice(b"aPv1");
|
||||
// Primary frame PBU (pbu_type = 1, group_id = 1)
|
||||
au.extend_from_slice(&(pbu_size as u32).to_be_bytes());
|
||||
au.push(PBU_PRIMARY_FRAME);
|
||||
au.extend_from_slice(&1u16.to_be_bytes());
|
||||
au.push(0); // reserved_zero_8bits
|
||||
au.extend_from_slice(&fh_bytes);
|
||||
for t in &encoded_tiles {
|
||||
au.extend_from_slice(&(t.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(t);
|
||||
}
|
||||
if let Some(m) = meta_pbu {
|
||||
au.extend_from_slice(&(m.len() as u32).to_be_bytes());
|
||||
au.extend_from_slice(&m);
|
||||
}
|
||||
Ok(au)
|
||||
}
|
||||
|
||||
/// Encode one frame as a `raw_bitstream_access_unit()` (prefixed by `au_size: u32 BE`, RFC 9924 Appendix A).
|
||||
pub fn encode_raw_au(&mut self, frame: &Frame) -> Result<Vec<u8>> {
|
||||
let au = self.encode(frame)?;
|
||||
let mut raw = Vec::with_capacity(4 + au.len());
|
||||
raw.extend_from_slice(&(au.len() as u32).to_be_bytes());
|
||||
raw.extend_from_slice(&au);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Build the `APVDecoderConfigurationRecord` payload (for ISOBMFF `apvC` or Matroska CodecPrivate).
|
||||
pub fn decoder_config_record(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(24);
|
||||
v.push(1); // configurationVersion = 1
|
||||
v.push(1); // number_of_configuration_entry = 1
|
||||
v.push(PBU_PRIMARY_FRAME); // pbu_type[0] = 1
|
||||
v.push(1); // number_of_frame_info[0] = 1
|
||||
let flags = if self.cfg.color_description_present { 0x02 } else { 0x00 } | 0x01; // capture_time_distance_ignored = 1
|
||||
v.push(flags);
|
||||
v.push(self.hdr.profile_idc);
|
||||
v.push(self.hdr.level_idc);
|
||||
v.push(self.hdr.band_idc);
|
||||
v.extend_from_slice(&self.hdr.width.to_be_bytes());
|
||||
v.extend_from_slice(&self.hdr.height.to_be_bytes());
|
||||
v.push((self.hdr.chroma.idc() << 4) | (self.hdr.bit_depth.saturating_sub(8) & 0x0F));
|
||||
v.push(0); // capture_time_distance = 0
|
||||
if self.cfg.color_description_present {
|
||||
v.push(self.cfg.color.primaries);
|
||||
v.push(self.cfg.color.transfer);
|
||||
v.push(self.cfg.color.matrix);
|
||||
v.push(if self.cfg.color.full_range { 0x80 } else { 0x00 });
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn prepare_tiles(&self, frame: &Frame) -> Vec<PreparedTile> {
|
||||
let num_tiles = self.hdr.num_tiles();
|
||||
let prepare_one = |tile_idx: usize| -> PreparedTile {
|
||||
let tx = tile_idx % (self.hdr.tile_cols as usize);
|
||||
let ty = tile_idx / (self.hdr.tile_cols as usize);
|
||||
let x0 = self.hdr.col_starts[tx];
|
||||
let x1 = self.hdr.col_starts[tx + 1];
|
||||
let y0 = self.hdr.row_starts[ty];
|
||||
let y1 = self.hdr.row_starts[ty + 1];
|
||||
let num_mb_cols = (x1 - x0) / 16;
|
||||
let num_mb_rows = (y1 - y0) / 16;
|
||||
let num_comps = self.hdr.chroma.num_comps();
|
||||
let mut comps = Vec::with_capacity(num_comps);
|
||||
|
||||
for c in 0..num_comps {
|
||||
let (plane, pw, ph, sub_w, sub_h) = match c {
|
||||
0 => (frame.y.as_slice(), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
1 => (
|
||||
frame.cb.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
2 => (
|
||||
frame.cr.as_slice(),
|
||||
frame.chroma_width() as usize,
|
||||
frame.chroma_height() as usize,
|
||||
self.hdr.chroma.sub_width_c(),
|
||||
self.hdr.chroma.sub_height_c(),
|
||||
),
|
||||
_ => (frame.alpha.as_deref().unwrap_or(&[]), frame.width as usize, frame.height as usize, 1u32, 1u32),
|
||||
};
|
||||
let blk_w = (16 / sub_w) as usize;
|
||||
let blk_h = (16 / sub_h) as usize;
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
let total_blocks = (num_mb_cols as usize) * (num_mb_rows as usize) * blocks_x * blocks_y;
|
||||
let mut blocks = Vec::with_capacity(total_blocks);
|
||||
|
||||
let coded_depth = self.hdr.bit_depth;
|
||||
let in_depth = frame.bit_depth;
|
||||
|
||||
for mb_y in 0..num_mb_rows {
|
||||
for mb_x in 0..num_mb_cols {
|
||||
let x_mb_c = ((x0 + mb_x * 16) / sub_w) as usize;
|
||||
let y_mb_c = ((y0 + mb_y * 16) / sub_h) as usize;
|
||||
for by in 0..blocks_y {
|
||||
for bx in 0..blocks_x {
|
||||
let bx0 = x_mb_c + bx * 8;
|
||||
let by0 = y_mb_c + by * 8;
|
||||
let mut samples = [0u16; 64];
|
||||
for ry in 0..8 {
|
||||
let sy = (by0 + ry).min(ph.saturating_sub(1));
|
||||
for rx in 0..8 {
|
||||
let sx = (bx0 + rx).min(pw.saturating_sub(1));
|
||||
let v = plane[sy * pw + sx];
|
||||
samples[ry * 8 + rx] = scale_sample(v, in_depth, coded_depth);
|
||||
}
|
||||
}
|
||||
blocks.push(fdct8x8(&samples, coded_depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comps.push(PreparedTileComp { blocks });
|
||||
}
|
||||
PreparedTile { tile_idx, comps }
|
||||
};
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
(0..num_tiles).into_par_iter().map(prepare_one).collect()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
(0..num_tiles).map(prepare_one).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn select_qp(&self, tiles: &[PreparedTile], max_qp: u8) -> u8 {
|
||||
let target_bytes = (self.cfg.target_frame_bytes as usize).max(64);
|
||||
let frame_bytes_at = |qp: u8| -> usize {
|
||||
let tile_bytes = |t: &PreparedTile| -> usize {
|
||||
let num_comps = t.comps.len();
|
||||
let mut sz = 4 + (5 + 5 * num_comps);
|
||||
for (c, comp) in t.comps.iter().enumerate() {
|
||||
let bits = self.estimate_comp_bits(comp, c, qp);
|
||||
sz += (bits.div_ceil(8) as usize).max(1);
|
||||
}
|
||||
sz
|
||||
};
|
||||
#[cfg(feature = "threads")]
|
||||
let sum: usize = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().map(tile_bytes).sum()
|
||||
};
|
||||
#[cfg(not(feature = "threads"))]
|
||||
let sum: usize = tiles.iter().map(tile_bytes).sum();
|
||||
24 + sum
|
||||
};
|
||||
|
||||
let mut lo = 0u8;
|
||||
let mut hi = max_qp;
|
||||
if frame_bytes_at(0) <= target_bytes {
|
||||
return 0;
|
||||
}
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if frame_bytes_at(mid) <= target_bytes {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
lo
|
||||
}
|
||||
|
||||
fn estimate_comp_bits(&self, comp: &PreparedTileComp, c: usize, qp: u8) -> u64 {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let depth = self.hdr.bit_depth;
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
let mut bits: u64 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
bits += vlc_bit_len(abs_dc_diff, k_dc) as u64;
|
||||
if abs_dc_diff > 0 {
|
||||
bits += 1;
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
bits += vlc_bit_len(run, k_run) as u64;
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
bits += vlc_bit_len(abs_level - 1, k_lvl) as u64 + 1;
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn encode_tile(&self, tile: &PreparedTile, qp: u8) -> Vec<u8> {
|
||||
let num_comps = tile.comps.len();
|
||||
let mut comp_payloads: Vec<Vec<u8>> = Vec::with_capacity(num_comps);
|
||||
let depth = self.hdr.bit_depth;
|
||||
|
||||
for (c, comp) in tile.comps.iter().enumerate() {
|
||||
let qm = &self.hdr.q_matrix[c];
|
||||
let mut w = BitWriter::new();
|
||||
let mut prev_dc: i32 = 0;
|
||||
let mut prev_dc_diff: u32 = 20;
|
||||
let mut prev_1st_ac_level: u32 = 0;
|
||||
|
||||
for f_blk in &comp.blocks {
|
||||
let coeff = quantize8x8(f_blk, qm, qp, depth);
|
||||
let dc = coeff[0] as i32;
|
||||
let diff = dc - prev_dc;
|
||||
let abs_dc_diff = diff.unsigned_abs();
|
||||
let k_dc = (prev_dc_diff >> 1).min(5);
|
||||
write_vlc(&mut w, abs_dc_diff, k_dc);
|
||||
if abs_dc_diff > 0 {
|
||||
w.write_bit(diff < 0);
|
||||
}
|
||||
prev_dc = dc;
|
||||
prev_dc_diff = abs_dc_diff;
|
||||
|
||||
let mut scan_pos = 1usize;
|
||||
let mut first_ac = true;
|
||||
let mut prev_level = prev_1st_ac_level;
|
||||
let mut prev_run = 0u32;
|
||||
|
||||
while scan_pos < 64 {
|
||||
let mut run = 0u32;
|
||||
while scan_pos + (run as usize) < 64 && coeff[ZIGZAG_8X8[scan_pos + (run as usize)] as usize] == 0 {
|
||||
run += 1;
|
||||
}
|
||||
let k_run = (prev_run >> 2).min(2);
|
||||
write_vlc(&mut w, run, k_run);
|
||||
scan_pos += run as usize;
|
||||
prev_run = run;
|
||||
if scan_pos < 64 {
|
||||
let level = coeff[ZIGZAG_8X8[scan_pos] as usize];
|
||||
let abs_level = level.unsigned_abs() as u32;
|
||||
let k_lvl = (prev_level >> 2).min(4);
|
||||
write_vlc(&mut w, abs_level - 1, k_lvl);
|
||||
w.write_bit(level < 0);
|
||||
scan_pos += 1;
|
||||
prev_level = abs_level;
|
||||
if first_ac {
|
||||
first_ac = false;
|
||||
prev_1st_ac_level = prev_level;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
comp_payloads.push(w.finish());
|
||||
}
|
||||
|
||||
// Write tile_header(tileIdx) (RFC 9924 §5.3.13)
|
||||
let tile_header_size = (5 + 5 * num_comps) as u16;
|
||||
let total_size = tile_header_size as usize + comp_payloads.iter().map(Vec::len).sum::<usize>();
|
||||
let mut out = Vec::with_capacity(total_size);
|
||||
out.extend_from_slice(&tile_header_size.to_be_bytes());
|
||||
out.extend_from_slice(&(tile.tile_idx as u16).to_be_bytes());
|
||||
for cp in &comp_payloads {
|
||||
out.extend_from_slice(&(cp.len() as u32).to_be_bytes());
|
||||
}
|
||||
for _ in 0..num_comps {
|
||||
out.push(qp);
|
||||
}
|
||||
out.push(0); // reserved_zero_8bits
|
||||
for cp in comp_payloads {
|
||||
out.extend_from_slice(&cp);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn scale_sample(v: u16, from_depth: u8, to_depth: u8) -> u16 {
|
||||
let max_in = (1u32 << from_depth) - 1;
|
||||
let v = (v as u32).min(max_in);
|
||||
if to_depth == from_depth {
|
||||
v as u16
|
||||
} else if to_depth > from_depth {
|
||||
let shift = (to_depth - from_depth) as u32;
|
||||
((v << shift) | (v >> ((from_depth as u32) - shift))) as u16
|
||||
} else {
|
||||
let shift = (from_depth - to_depth) as u32;
|
||||
let round = 1u32 << (shift - 1);
|
||||
let max_out = (1u32 << to_depth) - 1;
|
||||
(((v + round) >> shift).min(max_out)) as u16
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//! RFC 9924 §5.3 and §8 syntax structures: Access Unit, PBU, Frame Header, Tile Info, and Metadata.
|
||||
|
||||
use filmcraft_bitstream::{BitReader, BitWriter};
|
||||
|
||||
use crate::tables::DEFAULT_Q_MATRIX_VAL;
|
||||
use crate::{ChromaFormat, ColorInfo, ContentLightLevel, Error, MasteringDisplay, Result};
|
||||
|
||||
/// Four-character signature `'aPv1'` (`0x61507631`) at the start of every APV Access Unit (RFC 9924 §5.3.1).
|
||||
pub const AU_SIGNATURE: [u8; 4] = *b"aPv1";
|
||||
|
||||
/// Primitive Bitstream Unit (PBU) types (RFC 9924 §5.3.3 Table 3).
|
||||
pub const PBU_PRIMARY_FRAME: u8 = 1;
|
||||
pub const PBU_NON_PRIMARY_FRAME: u8 = 2;
|
||||
pub const PBU_PREVIEW_FRAME: u8 = 25;
|
||||
pub const PBU_DEPTH_FRAME: u8 = 26;
|
||||
pub const PBU_ALPHA_FRAME: u8 = 27;
|
||||
pub const PBU_AU_INFO: u8 = 65;
|
||||
pub const PBU_METADATA: u8 = 66;
|
||||
pub const PBU_FILLER: u8 = 67;
|
||||
|
||||
/// Maximum supported frame dimension in luma samples (guards against hostile allocations).
|
||||
pub const MAX_DIMENSION: u32 = 16384;
|
||||
|
||||
/// Maximum tiles per axis (RFC 9924 §9.4.1 specifies 20; we allow up to 64 for robustness).
|
||||
pub const MAX_TILE_DIM: u32 = 64;
|
||||
|
||||
/// Parsed `frame_header()` and derived tile geometry (RFC 9924 §5.3.5–5.3.8).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FrameHeader {
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
pub bit_depth: u8,
|
||||
pub capture_time_distance: u8,
|
||||
pub color_description_present: bool,
|
||||
pub color: ColorInfo,
|
||||
pub use_q_matrix: bool,
|
||||
/// Per-component 8x8 quantization matrix in raster order (`y * 8 + x`).
|
||||
pub q_matrix: [[u8; 64]; 4],
|
||||
pub tile_width_in_mbs: u32,
|
||||
pub tile_height_in_mbs: u32,
|
||||
pub tile_cols: u32,
|
||||
pub tile_rows: u32,
|
||||
/// Tile column start positions in luma samples (`len == tile_cols + 1`).
|
||||
pub col_starts: Vec<u32>,
|
||||
/// Tile row start positions in luma samples (`len == tile_rows + 1`).
|
||||
pub row_starts: Vec<u32>,
|
||||
pub tile_sizes_in_fh: Option<Vec<u32>>,
|
||||
/// Byte offset within the PBU payload where `frame_header()` ends (start of the tile loop).
|
||||
pub header_bytes: usize,
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn frame_width_in_mbs(&self) -> u32 {
|
||||
self.width.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn frame_height_in_mbs(&self) -> u32 {
|
||||
self.height.div_ceil(16)
|
||||
}
|
||||
|
||||
pub fn num_tiles(&self) -> usize {
|
||||
(self.tile_cols as usize) * (self.tile_rows as usize)
|
||||
}
|
||||
|
||||
pub fn qp_bd_offset(&self) -> i32 {
|
||||
(self.bit_depth.saturating_sub(8) as i32) * 6
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed PBU slice inside an Access Unit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PbuSlice<'a> {
|
||||
pub pbu_type: u8,
|
||||
pub group_id: u16,
|
||||
/// Payload after the 4-byte `pbu_header()`.
|
||||
pub payload: &'a [u8],
|
||||
}
|
||||
|
||||
/// Unwrap an Access Unit byte slice: accepts either a direct `access_unit` starting with `'aPv1'`
|
||||
/// or a `raw_bitstream_access_unit` prefixed with a 32-bit big-endian `au_size` (RFC 9924 Appendix A).
|
||||
pub fn unwrap_au(data: &[u8]) -> Result<&[u8]> {
|
||||
if data.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
if data[..4] == AU_SIGNATURE {
|
||||
return Ok(data);
|
||||
}
|
||||
if data.len() >= 8 && data[4..8] == AU_SIGNATURE {
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
return data.get(4..end).ok_or(Error::Truncated);
|
||||
}
|
||||
Err(Error::Invalid("missing aPv1 access unit signature"))
|
||||
}
|
||||
|
||||
/// Split a raw `.apv` bitstream (`raw_bitstream_access_unit()` sequence per RFC 9924 Appendix A)
|
||||
/// into individual Access Unit slices (each starting with `'aPv1'`).
|
||||
pub fn split_raw_bitstream(mut data: &[u8]) -> Result<Vec<&[u8]>> {
|
||||
if data.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
// If the buffer starts directly with 'aPv1', treat the entire buffer as a single AU.
|
||||
if data.len() >= 4 && data[..4] == AU_SIGNATURE {
|
||||
return Ok(vec![data]);
|
||||
}
|
||||
let mut aus = Vec::new();
|
||||
while !data.is_empty() {
|
||||
if data.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let au_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if au_size < 4 || au_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid au_size in raw bitstream"));
|
||||
}
|
||||
let end = 4usize.checked_add(au_size).ok_or(Error::Truncated)?;
|
||||
let au = data.get(4..end).ok_or(Error::Truncated)?;
|
||||
if au[..4] != AU_SIGNATURE {
|
||||
return Err(Error::Invalid("missing aPv1 signature in raw bitstream AU"));
|
||||
}
|
||||
aus.push(au);
|
||||
data = &data[end..];
|
||||
}
|
||||
if aus.is_empty() {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
Ok(aus)
|
||||
}
|
||||
|
||||
/// Parse the PBU list of a single Access Unit (RFC 9924 §5.3.1–5.3.3).
|
||||
/// PBUs with `reserved_zero_8bits > 0` are ignored as required by RFC 9924 §5.3.3.
|
||||
pub fn parse_au_pbus(data: &[u8]) -> Result<Vec<PbuSlice<'_>>> {
|
||||
let au = unwrap_au(data)?;
|
||||
if au.len() < 8 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let mut pos = 4usize;
|
||||
let mut pbus = Vec::new();
|
||||
while pos < au.len() {
|
||||
let sz_bytes = au.get(pos..pos + 4).ok_or(Error::Truncated)?;
|
||||
let pbu_size = u32::from_be_bytes([sz_bytes[0], sz_bytes[1], sz_bytes[2], sz_bytes[3]]) as usize;
|
||||
if pbu_size == 0 || pbu_size == 0xFFFF_FFFF {
|
||||
return Err(Error::Invalid("invalid pbu_size"));
|
||||
}
|
||||
if pbu_size < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
pos += 4;
|
||||
let pbu_end = pos.checked_add(pbu_size).ok_or(Error::Truncated)?;
|
||||
let pbu = au.get(pos..pbu_end).ok_or(Error::Truncated)?;
|
||||
pos = pbu_end;
|
||||
|
||||
let pbu_type = pbu[0];
|
||||
let group_id = u16::from_be_bytes([pbu[1], pbu[2]]);
|
||||
let reserved_zero_8bits = pbu[3];
|
||||
if reserved_zero_8bits > 0 {
|
||||
// RFC 9924 §5.3.3: Decoders MUST ignore PBU with reserved_zero_8bits > 0.
|
||||
continue;
|
||||
}
|
||||
if group_id == 0xFFFF || (group_id == 0 && pbu_type <= 64) {
|
||||
return Err(Error::Invalid("invalid PBU group_id"));
|
||||
}
|
||||
pbus.push(PbuSlice { pbu_type, group_id, payload: &pbu[4..] });
|
||||
}
|
||||
Ok(pbus)
|
||||
}
|
||||
|
||||
/// Read `byte_alignment()` and verify all alignment bits are zero (RFC 9924 §5.3.17).
|
||||
pub fn read_byte_alignment(r: &mut BitReader) -> Result<()> {
|
||||
while !r.is_byte_aligned() {
|
||||
let bit = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
if bit {
|
||||
return Err(Error::Invalid("non-zero alignment bit"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse `frame_header()` from the payload of a frame PBU (after `pbu_header()`), per RFC 9924 §5.3.5–5.3.8.
|
||||
/// Returns `Ok(None)` if any `reserved_zero_*` field is non-zero (RFC 9924 §5.3.5 requires ignoring the PBU).
|
||||
pub fn parse_frame_header(payload: &[u8]) -> Result<Option<FrameHeader>> {
|
||||
let mut r = BitReader::new(payload);
|
||||
|
||||
// frame_info() (RFC 9924 §5.3.6)
|
||||
let profile_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let level_idc = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let band_idc = r.read_bits(3).map_err(|_| Error::Truncated)? as u8;
|
||||
let reserved_zero_5bits = r.read_bits(5).map_err(|_| Error::Truncated)? as u8;
|
||||
let width = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let height = r.read_bits(24).map_err(|_| Error::Truncated)?;
|
||||
let chroma_format_idc = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let bit_depth_minus8 = r.read_bits(4).map_err(|_| Error::Truncated)? as u8;
|
||||
let capture_time_distance = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let fi_reserved_8 = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
|
||||
// frame_header() (RFC 9924 §5.3.5)
|
||||
let fh_reserved_8_a = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if reserved_zero_5bits > 0 || fi_reserved_8 > 0 || fh_reserved_8_a > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION {
|
||||
return Err(Error::Invalid("unsupported frame dimensions"));
|
||||
}
|
||||
let chroma = ChromaFormat::from_idc(chroma_format_idc).ok_or(Error::Invalid("invalid chroma_format_idc"))?;
|
||||
if chroma == ChromaFormat::Yuv422 && !width.is_multiple_of(2) {
|
||||
return Err(Error::Invalid("4:2:2 frame_width must be even"));
|
||||
}
|
||||
if !(2..=8).contains(&bit_depth_minus8) {
|
||||
return Err(Error::Invalid("bit_depth_minus8 out of range 2..=8"));
|
||||
}
|
||||
let bit_depth = bit_depth_minus8 + 8;
|
||||
|
||||
let color_description_present = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let color = if color_description_present {
|
||||
let primaries = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let transfer = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let matrix = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
let full_range = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
ColorInfo { primaries, transfer, matrix, full_range }
|
||||
} else {
|
||||
ColorInfo::default()
|
||||
};
|
||||
|
||||
let use_q_matrix = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let mut q_matrix = [[DEFAULT_Q_MATRIX_VAL; 64]; 4];
|
||||
if use_q_matrix {
|
||||
for c in 0..chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
let q = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if q == 0 {
|
||||
return Err(Error::Invalid("q_matrix entry 0 is reserved"));
|
||||
}
|
||||
q_matrix[c][y * 8 + x] = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tile_info() (RFC 9924 §5.3.8)
|
||||
let tile_width_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
let tile_height_in_mbs = r.read_bits(20).map_err(|_| Error::Truncated)?;
|
||||
if tile_width_in_mbs == 0 || tile_height_in_mbs == 0 {
|
||||
return Err(Error::Invalid("tile dimensions in MBs must be non-zero"));
|
||||
}
|
||||
|
||||
let w_mbs = width.div_ceil(16);
|
||||
let h_mbs = height.div_ceil(16);
|
||||
let tile_cols = w_mbs.div_ceil(tile_width_in_mbs);
|
||||
let tile_rows = h_mbs.div_ceil(tile_height_in_mbs);
|
||||
if tile_cols == 0 || tile_rows == 0 || tile_cols > MAX_TILE_DIM || tile_rows > MAX_TILE_DIM {
|
||||
return Err(Error::Invalid("too many tiles in frame"));
|
||||
}
|
||||
|
||||
let mut col_starts = Vec::with_capacity(tile_cols as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < w_mbs {
|
||||
col_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_width_in_mbs);
|
||||
}
|
||||
col_starts.push(w_mbs * 16);
|
||||
|
||||
let mut row_starts = Vec::with_capacity(tile_rows as usize + 1);
|
||||
let mut start_mb = 0u32;
|
||||
while start_mb < h_mbs {
|
||||
row_starts.push(start_mb * 16);
|
||||
start_mb = start_mb.saturating_add(tile_height_in_mbs);
|
||||
}
|
||||
row_starts.push(h_mbs * 16);
|
||||
|
||||
let num_tiles = (tile_cols as usize) * (tile_rows as usize);
|
||||
let tile_size_present_in_fh = r.read_bit().map_err(|_| Error::Truncated)?;
|
||||
let tile_sizes_in_fh = if tile_size_present_in_fh {
|
||||
let mut sizes = Vec::with_capacity(num_tiles);
|
||||
for _ in 0..num_tiles {
|
||||
let sz = r.read_bits(32).map_err(|_| Error::Truncated)?;
|
||||
if sz == 0 {
|
||||
return Err(Error::Invalid("tile_size_in_fh cannot be 0"));
|
||||
}
|
||||
sizes.push(sz);
|
||||
}
|
||||
Some(sizes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let fh_reserved_8_b = r.read_bits(8).map_err(|_| Error::Truncated)? as u8;
|
||||
if fh_reserved_8_b > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
read_byte_alignment(&mut r)?;
|
||||
|
||||
Ok(Some(FrameHeader {
|
||||
profile_idc,
|
||||
level_idc,
|
||||
band_idc,
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
capture_time_distance,
|
||||
color_description_present,
|
||||
color,
|
||||
use_q_matrix,
|
||||
q_matrix,
|
||||
tile_width_in_mbs,
|
||||
tile_height_in_mbs,
|
||||
tile_cols,
|
||||
tile_rows,
|
||||
col_starts,
|
||||
row_starts,
|
||||
tile_sizes_in_fh,
|
||||
header_bytes: r.byte_pos(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Write `frame_header()` to a byte vector (RFC 9924 §5.3.5–5.3.8).
|
||||
pub fn write_frame_header(h: &FrameHeader) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
// frame_info()
|
||||
w.write_bits(h.profile_idc as u32, 8);
|
||||
w.write_bits(h.level_idc as u32, 8);
|
||||
w.write_bits((h.band_idc & 7) as u32, 3);
|
||||
w.write_bits(0, 5); // reserved_zero_5bits
|
||||
w.write_bits(h.width, 24);
|
||||
w.write_bits(h.height, 24);
|
||||
w.write_bits(h.chroma.idc() as u32, 4);
|
||||
w.write_bits(h.bit_depth.saturating_sub(8) as u32, 4);
|
||||
w.write_bits(h.capture_time_distance as u32, 8);
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
|
||||
// frame_header()
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.write_bit(h.color_description_present);
|
||||
if h.color_description_present {
|
||||
w.write_bits(h.color.primaries as u32, 8);
|
||||
w.write_bits(h.color.transfer as u32, 8);
|
||||
w.write_bits(h.color.matrix as u32, 8);
|
||||
w.write_bit(h.color.full_range);
|
||||
}
|
||||
w.write_bit(h.use_q_matrix);
|
||||
if h.use_q_matrix {
|
||||
for c in 0..h.chroma.num_comps() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
w.write_bits(h.q_matrix[c][y * 8 + x].max(1) as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// tile_info()
|
||||
w.write_bits(h.tile_width_in_mbs, 20);
|
||||
w.write_bits(h.tile_height_in_mbs, 20);
|
||||
if let Some(sizes) = &h.tile_sizes_in_fh {
|
||||
w.write_bit(true);
|
||||
for &sz in sizes {
|
||||
w.write_bits(sz, 32);
|
||||
}
|
||||
} else {
|
||||
w.write_bit(false);
|
||||
}
|
||||
w.write_bits(0, 8); // reserved_zero_8bits
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// Parse a `metadata()` PBU payload (after `pbu_header()`), extracting MDCV (`payloadType == 5`)
|
||||
/// and CLL (`payloadType == 6`) if present (RFC 9924 §5.3.10 & §8).
|
||||
pub fn parse_metadata_pbu(payload: &[u8], mdcv: &mut Option<MasteringDisplay>, cll: &mut Option<ContentLightLevel>) -> Result<()> {
|
||||
if payload.len() < 4 {
|
||||
return Err(Error::Truncated);
|
||||
}
|
||||
let metadata_size = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
|
||||
let body = payload.get(4..4usize.checked_add(metadata_size).ok_or(Error::Truncated)?).ok_or(Error::Truncated)?;
|
||||
let mut pos = 0usize;
|
||||
while pos < body.len() {
|
||||
let mut payload_type = 0u32;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_type = payload_type.checked_add(255).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_type = payload_type.checked_add(*body.get(pos).ok_or(Error::Truncated)? as u32).ok_or(Error::Invalid("metadata payloadType overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let mut payload_size = 0usize;
|
||||
while *body.get(pos).ok_or(Error::Truncated)? == 0xFF {
|
||||
payload_size = payload_size.checked_add(255).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
}
|
||||
payload_size = payload_size.checked_add(*body.get(pos).ok_or(Error::Truncated)? as usize).ok_or(Error::Invalid("metadata payloadSize overflow"))?;
|
||||
pos += 1;
|
||||
|
||||
let end = pos.checked_add(payload_size).ok_or(Error::Truncated)?;
|
||||
let item = body.get(pos..end).ok_or(Error::Truncated)?;
|
||||
pos = end;
|
||||
|
||||
match payload_type {
|
||||
5 if item.len() >= 24 => {
|
||||
let u16_at = |i: usize| u16::from_be_bytes([item[i], item[i + 1]]);
|
||||
let u32_at = |i: usize| u32::from_be_bytes([item[i], item[i + 1], item[i + 2], item[i + 3]]);
|
||||
*mdcv = Some(MasteringDisplay {
|
||||
primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))],
|
||||
white_point: (u16_at(12), u16_at(14)),
|
||||
max_luminance: u32_at(16),
|
||||
min_luminance: u32_at(20),
|
||||
});
|
||||
}
|
||||
6 if item.len() >= 4 => {
|
||||
*cll = Some(ContentLightLevel { max_cll: u16::from_be_bytes([item[0], item[1]]), max_fall: u16::from_be_bytes([item[2], item[3]]) });
|
||||
}
|
||||
// Filler metadata (§8.2.1): must be 0xFF bytes
|
||||
10 if item.iter().any(|&b| b != 0xFF) => {
|
||||
return Err(Error::Invalid("non-0xFF byte in filler metadata"));
|
||||
}
|
||||
_ => {
|
||||
// RFC 9924 §10: A decoder MUST NOT try to process metadata whose type is not recognized.
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a `metadata()` PBU (including its 4-byte `pbu_header`) carrying MDCV and/or CLL metadata.
|
||||
pub fn write_metadata_pbu(group_id: u16, mdcv: Option<&MasteringDisplay>, cll: Option<&ContentLightLevel>) -> Option<Vec<u8>> {
|
||||
if mdcv.is_none() && cll.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(m) = mdcv {
|
||||
body.push(5); // payloadType = 5 (MDCV)
|
||||
body.push(24); // payloadSize = 24
|
||||
for &(x, y) in &m.primaries {
|
||||
body.extend_from_slice(&x.to_be_bytes());
|
||||
body.extend_from_slice(&y.to_be_bytes());
|
||||
}
|
||||
body.extend_from_slice(&m.white_point.0.to_be_bytes());
|
||||
body.extend_from_slice(&m.white_point.1.to_be_bytes());
|
||||
body.extend_from_slice(&m.max_luminance.to_be_bytes());
|
||||
body.extend_from_slice(&m.min_luminance.to_be_bytes());
|
||||
}
|
||||
if let Some(c) = cll {
|
||||
body.push(6); // payloadType = 6 (CLL)
|
||||
body.push(4); // payloadSize = 4
|
||||
body.extend_from_slice(&c.max_cll.to_be_bytes());
|
||||
body.extend_from_slice(&c.max_fall.to_be_bytes());
|
||||
}
|
||||
let mut pbu = Vec::with_capacity(8 + body.len());
|
||||
pbu.push(PBU_METADATA);
|
||||
pbu.extend_from_slice(&group_id.to_be_bytes());
|
||||
pbu.push(0); // reserved_zero_8bits
|
||||
pbu.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pbu.extend_from_slice(&body);
|
||||
Some(pbu)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Clean-room Advanced Professional Video (APV) codec, implemented strictly from IETF RFC 9924
|
||||
//! (February 2026).
|
||||
//!
|
||||
//! - **Decoder** ([`decode_frame`], [`decode_frame_with`], [`decode_frame_into`], [`probe`]):
|
||||
//! every profile in RFC 9924 §9 (`422-10`, `422-12`, `444-10`, `444-12`, `4444-10`, `4444-12`,
|
||||
//! `400-10`) at 10–16 bits per sample, custom quantization matrices, multi-tile parallel decoding
|
||||
//! (behind the `threads` feature), 4:4:4:4 and auxiliary alpha PBU (`pbu_type == 27`) support,
|
||||
//! and HDR static metadata (`MDCV` and `CLL`, RFC 9924 §8).
|
||||
//! - **Encoder** ([`Encoder`]): progressive intra frame encoding across all 7 profiles with fixed-QP
|
||||
//! or frame-budget rate control, plus `APVDecoderConfigurationRecord` (`apvC`) generation.
|
||||
//!
|
||||
//! See the crate README for specification edition, accuracy against external oracles, and design notes.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod dct;
|
||||
mod decode;
|
||||
mod encode;
|
||||
pub mod header;
|
||||
pub mod tables;
|
||||
|
||||
pub use decode::{DecodeOptions, decode_frame, decode_frame_into, decode_frame_with, probe};
|
||||
pub use encode::{Encoder, EncoderConfig};
|
||||
pub use header::{AU_SIGNATURE, FrameHeader, split_raw_bitstream, unwrap_au};
|
||||
|
||||
/// APV codec errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("truncated APV bitstream")]
|
||||
Truncated,
|
||||
#[error("invalid APV bitstream: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("corrupt APV tile {0}")]
|
||||
CorruptTile(usize),
|
||||
#[error("invalid APV encoder input: {0}")]
|
||||
Input(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Chroma format (`chroma_format_idc` in RFC 9924 §4.1 Table 2).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChromaFormat {
|
||||
/// `chroma_format_idc == 0`: 4:0:0 monochrome (1 component).
|
||||
Monochrome,
|
||||
/// `chroma_format_idc == 2`: 4:2:2 YCbCr (3 components, `SubWidthC = 2, SubHeightC = 1`).
|
||||
Yuv422,
|
||||
/// `chroma_format_idc == 3`: 4:4:4 YCbCr (3 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv444,
|
||||
/// `chroma_format_idc == 4`: 4:4:4:4 YCbCrA (4 components, `SubWidthC = 1, SubHeightC = 1`).
|
||||
Yuv4444,
|
||||
}
|
||||
|
||||
impl ChromaFormat {
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
match idc {
|
||||
0 => Some(Self::Monochrome),
|
||||
2 => Some(Self::Yuv422),
|
||||
3 => Some(Self::Yuv444),
|
||||
4 => Some(Self::Yuv4444),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => 2,
|
||||
Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_comps(self) -> usize {
|
||||
match self {
|
||||
Self::Monochrome => 1,
|
||||
Self::Yuv422 | Self::Yuv444 => 3,
|
||||
Self::Yuv4444 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_width_c(self) -> u32 {
|
||||
match self {
|
||||
Self::Yuv422 => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sub_height_c(self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn chroma_width(self, width: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
Self::Yuv422 => width.div_ceil(2),
|
||||
Self::Yuv444 | Self::Yuv4444 => width,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_height(self, height: u32) -> u32 {
|
||||
match self {
|
||||
Self::Monochrome => 0,
|
||||
_ => height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_alpha(self) -> bool {
|
||||
self == Self::Yuv4444
|
||||
}
|
||||
}
|
||||
|
||||
/// APV profiles defined in RFC 9924 §9.3.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Profile {
|
||||
/// `profile_idc == 33`: 422-10 profile (4:2:2, 10-bit).
|
||||
#[default]
|
||||
P422_10,
|
||||
/// `profile_idc == 44`: 422-12 profile (4:2:2, 10–12-bit).
|
||||
P422_12,
|
||||
/// `profile_idc == 55`: 444-10 profile (4:2:2..4:4:4, 10-bit).
|
||||
P444_10,
|
||||
/// `profile_idc == 66`: 444-12 profile (4:2:2..4:4:4, 10–12-bit).
|
||||
P444_12,
|
||||
/// `profile_idc == 77`: 4444-10 profile (4:2:2..4:4:4:4, 10-bit).
|
||||
P4444_10,
|
||||
/// `profile_idc == 88`: 4444-12 profile (4:2:2..4:4:4:4, 10–12-bit).
|
||||
P4444_12,
|
||||
/// `profile_idc == 99`: 400-10 profile (4:0:0, 10-bit).
|
||||
P400_10,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub const ALL: [Profile; 7] =
|
||||
[Profile::P422_10, Profile::P422_12, Profile::P444_10, Profile::P444_12, Profile::P4444_10, Profile::P4444_12, Profile::P400_10];
|
||||
|
||||
pub fn idc(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_10 => 33,
|
||||
Self::P422_12 => 44,
|
||||
Self::P444_10 => 55,
|
||||
Self::P444_12 => 66,
|
||||
Self::P4444_10 => 77,
|
||||
Self::P4444_12 => 88,
|
||||
Self::P400_10 => 99,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_idc(idc: u8) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| p.idc() == idc)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "APV 422-10",
|
||||
Self::P422_12 => "APV 422-12",
|
||||
Self::P444_10 => "APV 444-10",
|
||||
Self::P444_12 => "APV 444-12",
|
||||
Self::P4444_10 => "APV 4444-10",
|
||||
Self::P4444_12 => "APV 4444-12",
|
||||
Self::P400_10 => "APV 400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::P422_10 => "422-10",
|
||||
Self::P422_12 => "422-12",
|
||||
Self::P444_10 => "444-10",
|
||||
Self::P444_12 => "444-12",
|
||||
Self::P4444_10 => "4444-10",
|
||||
Self::P4444_12 => "4444-12",
|
||||
Self::P400_10 => "400-10",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_chroma(self) -> ChromaFormat {
|
||||
match self {
|
||||
Self::P422_10 | Self::P422_12 => ChromaFormat::Yuv422,
|
||||
Self::P444_10 | Self::P444_12 => ChromaFormat::Yuv444,
|
||||
Self::P4444_10 | Self::P4444_12 => ChromaFormat::Yuv4444,
|
||||
Self::P400_10 => ChromaFormat::Monochrome,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_bit_depth(self) -> u8 {
|
||||
match self {
|
||||
Self::P422_12 | Self::P444_12 | Self::P4444_12 => 12,
|
||||
_ => 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether `(chroma, bit_depth)` conforms to this profile per RFC 9924 §9.3.
|
||||
pub fn supports(self, chroma: ChromaFormat, bit_depth: u8) -> bool {
|
||||
let c = chroma.idc();
|
||||
let b = bit_depth.saturating_sub(8);
|
||||
match self {
|
||||
Self::P422_10 => c == 2 && b == 2,
|
||||
Self::P422_12 => c == 2 && (2..=4).contains(&b),
|
||||
Self::P444_10 => (2..=3).contains(&c) && b == 2,
|
||||
Self::P444_12 => (2..=3).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P4444_10 => (2..=4).contains(&c) && b == 2,
|
||||
Self::P4444_12 => (2..=4).contains(&c) && (2..=4).contains(&b),
|
||||
Self::P400_10 => c == 0 && b == 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nominal target bytes per frame for `(width, height)`.
|
||||
pub fn nominal_frame_bytes(self, width: u32, height: u32) -> u32 {
|
||||
let bpp = match self {
|
||||
Self::P400_10 => 1.5,
|
||||
Self::P422_10 => 3.0,
|
||||
Self::P422_12 => 3.8,
|
||||
Self::P444_10 => 4.2,
|
||||
Self::P444_12 => 5.0,
|
||||
Self::P4444_10 => 5.2,
|
||||
Self::P4444_12 => 6.2,
|
||||
};
|
||||
let bytes = ((width as f64) * (height as f64) * bpp / 8.0).round() as u32;
|
||||
bytes.max(512)
|
||||
}
|
||||
|
||||
/// Nominal data rate in Mbit/s at 1920x1080 29.97 fps.
|
||||
pub fn nominal_mbps_1080p30(self) -> f64 {
|
||||
(self.nominal_frame_bytes(1920, 1080) as f64) * 8.0 * 29.97 / 1e6
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable profile name from `profile_idc` (e.g. `"APV 422-10"`).
|
||||
pub fn profile_name(profile_idc: u8) -> String {
|
||||
Profile::from_idc(profile_idc).map(|p| p.name().to_string()).unwrap_or_else(|| format!("APV (profile {profile_idc})"))
|
||||
}
|
||||
|
||||
/// Colour description from `frame_header()` (ITU-T H.273 code points, RFC 9924 §5.3.5).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ColorInfo {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
pub full_range: bool,
|
||||
}
|
||||
|
||||
impl Default for ColorInfo {
|
||||
/// Inferred values when `color_description_present_flag == 0` (RFC 9924 §5.3.5: 2, 2, 2, 0).
|
||||
fn default() -> Self {
|
||||
Self { primaries: 2, transfer: 2, matrix: 2, full_range: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
pub const BT709: Self = Self { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
pub const BT2020_PQ: Self = Self { primaries: 9, transfer: 16, matrix: 9, full_range: false };
|
||||
pub const BT2020_HLG: Self = Self { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
}
|
||||
|
||||
/// Mastering Display Color Volume metadata (`payloadType == 5`, RFC 9924 §8.2.3).
|
||||
///
|
||||
/// Chromaticities are in 0.16 fixed-point (1/65536), order `[R, G, B]`;
|
||||
/// `max_luminance` is 24.8 fixed-point cd/m²; `min_luminance` is 18.14 fixed-point cd/m².
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MasteringDisplay {
|
||||
pub primaries: [(u16, u16); 3],
|
||||
pub white_point: (u16, u16),
|
||||
pub max_luminance: u32,
|
||||
pub min_luminance: u32,
|
||||
}
|
||||
|
||||
/// Content Light-Level Information metadata (`payloadType == 6`, RFC 9924 §8.2.4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ContentLightLevel {
|
||||
pub max_cll: u16,
|
||||
pub max_fall: u16,
|
||||
}
|
||||
|
||||
/// A decoded (or to-be-encoded) APV frame: planar `u16` samples, rows packed without padding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub chroma: ChromaFormat,
|
||||
/// Significant bits per sample (`10..=16`, or `8..=16` when rescaled via [`DecodeOptions`]).
|
||||
pub bit_depth: u8,
|
||||
pub y: Vec<u16>,
|
||||
pub cb: Vec<u16>,
|
||||
pub cr: Vec<u16>,
|
||||
pub alpha: Option<Vec<u16>>,
|
||||
pub color: ColorInfo,
|
||||
pub profile_idc: u8,
|
||||
pub level_idc: u8,
|
||||
pub band_idc: u8,
|
||||
pub mastering_display: Option<MasteringDisplay>,
|
||||
pub content_light: Option<ContentLightLevel>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Allocate a black video-range frame.
|
||||
pub fn new(width: u32, height: u32, chroma: ChromaFormat, bit_depth: u8, with_alpha: bool) -> Self {
|
||||
let n = (width as usize) * (height as usize);
|
||||
let cw = chroma.chroma_width(width) as usize;
|
||||
let ch = chroma.chroma_height(height) as usize;
|
||||
let cn = cw * ch;
|
||||
let shift = bit_depth.saturating_sub(8) as u32;
|
||||
let has_alpha = with_alpha || chroma == ChromaFormat::Yuv4444;
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
chroma,
|
||||
bit_depth,
|
||||
y: vec![(16u32 << shift) as u16; n],
|
||||
cb: vec![(128u32 << shift) as u16; cn],
|
||||
cr: vec![(128u32 << shift) as u16; cn],
|
||||
alpha: has_alpha.then(|| vec![((1u32 << bit_depth) - 1) as u16; n]),
|
||||
color: ColorInfo::BT709,
|
||||
profile_idc: Profile::P422_10.idc(),
|
||||
level_idc: 90,
|
||||
band_idc: 2,
|
||||
mastering_display: None,
|
||||
content_light: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chroma_width(&self) -> u32 {
|
||||
self.chroma.chroma_width(self.width)
|
||||
}
|
||||
|
||||
pub fn chroma_height(&self) -> u32 {
|
||||
self.chroma.chroma_height(self.height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! RFC 9924 tables: zig-zag scan, 8x8 transform matrix, quantization level scales, and level limits.
|
||||
|
||||
/// 8x8 zig-zag scan order (`ScanOrder[sPos] = yC * 8 + xC`), derived from RFC 9924 §4.4.1 Figure 5.
|
||||
pub const ZIGZAG_8X8: [u8; 64] = [
|
||||
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29,
|
||||
22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
|
||||
];
|
||||
|
||||
/// 8x8 transform matrix (`transMatrix` in RFC 9924 §6.3.2.3 Figure 25).
|
||||
pub const TRANS_MATRIX: [[i32; 8]; 8] = [
|
||||
[64, 64, 64, 64, 64, 64, 64, 64],
|
||||
[89, 75, 50, 18, -18, -50, -75, -89],
|
||||
[84, 35, -35, -84, -84, -35, 35, 84],
|
||||
[75, -18, -89, -50, 50, 89, 18, -75],
|
||||
[64, -64, -64, 64, 64, -64, -64, 64],
|
||||
[50, -89, 18, 75, -75, -18, 89, -50],
|
||||
[35, -84, 84, -35, -35, 84, -84, 35],
|
||||
[18, -50, 75, -89, 89, -75, 50, -18],
|
||||
];
|
||||
|
||||
/// Quantization scaling list `levelScale[k]` for `k = qP % 6` (RFC 9924 §6.3.1).
|
||||
pub const LEVEL_SCALE: [i32; 6] = [40, 45, 51, 57, 64, 71];
|
||||
|
||||
/// Default flat quantization matrix value when `use_q_matrix == 0` (RFC 9924 §5.3.7).
|
||||
pub const DEFAULT_Q_MATRIX_VAL: u8 = 16;
|
||||
|
||||
/// General level limits (`level_idc`, max luma sample rate, and max coded data rate in Mbit/s per band)
|
||||
/// from RFC 9924 §9.4.2 Table 4.
|
||||
pub const LEVEL_LIMITS: [(u8, u64, [u32; 4]); 14] = [
|
||||
(30, 3_041_280, [8, 11, 15, 23]),
|
||||
(33, 6_082_560, [16, 21, 30, 45]),
|
||||
(60, 15_667_200, [39, 54, 76, 114]),
|
||||
(63, 31_334_400, [78, 108, 152, 227]),
|
||||
(90, 66_846_720, [114, 159, 222, 333]),
|
||||
(93, 133_693_440, [227, 317, 444, 666]),
|
||||
(120, 265_420_800, [455, 637, 892, 1_338]),
|
||||
(123, 530_841_600, [910, 1_274, 1_784, 2_675]),
|
||||
(150, 1_061_683_200, [1_820, 2_548, 3_567, 5_350]),
|
||||
(153, 2_123_366_400, [3_639, 5_095, 7_133, 10_699]),
|
||||
(180, 4_777_574_400, [7_278, 10_189, 14_265, 21_397]),
|
||||
(183, 8_493_465_600, [14_556, 20_378, 28_529, 42_793]),
|
||||
(210, 16_986_931_200, [29_111, 40_756, 57_058, 85_586]),
|
||||
(213, 33_973_862_400, [58_222, 81_511, 114_115, 171_172]),
|
||||
];
|
||||
|
||||
/// Choose the lowest conforming `(level_idc, band_idc)` for a luma sample rate and bitrate (in kbps).
|
||||
pub fn select_level_and_band(luma_samples_per_sec: u64, kbps: u32) -> (u8, u8) {
|
||||
let mbps = kbps.div_ceil(1000);
|
||||
for &(level_idc, max_samples, bands) in &LEVEL_LIMITS {
|
||||
if luma_samples_per_sec <= max_samples {
|
||||
for (band_idc, &max_mbps) in bands.iter().enumerate() {
|
||||
if mbps <= max_mbps {
|
||||
return (level_idc, band_idc as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(213, 3)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Robustness: mutated, truncated, spliced, and random bitstreams must never panic (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_apv::*;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (p, w, h) in
|
||||
[(Profile::P422_10, 64u32, 48u32), (Profile::P422_12, 70, 38), (Profile::P444_10, 48, 32), (Profile::P4444_10, 64, 32), (Profile::P400_10, 32, 32)]
|
||||
{
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for (i, s) in a.iter_mut().enumerate() {
|
||||
*s = ((i * 19) % 1024) as u16;
|
||||
}
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let mut enc = Encoder::with_config(cfg).expect("seed encoder");
|
||||
v.push(enc.encode(&f).expect("seed encode"));
|
||||
v.push(enc.encode_raw_au(&f).expect("seed encode_raw_au"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_streams_never_panic() {
|
||||
let seeds = seeds();
|
||||
for s in &seeds {
|
||||
decode_frame(s).expect("seed decodes");
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||
let mut decoded_ok = 0usize;
|
||||
let iters = 3000usize;
|
||||
|
||||
for i in 0..iters {
|
||||
let mut d = seeds[i % seeds.len()].clone();
|
||||
match rng.below(6) {
|
||||
0 => {
|
||||
// Bit flips
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Random byte overwrites
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
let p = rng.below(d.len());
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Truncation
|
||||
let n = rng.below(d.len());
|
||||
d.truncate(n);
|
||||
}
|
||||
3 => {
|
||||
// Corrupt header fields
|
||||
if d.len() > 8 {
|
||||
let p = 4 + rng.below(24.min(d.len() - 4));
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
// Corrupt tile payload region
|
||||
let start = 24.min(d.len().saturating_sub(1));
|
||||
let off = start + rng.below(d.len().saturating_sub(start).max(1));
|
||||
for b in d.iter_mut().skip(off).take(1 + rng.below(48)) {
|
||||
*b = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Splice random bytes
|
||||
let p = rng.below(d.len());
|
||||
let extra: Vec<u8> = (0..1 + rng.below(16)).map(|_| rng.next() as u8).collect();
|
||||
d.splice(p..p, extra);
|
||||
}
|
||||
}
|
||||
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let opts = DecodeOptions { bit_depth: depth, threads: i % 2 == 0 };
|
||||
if let Ok(f) = decode_frame_with(&d, &opts) {
|
||||
decoded_ok += 1;
|
||||
assert_eq!(f.y.len(), (f.width as usize) * (f.height as usize));
|
||||
}
|
||||
}
|
||||
let _ = probe(&d);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on mutated stream iteration {i}");
|
||||
}
|
||||
println!("{decoded_ok} of {} mutated decodes succeeded", iters * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic() {
|
||||
let mut rng = Rng(123456789);
|
||||
for i in 0..2000 {
|
||||
let n = rng.below(400);
|
||||
let mut d: Vec<u8> = (0..n).map(|_| rng.next() as u8).collect();
|
||||
if d.len() >= 8 && rng.below(2) == 0 {
|
||||
d[..4].copy_from_slice(b"aPv1");
|
||||
} else if d.len() >= 12 && rng.below(2) == 0 {
|
||||
let au_sz = (n - 4) as u32;
|
||||
d[..4].copy_from_slice(&au_sz.to_be_bytes());
|
||||
d[4..8].copy_from_slice(b"aPv1");
|
||||
}
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
let _ = decode_frame(&d);
|
||||
let _ = probe(&d);
|
||||
let _ = split_raw_bitstream(&d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic on random bytes iteration {i}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Conformance, bit-exactness, and quality tests against ffmpeg's APV decoder (external oracle).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use filmcraft_apv::{
|
||||
ChromaFormat, ContentLightLevel, DecodeOptions, Encoder, EncoderConfig, Frame, MasteringDisplay, Profile, decode_frame, decode_frame_with, probe,
|
||||
};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
filmcraft_testkit::fixtures_dir("apv")
|
||||
}
|
||||
|
||||
fn native_pix_fmt(profile: Profile) -> &'static str {
|
||||
match profile {
|
||||
Profile::P422_10 => "yuv422p10le",
|
||||
Profile::P422_12 => "yuv422p12le",
|
||||
Profile::P444_10 => "yuv444p10le",
|
||||
Profile::P444_12 => "yuv444p12le",
|
||||
Profile::P4444_10 => "yuva444p10le",
|
||||
Profile::P4444_12 => "yuva444p12le",
|
||||
Profile::P400_10 => "gray10le",
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a raw `.apv` file with ffmpeg (`-v error -xerror`) to planar `u16` samples.
|
||||
fn ffmpeg_decode_raw(ff: &Path, apv_path: &Path, pix_fmt: &str) -> (Vec<u16>, String) {
|
||||
let out = Command::new(ff)
|
||||
.args(["-hide_banner", "-v", "error", "-xerror", "-f", "apv", "-i"])
|
||||
.arg(apv_path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
assert!(out.status.success(), "ffmpeg failed on {:?}: {}", apv_path, stderr);
|
||||
let samples = out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
|
||||
(samples, stderr)
|
||||
}
|
||||
|
||||
fn synthetic_frame(profile: Profile, w: u32, h: u32) -> Frame {
|
||||
let chroma = profile.default_chroma();
|
||||
let depth = profile.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
let max_v = (1u32 << depth) - 1;
|
||||
let lo = 16u32 << (depth - 8);
|
||||
let hi = 235u32 << (depth - 8);
|
||||
let span = hi - lo;
|
||||
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Mix smooth gradients, diagonal edges, and high-frequency patterns
|
||||
let grad = (x * 7 + y * 13) % span;
|
||||
let checker = if ((x / 3) ^ (y / 5)) & 1 == 0 { 40 << (depth - 10) } else { 0 };
|
||||
f.y[(y * w + x) as usize] = (lo + (grad + checker) % span).min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
let cw = f.chroma_width();
|
||||
let ch = f.chroma_height();
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let u = lo + ((x * 11 + y * 5 + 100) % span);
|
||||
let v = lo + ((x * 3 + y * 17 + 300) % span);
|
||||
f.cb[(y * cw + x) as usize] = u.min(max_v) as u16;
|
||||
f.cr[(y * cw + x) as usize] = v.min(max_v) as u16;
|
||||
}
|
||||
}
|
||||
if let Some(a) = f.alpha.as_mut() {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let dx = x as i32 - (w as i32) / 2;
|
||||
let dy = y as i32 - (h as i32) / 2;
|
||||
a[(y * w + x) as usize] =
|
||||
if dx * dx + dy * dy < ((w.min(h) as i32) / 3).pow(2) { max_v as u16 } else { ((x * 19 + y * 23) % (max_v + 1)) as u16 };
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn flatten_frame(f: &Frame) -> Vec<u16> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&f.y);
|
||||
v.extend_from_slice(&f.cb);
|
||||
v.extend_from_slice(&f.cr);
|
||||
if let Some(a) = &f.alpha {
|
||||
v.extend_from_slice(a);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn psnr(a: &[u16], b: &[u16], peak: f64) -> f64 {
|
||||
if a.is_empty() {
|
||||
return 99.0;
|
||||
}
|
||||
let mse: f64 = a.iter().zip(b).map(|(&x, &y)| (x as f64 - y as f64).powi(2)).sum::<f64>() / a.len() as f64;
|
||||
if mse == 0.0 { 99.0 } else { 10.0 * (peak * peak / mse).log10() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for profile in Profile::ALL {
|
||||
let (w, h) = (320u32, 192u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(12);
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode_raw_au");
|
||||
let apv_path = dir.join(format!("oracle_{}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?}: ffmpeg stderr: {stderr}");
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("our decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples.len(), ff_samples.len(), "{profile:?}: sample count mismatch");
|
||||
|
||||
let max_diff = our_samples.iter().zip(&ff_samples).map(|(&a, &b)| (a as i32 - b as i32).unsigned_abs()).max().unwrap_or(0);
|
||||
assert_eq!(max_diff, 0, "{profile:?}: our decoder must be 100% bit-exact with ffmpeg (max_diff={max_diff})");
|
||||
|
||||
let peak = ((1u32 << profile.default_bit_depth()) - 1) as f64;
|
||||
let y_psnr = psnr(&ours.y, &src.y, peak);
|
||||
assert!(y_psnr > 45.0, "{profile:?}: expected high PSNR at qp=12, got {y_psnr:.2} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_dimensions_and_multi_tile_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
for (profile, w, h) in [
|
||||
(Profile::P422_10, 640u32, 360u32),
|
||||
(Profile::P422_10, 334, 202),
|
||||
(Profile::P444_10, 333, 201),
|
||||
(Profile::P4444_12, 512, 288),
|
||||
(Profile::P400_10, 70, 38),
|
||||
] {
|
||||
let mut src = synthetic_frame(profile, w, h);
|
||||
src.mastering_display = Some(MasteringDisplay {
|
||||
primaries: [(46400, 19136), (11141, 52232), (8585, 3015)],
|
||||
white_point: (20493, 21561),
|
||||
max_luminance: 1000 << 8,
|
||||
min_luminance: 1,
|
||||
});
|
||||
src.content_light = Some(ContentLightLevel { max_cll: 1000, max_fall: 400 });
|
||||
|
||||
let mut enc = Encoder::new(profile, w, h).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join(format!("oracle_geom_{}_{w}x{h}.apv", profile.short_name()));
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "{profile:?} {w}x{h}: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert_eq!((hdr.width, hdr.height), (w, h));
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
assert_eq!(ours.mastering_display, src.mastering_display);
|
||||
assert_eq!(ours.content_light, src.content_light);
|
||||
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "{profile:?} {w}x{h}: bit-exact match with ffmpeg failed");
|
||||
|
||||
// Also verify single-threaded decode produces identical output
|
||||
let ours_st = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: None, threads: false }).expect("decode single-threaded");
|
||||
assert_eq!(ours, ours_st);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_q_matrix_and_fh_tile_sizes_bit_exact_with_ffmpeg() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let dir = fixture_dir();
|
||||
|
||||
let profile = Profile::P444_12;
|
||||
let (w, h) = (512u32, 256u32);
|
||||
let src = synthetic_frame(profile, w, h);
|
||||
|
||||
let mut q_matrix = [[16u8; 64]; 4];
|
||||
for (c, qm) in q_matrix.iter_mut().enumerate() {
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
// Intentionally asymmetric (x != y weight) to verify exact (x, y) matrix orientation
|
||||
qm[y * 8 + x] = (10 + c * 3 + y * 5 + x * 2) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cfg = EncoderConfig::new(profile, w, h);
|
||||
cfg.qp = Some(18);
|
||||
cfg.q_matrix = Some(q_matrix);
|
||||
cfg.write_tile_sizes_in_fh = true;
|
||||
cfg.tile_width_in_mbs = 16;
|
||||
cfg.tile_height_in_mbs = 8;
|
||||
|
||||
let mut enc = Encoder::with_config(cfg).expect("encoder init");
|
||||
let raw_au = enc.encode_raw_au(&src).expect("encode");
|
||||
let apv_path = dir.join("oracle_custom_qmat_444-12.apv");
|
||||
std::fs::write(&apv_path, &raw_au).expect("write .apv");
|
||||
|
||||
let (ff_samples, stderr) = ffmpeg_decode_raw(&ff, &apv_path, native_pix_fmt(profile));
|
||||
assert!(stderr.trim().is_empty(), "ffmpeg stderr: {stderr}");
|
||||
|
||||
let hdr = probe(&raw_au).expect("probe");
|
||||
assert!(hdr.use_q_matrix);
|
||||
assert!(hdr.tile_sizes_in_fh.is_some());
|
||||
assert_eq!(hdr.num_tiles(), 4);
|
||||
|
||||
let ours = decode_frame(&raw_au).expect("decode");
|
||||
let our_samples = flatten_frame(&ours);
|
||||
assert_eq!(our_samples, ff_samples, "asymmetric q_matrix decode must be bit-exact with ffmpeg");
|
||||
|
||||
// Verify 8-bit and 16-bit rescaled output
|
||||
let d8 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(8), threads: true }).expect("decode 8-bit");
|
||||
assert_eq!(d8.bit_depth, 8);
|
||||
assert!(d8.y.iter().all(|&s| s <= 255));
|
||||
|
||||
let d16 = decode_frame_with(&raw_au, &DecodeOptions { bit_depth: Some(16), threads: true }).expect("decode 16-bit");
|
||||
assert_eq!(d16.bit_depth, 16);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! engine and UI can build controls generically.
|
||||
//! * [`biquad`] — RBJ-cookbook biquads (TDF-II) with analytic magnitude response.
|
||||
//! * [`sync`] — offset between two recordings of one event (GCC-PHAT, sample-accurate).
|
||||
//! * [`resample`] — streaming, block-invariant rate conversion for playback on a device at another rate.
|
||||
//! * [`channels`] — channel layouts, ITU-R BS.775 up/downmix, 5.1 mixdown types, the 5.1 panner.
|
||||
//!
|
||||
//! Conventions: audio is **planar f32** (`&mut [&mut [f32]]`, one slice per channel, all the same
|
||||
@@ -27,6 +28,7 @@ pub mod fft;
|
||||
pub mod loudness;
|
||||
pub mod oversample;
|
||||
pub mod remix;
|
||||
pub mod resample;
|
||||
mod smooth;
|
||||
pub mod sync;
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Streaming sample-rate conversion for playback, when the output device runs at another rate
|
||||
//! than the sequence (a 44.1 kHz device playing a 48 kHz sequence).
|
||||
//!
|
||||
//! Every output sample's source position comes from its absolute output frame, as an exact
|
||||
//! rational (`frame × from / to`), and is linearly interpolated, so the output is identical however
|
||||
//! the stream is cut into blocks: no phase jump at block boundaries. Source frames are pulled in
|
||||
//! contiguous, non-overlapping spans, so whatever produces them (the mixer, with its effect state)
|
||||
//! sees one continuous stream. Output lags the source by one source sample (≈21 µs at 48 kHz),
|
||||
//! which is what lets each block interpolate without reading ahead.
|
||||
|
||||
/// Converts a planar stream from `from` Hz to `to` Hz, block by block.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamResampler {
|
||||
from: u32,
|
||||
to: u32,
|
||||
/// Source frames `[held_start, held_start + held[c].len())` kept from the last pull.
|
||||
held_start: i64,
|
||||
held: Vec<Vec<f32>>,
|
||||
/// The output frame the next block is expected to start at (contiguous playback).
|
||||
next_out: Option<i64>,
|
||||
}
|
||||
|
||||
impl StreamResampler {
|
||||
/// Rates of zero are treated as 1 Hz rather than dividing by zero.
|
||||
pub fn new(from: u32, to: u32) -> Self {
|
||||
Self { from: from.max(1), to: to.max(1), held_start: 0, held: Vec::new(), next_out: None }
|
||||
}
|
||||
|
||||
pub fn rates(&self) -> (u32, u32) {
|
||||
(self.from, self.to)
|
||||
}
|
||||
|
||||
/// The source frame whose sample lands at (or just before) output frame `out`.
|
||||
fn source_index(&self, out: i64) -> (i64, f32) {
|
||||
let p = out as i128 * self.from as i128;
|
||||
let to = self.to as i128;
|
||||
(p.div_euclid(to) as i64, (p.rem_euclid(to) as f64 / to as f64) as f32)
|
||||
}
|
||||
|
||||
/// Output frames `[out, out + n)`, planar. `pull(start, frames)` returns source frames
|
||||
/// `[start, start + frames)`, planar (missing samples read as silence). A block that does not
|
||||
/// continue the previous one (a seek) starts the stream afresh.
|
||||
pub fn process(&mut self, out: i64, n: usize, mut pull: impl FnMut(i64, usize) -> Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
if n == 0 {
|
||||
return vec![Vec::new(); self.held.len().max(1)];
|
||||
}
|
||||
let (first, _) = self.source_index(out);
|
||||
let (last, _) = self.source_index(out.saturating_add(n as i64 - 1));
|
||||
// interpolation reads source frames first - 1 ..= last
|
||||
if self.next_out != Some(out) || self.held.is_empty() {
|
||||
self.held_start = first - 1;
|
||||
self.held.clear();
|
||||
}
|
||||
let held_end = self.held_start + self.held.first().map_or(0, Vec::len) as i64;
|
||||
if last + 1 > held_end {
|
||||
let want = (last + 1 - held_end) as usize;
|
||||
let got = pull(held_end, want);
|
||||
if self.held.is_empty() {
|
||||
self.held = vec![Vec::new(); got.len().max(1)];
|
||||
}
|
||||
for (c, h) in self.held.iter_mut().enumerate() {
|
||||
let src = got.get(c).map(Vec::as_slice).unwrap_or_default();
|
||||
h.extend((0..want).map(|i| src.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
}
|
||||
let mut dst = vec![vec![0.0f32; n]; self.held.len()];
|
||||
for (i, frame) in (out..).take(n).enumerate() {
|
||||
let (s, frac) = self.source_index(frame);
|
||||
// held starts at first - 1, so the earlier frame is always held
|
||||
let k = usize::try_from(s - self.held_start).unwrap_or(0);
|
||||
for (d, h) in dst.iter_mut().zip(&self.held) {
|
||||
let a = k.checked_sub(1).and_then(|j| h.get(j)).copied().unwrap_or(0.0);
|
||||
let b = h.get(k).copied().unwrap_or(0.0);
|
||||
if let Some(x) = d.get_mut(i) {
|
||||
*x = a + (b - a) * frac;
|
||||
}
|
||||
}
|
||||
}
|
||||
// keep what the next block can still read: from its first frame - 1 on
|
||||
let (next_first, _) = self.source_index(out.saturating_add(n as i64));
|
||||
let drop = (next_first - 1 - self.held_start).clamp(0, self.held.first().map_or(0, Vec::len) as i64) as usize;
|
||||
for h in &mut self.held {
|
||||
h.drain(..drop);
|
||||
}
|
||||
self.held_start += drop as i64;
|
||||
self.next_out = Some(out.saturating_add(n as i64));
|
||||
dst
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A sine at `hz`, sampled at `rate`, from absolute frame `start`; logs every pull.
|
||||
fn sine(hz: f64, rate: u32, pulls: &mut Vec<(i64, usize)>) -> impl FnMut(i64, usize) -> Vec<Vec<f32>> + '_ {
|
||||
move |start, frames| {
|
||||
pulls.push((start, frames));
|
||||
let ch: Vec<f32> = (0..frames).map(|i| (2.0 * std::f64::consts::PI * hz * (start + i as i64) as f64 / rate as f64).sin() as f32).collect();
|
||||
vec![ch.clone(), ch]
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(from: u32, to: u32, blocks: &[usize], total: usize) -> (Vec<f32>, Vec<(i64, usize)>) {
|
||||
let mut r = StreamResampler::new(from, to);
|
||||
let mut pulls = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0i64;
|
||||
let mut k = 0;
|
||||
while (pos as usize) < total {
|
||||
let n = blocks[k % blocks.len()].min(total - pos as usize);
|
||||
let b = r.process(pos, n, sine(1000.0, from, &mut pulls));
|
||||
assert_eq!(b.len(), 2);
|
||||
assert!(b.iter().all(|c| c.len() == n));
|
||||
out.extend_from_slice(&b[0]);
|
||||
pos += n as i64;
|
||||
k += 1;
|
||||
}
|
||||
(out, pulls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_the_same_however_the_stream_is_cut() {
|
||||
for (from, to) in [(48_000, 44_100), (44_100, 48_000), (48_000, 96_000), (96_000, 48_000)] {
|
||||
let (whole, _) = convert(from, to, &[20_000], 20_000);
|
||||
for blocks in [&[512][..], &[333], &[1, 7, 512, 64]] {
|
||||
let (cut, _) = convert(from, to, blocks, 20_000);
|
||||
assert_eq!(cut, whole, "{from}→{to} in blocks {blocks:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tone_converts_to_the_same_tone() {
|
||||
// 1 kHz at 48 kHz played on a 44.1 kHz device: within linear interpolation's error of the
|
||||
// ideal tone, one source sample late
|
||||
let (from, to) = (48_000u32, 44_100u32);
|
||||
let (out, _) = convert(from, to, &[512], 20_000);
|
||||
for (o, x) in out.iter().enumerate().skip(2) {
|
||||
let t = o as f64 / to as f64 - 1.0 / from as f64;
|
||||
let want = (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32;
|
||||
assert!((x - want).abs() < 3e-3, "frame {o}: {x} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_is_pulled_as_one_continuous_stream() {
|
||||
for (from, to) in [(48_000, 44_100), (44_100, 48_000)] {
|
||||
let (_, pulls) = convert(from, to, &[512, 333], 20_000);
|
||||
for w in pulls.windows(2) {
|
||||
assert_eq!(w[0].0 + w[0].1 as i64, w[1].0, "{from}→{to}: {:?} then {:?}", w[0], w[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seek_starts_afresh_and_hostile_input_stays_finite() {
|
||||
let mut r = StreamResampler::new(48_000, 44_100);
|
||||
let mut pulls = Vec::new();
|
||||
let _ = r.process(0, 512, sine(1000.0, 48_000, &mut pulls));
|
||||
let _ = r.process(1_000_000, 512, sine(1000.0, 48_000, &mut pulls));
|
||||
assert_eq!(pulls.last().map(|p| p.0), Some(1_000_000 * 48_000 / 44_100 - 1));
|
||||
// a mixer returning too few channels and samples, and zero rates
|
||||
let mut r = StreamResampler::new(0, 0);
|
||||
let b = r.process(5, 64, |_, _| vec![vec![1.0; 3]]);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert!(b[0].iter().all(|x| x.is_finite()));
|
||||
assert!(r.process(0, 0, |_, _| Vec::new()).iter().all(Vec::is_empty));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::AutomationError;
|
||||
|
||||
/// How often a blocking export's app job is polled.
|
||||
const JOB_POLL: Duration = Duration::from_millis(250);
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
@@ -29,6 +32,40 @@ impl BridgeClient {
|
||||
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
/// `wait: true`, see [`crate::long_job::LONG_COMMANDS`]) is started as an app job and polled
|
||||
/// here until it finishes, so the app keeps repainting (its status bar shows the progress) and
|
||||
/// answering other requests, and no request waits on the control server's 60 s reply limit
|
||||
/// (#91, #92). The reply is the same as a blocking export's: `{job, path, result}`, or the
|
||||
/// export's error.
|
||||
pub async fn execute(&self, id: &str, mut params: Value) -> Result<Value, AutomationError> {
|
||||
if !crate::long_job::is_long(id, ¶ms) {
|
||||
return self.call("engine.execute", json!({"command": id, "params": params})).await;
|
||||
}
|
||||
if let Some(p) = params.as_object_mut() {
|
||||
p.insert("wait".into(), json!(false));
|
||||
}
|
||||
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
|
||||
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
|
||||
let result = loop {
|
||||
tokio::time::sleep(JOB_POLL).await;
|
||||
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
|
||||
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
|
||||
break Value::Null;
|
||||
};
|
||||
if j["finished"].as_bool() == Some(true) {
|
||||
break j["result"].clone();
|
||||
}
|
||||
};
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return Err(AutomationError::App(format!("export failed: {e}")));
|
||||
}
|
||||
if let Some(o) = start.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
}
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Call a control method; returns `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
@@ -74,3 +111,77 @@ impl BridgeClient {
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
|
||||
/// running twice and then finished with `result`. Every request is recorded.
|
||||
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let (r, mut w) = s.into_split();
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
let mut polls = 0;
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
log.lock().await.push(req.clone());
|
||||
let reply = match req["params"]["command"].as_str() {
|
||||
Some("file.exportMedia") => json!({"job": 7, "path": "/tmp/out.mov"}),
|
||||
Some("jobs.list") => {
|
||||
polls += 1;
|
||||
let finished = polls >= 3;
|
||||
json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}])
|
||||
}
|
||||
_ => json!(null),
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": reply}));
|
||||
w.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
/// A blocking bridge export runs as an app job (#91, #92): the app never sees `wait: true` (it
|
||||
/// would encode on its UI thread and the reply would time out after 60 s), and the caller still
|
||||
/// gets the finished result.
|
||||
#[tokio::test]
|
||||
async fn a_blocking_export_is_polled_as_a_job() {
|
||||
let (addr, seen) = fake_app(json!({"frames": 642})).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
|
||||
assert_eq!(r, json!({"job": 7, "path": "/tmp/out.mov", "result": {"frames": 642}}));
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen[0]["params"]["params"]["wait"], json!(false));
|
||||
assert!(seen.iter().all(|q| q["params"]["params"]["wait"] != json!(true)));
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "file.exportMedia").count(), 1, "started once");
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_blocking_export_is_an_error() {
|
||||
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let e = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap_err();
|
||||
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
|
||||
}
|
||||
|
||||
/// Everything else is forwarded unchanged.
|
||||
#[tokio::test]
|
||||
async fn other_commands_are_forwarded() {
|
||||
let (addr, seen) = fake_app(Value::Null).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
b.execute("file.exportMedia", json!({"path": "/tmp/out.mov"})).await.unwrap();
|
||||
b.execute("sequence.inspect", json!({})).await.unwrap();
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ impl FilmcraftMcp {
|
||||
.await
|
||||
.map_err(join_error)?
|
||||
}
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
|
||||
Backend::Bridge(b) => b.execute(id, params).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,14 +397,13 @@ impl FilmcraftMcp {
|
||||
Backend::Headless(s) => {
|
||||
let s = s.clone();
|
||||
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(sec) = p.seconds {
|
||||
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
|
||||
}
|
||||
let g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
// read-only: render at `seconds` without moving the playhead (#114)
|
||||
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
|
||||
let (w, h) =
|
||||
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let scale = (max as f32 / w.max(h) as f32).min(1.0);
|
||||
let img = g.render_program(scale).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let img = g.render_program_at(scale, t).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
.await
|
||||
@@ -415,12 +414,31 @@ impl FilmcraftMcp {
|
||||
})
|
||||
}
|
||||
Backend::Bridge(b) => {
|
||||
if let Some(sec) = p.seconds
|
||||
&& let Err(e) = b.call("engine.execute", json!({"command": "playhead.set", "params": {"seconds": sec}})).await
|
||||
{
|
||||
let Some(sec) = p.seconds else {
|
||||
return self.screenshot(&b.clone(), Some("Program".into()), max).await;
|
||||
};
|
||||
// The Program monitor shows the playhead's frame, so the live playhead has to move
|
||||
// for the screenshot; put it (and the selection, which may follow it) back after,
|
||||
// so the tool stays read-only (#114).
|
||||
let before = match self.run("sequence.inspect", json!({})).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok(fail(e)),
|
||||
};
|
||||
if let Err(e) = self.run("playhead.set", json!({"seconds": sec})).await {
|
||||
return Ok(fail(e));
|
||||
}
|
||||
self.screenshot(&b.clone(), Some("Program".into()), max).await
|
||||
let shot = self.screenshot(&b.clone(), Some("Program".into()), max).await;
|
||||
if let Some(t) = before.get("playhead").and_then(Value::as_i64) {
|
||||
let _ = self.run("playhead.set", json!({"time": t})).await;
|
||||
}
|
||||
// only when it changed: selecting also leaves trim mode
|
||||
let now = self.run("sequence.inspect", json!({})).await.ok();
|
||||
if let Some(sel) = before.get("selection").filter(|v| v.is_array())
|
||||
&& now.as_ref().and_then(|n| n.get("selection")) != Some(sel)
|
||||
{
|
||||
let _ = self.run("timeline.select", json!({"clips": sel})).await;
|
||||
}
|
||||
shot
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,8 +592,8 @@ impl ServerHandler for FilmcraftMcp {
|
||||
return Err(McpError::invalid_params(m, None));
|
||||
}
|
||||
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
|
||||
// Bridge mode too: the app runs it as a job, so it stays responsive (#91, #92).
|
||||
if request.name == "command_run"
|
||||
&& matches!(&*self.backend, Backend::Headless(_))
|
||||
&& let Some(a) = &request.arguments
|
||||
&& let Some(id) = a.get("id").and_then(Value::as_str)
|
||||
{
|
||||
@@ -645,6 +663,27 @@ mod tests {
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
|
||||
/// `render_frame` / `render_preview` are annotated read-only (#114): rendering at `seconds`
|
||||
/// leaves the playhead, and the selection that follows it, where they were, and still renders
|
||||
/// the frame at `seconds`.
|
||||
#[tokio::test]
|
||||
async fn rendering_at_a_time_does_not_move_the_playhead() {
|
||||
let m = FilmcraftMcp::headless(demo());
|
||||
m.run("sequence.selectionFollowsPlayhead", json!({"on": true})).await.unwrap();
|
||||
m.run("playhead.set", json!({"seconds": 1.0})).await.unwrap();
|
||||
let before = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
let png = |r: CallToolResult| serde_json::to_string(&r.content).unwrap();
|
||||
let at_playhead = png(m.render_frame(Parameters(RenderParams { seconds: None, max_side: Some(64) })).await.unwrap());
|
||||
for seconds in [5.0, 8.0] {
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
|
||||
assert_ne!(png(r), at_playhead, "renders the frame at {seconds} s");
|
||||
m.render_preview(Parameters(RenderParams { seconds: Some(seconds), max_side: Some(64) })).await.unwrap();
|
||||
}
|
||||
let after = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
assert_eq!(after["playhead"], before["playhead"]);
|
||||
assert_eq!(after["selection"], before["selection"]);
|
||||
}
|
||||
|
||||
/// One request/response exchange over `serve_io`, as an MCP client sees it.
|
||||
struct Client {
|
||||
tx: tokio::io::DuplexStream,
|
||||
@@ -818,32 +857,48 @@ mod tests {
|
||||
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
|
||||
let m = c.next().await;
|
||||
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs
|
||||
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
|
||||
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs. The
|
||||
// job is polled every 100 ms, so a fast machine finishes a short export within one poll:
|
||||
// a longer one is tried until the export outlasts a few polls.
|
||||
let (mut notes, mut pong) = (0, false);
|
||||
for (attempt, seconds) in [1.0, 4.0, 16.0].into_iter().enumerate() {
|
||||
let (id, ping, name) = (20 + attempt as u64, 30 + attempt as u64, format!("a{attempt}.mp4"));
|
||||
c.send(&export(id, &name, Some(json!("tok")), seconds)).await;
|
||||
let (mut last, mut pinged) = (-1.0, false);
|
||||
(notes, pong) = (0, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(&json!({"jsonrpc": "2.0", "id": ping, "method": "ping"}).to_string()).await;
|
||||
}
|
||||
} else if m["id"] == ping {
|
||||
pong = true;
|
||||
} else if m["id"] == id {
|
||||
break m;
|
||||
}
|
||||
} else if m["id"] == 3 {
|
||||
pong = true;
|
||||
} else if m["id"] == 2 {
|
||||
break m;
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
assert!(notes >= 1, "a finished export reports its progress at least once");
|
||||
assert!(dir.join(&name).exists());
|
||||
if notes >= 2 && pong {
|
||||
break;
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
// the ping's answer is still on its way: read it so it cannot be taken for a later reply
|
||||
while pinged && !pong {
|
||||
pong = c.next().await["id"] == ping;
|
||||
}
|
||||
pong = false;
|
||||
}
|
||||
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
|
||||
assert!(dir.join("a.mp4").exists());
|
||||
// cancel after the first notification: no response, no partial file
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 20.0)).await;
|
||||
loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
|
||||
|
||||
@@ -28,6 +28,7 @@ filmcraft-vp9 = { workspace = true }
|
||||
filmcraft-prores = { workspace = true }
|
||||
filmcraft-dnx = { workspace = true }
|
||||
filmcraft-av1 = { workspace = true }
|
||||
filmcraft-apv = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Standalone APV raw bitstream source (RFC 9924 Appendix A: `.apv` elementary streams).
|
||||
//!
|
||||
//! Each access unit in a raw bitstream is prefixed by a 4-byte big-endian `au_size` followed by
|
||||
//! the `'aPv1'` signature (unprefixed single-AU streams starting directly with `'aPv1'` are also
|
||||
//! accepted). Every frame is intra-coded, so random access is direct.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::CodecError;
|
||||
use crate::gop::{GopCache, VideoSamples};
|
||||
use crate::video::{ApvDecoder, VideoDecoder, primaries_from_code};
|
||||
|
||||
/// Sniff a raw APV bitstream: either a `raw_bitstream_access_unit()` (`[au_size: u32]['aPv1']`) or
|
||||
/// an unprefixed `access_unit()` starting with `'aPv1'`.
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
(head.len() >= 8 && &head[4..8] == b"aPv1" && u32::from_be_bytes([head[0], head[1], head[2], head[3]]) >= 8) || head.starts_with(b"aPv1")
|
||||
}
|
||||
|
||||
pub struct ApvSource {
|
||||
info: MediaInfo,
|
||||
bytes: crate::Src,
|
||||
samples: Vec<(u64, u32)>,
|
||||
rate: FrameRate,
|
||||
video: GopCache,
|
||||
}
|
||||
|
||||
pub(crate) fn apv_pixfmt_label(chroma: filmcraft_apv::ChromaFormat, bit_depth: u8) -> String {
|
||||
let sub = match chroma {
|
||||
filmcraft_apv::ChromaFormat::Monochrome => "Monochrome",
|
||||
filmcraft_apv::ChromaFormat::Yuv422 => "YUV 4:2:2",
|
||||
filmcraft_apv::ChromaFormat::Yuv444 => "YUV 4:4:4",
|
||||
filmcraft_apv::ChromaFormat::Yuv4444 => "YUVA 4:4:4:4",
|
||||
};
|
||||
format!("{sub} {bit_depth}-bit")
|
||||
}
|
||||
|
||||
impl ApvSource {
|
||||
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
|
||||
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
|
||||
}
|
||||
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let len = reader.len();
|
||||
let mut samples = Vec::new();
|
||||
let mut pos = 0u64;
|
||||
let mut hdr = [0u8; 8];
|
||||
while pos + 8 <= len {
|
||||
if reader.read_at(pos, &mut hdr).is_err() {
|
||||
break;
|
||||
}
|
||||
if &hdr[4..8] == b"aPv1" {
|
||||
let sz = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
|
||||
if sz < 8 || pos + 4 + sz as u64 > len {
|
||||
break;
|
||||
}
|
||||
samples.push((pos + 4, sz));
|
||||
pos += 4 + sz as u64;
|
||||
} else if pos == 0 && &hdr[0..4] == b"aPv1" && len <= u32::MAX as u64 {
|
||||
samples.push((0, len as u32));
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(&(off0, sz0)) = samples.first() else {
|
||||
return Err(CodecError::Container("APV: no valid access units".into()));
|
||||
};
|
||||
let first = filmcraft_media::reader::read_range(&*reader, off0, sz0 as usize).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let fh = filmcraft_apv::probe(&first).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let mut color = ColorInfo { matrix: filmcraft_frame::default_matrix(fh.width, fh.height), ..ColorInfo::REC709 };
|
||||
let mut explicit_color = None;
|
||||
if fh.color_description_present {
|
||||
if let Some(m) = Matrix::from_code(fh.color.matrix) {
|
||||
color.matrix = m;
|
||||
}
|
||||
if let Some(t) = Transfer::from_code(fh.color.transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
if let Some(p) = primaries_from_code(fh.color.primaries) {
|
||||
color.primaries = p;
|
||||
}
|
||||
if fh.color.full_range {
|
||||
color.range = Range::Full;
|
||||
}
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
let rate = FrameRate::FPS_24;
|
||||
let duration = Tick::from_rational(samples.len() as i64 * rate.den, 1, rate.num);
|
||||
let total_bits: u64 = samples.iter().map(|s| s.1 as u64 * 8).sum();
|
||||
let secs = duration.seconds();
|
||||
let bitrate = (secs > 0.0).then(|| (total_bits as f64 / secs) as u64);
|
||||
let codec = match filmcraft_apv::Profile::from_idc(fh.profile_idc) {
|
||||
Some(p) => p.name().into(),
|
||||
None => "APV".into(),
|
||||
};
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: MediaKind::Movie,
|
||||
duration,
|
||||
video: Some(VideoStreamInfo {
|
||||
width: fh.width,
|
||||
height: fh.height,
|
||||
frame_rate: rate,
|
||||
par: (1, 1),
|
||||
codec,
|
||||
pixel_format: apv_pixfmt_label(fh.chroma, fh.bit_depth),
|
||||
color,
|
||||
has_alpha: fh.chroma == filmcraft_apv::ChromaFormat::Yuv4444,
|
||||
bitrate,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: None,
|
||||
container: "APV".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(len),
|
||||
};
|
||||
Ok(Self { info, bytes: crate::Src(reader), samples, rate, video: GopCache::new(explicit_color) })
|
||||
}
|
||||
}
|
||||
|
||||
struct ApvVideo<'a>(&'a ApvSource);
|
||||
|
||||
impl VideoSamples for ApvVideo<'_> {
|
||||
fn count(&self) -> usize {
|
||||
self.0.samples.len()
|
||||
}
|
||||
fn pts(&self, i: usize) -> i64 {
|
||||
i as i64
|
||||
}
|
||||
fn sync_before(&self, i: usize) -> usize {
|
||||
i
|
||||
}
|
||||
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||
(!self.0.samples.is_empty()).then(|| t.clamp(0, self.0.samples.len() as i64 - 1) as usize)
|
||||
}
|
||||
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||
let (off, sz) = self.0.samples.get(i).copied().ok_or_else(|| CodecError::Container("sample index out of range".into()))?;
|
||||
filmcraft_media::reader::read_range(&*self.0.bytes.0, off, sz as usize).map_err(|e| CodecError::Container(e.to_string()))
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
Ok(Box::new(ApvDecoder))
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for ApvSource {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
|
||||
let frame_idx = self.rate.frame_at(req.time.max(Tick::ZERO));
|
||||
Ok(self.video.frame(&ApvVideo(self), frame_idx)?)
|
||||
}
|
||||
|
||||
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
Err(MediaError::NoStream("audio"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
|
||||
if !sniff(&bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(ApvSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
|
||||
if !sniff(head) {
|
||||
return None;
|
||||
}
|
||||
Some(ApvSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
+147
-11
@@ -15,8 +15,8 @@ use symphonia::core::probe::Hint;
|
||||
use crate::{CodecError, Result};
|
||||
|
||||
enum Inner {
|
||||
/// Our own AAC-LC decoder.
|
||||
Aac { dec: Box<filmcraft_aac::Decoder>, asc: Vec<u8> },
|
||||
/// Our own AAC-LC decoder. `up` interpolates HE-AAC's core to the output rate (see [`Upsample2x`]).
|
||||
Aac { dec: Box<filmcraft_aac::Decoder>, asc: Vec<u8>, up: Option<Box<Upsample2x>> },
|
||||
/// AAC whose configuration arrives with the first frame (ADTS / LATM).
|
||||
AacPending,
|
||||
/// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps).
|
||||
@@ -82,11 +82,13 @@ impl PacketDecoder {
|
||||
let dec = symphonia::default::get_codecs().make(&p, &DecoderOptions::default()).map_err(|e| CodecError::Unsupported(e.to_string()))?;
|
||||
Ok(Self { inner: Inner::Symphonia(dec), channels: 0 })
|
||||
}
|
||||
/// AAC: our decoder for AAC-LC; symphonia for other object types (HE-AAC…).
|
||||
/// AAC: our decoder for AAC-LC and the AAC-LC core of HE-AAC (v1/v2); symphonia for other
|
||||
/// object types. `sample_rate` is the stream's output rate: when it is twice the core rate
|
||||
/// (HE-AAC, see [`aac_output_rate`]) the decoded core is upsampled to it.
|
||||
pub fn aac(asc: &[u8], sample_rate: u32) -> Result<Self> {
|
||||
let lc = asc.first().map(|b| b >> 3) == Some(2);
|
||||
if lc && let Ok(dec) = filmcraft_aac::Decoder::new(asc) {
|
||||
return Ok(Self { inner: Inner::Aac { dec: Box::new(dec), asc: asc.to_vec() }, channels: 0 });
|
||||
if let Ok(dec) = filmcraft_aac::Decoder::new(asc) {
|
||||
let up = (dec.sample_rate().checked_mul(2) == Some(sample_rate)).then(|| Box::new(Upsample2x::new(dec.channels())));
|
||||
return Ok(Self { inner: Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up }, channels: 0 });
|
||||
}
|
||||
Self::new(CODEC_TYPE_AAC, sample_rate, Some(asc.to_vec()))
|
||||
}
|
||||
@@ -119,7 +121,7 @@ impl PacketDecoder {
|
||||
return Ok(());
|
||||
}
|
||||
let dec = filmcraft_aac::Decoder::new(asc).map_err(|e| CodecError::Unsupported(format!("AAC: {e}")))?;
|
||||
self.inner = Inner::Aac { dec: Box::new(dec), asc: asc.to_vec() };
|
||||
self.inner = Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up: None };
|
||||
Ok(())
|
||||
}
|
||||
/// AC-3 (ATSC A/52).
|
||||
@@ -133,7 +135,7 @@ impl PacketDecoder {
|
||||
pub fn for_isobmff(c: &filmcraft_isobmff::CodecConfig, rate: u32) -> Result<Self> {
|
||||
use filmcraft_isobmff::CodecConfig as C;
|
||||
match c {
|
||||
C::Aac(a) => Self::aac(&a.asc, if a.sample_rate > 0 { a.sample_rate } else { rate }),
|
||||
C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }),
|
||||
C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None),
|
||||
C::Ac3 { .. } => Self::ac3(),
|
||||
C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())),
|
||||
@@ -145,8 +147,11 @@ impl PacketDecoder {
|
||||
/// Decode one packet into planar channels.
|
||||
pub fn decode(&mut self, data: &[u8], ts: u64) -> Result<Vec<Vec<f32>>> {
|
||||
let dec = match &mut self.inner {
|
||||
Inner::Aac { dec, .. } => {
|
||||
let out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
Inner::Aac { dec, up, .. } => {
|
||||
let mut out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
if let Some(up) = up {
|
||||
out = up.process(out);
|
||||
}
|
||||
self.channels = out.len();
|
||||
return Ok(out);
|
||||
}
|
||||
@@ -202,10 +207,13 @@ impl PacketDecoder {
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
match &mut self.inner {
|
||||
Inner::Aac { dec, asc } => {
|
||||
Inner::Aac { dec, asc, up } => {
|
||||
if let Ok(d) = filmcraft_aac::Decoder::new(asc) {
|
||||
**dec = d;
|
||||
}
|
||||
if let Some(up) = up {
|
||||
up.reset();
|
||||
}
|
||||
}
|
||||
Inner::Opus { dec, .. } => dec.reset(),
|
||||
Inner::Symphonia(d) => d.reset(),
|
||||
@@ -215,6 +223,94 @@ impl PacketDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The output rate of an AAC stream: twice the core rate for HE-AAC (ISO/IEC 14496-3 §1.6.5).
|
||||
/// Explicit signalling names the rate in the `AudioSpecificConfig`; with implicit signalling (an
|
||||
/// AAC-LC config at 24 kHz or less) SBR is only visible as fill-element data in the access units,
|
||||
/// so the first ones are decoded to look for it. `None` if `asc` is not one our decoder reads.
|
||||
pub fn aac_output_rate<'a>(asc: &[u8], first_units: impl IntoIterator<Item = &'a [u8]>) -> Option<u32> {
|
||||
let mut dec = filmcraft_aac::Decoder::new(asc).ok()?;
|
||||
let core = dec.sample_rate();
|
||||
if let Some(ext) = dec.config().extension_sample_rate {
|
||||
return Some(ext);
|
||||
}
|
||||
if core <= 24_000 {
|
||||
for au in first_units {
|
||||
let _ = dec.decode(au);
|
||||
if dec.sbr() {
|
||||
return Some(core * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(core)
|
||||
}
|
||||
|
||||
/// Taps on each side of the interpolation point in [`Upsample2x`].
|
||||
const UPSAMPLE_HALF: usize = 12;
|
||||
|
||||
/// 2× upsampler for HE-AAC. Our decoder reconstructs the AAC-LC core at half the output rate and
|
||||
/// not yet the SBR high band, so the core is interpolated to the output rate: timing and pitch are
|
||||
/// right and the band above the core's Nyquist stays empty. Even outputs are the input samples,
|
||||
/// odd ones a Blackman-windowed sinc half-band interpolation over 2 × [`UPSAMPLE_HALF`] inputs.
|
||||
/// The look-ahead delays the output by `UPSAMPLE_HALF` input samples (0.5 ms at 24 kHz); the
|
||||
/// state carries across packets, so a seek primed with the preceding packet gives the same
|
||||
/// samples as continuous decoding.
|
||||
#[derive(Clone)]
|
||||
pub struct Upsample2x {
|
||||
taps: [f32; 2 * UPSAMPLE_HALF],
|
||||
/// The last `2 × UPSAMPLE_HALF` input samples per channel.
|
||||
hist: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl Upsample2x {
|
||||
pub fn new(channels: usize) -> Self {
|
||||
let m = UPSAMPLE_HALF as f64;
|
||||
let mut taps = [0f32; 2 * UPSAMPLE_HALF];
|
||||
let mut sum = 0.0;
|
||||
for (k, t) in taps.iter_mut().enumerate() {
|
||||
// distance from the interpolation point (halfway between two inputs)
|
||||
let x = k as f64 - m + 0.5;
|
||||
let sinc = (std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x);
|
||||
let w = 0.42 + 0.5 * (std::f64::consts::PI * x / m).cos() + 0.08 * (2.0 * std::f64::consts::PI * x / m).cos();
|
||||
*t = (sinc * w) as f32;
|
||||
sum += sinc * w;
|
||||
}
|
||||
for t in &mut taps {
|
||||
*t = (*t as f64 / sum) as f32;
|
||||
}
|
||||
Self { taps, hist: vec![vec![0.0; 2 * UPSAMPLE_HALF]; channels] }
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
for h in &mut self.hist {
|
||||
h.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, input: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
if self.hist.len() < input.len() {
|
||||
self.hist.resize(input.len(), vec![0.0; 2 * UPSAMPLE_HALF]);
|
||||
}
|
||||
let m = UPSAMPLE_HALF;
|
||||
input
|
||||
.into_iter()
|
||||
.zip(&mut self.hist)
|
||||
.map(|(x, hist)| {
|
||||
let mut buf = std::mem::take(hist);
|
||||
buf.extend_from_slice(&x);
|
||||
let mut out = Vec::with_capacity(x.len() * 2);
|
||||
// output pair i is centred on buf[i + m]: the sample itself, then the point halfway
|
||||
// to the next one from buf[i + 1 ..= i + 2m]
|
||||
for w in buf.windows(2 * m + 1).take(x.len()) {
|
||||
out.push(w[m]);
|
||||
out.push(w[1..].iter().zip(&self.taps).map(|(a, b)| a * b).sum());
|
||||
}
|
||||
*hist = buf.split_off(buf.len() - 2 * m);
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether AC-3 audio decodes (our ATSC A/52 decoder).
|
||||
pub const AC3_DECODER: bool = true;
|
||||
|
||||
@@ -603,3 +699,43 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<std::result::Result<Shared
|
||||
}
|
||||
Some(AudioFileSource::decode(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod he_aac_tests {
|
||||
use super::*;
|
||||
|
||||
/// AAC-LC config, 22.05 kHz stereo.
|
||||
const LC_22K: [u8; 2] = [0x13, 0x90];
|
||||
/// An access unit holding only a fill element with SBR data (ID_FIL, EXT_SBR_DATA, ID_END).
|
||||
const SBR_FILL: [u8; 3] = [0b1100_0011, 0b1010_0001, 0b1100_0000];
|
||||
|
||||
#[test]
|
||||
fn output_rate_doubles_for_he_aac() {
|
||||
// explicit: the extension rate from the config
|
||||
assert_eq!(aac_output_rate(&[0x2B, 0x92, 0x08, 0x00], []), Some(44_100));
|
||||
// implicit: SBR data in the first access units of a ≤ 24 kHz AAC-LC stream
|
||||
assert_eq!(aac_output_rate(&LC_22K, [&SBR_FILL[..]]), Some(44_100));
|
||||
assert_eq!(aac_output_rate(&LC_22K, []), Some(22_050));
|
||||
// AAC-LC at 44.1 kHz stays as it is, even with SBR-looking fill data
|
||||
assert_eq!(aac_output_rate(&[0x12, 0x10], [&SBR_FILL[..]]), Some(44_100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsampler_interpolates_continuously_across_packets() {
|
||||
let f = 1000.0 / 22_050.0;
|
||||
let x: Vec<f32> = (0..4096).map(|i| (2.0 * std::f32::consts::PI * f * i as f32).sin()).collect();
|
||||
let mut up = Upsample2x::new(1);
|
||||
let mut y = Vec::new();
|
||||
for chunk in x.chunks(1024) {
|
||||
y.extend(up.process(vec![chunk.to_vec()]).remove(0));
|
||||
}
|
||||
assert_eq!(y.len(), 8192);
|
||||
// output 2k + 1 is input k − UPSAMPLE_HALF + ½ (the look-ahead delay), within 0.1 %
|
||||
let d = UPSAMPLE_HALF as f32;
|
||||
for k in 100..4000 {
|
||||
let want = (2.0 * std::f32::consts::PI * f * (k as f32 - d + 0.5)).sin();
|
||||
assert!((y[2 * k + 1] - want).abs() < 1e-3, "{k}: {} vs {want}", y[2 * k + 1]);
|
||||
assert_eq!(y[2 * k], x[k - UPSAMPLE_HALF]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use filmcraft_isobmff::{CodecConfig, SampleEntry};
|
||||
|
||||
pub use crate::hw_frame::{FrameCodec, FrameStreamInfo, PictureParams};
|
||||
use crate::video::{avcc_length_size, h264_disposable, hevc_disposable, hvcc_length_size_and_tid, sar_par, vui_color};
|
||||
use crate::{CodecError, Result};
|
||||
|
||||
@@ -29,6 +30,19 @@ pub fn hardware_decoding() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static BACKEND: std::sync::Mutex<Option<&'static str>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Record the OS decoder backend `filmcraft_platform::register` put in front of our decoders
|
||||
/// ("VideoToolbox", "Media Foundation"), for `perf.stats`.
|
||||
pub fn set_hw_backend(name: &'static str) {
|
||||
*BACKEND.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(name);
|
||||
}
|
||||
|
||||
/// The registered hardware decoder backend, if any ([`set_hw_backend`]).
|
||||
pub fn hw_backend() -> Option<&'static str> {
|
||||
*BACKEND.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Process-wide hardware decoding counters (they only grow; subtract two snapshots to measure an
|
||||
/// interval). Frames decoded in software are `GopStats::frames - frames`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -120,6 +134,71 @@ pub struct NalStreamInfo {
|
||||
pub reorder: usize,
|
||||
}
|
||||
|
||||
/// Any stream a hardware decoder can take: what [`crate::hw`]'s hybrid decoder needs to know about
|
||||
/// it to find restart points and in-band parameter changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum StreamInfo {
|
||||
Nal(NalStreamInfo),
|
||||
Frame(FrameStreamInfo),
|
||||
}
|
||||
|
||||
impl From<NalStreamInfo> for StreamInfo {
|
||||
fn from(i: NalStreamInfo) -> Self {
|
||||
Self::Nal(i)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FrameStreamInfo> for StreamInfo {
|
||||
fn from(i: FrameStreamInfo) -> Self {
|
||||
Self::Frame(i)
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamInfo {
|
||||
/// Whether decoding can restart at `sample` with no earlier state (IDR / IRAP, a key frame).
|
||||
pub fn is_irap(&self, sample: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::Nal(i) => i.is_irap(sample),
|
||||
Self::Frame(i) => i.is_random_access(sample),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HEVC CRA restarts nothing by itself (its RASL pictures need the previous restart point).
|
||||
pub fn keeps_previous_restart(&self, sample: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::Nal(i) => i.codec == NalCodec::Hevc && i.nal_types(sample).contains(&21),
|
||||
Self::Frame(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `sample` carries parameters different from the ones the hardware decoder was set up
|
||||
/// with (parameter sets, a sequence header, a key frame of another format).
|
||||
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::Nal(i) => {
|
||||
i.nals(sample).into_iter().any(|n| n.first().is_some_and(|&h| i.is_parameter_set(i.nal_type(h))) && !i.parameter_sets.iter().any(|p| p == n))
|
||||
}
|
||||
Self::Frame(i) => i.parameters_changed(sample),
|
||||
}
|
||||
}
|
||||
|
||||
/// As the software decoder's [`crate::VideoDecoder::is_random_access`].
|
||||
pub fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
match self {
|
||||
Self::Nal(i) => i.is_random_access(sample),
|
||||
Self::Frame(i) => Some(i.is_random_access(sample)),
|
||||
}
|
||||
}
|
||||
|
||||
/// As the software decoder's [`crate::VideoDecoder::is_disposable`].
|
||||
pub fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
match self {
|
||||
Self::Nal(i) => i.is_disposable(sample),
|
||||
Self::Frame(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`.
|
||||
fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec<Vec<u8>>) -> Result<()> {
|
||||
for _ in 0..count {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! What a hardware decoder needs to know about a VP9 or AV1 stream so that it behaves exactly like
|
||||
//! our software decoder for it (the frame-coded counterpart of [`crate::hw::NalStreamInfo`]):
|
||||
//! codec profile, bit depth and chroma format from the container's `vpcC` / `av1C`, the picture
|
||||
//! size and colour as the software decoder reports them (read from the bitstream the same way),
|
||||
//! random-access samples, and in-band changes of the stream's parameters.
|
||||
|
||||
use filmcraft_color::{ColorInfo, Primaries, Transfer};
|
||||
use filmcraft_isobmff::{CodecConfig, SampleEntry};
|
||||
|
||||
use crate::video::primaries_from_code;
|
||||
use crate::{CodecError, Result};
|
||||
|
||||
/// The two frame-coded codecs hardware decoders take besides H.264 / HEVC.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FrameCodec {
|
||||
Vp9,
|
||||
Av1,
|
||||
}
|
||||
|
||||
/// Picture parameters read from a random-access sample.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PictureParams {
|
||||
/// Picture size in luma samples (the decoded size).
|
||||
pub size: (u32, u32),
|
||||
pub bit_depth: u32,
|
||||
/// `subsampling_x`, `subsampling_y`.
|
||||
pub subsampling: (u8, u8),
|
||||
/// Colour of every output picture, as the software decoder reports it.
|
||||
pub color: ColorInfo,
|
||||
}
|
||||
|
||||
/// A VP9 or AV1 stream, from its sample entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FrameStreamInfo {
|
||||
pub codec: FrameCodec,
|
||||
/// VP9 `profile` (0..=3) / AV1 `seq_profile` (0..=2).
|
||||
pub profile: u8,
|
||||
pub bit_depth: u32,
|
||||
pub subsampling: (u8, u8),
|
||||
pub mono: bool,
|
||||
/// Picture size: the sample entry's, checked against the bitstream's.
|
||||
pub size: (u32, u32),
|
||||
/// AV1: the `av1C` configuration OBUs (the sequence header the decoder is primed with).
|
||||
pub config_obus: Vec<u8>,
|
||||
/// AV1: the sequence header's payload; VP9: none.
|
||||
sequence_header: Option<Vec<u8>>,
|
||||
/// Colour of the stream when it is known before the first picture (AV1: from the sequence
|
||||
/// header).
|
||||
pub color: Option<ColorInfo>,
|
||||
/// VP9: transfer and primaries from the container (not in the bitstream).
|
||||
transfer: Option<Transfer>,
|
||||
primaries: Option<Primaries>,
|
||||
}
|
||||
|
||||
/// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one.
|
||||
fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while let Some(&h) = data.get(pos) {
|
||||
let (obu_type, ext, has_size) = ((h >> 3) & 0xf, h & 4 != 0, h & 2 != 0);
|
||||
let mut p = pos + 1 + usize::from(ext);
|
||||
let size = if has_size {
|
||||
let (mut v, mut shift, mut n) = (0usize, 0u32, 0usize);
|
||||
loop {
|
||||
let Some(&b) = data.get(p + n) else { return out };
|
||||
v |= usize::from(b & 0x7f).checked_shl(shift).unwrap_or(0);
|
||||
n += 1;
|
||||
if b & 0x80 == 0 || n >= 8 {
|
||||
break;
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
p += n;
|
||||
v
|
||||
} else {
|
||||
data.len().saturating_sub(p)
|
||||
};
|
||||
let Some(payload) = data.get(p..p.saturating_add(size)) else { return out };
|
||||
out.push((obu_type, payload));
|
||||
pos = p + size;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// VP9 colour as our decoder reports it: `color_space` and range from the bitstream, transfer and
|
||||
/// primaries from the container when it says. (Shared with `Vp9Decoder`.)
|
||||
pub(crate) fn vp9_color(width: u32, height: u32, color_space: u8, full_range: bool, transfer: Option<Transfer>, primaries: Option<Primaries>) -> ColorInfo {
|
||||
use filmcraft_color::{Matrix, Range};
|
||||
let mut color = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 };
|
||||
// color_space (7.2.2): 1 BT.601, 2 BT.709, 3 SMPTE-170, 4 SMPTE-240, 5 BT.2020, 7 sRGB.
|
||||
match color_space {
|
||||
1 | 3 => color.matrix = Matrix::Bt601,
|
||||
2 | 4 => color.matrix = Matrix::Bt709,
|
||||
5 => {
|
||||
color.matrix = Matrix::Bt2020Ncl;
|
||||
color.primaries = Primaries::Bt2020;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(t) = transfer {
|
||||
color.transfer = t;
|
||||
}
|
||||
if let Some(p) = primaries {
|
||||
color.primaries = p;
|
||||
}
|
||||
if full_range {
|
||||
color.range = Range::Full;
|
||||
}
|
||||
color
|
||||
}
|
||||
|
||||
/// AV1 colour as our decoder reports it. (Shared with `Av1Decoder`.)
|
||||
pub(crate) fn av1_color(width: u32, height: u32, matrix_coefficients: u8, transfer: u8, primaries: u8, full_range: bool) -> ColorInfo {
|
||||
let mut color = ColorInfo::REC709;
|
||||
color.matrix = filmcraft_color::Matrix::from_code(matrix_coefficients).unwrap_or_else(|| filmcraft_frame::default_matrix(width, height));
|
||||
if let Some(t) = Transfer::from_code(transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
color.primaries = match primaries {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
};
|
||||
if full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
color
|
||||
}
|
||||
|
||||
impl FrameStreamInfo {
|
||||
/// The stream info of a `vpcC` / `av1C` sample entry (`None` for other codecs).
|
||||
pub fn from_entry(e: &SampleEntry) -> Option<Result<Self>> {
|
||||
let size = e.video.as_ref().map_or((0, 0), |v| (u32::from(v.width), u32::from(v.height)));
|
||||
match &e.codec {
|
||||
CodecConfig::Vp9(c) => {
|
||||
// vpcC chroma_subsampling: 0 / 1 4:2:0, 2 4:2:2, 3 4:4:4
|
||||
let subsampling = match c.chroma_subsampling {
|
||||
0 | 1 => (1, 1),
|
||||
2 => (1, 0),
|
||||
_ => (0, 0),
|
||||
};
|
||||
Some(Ok(Self {
|
||||
codec: FrameCodec::Vp9,
|
||||
profile: c.profile,
|
||||
bit_depth: u32::from(c.bit_depth),
|
||||
subsampling,
|
||||
mono: false,
|
||||
size,
|
||||
config_obus: Vec::new(),
|
||||
sequence_header: None,
|
||||
color: None,
|
||||
transfer: Transfer::from_code(c.transfer_characteristics),
|
||||
primaries: primaries_from_code(c.colour_primaries),
|
||||
}))
|
||||
}
|
||||
CodecConfig::Av1(c) => Some(Self::from_av1(c, size)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_av1(c: &filmcraft_isobmff::Av1Config, entry_size: (u32, u32)) -> Result<Self> {
|
||||
let seq =
|
||||
obus(&c.config_obus).into_iter().find(|(t, _)| *t == 1).map(|(_, p)| p).ok_or_else(|| CodecError::Decode("av1C has no sequence header".into()))?;
|
||||
let h = filmcraft_av1::SequenceHeader::parse(seq).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let size = (h.max_frame_width, h.max_frame_height);
|
||||
if entry_size != (0, 0) && entry_size != size {
|
||||
// the sample entry and the sequence header disagree: leave the stream to our decoder
|
||||
return Err(CodecError::Decode(format!("sample entry says {}x{}, the sequence header {}x{}", entry_size.0, entry_size.1, size.0, size.1)));
|
||||
}
|
||||
let cc = &h.color;
|
||||
Ok(Self {
|
||||
codec: FrameCodec::Av1,
|
||||
profile: h.profile,
|
||||
bit_depth: u32::from(cc.bit_depth),
|
||||
subsampling: (cc.subsampling_x, cc.subsampling_y),
|
||||
mono: cc.mono_chrome,
|
||||
size,
|
||||
config_obus: c.config_obus.clone(),
|
||||
sequence_header: Some(seq.to_vec()),
|
||||
color: Some(av1_color(size.0, size.1, cc.matrix_coefficients, cc.transfer_characteristics, cc.color_primaries, cc.color_range)),
|
||||
transfer: None,
|
||||
primaries: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `sample` has a sequence header OBU of its own (AV1).
|
||||
pub fn carries_sequence_header(&self, sample: &[u8]) -> bool {
|
||||
self.codec == FrameCodec::Av1 && obus(sample).iter().any(|(t, _)| *t == 1)
|
||||
}
|
||||
|
||||
/// A description made by hand (tests of what a backend declines).
|
||||
#[doc(hidden)]
|
||||
pub fn for_tests(codec: FrameCodec, profile: u8, bit_depth: u32, subsampling: (u8, u8), size: (u32, u32)) -> Self {
|
||||
Self {
|
||||
codec,
|
||||
profile,
|
||||
bit_depth,
|
||||
subsampling,
|
||||
mono: false,
|
||||
size,
|
||||
config_obus: Vec::new(),
|
||||
sequence_header: None,
|
||||
color: None,
|
||||
transfer: None,
|
||||
primaries: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether decoding can start at `sample` (a VP9 key frame / an AV1 temporal unit whose first
|
||||
/// frame is a shown key frame): the software decoders' own test.
|
||||
pub fn is_random_access(&self, sample: &[u8]) -> bool {
|
||||
match self.codec {
|
||||
FrameCodec::Vp9 => filmcraft_vp9::is_keyframe(sample),
|
||||
FrameCodec::Av1 => filmcraft_av1::is_key_frame_unit(sample),
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture parameters a random-access `sample` declares (a VP9 key frame header; an AV1
|
||||
/// sample carrying a sequence header), or `None`.
|
||||
pub fn picture_params(&self, sample: &[u8]) -> Option<PictureParams> {
|
||||
match self.codec {
|
||||
FrameCodec::Vp9 => {
|
||||
let k = filmcraft_vp9::keyframe_info(sample)?;
|
||||
let color = vp9_color(k.width, k.height, k.color.color_space, k.color.full_range, self.transfer, self.primaries);
|
||||
Some(PictureParams {
|
||||
size: (k.width, k.height),
|
||||
bit_depth: k.bit_depth,
|
||||
subsampling: (u8::from(k.subsampling_x), u8::from(k.subsampling_y)),
|
||||
color,
|
||||
})
|
||||
}
|
||||
FrameCodec::Av1 => {
|
||||
let seq = obus(sample).into_iter().find(|(t, _)| *t == 1).map(|(_, p)| p)?;
|
||||
let h = filmcraft_av1::SequenceHeader::parse(seq).ok()?;
|
||||
let cc = &h.color;
|
||||
let size = (h.max_frame_width, h.max_frame_height);
|
||||
let color = av1_color(size.0, size.1, cc.matrix_coefficients, cc.transfer_characteristics, cc.color_primaries, cc.color_range);
|
||||
Some(PictureParams { size, bit_depth: u32::from(cc.bit_depth), subsampling: (cc.subsampling_x, cc.subsampling_y), color })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `sample` declares parameters the hardware session was not set up for: an AV1
|
||||
/// sequence header that differs from `av1C`'s, a VP9 key frame of another size, bit depth or
|
||||
/// chroma format. The decoder then hands the stream to our software decoder.
|
||||
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
|
||||
match self.codec {
|
||||
FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref() != Some(p)),
|
||||
FrameCodec::Vp9 => {
|
||||
self.picture_params(sample).is_some_and(|p| p.size != self.size || p.bit_depth != self.bit_depth || p.subsampling != self.subsampling)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod apv;
|
||||
pub mod audio;
|
||||
pub mod gop;
|
||||
pub mod hw;
|
||||
mod hw_frame;
|
||||
pub mod mkv;
|
||||
pub mod mp4;
|
||||
pub mod mpeg;
|
||||
@@ -30,6 +32,7 @@ pub mod video;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use apv::ApvSource;
|
||||
pub use audio::AudioFileSource;
|
||||
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
|
||||
pub use mkv::MkvSource;
|
||||
@@ -65,11 +68,12 @@ impl From<CodecError> for filmcraft_media::MediaError {
|
||||
}
|
||||
|
||||
/// Our own (pure-Rust) decoders, in the order they are tried.
|
||||
const BUILTIN_FACTORIES: [VideoDecoderFactory; 8] = [
|
||||
const BUILTIN_FACTORIES: [VideoDecoderFactory; 9] = [
|
||||
video::h264_factory,
|
||||
video::hevc_factory,
|
||||
video::vp9_factory,
|
||||
video::av1_factory,
|
||||
video::apv_factory,
|
||||
video::prores_factory,
|
||||
video::dnx_factory,
|
||||
video::mjpeg_factory,
|
||||
@@ -117,14 +121,14 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
|
||||
}
|
||||
|
||||
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
|
||||
/// MPEG-1/2 video elementary streams, standalone audio).
|
||||
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
|
||||
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, audio::opener]
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener]
|
||||
}
|
||||
|
||||
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||
static R: std::sync::OnceLock<RwLock<Vec<filmcraft_media::ReaderOpener>>> = std::sync::OnceLock::new();
|
||||
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener]))
|
||||
R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener]))
|
||||
}
|
||||
|
||||
/// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on
|
||||
@@ -169,5 +173,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result<filmcraft
|
||||
filmcraft_media::open_bytes(name, bytes, &openers())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rounded_pts_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -100,9 +100,12 @@ fn to_tick(t: &filmcraft_matroska::Track, pts: i64) -> Tick {
|
||||
Tick::from_rational(pts * n, 1, d)
|
||||
}
|
||||
|
||||
/// A time as the track timestamp to look a frame up by: rounded to the nearest tick, as muxers
|
||||
/// round frame times to `TimestampScale` (usually 1 ms). Flooring skipped every frame whose
|
||||
/// timestamp was rounded up (every third frame at 30 fps).
|
||||
fn from_tick(t: &filmcraft_matroska::Track, time: Tick) -> i64 {
|
||||
let (n, d) = tb(t);
|
||||
time.to_rational_floor(n, d)
|
||||
time.to_rational_round(n, d)
|
||||
}
|
||||
|
||||
/// VP9 configuration from the Matroska `CodecPrivate` feature list (ID / length / value triples:
|
||||
@@ -142,6 +145,7 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
|
||||
Some(match c {
|
||||
Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h),
|
||||
Codec::Av1 { av1c } => SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(filmcraft_isobmff::Av1Config::parse(av1c).unwrap_or_default()), w, h),
|
||||
Codec::Apv { apvc } => SampleEntry::apv(filmcraft_isobmff::ApvConfig::parse(apvc).unwrap_or_default(), w, h),
|
||||
Codec::Avc { avcc } => SampleEntry::avc(AvcConfig::parse(avcc).ok()?, w, h),
|
||||
Codec::Hevc { hvcc } => SampleEntry::hevc(HevcConfig::parse(hvcc).ok()?, w, h),
|
||||
Codec::ProRes { fourcc } => SampleEntry::prores(FourCc(fourcc.unwrap_or(*b"apcn")), w, h),
|
||||
@@ -203,6 +207,10 @@ fn codec_label(c: &Codec) -> String {
|
||||
Codec::Vp8 => "VP8".into(),
|
||||
Codec::Vp9 { .. } => "VP9".into(),
|
||||
Codec::Av1 { .. } => "AV1".into(),
|
||||
Codec::Apv { apvc } => match filmcraft_isobmff::ApvConfig::parse(apvc).ok().and_then(|a| filmcraft_apv::Profile::from_idc(a.profile_idc)) {
|
||||
Some(p) => p.name().into(),
|
||||
None => "APV".into(),
|
||||
},
|
||||
Codec::ProRes { .. } => "Apple ProRes".into(),
|
||||
Codec::Mjpeg => "Motion JPEG".into(),
|
||||
Codec::Aac { .. } => "AAC".into(),
|
||||
|
||||
+287
-26
@@ -18,6 +18,9 @@ use crate::gop::{GopCache, VideoSamples};
|
||||
use crate::video::VideoDecoder;
|
||||
use crate::{CodecError, make_video_decoder};
|
||||
|
||||
/// Most bytes read from one AAC access unit when probing for HE-AAC at open (8 channels × 6144 bits).
|
||||
const MAX_AAC_PROBE_UNIT: usize = 8 * 6144 / 8;
|
||||
|
||||
struct AudioState {
|
||||
decoder: Option<PacketDecoder>,
|
||||
/// Decoded packets by sample index.
|
||||
@@ -62,7 +65,7 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
|
||||
|
||||
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
// VP9 carries its colour description in vpcC (used when there is no colr box).
|
||||
// VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
|
||||
let vpc = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: v.colour_primaries as u16,
|
||||
@@ -70,6 +73,12 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn
|
||||
matrix: v.matrix_coefficients as u16,
|
||||
full_range: v.full_range,
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: a.color_primaries as u16,
|
||||
transfer: a.transfer_characteristics as u16,
|
||||
matrix: a.matrix_coefficients as u16,
|
||||
full_range: a.full_range,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
|
||||
@@ -148,7 +157,8 @@ impl Mp4Source {
|
||||
codec: codec_label(&entry.codec),
|
||||
pixel_format: pixfmt_label(&entry.codec),
|
||||
color,
|
||||
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x"),
|
||||
// ProRes 4444 alpha comes from its first frame header (`open_reader`)
|
||||
has_alpha: matches!(&entry.codec, CodecConfig::Apv(a) if a.chroma_format_idc == 4),
|
||||
bitrate,
|
||||
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
|
||||
};
|
||||
@@ -170,6 +180,19 @@ impl Mp4Source {
|
||||
info.has_alpha = h.alpha;
|
||||
}
|
||||
}
|
||||
if matches!(entry.codec, CodecConfig::ProRes { .. }) {
|
||||
// a 4444 fourcc doesn't mean the frames carry alpha (alpha_channel_type does): read the first frame header
|
||||
let s0 = &t.samples[0];
|
||||
if let Some(h) = filmcraft_media::reader::read_range(&*bytes.0, s0.offset, s0.size as usize).ok().and_then(|d| filmcraft_prores::probe(&d).ok())
|
||||
{
|
||||
info.has_alpha = h.alpha != filmcraft_prores::AlphaType::None;
|
||||
let sub = match h.chroma {
|
||||
filmcraft_prores::ChromaFormat::Yuv422 => "4:2:2 10-bit",
|
||||
filmcraft_prores::ChromaFormat::Yuv444 => "4:4:4 12-bit",
|
||||
};
|
||||
info.pixel_format = format!("{} {sub}", if info.has_alpha { "YUVA" } else { "YUV" });
|
||||
}
|
||||
}
|
||||
info
|
||||
});
|
||||
let audio = atrack.map(|i| {
|
||||
@@ -178,7 +201,21 @@ impl Mp4Source {
|
||||
let ap = entry.audio.clone().unwrap_or_default();
|
||||
let (rate, ch, bits) = match &entry.codec {
|
||||
CodecConfig::Aac(a) => (
|
||||
if a.sample_rate > 0 { a.sample_rate } else { ap.sample_rate as u32 },
|
||||
{
|
||||
// HE-AAC plays at twice the core rate the AudioSpecificConfig starts with
|
||||
// an AAC access unit is at most 6144 bits per channel: a hostile `stsz` can't make us read more
|
||||
let units = t
|
||||
.samples
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|x| filmcraft_media::reader::read_range(&*bytes.0, x.offset, (x.size as usize).min(MAX_AAC_PROBE_UNIT)).ok());
|
||||
let units: Vec<Vec<u8>> = units.collect();
|
||||
match crate::audio::aac_output_rate(&a.asc, units.iter().map(Vec::as_slice)) {
|
||||
Some(r) => r,
|
||||
None if a.sample_rate > 0 => a.sample_rate,
|
||||
None => ap.sample_rate as u32,
|
||||
}
|
||||
},
|
||||
if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels },
|
||||
None,
|
||||
),
|
||||
@@ -188,17 +225,7 @@ impl Mp4Source {
|
||||
};
|
||||
AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits }
|
||||
});
|
||||
let duration = {
|
||||
let v = vtrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
Tick::from_rational(t.duration as i64, 1, t.timescale as i64)
|
||||
});
|
||||
let a = atrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
Tick::from_rational(t.duration as i64, 1, t.timescale as i64)
|
||||
});
|
||||
v.or(a).unwrap_or_default()
|
||||
};
|
||||
let duration = vtrack.or(atrack).and_then(|i| file.tracks.get(i)).map(|t| presentation_duration(&file, t)).unwrap_or_default();
|
||||
let start_timecode = file.tracks.iter().find_map(|t| match t.codec() {
|
||||
Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64),
|
||||
_ => None,
|
||||
@@ -261,8 +288,10 @@ impl Mp4Source {
|
||||
fn video_at(&self, t: Tick) -> crate::Result<Arc<VideoFrame>> {
|
||||
let ti = self.vtrack.ok_or_else(|| CodecError::Unsupported("no video".into()))?;
|
||||
let ts = self.file.tracks[ti].timescale as i64;
|
||||
let late = filmcraft_media::cancel::catch_up().map(|m| (t - m).to_rational_floor(1, ts));
|
||||
self.video.frame_late(&Mp4Video { src: self, track: ti }, t.to_rational_floor(1, ts), late)
|
||||
// Nearest, not floor: with a coarse timescale (1000) sample times are rounded, and a
|
||||
// floored lookup lands one unit short of every frame whose time was rounded up.
|
||||
let late = filmcraft_media::cancel::catch_up().map(|m| (t - m).to_rational_round(1, ts));
|
||||
self.video.frame_late(&Mp4Video { src: self, track: ti }, t.to_rational_round(1, ts), late)
|
||||
}
|
||||
|
||||
fn audio_packet(&self, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
@@ -360,7 +389,9 @@ impl MediaSource for Mp4Source {
|
||||
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let p0 = (start as f64 * ratio).floor() as i64;
|
||||
let s0 = p0 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
|
||||
// (in i128: a hostile edit list can make the offset large enough to overflow i64)
|
||||
let offset = i128::from(self.audio_offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
|
||||
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
// Samples-per-unit: the track timescale is usually the sample rate for audio.
|
||||
let unit = src_rate as f64 / track.timescale.max(1) as f64;
|
||||
@@ -370,14 +401,14 @@ impl MediaSource for Mp4Source {
|
||||
let mut i = first;
|
||||
while i < track.samples.len() {
|
||||
let pk_start = (self.audio_starts[i] as f64 * unit) as i64;
|
||||
if pk_start >= s0 + need {
|
||||
if pk_start >= s0.saturating_add(need) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(&mut st, i)?;
|
||||
for (c, dst) in src.iter_mut().enumerate() {
|
||||
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
|
||||
for (k, v) in chan.iter().enumerate() {
|
||||
let pos = pk_start + k as i64 - s0;
|
||||
let pos = pk_start.saturating_add(k as i64).saturating_sub(s0);
|
||||
if pos >= 0 && (pos as usize) < dst.len() {
|
||||
dst[pos as usize] = *v;
|
||||
}
|
||||
@@ -429,6 +460,10 @@ fn codec_label(c: &CodecConfig) -> String {
|
||||
CodecConfig::Hevc(_) => "HEVC".into(),
|
||||
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
|
||||
CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")),
|
||||
CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) {
|
||||
Some(p) => p.name().into(),
|
||||
None => "APV".into(),
|
||||
},
|
||||
CodecConfig::ProRes { fourcc } => match &fourcc.0 {
|
||||
b"apco" => "Apple ProRes 422 Proxy".into(),
|
||||
b"apcs" => "Apple ProRes 422 LT".into(),
|
||||
@@ -453,14 +488,37 @@ fn codec_label(c: &CodecConfig) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "YUV 4:2:2 10-bit" from an H.264 / HEVC `chroma_format_idc` and bit depth.
|
||||
fn yuv_label(chroma_format_idc: u32, bits: u32) -> String {
|
||||
let sub = match chroma_format_idc {
|
||||
0 => "4:0:0",
|
||||
1 => "4:2:0",
|
||||
2 => "4:2:2",
|
||||
_ => "4:4:4",
|
||||
};
|
||||
format!("YUV {sub} {bits}-bit")
|
||||
}
|
||||
|
||||
/// `chroma_format_idc` and luma bit depth of an H.264 track: from the `avcC` High-profile extension
|
||||
/// (ISO/IEC 14496-15 §5.3.3), else from its first SPS. The profile alone can't say (High 4:2:2 is
|
||||
/// 8 or 10-bit, High 10 can be 8-bit).
|
||||
fn avc_format(a: &filmcraft_isobmff::AvcConfig) -> Option<(u32, u32)> {
|
||||
// reserved bits set: '111111' chroma_format, '11111' bit_depth_luma_minus8 (else not an extension)
|
||||
if !matches!(a.profile, 66 | 77 | 88)
|
||||
&& let &[chroma, depth, ..] = a.ext.as_slice()
|
||||
&& chroma & 0xFC == 0xFC
|
||||
&& depth & 0xF8 == 0xF8
|
||||
{
|
||||
return Some(((chroma & 3) as u32, (depth & 7) as u32 + 8));
|
||||
}
|
||||
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(a.sps.first()?.get(1..)?)).ok()?;
|
||||
Some((sps.chroma_format_idc, sps.bit_depth_luma))
|
||||
}
|
||||
|
||||
fn pixfmt_label(c: &CodecConfig) -> String {
|
||||
match c {
|
||||
CodecConfig::Avc(a) => match a.profile {
|
||||
110 => "YUV 4:2:0 10-bit".into(),
|
||||
122 => "YUV 4:2:2".into(),
|
||||
244 => "YUV 4:4:4".into(),
|
||||
_ => "YUV 4:2:0 8-bit".into(),
|
||||
},
|
||||
CodecConfig::Avc(a) => avc_format(a).map(|(chroma, bits)| yuv_label(chroma, bits)).unwrap_or_default(),
|
||||
CodecConfig::Hevc(h) => yuv_label(h.chroma_format_idc as u32, h.bit_depth_luma as u32),
|
||||
CodecConfig::Vp9(c) => {
|
||||
let sub = match c.chroma_subsampling {
|
||||
2 => "4:2:2",
|
||||
@@ -469,7 +527,12 @@ fn pixfmt_label(c: &CodecConfig) -> String {
|
||||
};
|
||||
format!("YUV {sub} {}-bit", c.bit_depth.max(8))
|
||||
}
|
||||
CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(),
|
||||
CodecConfig::Apv(a) => {
|
||||
let chroma = filmcraft_apv::ChromaFormat::from_idc(a.chroma_format_idc).unwrap_or(filmcraft_apv::ChromaFormat::Yuv422);
|
||||
crate::apv::apv_pixfmt_label(chroma, a.bit_depth_minus8.saturating_add(8))
|
||||
}
|
||||
// 4444 until the first frame header says whether it codes alpha (`open_reader`)
|
||||
CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUV 4:4:4 12-bit".into(),
|
||||
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
|
||||
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),
|
||||
_ => String::new(),
|
||||
@@ -483,6 +546,25 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
|
||||
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
/// How long a track plays. With an edit list that is the sum of its edits (ISO/IEC 14496-12
|
||||
/// §8.6.6, movie timescale), as `tkhd` records it. The media duration (`mdhd`) runs on the decode
|
||||
/// timeline: it is longer than the presentation by the B-frame delay an edit skips (`media_time`, one
|
||||
/// extra frame on every ffmpeg / x264 MP4 with B-frames) and shorter by a leading empty edit (the last
|
||||
/// frame of a delayed video track was never shown). Fragmented files, and edit lists that add up to
|
||||
/// nothing or to more than a [`Tick`] holds, keep the media duration.
|
||||
fn presentation_duration(file: &Mp4File, t: &filmcraft_isobmff::Track) -> Tick {
|
||||
let media = Tick::from_rational(t.duration as i64, 1, t.timescale as i64);
|
||||
if file.fragmented || t.edits.is_empty() || file.timescale == 0 {
|
||||
return media;
|
||||
}
|
||||
let edits = t.edits.iter().fold(0u128, |a, e| a.saturating_add(e.segment_duration as u128));
|
||||
let ticks = edits.saturating_mul(filmcraft_time::TICKS_PER_SECOND as u128) / file.timescale as u128;
|
||||
match i64::try_from(ticks) {
|
||||
Ok(t) if t > 0 => Tick(t),
|
||||
_ => media,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`filmcraft_media::ReaderOpener`] for MP4/MOV.
|
||||
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
|
||||
if !sniff(head) {
|
||||
@@ -520,6 +602,45 @@ mod tests {
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// A four-frame 25 fps ProRes MOV (64×32) whose video track carries `edits`.
|
||||
fn mov_with_edits(edits: Vec<filmcraft_isobmff::Edit>) -> Arc<[u8]> {
|
||||
let fr = filmcraft_prores::Frame::new(64, 32, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
|
||||
let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, 64, 32).encode(&fr).expect("encode");
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
|
||||
let mut cfg = TrackConfig::new(SampleEntry::prores(FourCc(*b"apch"), 64, 32), 25);
|
||||
cfg.edits = edits;
|
||||
let t = mux.add_track(cfg).expect("track");
|
||||
for _ in 0..4 {
|
||||
mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
}
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
/// A clip lasts as long as its edit list presents it, not as long as its media (`mdhd`) runs.
|
||||
/// Every ffmpeg / x264 MP4 with B-frames skips its reorder delay with an edit and imported one
|
||||
/// frame too long; a video track delayed by an empty edit lost its last frame.
|
||||
#[test]
|
||||
fn duration_follows_the_edit_list() {
|
||||
use filmcraft_isobmff::Edit;
|
||||
let frames = |s: &Mp4Source| s.info().duration.to_rational_floor(1, 25);
|
||||
// no edit list: the media duration
|
||||
assert_eq!(frames(&Mp4Source::open("plain.mov", mov_with_edits(Vec::new())).expect("open")), 4);
|
||||
// skip the first frame (B-frame delay style): three frames play (movie timescale 1000 ms)
|
||||
let skip = vec![Edit { segment_duration: 120, media_time: 1, media_rate: 0x10000 }];
|
||||
assert_eq!(frames(&Mp4Source::open("skip.mov", mov_with_edits(skip)).expect("open")), 3);
|
||||
// a one-frame empty edit, then all four frames: five frames, and the last one is there
|
||||
let delayed =
|
||||
vec![Edit { segment_duration: 40, media_time: -1, media_rate: 0x10000 }, Edit { segment_duration: 160, media_time: 0, media_rate: 0x10000 }];
|
||||
let s = Mp4Source::open("delayed.mov", mov_with_edits(delayed)).expect("open");
|
||||
assert_eq!(frames(&s), 5);
|
||||
assert!(s.video_frame(FrameRequest::full(Tick::from_rational(4, 1, 25))).is_ok());
|
||||
// hostile segment durations fall back to the media duration instead of wrapping
|
||||
let huge = vec![Edit { segment_duration: u64::MAX, media_time: 0, media_rate: 0x10000 }];
|
||||
assert_eq!(frames(&Mp4Source::open("huge.mov", mov_with_edits(huge)).expect("open")), 4);
|
||||
let zero = vec![Edit { segment_duration: 0, media_time: 0, media_rate: 0x10000 }];
|
||||
assert_eq!(frames(&Mp4Source::open("zero.mov", mov_with_edits(zero)).expect("open")), 4);
|
||||
}
|
||||
|
||||
/// Overwrite the big-endian u32 `skip` bytes after the first `fourcc` box type.
|
||||
fn patch_u32(b: &mut [u8], fourcc: &[u8; 4], skip: usize, v: u32) {
|
||||
let k = b.windows(4).position(|x| x == fourcc).expect("box") + 4 + skip;
|
||||
@@ -603,4 +724,144 @@ mod tests {
|
||||
let s = Mp4Source::open("cw2.mov", rotated_mov([0, 2 * ONE, 0, -2 * ONE, 0, 0, 64 * ONE, 0, W])).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_pixel_format_comes_from_hvcc() {
|
||||
for (chroma, bits, want) in [(1, 8, "YUV 4:2:0 8-bit"), (1, 10, "YUV 4:2:0 10-bit"), (2, 8, "YUV 4:2:2 8-bit"), (2, 10, "YUV 4:2:2 10-bit")] {
|
||||
let c = filmcraft_isobmff::HevcConfig { general_profile_idc: 1, chroma_format_idc: chroma, bit_depth_luma: bits, ..Default::default() };
|
||||
assert_eq!(pixfmt_label(&CodecConfig::Hevc(c)), want);
|
||||
}
|
||||
}
|
||||
|
||||
/// A High-profile SPS NAL unit (ITU-T H.264 §7.3.2.1.1) with the given chroma format and bit depth, 16×16.
|
||||
fn high_sps(profile: u8, chroma: u32, bits: u32) -> Vec<u8> {
|
||||
let mut w = filmcraft_bitstream::BitWriter::new();
|
||||
w.write_bits(0x67, 8);
|
||||
w.write_bits(profile as u32, 8);
|
||||
w.write_bits(0, 8); // constraint flags
|
||||
w.write_bits(30, 8); // level
|
||||
w.write_ue(0); // seq_parameter_set_id
|
||||
w.write_ue(chroma);
|
||||
if chroma == 3 {
|
||||
w.write_bit(false); // separate_colour_plane_flag
|
||||
}
|
||||
w.write_ue(bits - 8); // luma
|
||||
w.write_ue(bits - 8); // chroma
|
||||
w.write_bits(0, 2); // no transform bypass, no scaling matrices
|
||||
w.write_ue(0); // log2_max_frame_num_minus4
|
||||
w.write_ue(2); // pic_order_cnt_type
|
||||
w.write_ue(1); // max_num_ref_frames
|
||||
w.write_bit(false); // gaps_in_frame_num_allowed
|
||||
w.write_ue(0); // pic_width_in_mbs_minus1
|
||||
w.write_ue(0); // pic_height_in_map_units_minus1
|
||||
w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping
|
||||
w.write_bit(false); // no VUI
|
||||
w.rbsp_trailing();
|
||||
w.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avc_pixel_format_comes_from_avcc_not_the_profile() {
|
||||
let avc =
|
||||
|profile: u8, sps: Vec<u8>, ext: Vec<u8>| CodecConfig::Avc(filmcraft_isobmff::AvcConfig { profile, sps: vec![sps], ext, ..Default::default() });
|
||||
// High 4:2:2 at 10 and 8 bits, from the avcC extension (chroma_format 2, bit_depth_luma_minus8)
|
||||
assert_eq!(pixfmt_label(&avc(122, high_sps(122, 2, 10), vec![0xFE, 0xFA, 0xFA, 0])), "YUV 4:2:2 10-bit");
|
||||
assert_eq!(pixfmt_label(&avc(122, high_sps(122, 2, 8), vec![0xFE, 0xF8, 0xF8, 0])), "YUV 4:2:2 8-bit");
|
||||
// no extension (or not a valid one): the SPS says
|
||||
assert_eq!(pixfmt_label(&avc(122, high_sps(122, 2, 10), Vec::new())), "YUV 4:2:2 10-bit");
|
||||
assert_eq!(pixfmt_label(&avc(110, high_sps(110, 1, 10), vec![0, 0, 0, 0])), "YUV 4:2:0 10-bit");
|
||||
assert_eq!(pixfmt_label(&avc(244, high_sps(244, 3, 10), Vec::new())), "YUV 4:4:4 10-bit");
|
||||
// an unreadable SPS and no extension: unknown, not a guess
|
||||
assert_eq!(pixfmt_label(&avc(122, vec![0x67, 122], Vec::new())), "");
|
||||
}
|
||||
|
||||
/// A one-frame 32×16 ProRes MOV with the given profile, chroma format and fourcc.
|
||||
fn prores_mov(profile: filmcraft_prores::Profile, fourcc: &[u8; 4], chroma: filmcraft_prores::ChromaFormat, alpha: bool) -> Arc<[u8]> {
|
||||
let (w, h) = (32u32, 16u32);
|
||||
let fr = filmcraft_prores::Frame::new(w, h, chroma, if chroma == filmcraft_prores::ChromaFormat::Yuv444 { 12 } else { 10 }, alpha);
|
||||
let data = filmcraft_prores::Encoder::new(profile, w, h).encode(&fr).expect("encode");
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::prores(FourCc(*fourcc), w as u16, h as u16), 25)).expect("track");
|
||||
mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
/// `ap4h` / `ap4x` don't imply alpha: the frame header's alpha_channel_type does.
|
||||
#[test]
|
||||
fn prores_alpha_comes_from_the_frame_header() {
|
||||
use filmcraft_prores::{ChromaFormat, Profile};
|
||||
for (profile, fourcc, chroma, alpha, want) in [
|
||||
(Profile::P4444, b"ap4h", ChromaFormat::Yuv444, false, "YUV 4:4:4 12-bit"),
|
||||
(Profile::P4444, b"ap4h", ChromaFormat::Yuv444, true, "YUVA 4:4:4 12-bit"),
|
||||
(Profile::P4444Xq, b"ap4x", ChromaFormat::Yuv444, false, "YUV 4:4:4 12-bit"),
|
||||
(Profile::Hq, b"apch", ChromaFormat::Yuv422, false, "YUV 4:2:2 10-bit"),
|
||||
] {
|
||||
let s = Mp4Source::open("p.mov", prores_mov(profile, fourcc, chroma, alpha)).expect("open");
|
||||
let v = s.info().video.as_ref().expect("video");
|
||||
assert_eq!((v.pixel_format.as_str(), v.has_alpha), (want, alpha), "{}", String::from_utf8_lossy(fourcc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod he_aac_tests {
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
/// #108: an HE-AAC MP4 (explicit SBR signalling, core 22.05 kHz, output 44.1 kHz, timescale
|
||||
/// 44.1 kHz) built here from our AAC-LC encoder: the stream has no SBR data, which only the
|
||||
/// high band would need. It was reported at the 22.05 kHz core rate, so sequences made from
|
||||
/// such a clip were created at 22.05 kHz.
|
||||
#[test]
|
||||
fn he_aac_plays_at_the_output_rate() {
|
||||
let core = 22_050u32;
|
||||
let n = core as usize * 2;
|
||||
let tone: Vec<f32> = (0..n).map(|i| 0.25 * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / core as f32).sin()).collect();
|
||||
let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(core, 2, 64_000)).unwrap();
|
||||
let mut aus = enc.encode(&[&tone, &tone]);
|
||||
aus.extend(enc.flush());
|
||||
// AOT 5, core 22.05 kHz, stereo, extension 44.1 kHz, core AOT 2
|
||||
let asc = vec![0x2B, 0x92, 0x08, 0x00];
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, 2, 44_100), 44_100)).unwrap();
|
||||
for au in &aus {
|
||||
mux.write_sample(t, WriteSample { data: au, duration: 2048, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let b = mux.finish().unwrap().into_inner();
|
||||
let src = super::Mp4Source::open("he.mp4", b.into()).unwrap();
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().sample_rate, 44_100);
|
||||
// one second from 0.5 s at 44.1 kHz: the 1 kHz tone at its level (not silence, not shifted)
|
||||
let buf = src.audio(22_050, 44_100, 44_100).unwrap();
|
||||
let x = &buf.channels[0];
|
||||
let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!((0.2..0.3).contains(&peak), "peak {peak}");
|
||||
let crossings = x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count();
|
||||
assert!((995..=1005).contains(&crossings), "{crossings} Hz");
|
||||
}
|
||||
|
||||
/// A hostile edit list (#66): an AAC track whose edit puts it absurdly far off, read at another
|
||||
/// rate, scaled the offset to the source rate in i64 and overflowed. It reads as silence now.
|
||||
#[test]
|
||||
fn hostile_audio_edit_offset_never_overflows() {
|
||||
use filmcraft_isobmff::Edit;
|
||||
let rate = 32_000u32;
|
||||
let tone: Vec<f32> = (0..rate as usize / 2).map(|i| 0.25 * (i as f32 * 0.05).sin()).collect();
|
||||
let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 1, 64_000)).unwrap();
|
||||
let mut aus = enc.encode(&[&tone]);
|
||||
aus.extend(enc.flush());
|
||||
// AOT 2 (AAC-LC), 32 kHz (index 5), mono
|
||||
let asc = vec![0x12, 0x88];
|
||||
for media_time in [i64::MAX, i64::MAX / 3, i64::MIN / 3] {
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let mut cfg = TrackConfig::new(SampleEntry::aac(asc.clone(), 1, rate), rate);
|
||||
cfg.edits = vec![Edit { segment_duration: 500, media_time, media_rate: 0x10000 }];
|
||||
let t = mux.add_track(cfg).unwrap();
|
||||
for au in &aus {
|
||||
mux.write_sample(t, WriteSample { data: au, duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let src = super::Mp4Source::open("hostile.mp4", mux.finish().unwrap().into_inner().into()).unwrap();
|
||||
let buf = src.audio(0, 4800, 48_000).unwrap();
|
||||
assert!(buf.channels.iter().flatten().all(|v| v.is_finite() && v.abs() <= 1.0), "media_time {media_time}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Frame lookup against timestamps rounded to the container's timebase (issue #73).
|
||||
//!
|
||||
//! WebM stores timestamps in 1 ms units, so at 30 fps frame 2 (66.67 ms) is stored as 67. Flooring
|
||||
//! the requested time to 66 picked frame 1 instead: every third frame was skipped and the one
|
||||
//! before it shown twice. MP4 has the same problem when a muxer picks a 1 ms track timescale.
|
||||
//!
|
||||
//! The streams are synthetic Motion-JPEG: each picture shows its index as a row of black and
|
||||
//! white blocks, so a decoded frame says which sample it came from.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_frame::{PixelData, VideoFrame};
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::{FrameRequest, MediaSource};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
/// Bits of the frame index drawn per picture, one `BLOCK`-pixel square each.
|
||||
const BITS: u32 = 8;
|
||||
const BLOCK: u32 = 16;
|
||||
const SECONDS: i64 = 3;
|
||||
|
||||
/// The rates that don't divide 1 ms evenly (affected), and 25 fps (never was).
|
||||
const RATES: [FrameRate; 7] =
|
||||
[FrameRate::FPS_30, FrameRate::FPS_29_97, FrameRate::FPS_60, FrameRate::FPS_59_94, FrameRate::FPS_24, FrameRate::FPS_23_976, FrameRate::FPS_25];
|
||||
|
||||
/// How the muxer quantised the exact frame times to milliseconds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Stamp {
|
||||
/// To the nearest millisecond, halves up (mkvmerge, ffmpeg).
|
||||
Nearest,
|
||||
/// Truncated.
|
||||
Floor,
|
||||
}
|
||||
|
||||
fn picture(index: usize) -> Vec<u8> {
|
||||
let img = image::GrayImage::from_fn(BITS * BLOCK, BLOCK, |x, _| image::Luma([if (index >> (x / BLOCK)) & 1 == 1 { 255 } else { 0 }]));
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90).encode_image(&img).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn index_of(f: &VideoFrame) -> usize {
|
||||
let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") };
|
||||
(0..BITS).filter(|b| px[((BLOCK / 2 * f.width + b * BLOCK + BLOCK / 2) * 4) as usize] > 127).map(|b| 1 << b).sum()
|
||||
}
|
||||
|
||||
fn frame_count(rate: FrameRate) -> usize {
|
||||
rate.frame_at(Tick(SECONDS * TICKS_PER_SECOND)) as usize
|
||||
}
|
||||
|
||||
/// Frame `k`'s start in milliseconds, as the muxer stores it.
|
||||
fn stamp_ms(rate: FrameRate, k: usize, stamp: Stamp) -> i64 {
|
||||
let (num, den) = (rate.num as i128, rate.den as i128);
|
||||
let exact2 = 2 * k as i128 * 1000 * den; // twice the time in ms, times num
|
||||
(match stamp {
|
||||
Stamp::Nearest => (exact2 + num) / (2 * num),
|
||||
Stamp::Floor => exact2 / (2 * num),
|
||||
}) as i64
|
||||
}
|
||||
|
||||
fn webm(rate: FrameRate, stamp: Stamp) -> Arc<[u8]> {
|
||||
let mut spec = TrackSpec::new(TrackKind::Video, "V_MJPEG");
|
||||
spec.video_size = Some((BITS * BLOCK, BLOCK));
|
||||
spec.default_duration_ns = Some((1_000_000_000 * rate.den / rate.num) as u64);
|
||||
let opts = MuxOptions { doc_type: "webm".into(), ..MuxOptions::default() };
|
||||
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
|
||||
for k in 0..frame_count(rate) {
|
||||
w.write_frame(0, stamp_ms(rate, k, stamp) * 1_000_000, true, &picture(k), None).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
fn mp4_1ms(rate: FrameRate, stamp: Stamp) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::jpeg((BITS * BLOCK) as u16, BLOCK as u16), 1000)).unwrap();
|
||||
let n = frame_count(rate);
|
||||
for k in 0..n {
|
||||
let duration = (stamp_ms(rate, k + 1, stamp) - stamp_ms(rate, k, stamp)) as u32;
|
||||
w.write_sample(t, WriteSample { data: &picture(k), duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// Plays the stream back at its own rate (every frame's start), and asks for every frame's
|
||||
/// midpoint too. Returns what went wrong: frames never shown, frames shown twice, wrong midpoints.
|
||||
fn problems(src: &dyn MediaSource, rate: FrameRate) -> Vec<String> {
|
||||
let n = frame_count(rate);
|
||||
let at = |t: Tick| index_of(&src.video_frame(FrameRequest::full(t)).unwrap());
|
||||
let mut shown = vec![0usize; n];
|
||||
for k in 0..n {
|
||||
shown[at(rate.tick_of(k as i64)).min(n - 1)] += 1;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let dropped: Vec<usize> = (0..n).filter(|&k| shown[k] == 0).collect();
|
||||
let repeated: Vec<usize> = (0..n).filter(|&k| shown[k] > 1).collect();
|
||||
if !dropped.is_empty() || !repeated.is_empty() {
|
||||
out.push(format!(
|
||||
"{} of {n} frames never shown {:?}…, {} shown twice {:?}…",
|
||||
dropped.len(),
|
||||
&dropped[..dropped.len().min(4)],
|
||||
repeated.len(),
|
||||
&repeated[..repeated.len().min(4)]
|
||||
));
|
||||
}
|
||||
let mid: Vec<(usize, usize)> = (0..n).map(|k| (k, at(rate.tick_of(k as i64) + Tick(rate.frame_duration().0 / 2)))).filter(|(k, got)| k != got).collect();
|
||||
if !mid.is_empty() {
|
||||
out.push(format!("{} mid-frame requests wrong, e.g. (wanted, got) {:?}", mid.len(), &mid[..mid.len().min(4)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check(container: &str, open: fn(FrameRate, Stamp) -> Arc<[u8]>) {
|
||||
let mut failures = Vec::new();
|
||||
for rate in RATES {
|
||||
for stamp in [Stamp::Nearest, Stamp::Floor] {
|
||||
let src = crate::open_bytes(&format!("synthetic.{container}"), open(rate, stamp)).unwrap();
|
||||
for p in problems(src.as_ref(), rate) {
|
||||
failures.push(format!("{rate}, {stamp:?} ms stamps: {p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{container}:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webm_with_1ms_timestamps_shows_every_frame_once() {
|
||||
check("webm", webm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_with_a_1ms_timescale_shows_every_frame_once() {
|
||||
check("mov", mp4_1ms);
|
||||
}
|
||||
@@ -578,3 +578,40 @@ fn opus_mp4_stereo() {
|
||||
"MPEG-4",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apv_mp4_and_raw_bitstream_decode() {
|
||||
use filmcraft_isobmff::{ApvConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
|
||||
let (w, h) = (64u32, 32u32);
|
||||
let mut fr = filmcraft_apv::Frame::new(w, h, filmcraft_apv::ChromaFormat::Yuv422, 10, false);
|
||||
for (i, y) in fr.y.iter_mut().enumerate() {
|
||||
*y = if (i as u32 % w) < w / 2 { 850 } else { 150 };
|
||||
}
|
||||
let mut enc = filmcraft_apv::Encoder::new(filmcraft_apv::Profile::P422_10, w, h).expect("encoder");
|
||||
let au = enc.encode_raw_au(&fr).expect("encode_raw_au");
|
||||
let apvc = ApvConfig::parse(&enc.decoder_config_record()).expect("parse apvC");
|
||||
|
||||
// 1. Raw .apv elementary stream
|
||||
let raw_src = crate::open_bytes("clip.apv", au.clone().into()).expect("open raw .apv");
|
||||
let raw_v = raw_src.info().video.as_ref().expect("video info");
|
||||
assert_eq!((raw_v.width, raw_v.height), (w, h));
|
||||
assert_eq!(raw_v.codec, "APV 422-10");
|
||||
assert_eq!(raw_v.pixel_format, "YUV 4:2:2 10-bit");
|
||||
let decoded_raw = raw_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode raw frame");
|
||||
assert_eq!((decoded_raw.width, decoded_raw.height), (w, h));
|
||||
|
||||
// 2. MP4 with apv1 + apvC
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
|
||||
let vt = mux.add_track(TrackConfig::new(SampleEntry::apv(apvc, w as u16, h as u16), 24)).expect("track");
|
||||
mux.write_sample(vt, WriteSample { data: &au, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
let mp4_bytes: Arc<[u8]> = mux.finish().expect("finish").into_inner().into();
|
||||
|
||||
let mp4_src = crate::open_bytes("clip.mp4", mp4_bytes).expect("open mp4");
|
||||
let mp4_v = mp4_src.info().video.as_ref().expect("mp4 video info");
|
||||
assert_eq!((mp4_v.width, mp4_v.height), (w, h));
|
||||
assert_eq!(mp4_v.codec, "APV 422-10");
|
||||
assert_eq!(mp4_v.pixel_format, "YUV 4:2:2 10-bit");
|
||||
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
|
||||
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
|
||||
}
|
||||
+71
-40
@@ -412,32 +412,14 @@ impl Vp9Decoder {
|
||||
|
||||
/// `None` for a picture whose planes mix sample types (never produced by the decoder).
|
||||
fn convert(&self, p: filmcraft_vp9::Picture) -> Option<DecodedFrame> {
|
||||
use filmcraft_color::{Matrix, Primaries, Range};
|
||||
use filmcraft_color::Range;
|
||||
use filmcraft_frame::{Chroma, PixelData};
|
||||
use filmcraft_vp9::Plane;
|
||||
use std::sync::Arc;
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
let (cw, ch) = (p.chroma_width as usize, p.chroma_height as usize);
|
||||
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(p.width, p.height), ..filmcraft_color::ColorInfo::REC709 };
|
||||
// color_space (7.2.2): 1 BT.601, 2 BT.709, 3 SMPTE-170, 4 SMPTE-240, 5 BT.2020, 7 sRGB.
|
||||
match p.color.color_space {
|
||||
1 | 3 => color.matrix = Matrix::Bt601,
|
||||
2 | 4 => color.matrix = Matrix::Bt709,
|
||||
5 => {
|
||||
color.matrix = Matrix::Bt2020Ncl;
|
||||
color.primaries = Primaries::Bt2020;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(t) = self.transfer {
|
||||
color.transfer = t;
|
||||
}
|
||||
if let Some(pr) = self.primaries {
|
||||
color.primaries = pr;
|
||||
}
|
||||
if p.color.full_range {
|
||||
color.range = Range::Full;
|
||||
}
|
||||
// (shared with the hardware decoders: `hw_frame::vp9_color`)
|
||||
let color = crate::hw_frame::vp9_color(p.width, p.height, p.color.color_space, p.color.full_range, self.transfer, self.primaries);
|
||||
let (pts, draft) = (p.pts, p.draft);
|
||||
if p.color.color_space == 7 {
|
||||
// RGB (profiles 1 / 3, 4:4:4): the planes carry G, B, R.
|
||||
@@ -536,25 +518,8 @@ impl Av1Decoder {
|
||||
let draft = p.draft;
|
||||
let w = p.width as usize;
|
||||
let h = p.height as usize;
|
||||
let mut color = filmcraft_color::ColorInfo::REC709;
|
||||
if let Some(m) = filmcraft_color::Matrix::from_code(p.matrix_coefficients) {
|
||||
color.matrix = m;
|
||||
} else {
|
||||
color.matrix = filmcraft_frame::default_matrix(p.width, p.height);
|
||||
}
|
||||
if let Some(t) = filmcraft_color::Transfer::from_code(p.transfer_characteristics) {
|
||||
color.transfer = t;
|
||||
}
|
||||
color.primaries = match p.color_primaries {
|
||||
9 => filmcraft_color::Primaries::Bt2020,
|
||||
12 => filmcraft_color::Primaries::P3D65,
|
||||
5 => filmcraft_color::Primaries::Bt601_625,
|
||||
6 => filmcraft_color::Primaries::Bt601_525,
|
||||
_ => filmcraft_color::Primaries::Bt709,
|
||||
};
|
||||
if p.full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
// (shared with the hardware decoders: `hw_frame::av1_color`)
|
||||
let color = crate::hw_frame::av1_color(p.width, p.height, p.matrix_coefficients, p.transfer_characteristics, p.color_primaries, p.full_range);
|
||||
let chroma = match (p.subsampling_x, p.subsampling_y) {
|
||||
(1, 1) => filmcraft_frame::Chroma::C420,
|
||||
(1, 0) => filmcraft_frame::Chroma::C422,
|
||||
@@ -676,6 +641,72 @@ pub fn prores_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>>
|
||||
matches!(e.codec, CodecConfig::ProRes { .. }).then(|| Ok(Box::new(ProResDecoder) as Box<dyn VideoDecoder>))
|
||||
}
|
||||
|
||||
/// Our APV (Advanced Professional Video, RFC 9924) decoder (every frame is intra; tiles decode in
|
||||
/// parallel).
|
||||
pub struct ApvDecoder;
|
||||
|
||||
/// Convert a decoded APV frame to a [`VideoFrame`].
|
||||
pub fn apv_to_video_frame(f: filmcraft_apv::Frame) -> VideoFrame {
|
||||
use std::sync::Arc;
|
||||
let chroma = match f.chroma {
|
||||
filmcraft_apv::ChromaFormat::Yuv422 => filmcraft_frame::Chroma::C422,
|
||||
filmcraft_apv::ChromaFormat::Monochrome | filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => filmcraft_frame::Chroma::C444,
|
||||
};
|
||||
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(f.width, f.height), ..filmcraft_color::ColorInfo::REC709 };
|
||||
if let Some(m) = filmcraft_color::Matrix::from_code(f.color.matrix) {
|
||||
color.matrix = m;
|
||||
}
|
||||
if let Some(t) = filmcraft_color::Transfer::from_code(f.color.transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
if let Some(p) = primaries_from_code(f.color.primaries) {
|
||||
color.primaries = p;
|
||||
}
|
||||
if f.color.full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
let neutral = 1u16 << f.bit_depth.saturating_sub(1);
|
||||
let (cb, cr) = if f.chroma == filmcraft_apv::ChromaFormat::Monochrome {
|
||||
let u = vec![neutral; (f.width as usize) * (f.height as usize)];
|
||||
(u.clone(), u)
|
||||
} else {
|
||||
(f.cb, f.cr)
|
||||
};
|
||||
let data = if f.bit_depth == 8 {
|
||||
let to8 = |p: Vec<u16>| Arc::new(p.into_iter().map(|v| v as u8).collect::<Vec<u8>>());
|
||||
filmcraft_frame::PixelData::Yuv8 { planes: [to8(f.y), to8(cb), to8(cr)], chroma, alpha: f.alpha.map(to8) }
|
||||
} else {
|
||||
filmcraft_frame::PixelData::Yuv16 {
|
||||
planes: [Arc::new(f.y), Arc::new(cb), Arc::new(cr)],
|
||||
chroma,
|
||||
bits: f.bit_depth as u32,
|
||||
alpha: f.alpha.map(Arc::new),
|
||||
}
|
||||
};
|
||||
VideoFrame { width: f.width, height: f.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }
|
||||
}
|
||||
|
||||
impl VideoDecoder for ApvDecoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let f = filmcraft_apv::decode_frame(sample).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
Ok(vec![DecodedFrame { pts, frame: apv_to_video_frame(f), draft: false }])
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
Vec::new()
|
||||
}
|
||||
fn reset(&mut self) {}
|
||||
fn name(&self) -> &str {
|
||||
"FilmCraft APV"
|
||||
}
|
||||
fn intra_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apv_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
|
||||
matches!(e.codec, CodecConfig::Apv(_)).then(|| Ok(Box::new(ApvDecoder) as Box<dyn VideoDecoder>))
|
||||
}
|
||||
|
||||
/// Our DNxHD / DNxHR (VC-3) decoder (every frame is intra; macroblock rows decode in parallel).
|
||||
pub struct DnxDecoder;
|
||||
|
||||
|
||||
@@ -13,19 +13,33 @@ use filmcraft_media::sequence::{FrameLoader, ImageSequenceSource, Numbered, sequ
|
||||
use filmcraft_media::{FrameRequest, MediaKind, MediaSource};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
/// Twelve numbered stills written by ffmpeg, shared by every test that uses `ext` (#125).
|
||||
///
|
||||
/// ffmpeg writes into a private temporary directory, which is renamed into place only when all
|
||||
/// twelve frames are there, so a test never reads a sequence another test is still writing (a
|
||||
/// half-written PNG decodes as missing, and the sequence then holds the previous frame). A test that
|
||||
/// loses the race keeps the winner's copy. The `.complete` marker tells a finished cache from a
|
||||
/// directory left by an older, non-atomic generator (hence the `_v2` name).
|
||||
fn make_sequence(ff: &Path, ext: &str, codec_args: &[&str]) -> Option<std::path::PathBuf> {
|
||||
let d = dir().join(format!("seq_{ext}"));
|
||||
let first = d.join(format!("img_0001.{ext}"));
|
||||
if first.exists() && d.join(format!("img_0012.{ext}")).exists() {
|
||||
let d = dir().join(format!("seq_{ext}_v2"));
|
||||
let complete = |d: &Path| d.join(".complete").exists();
|
||||
if complete(&d) {
|
||||
return Some(d);
|
||||
}
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
let pattern = d.join(format!("img_%04d.{ext}"));
|
||||
let tmp = filmcraft_testkit::fixtures::temp_path(&d);
|
||||
std::fs::create_dir_all(&tmp).ok()?;
|
||||
let pattern = tmp.join(format!("img_%04d.{ext}"));
|
||||
let mut args = vec!["-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=96x64:rate=25", "-frames:v", "12"];
|
||||
args.extend_from_slice(codec_args);
|
||||
args.extend_from_slice(&["-f", "image2"]);
|
||||
let st = std::process::Command::new(ff).args(&args).arg(&pattern).status().ok()?;
|
||||
st.success().then_some(d)
|
||||
let made = std::process::Command::new(ff).args(&args).arg(&pattern).status().is_ok_and(|st| st.success())
|
||||
&& (1..=12).all(|n| tmp.join(format!("img_{n:04}.{ext}")).exists())
|
||||
&& std::fs::write(tmp.join(".complete"), b"").is_ok();
|
||||
if made && std::fs::rename(&tmp, &d).is_ok() {
|
||||
return Some(d);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
complete(&d).then_some(d)
|
||||
}
|
||||
|
||||
fn loader() -> FrameLoader {
|
||||
@@ -104,9 +118,9 @@ fn webp_sequence_lossless() {
|
||||
fn missing_frames_hold_the_previous_frame() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let Some(d) = make_sequence(&ff, "png", &[]) else { return };
|
||||
// a copy without frames 5 and 6
|
||||
let gap = dir().join("seq_png_gap");
|
||||
let _ = std::fs::create_dir_all(&gap);
|
||||
// a private copy without frames 5 and 6
|
||||
let gap = filmcraft_testkit::fixtures::temp_path(&dir().join("seq_png_gap"));
|
||||
std::fs::create_dir_all(&gap).unwrap();
|
||||
for n in (1..=12).filter(|n| *n != 5 && *n != 6) {
|
||||
let name = format!("img_{n:04}.png");
|
||||
std::fs::copy(d.join(&name), gap.join(&name)).unwrap();
|
||||
@@ -119,6 +133,7 @@ fn missing_frames_hold_the_previous_frame() {
|
||||
assert_eq!(at(4), at(3));
|
||||
assert_eq!(at(5), at(3));
|
||||
assert_ne!(at(6), at(3));
|
||||
let _ = std::fs::remove_dir_all(&gap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,3 +37,19 @@ fn aac_mp4_resampled_reads_follow_the_edit_list() {
|
||||
assert!(peak <= level * 1.01, "resampled peak {peak} above the source's {level}");
|
||||
assert!(worst < 1e-4, "resampled read differs from the source by {worst}");
|
||||
}
|
||||
|
||||
/// ffmpeg's MP4 muxer skips the B-frame reorder delay with an edit list, so the media (`mdhd`) runs
|
||||
/// one frame past what plays. The clip must be as long as ffmpeg decodes it: 60 frames, not 61.
|
||||
#[test]
|
||||
fn bframe_mp4_duration_is_the_decoded_frame_count() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let args = ["-f", "lavfi", "-i", "testsrc2=size=160x96:rate=30:duration=2", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"];
|
||||
let Some(f) = fixture(&ff, "bframes_x264.mp4", &args) else {
|
||||
eprintln!("SKIPPED: ffmpeg without libx264");
|
||||
return;
|
||||
};
|
||||
let n = ffmpeg_frames(&ff, &f, "yuv420p").len() / (160 * 96 * 3 / 2);
|
||||
assert_eq!(n, 60);
|
||||
let src = filmcraft_codecs::open_bytes("bframes_x264.mp4", bytes(&f)).unwrap();
|
||||
assert_eq!(src.info().duration, filmcraft_time::FrameRate::FPS_30.tick_of(n as i64));
|
||||
}
|
||||
+27
-6
@@ -550,6 +550,7 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let old_end = it.end();
|
||||
let old_start = it.start;
|
||||
let own_link = it.link;
|
||||
let speed = it.speed.abs();
|
||||
let mut work = seq.clone();
|
||||
{
|
||||
@@ -582,8 +583,12 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
continue;
|
||||
}
|
||||
if tr.id == tid || tr.sync_lock {
|
||||
if tr.id != tid && shift < Tick::ZERO && !track_range_empty(tr, TimeRange::new(at + shift - Tick(if edge == Edge::In { 1 } else { 0 }), -shift))
|
||||
{
|
||||
// the stretch that closes: a shorter head takes it from just after the cut, a
|
||||
// shorter tail from just before it
|
||||
let closing = if edge == Edge::In { TimeRange::new(old_start, -shift) } else { TimeRange::new(at + shift, -shift) };
|
||||
// (the clip's own linked partners are the caller's to trim or leave)
|
||||
let in_the_way = tr.items.iter().any(|i| i.range().overlaps(&closing) && (own_link.is_none() || i.link != own_link));
|
||||
if tr.id != tid && shift < Tick::ZERO && in_the_way {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
let from = if edge == Edge::In { old_start + Tick(1) } else { old_end };
|
||||
@@ -647,7 +652,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
origins.push((tid, from));
|
||||
}
|
||||
}
|
||||
let main_from = {
|
||||
// a shorter or longer head: the cut is the latest member's start (a sound that leads its picture
|
||||
// starts earlier), whichever member was grabbed
|
||||
let main_from = if edge == Edge::In {
|
||||
origins.iter().map(|o| o.1).max().unwrap_or_default()
|
||||
} else {
|
||||
let (tid, _) = seq.find_item(first).ok_or(EditError::NoItem(first))?;
|
||||
origins.iter().find(|(t, _)| *t == tid).map(|o| o.1).unwrap_or_default()
|
||||
};
|
||||
@@ -672,13 +681,25 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
let later = tr.items.iter().filter(|i| !clips.contains(&i.id) && i.start >= *from);
|
||||
shifting_links.extend(later.filter_map(|i| i.link).filter(|l| !own_links.contains(l)));
|
||||
}
|
||||
// every linked clip: its link, and where it is and ends
|
||||
let linked: Vec<(u64, ClipId, Tick)> = work.all_tracks().flat_map(|tr| tr.items.iter().filter_map(|i| i.link.map(|l| (l, i.id, i.end())))).collect();
|
||||
for (tid, from, member) in rippling {
|
||||
let Some(tr) = work.track_mut(tid) else { continue };
|
||||
let follows = |i: &TrackItem| i.start < from && i.end() > from && i.link.is_some_and(|l| shifting_links.contains(&l));
|
||||
// An L cut: the sound of a clip that ends at the cut runs on into the shortened head. That clip
|
||||
// stays put, so its sound stays with it and the L cut still ends where it did. (Unlinked material
|
||||
// across the cut, a music bed, still refuses, and so does a linked clip whose partner reaches past
|
||||
// the cut.)
|
||||
let stays = |i: &TrackItem| {
|
||||
let cut = from - Tick(1);
|
||||
let mut partners = linked.iter().filter(|p| Some(p.0) == i.link && p.1 != i.id).peekable();
|
||||
edge == Edge::In && i.start < cut && i.link.is_some_and(|l| !own_links.contains(&l)) && partners.peek().is_some() && partners.all(|p| p.2 <= cut)
|
||||
};
|
||||
if !member && shift < Tick::ZERO {
|
||||
let at = if edge == Edge::In { from - Tick(1) } else { from };
|
||||
let closing = TimeRange::new(at + shift, -shift);
|
||||
if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i)) {
|
||||
// a shorter head closes the stretch just after the cut, a shorter tail the one before it
|
||||
let closing = if edge == Edge::In { TimeRange::new(from - Tick(1), -shift) } else { TimeRange::new(from + shift, -shift) };
|
||||
let own = |i: &TrackItem| i.link.is_some_and(|l| own_links.contains(&l));
|
||||
if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i) && !own(i) && !stays(i)) {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,3 +571,104 @@ fn hostile_speeds_are_refused_or_clamped_never_overflow() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_head_into_an_l_cut_keeps_the_earlier_sound_where_it_is() {
|
||||
// V1: a [0,10), b [10,20), c [20,30). a's sound on sync-locked A1 runs 3 into b: [0,13) (an L
|
||||
// cut). b loses 3 at its head: a never moves, so its sound stays and the L cut still ends at 13.
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 20, 10, 0);
|
||||
let a_sound = fx.put(a1, 0, 13, 0);
|
||||
for x in [a, a_sound] {
|
||||
fx.seq.find_item_mut(x).unwrap().1.link = Some(7);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 7), (17, 10)], vec![(0, 13)]));
|
||||
fx.seq.check().unwrap();
|
||||
|
||||
// later material on A1 moves up with the pictures; landing on the L cut's sound refuses
|
||||
let later = fx.put(a1, 15, 5, 0);
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
assert_eq!(fx.spans(a1), vec![(0, 13), (15, 5)], "unchanged on failure");
|
||||
fx.seq.find_item_mut(later).unwrap().1.start = f(20);
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 5), (15, 10)], vec![(0, 13), (18, 5)]));
|
||||
|
||||
// unlinked material across the cut (a music bed) still refuses
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
fx.put(a1, 0, 13, 0);
|
||||
let mut n = fx.next;
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
// so does a link with no partner left
|
||||
fx.seq.track_mut(a1).unwrap().items[0].link = Some(9);
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
|
||||
// a linked cutaway across the cut whose own partner reaches past it too is no L cut: it refuses
|
||||
let mut fx = Fx::new();
|
||||
let (v1, v2, a1) = (fx.v(0), fx.v(1), fx.a(0));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 20, 10, 0);
|
||||
let x = fx.put(v2, 5, 20, 0);
|
||||
let xs = fx.put(a1, 5, 20, 0);
|
||||
for c in [x, xs] {
|
||||
fx.seq.find_item_mut(c).unwrap().1.link = Some(7);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() {
|
||||
// V1: a [0,10), b [10,20). a's sound on sync-locked A1 runs up to the cut: [0,10). b loses 3 at
|
||||
// its head; the stretch that closes is [10,13), where A1 is empty, so nothing refuses.
|
||||
for group in [false, true] {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 0, 10, 0);
|
||||
let later = fx.put(a1, 20, 5, 0);
|
||||
let mut n = fx.next;
|
||||
if group {
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
} else {
|
||||
trim(&mut fx.seq, b, Edge::In, TrimMode::Ripple, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
}
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 7), (17, 10)], vec![(0, 10), (17, 5)]), "group {group}");
|
||||
// material inside the stretch that closes still refuses
|
||||
fx.seq.find_item_mut(later).unwrap().1.start = f(11);
|
||||
let r = if group {
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(2), &mut Fx::ctx(&mut n))
|
||||
} else {
|
||||
trim(&mut fx.seq, b, Edge::In, TrimMode::Ripple, f(2), &mut Fx::ctx(&mut n))
|
||||
};
|
||||
assert_eq!(r, Err(EditError::SyncLockConflict), "group {group}");
|
||||
}
|
||||
// a clip on a sync-locked track that starts at the cut stays put while the pictures after it move
|
||||
// up: only the stretch after the cut sees it (the old stretch, before the cut, let it through)
|
||||
for group in [false, true] {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 10, 5, 0);
|
||||
let mut n = fx.next;
|
||||
let r = if group {
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n))
|
||||
} else {
|
||||
trim(&mut fx.seq, b, Edge::In, TrimMode::Ripple, f(3), &mut Fx::ctx(&mut n))
|
||||
};
|
||||
assert_eq!(r, Err(EditError::SyncLockConflict), "group {group}");
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use filmcraft_interchange::essence::{AudioEssence, AudioNeed, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, audio_needs};
|
||||
use filmcraft_project::{ItemId, Project, SequenceSettings, TrackKind};
|
||||
use filmcraft_interchange::essence::{AudioEssence, AudioNeed, EssenceData, EssenceKey, MediaOptions, MixdownVideo, NeedOptions, NestNeeds, audio_needs};
|
||||
use filmcraft_project::{ClipId, ItemId, Project, Sequence, SequenceSettings, Track, TrackItem, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
use crate::commands::{bad, bool_p, str_p, u64_p};
|
||||
@@ -50,6 +50,10 @@ pub struct AudioPlan {
|
||||
pub handles: Tick,
|
||||
pub render_effects: bool,
|
||||
pub breakout: bool,
|
||||
/// Nested sequences on the audio tracks. OMF: their sound is rendered into the export, as
|
||||
/// Premiere Pro does. AAF: they are written as compositions, so the media of the clips inside
|
||||
/// them is prepared like the rest.
|
||||
pub nests: NestNeeds,
|
||||
}
|
||||
|
||||
fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Result<AudioPlan> {
|
||||
@@ -85,6 +89,7 @@ fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Resul
|
||||
handles: q.settings.frame_rate.tick_of(frames),
|
||||
render_effects: bool_p(p, "renderAudioEffects").unwrap_or(false),
|
||||
breakout: bool_p(p, "breakoutToMono").unwrap_or(false),
|
||||
nests: if omf { NestNeeds::Render } else { NestNeeds::Inside },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,12 +159,8 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
|
||||
/// pipeline's audio mixer.
|
||||
fn render_clip(s: &Session, seq: ItemId, need: &AudioNeed, sample_rate: u32, bits: u16) -> Result<Vec<Vec<f32>>> {
|
||||
let EssenceKey::Clip(clip_id) = need.key else { return decode(s, need, sample_rate) };
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let (track, c) = q
|
||||
.tracks(TrackKind::Audio)
|
||||
.iter()
|
||||
.find_map(|t| t.items.iter().find(|c| c.id == clip_id).map(|c| (t, c.clone())))
|
||||
.ok_or_else(|| EngineError::Other("clip not found".into()))?;
|
||||
let (q, track, c) = audio_clip(&s.project, seq, clip_id).ok_or_else(|| EngineError::Other("clip not found".into()))?;
|
||||
let c = c.clone();
|
||||
let mut p: Project = (*s.project).clone();
|
||||
let settings = SequenceSettings { sample_rate, ..q.settings.clone() };
|
||||
let rs = p.new_sequence("AAF audio render", settings, 0, 1, None);
|
||||
@@ -204,6 +205,19 @@ fn render_clip(s: &Session, seq: ItemId, need: &AudioNeed, sample_rate: u32, bit
|
||||
Ok(dequantize(&pcm, ch as usize, b))
|
||||
}
|
||||
|
||||
/// The audio clip `clip` with its track and sequence: in `seq`, or in a sequence nested in it
|
||||
/// (clip ids are unique in a project).
|
||||
fn audio_clip(p: &Project, seq: ItemId, clip: ClipId) -> Option<(&Sequence, &Track, &TrackItem)> {
|
||||
let first = p.sequence(seq).into_iter();
|
||||
let others = p.sequences().filter(|i| i.id != seq).filter_map(|i| p.sequence(i.id));
|
||||
for q in first.chain(others) {
|
||||
if let Some((t, c)) = q.tracks(TrackKind::Audio).iter().find_map(|t| t.items.iter().find(|c| c.id == clip).map(|c| (t, c))) {
|
||||
return Some((q, t, c));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn safe_name(s: &str) -> String {
|
||||
let n: String = s.chars().map(|c| if c.is_alphanumeric() || " -_.".contains(c) { c } else { '_' }).collect();
|
||||
let n = n.trim().trim_matches('.').to_string();
|
||||
@@ -221,19 +235,24 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let needs =
|
||||
audio_needs(&s.project, seq, &NeedOptions { handles: plan.handles, per_clip: plan.render_effects, whole_media: !plan.trim && !plan.render_effects });
|
||||
let needs = audio_needs(
|
||||
&s.project,
|
||||
seq,
|
||||
&NeedOptions { handles: plan.handles, per_clip: plan.render_effects, whole_media: !plan.trim && !plan.render_effects, nests: plan.nests },
|
||||
);
|
||||
let mut essence = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for need in &needs {
|
||||
let planar = if plan.render_effects { render_clip(s, seq, need, plan.sample_rate, plan.bits)? } else { decode(s, need, plan.sample_rate)? };
|
||||
// a nested sequence has no file to read: its sound is mixed, with what is on the clip
|
||||
let nest = s.project.sequence(need.item).is_some();
|
||||
let planar = if plan.render_effects || nest { render_clip(s, seq, need, plan.sample_rate, plan.bits)? } else { decode(s, need, plan.sample_rate)? };
|
||||
let frames = planar.first().map_or(0, Vec::len) as u64;
|
||||
let groups: Vec<(Option<u32>, Vec<&Vec<f32>>)> =
|
||||
if plan.breakout { planar.iter().enumerate().map(|(c, x)| (Some(c as u32), vec![x])).collect() } else { vec![(None, planar.iter().collect())] };
|
||||
let base = match need.key {
|
||||
EssenceKey::Clip(c) => {
|
||||
let name = s.project.sequence(seq).and_then(|q| q.find_item(c)).map(|(_, ti)| ti.name.clone()).unwrap_or_default();
|
||||
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
@@ -302,7 +321,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
|
||||
bits: plan.bits,
|
||||
channels: n,
|
||||
data,
|
||||
effects_rendered: plan.render_effects,
|
||||
effects_rendered: plan.render_effects || nest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,95 @@ fn omf_renders_clip_effects_into_embedded_audio() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// `setup`, with both clips nested into "Inner" (one linked picture + sound nest on V1 / A1) and
|
||||
/// −6.0206 dB (half amplitude) on the nest's sound clip.
|
||||
fn setup_nested(name: &str) -> (Session, Vec<ItemId>, ItemId, ItemId, std::path::PathBuf) {
|
||||
let (mut s, items, seq, dir) = setup(name);
|
||||
s.execute("sequence.open", json!({"item": seq.0})).unwrap();
|
||||
let clips: Vec<u64> = s.project.sequence(seq).unwrap().all_tracks().flat_map(|t| t.items.iter().map(|c| c.id.0)).collect();
|
||||
s.execute("timeline.select", json!({"clips": clips})).unwrap();
|
||||
let inner = ItemId(s.execute("clip.nest", json!({"name": "Inner"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
let mut p = (*s.project).clone();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
assert_eq!((q.video_tracks[0].items.len(), q.audio_tracks[0].items.len()), (1, 1), "one linked nest");
|
||||
q.audio_tracks[0].items[0].effect_mut("volume").unwrap().params.insert("level".into(), Param::new(ParamValue::Float(-6.020_599_913)));
|
||||
s.project = Arc::new(p);
|
||||
(s, items, seq, inner, dir)
|
||||
}
|
||||
|
||||
/// Premiere Pro renders the sound of a nested sequence into an OMF as one clip named after the
|
||||
/// sequence (seen in an OMF it exported). So do we: the nest is mixed, with what is on its clip.
|
||||
#[test]
|
||||
fn omf_renders_the_sound_of_a_nested_sequence() {
|
||||
let (mut s, items, seq, inner, dir) = setup_nested("omf-nest");
|
||||
let nest = s.project.sequence(seq).unwrap().audio_tracks[0].items[0].clone();
|
||||
let path = dir.join("nest.omf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportOmf", json!({"path": path, "handles": 0, "sequence": seq.0})).unwrap();
|
||||
assert!(!r["report"].to_string().contains("nested"), "{r}");
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let q = s.project.sequence(imported_seq(&r)).unwrap().clone();
|
||||
let a = &q.audio_tracks[0].items;
|
||||
assert_eq!(a.len(), 1, "the nest is one clip");
|
||||
assert_eq!((a[0].name.as_str(), a[0].start, a[0].duration), ("Inner", nest.start, nest.duration));
|
||||
assert!(s.project.sequence(a[0].item).is_none(), "media in the document, not a sequence");
|
||||
// what it plays: the nested sequence's two clips one after the other, at half amplitude
|
||||
let inside = s.project.sequence(inner).unwrap().audio_tracks[0].items.clone();
|
||||
assert_eq!(inside.len(), 2);
|
||||
let got = audio_of(&s, a[0].item, a[0].source_in.to_units_floor(48_000), 96_000);
|
||||
for (k, oc) in inside.iter().enumerate() {
|
||||
let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 48_000);
|
||||
assert_eq!(oc.item, items[k]);
|
||||
let peak = want[0].iter().fold(0f32, |m, x| m.max(x.abs()));
|
||||
assert!(peak > 0.01, "the demo scene has audio");
|
||||
// (away from the cut between the two clips)
|
||||
for i in 480..47_520 {
|
||||
let g = got[0][k * 48_000 + i];
|
||||
assert!((g - want[0][i] * 0.5).abs() < 2e-3, "clip {k} sample {i}: {g} vs {}", want[0][i] * 0.5);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An AAF holds a nested sequence as a composition of its own (as Premiere Pro's does): it comes
|
||||
/// back as a sequence that the nest clips use, with its own clips, and with embedded audio the
|
||||
/// media inside it is in the document too.
|
||||
#[test]
|
||||
fn aaf_keeps_a_nested_sequence_as_a_sequence() {
|
||||
let (mut s, items, seq, inner, dir) = setup_nested("aaf-nest");
|
||||
let path = dir.join("nest.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "embedded", "trimAudio": true, "handles": 6, "sequence": seq.0})).unwrap();
|
||||
assert!(!r["report"].to_string().contains("gap"), "{r}");
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let q = s.project.sequence(imported_seq(&r)).unwrap().clone();
|
||||
let (v, a) = (&q.video_tracks[0].items, &q.audio_tracks[0].items);
|
||||
assert_eq!((v.len(), a.len()), (1, 1));
|
||||
let nested = v[0].item;
|
||||
assert_ne!(nested, inner, "a sequence of the imported document");
|
||||
assert_eq!((a[0].item, s.project.item(nested).unwrap().name.as_str()), (nested, "Inner"));
|
||||
assert!(v[0].link.is_some() && v[0].link == a[0].link, "picture and sound of the nest are linked");
|
||||
let level = a[0].effect("volume").and_then(|e| e.param("level")).and_then(|p| p.value.as_f64()).unwrap();
|
||||
assert!((level + 6.0206).abs() < 1e-2, "the level stays on the nest's clip: {level}");
|
||||
// inside: the two clips, their picture linked to the movies, their sound in the document
|
||||
let n = s.project.sequence(nested).unwrap().clone();
|
||||
let orig = s.project.sequence(inner).unwrap().clone();
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
let got: Vec<_> = n.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
let want: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
assert_eq!(got, want, "{kind:?}");
|
||||
}
|
||||
assert_eq!(path_of(&s, n.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
for (c, oc) in n.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
let got = audio_of(&s, c.item, c.source_in.to_units_floor(48_000), 4800);
|
||||
let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 4800);
|
||||
for i in 0..4800 {
|
||||
assert!((got[0][i] - want[0][i]).abs() <= 1.0 / 32_768.0 + 1e-6, "sample {i}");
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_registered_with_menus_and_validate_params() {
|
||||
for (id, menu) in [("file.exportAaf", &["File", "Export"][..]), ("file.exportOmf", &["File", "Export"][..]), ("file.importAaf", &[][..])] {
|
||||
|
||||
@@ -837,3 +837,67 @@ fn subclip_source_monitor_trims_and_markers() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.item(sub).unwrap().kind, ItemKind::Subclip { restrict_trims: false, .. }));
|
||||
}
|
||||
|
||||
/// #29: a Color Matte's color can be changed after it is created; the generated frames follow,
|
||||
/// and undo brings the old color back (the media pool regenerates the matte, it doesn't keep
|
||||
/// serving the first one).
|
||||
#[test]
|
||||
fn color_matte_color_changes_and_undoes() {
|
||||
let mut s = demo();
|
||||
let matte = s.execute("file.newColorMatte", json!({"width": 64, "height": 36, "color": "#ff0000"})).unwrap()["item"].as_u64().unwrap();
|
||||
let px = |s: &Session| {
|
||||
let f = s.source(ItemId(matte)).unwrap().video_frame(filmcraft_media::FrameRequest::full(Tick::ZERO)).unwrap();
|
||||
f.to_rgba8()[..3].to_vec()
|
||||
};
|
||||
assert_eq!(px(&s), [255, 0, 0]);
|
||||
let r = s.execute("project.matteColor", json!({"item": matte, "color": "#0000ff"})).unwrap();
|
||||
assert_eq!(r["color"], "#0000ff");
|
||||
assert_eq!(px(&s), [0, 0, 255]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(px(&s), [255, 0, 0]);
|
||||
// the selected matte when no item is named; anything else is refused
|
||||
s.state.project_selection = vec![ItemId(matte)];
|
||||
s.execute("project.matteColor", json!({"color": "#00ff00"})).unwrap();
|
||||
assert_eq!(px(&s), [0, 255, 0]);
|
||||
let bars = s.execute("file.newBarsAndTone", json!({})).unwrap()["item"].as_u64().unwrap();
|
||||
assert!(s.execute("project.matteColor", json!({"item": bars, "color": "#00ff00"})).is_err());
|
||||
assert!(s.execute("project.matteColor", json!({"item": matte, "color": "green"})).is_err());
|
||||
}
|
||||
|
||||
/// A damaged project whose subclip is its own parent (#66): `media_duration` followed the chain
|
||||
/// without a bound and overflowed the stack. It gives up after a few hops now.
|
||||
#[test]
|
||||
fn a_cyclic_subclip_chain_has_no_media_duration() {
|
||||
let mut s = demo();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("source.open", json!({"item": ocean.0})).unwrap();
|
||||
let sub = ItemId(s.execute("clip.makeSubclip", json!({})).unwrap()["item"].as_u64().unwrap());
|
||||
s.execute("clip.editSubclip", json!({"item": sub.0, "restrictTrims": false})).unwrap();
|
||||
let Some(ItemKind::Subclip { parent, .. }) = std::sync::Arc::make_mut(&mut s.project).item_mut(sub).map(|i| &mut i.kind) else { panic!() };
|
||||
*parent = sub;
|
||||
assert_eq!(crate::media_duration(&s.project, &s.media, sub), None);
|
||||
}
|
||||
|
||||
/// #164: Add Edit with a clip selected cut every targeted track. Like Premiere it now cuts only
|
||||
/// the selected clips under the playhead; with nothing selected there, the targeted tracks.
|
||||
#[test]
|
||||
fn add_edit_cuts_only_the_selected_clips() {
|
||||
let mut s = demo();
|
||||
let count = |s: &Session| {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), q.audio_tracks.iter().map(|t| t.items.len()).sum::<usize>())
|
||||
};
|
||||
// a V1 clip and a time inside it
|
||||
let first = v1(&s)[1].clone();
|
||||
let t = first.start + filmcraft_time::Tick(first.duration.0 / 2);
|
||||
let (v0, a0) = count(&s);
|
||||
s.state.selection = vec![first.id];
|
||||
s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
|
||||
let (v1n, a1n) = count(&s);
|
||||
assert_eq!((v1n, a1n), (v0 + 1, a0), "only the selected V1 clip is cut");
|
||||
// nothing selected under the playhead: every targeted track is cut
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.state.selection.clear();
|
||||
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
|
||||
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
|
||||
}
|
||||
+331
-64
@@ -250,6 +250,9 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
|
||||
/// the same time offset on their own tracks, so picture and sound stay in sync; a partner on a
|
||||
/// locked track, or one whose position would not change, stays as it is. Nothing moves before the sequence start: when a clip would,
|
||||
/// every clip of the call lands later by the same amount, so the spacing asked for is kept.
|
||||
/// `overwritten` lists every clip the move covered without being asked to move it: its length
|
||||
/// before and after (ticks) and the clips what is left of it now lives on as (none = removed; a
|
||||
/// clip a moved clip lands inside is split in two).
|
||||
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "timeline.move";
|
||||
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
|
||||
@@ -292,8 +295,31 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
moves.push((clip, track, start));
|
||||
}
|
||||
let ins = bool_p(p, "insert").unwrap_or(false);
|
||||
// every clip on the sequence now: id -> (track, start, end, project item)
|
||||
let spots = |s: &Session| -> Vec<(u64, TrackId, Tick, Tick, ItemId)> {
|
||||
let q = s.active_sequence();
|
||||
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
|
||||
};
|
||||
let before = spots(s);
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>()}))
|
||||
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
|
||||
// shortened clip may live on under a new id: name each clip the move changed without being
|
||||
// asked to, with what is left of it, so the caller hears about it instead of finding out later.
|
||||
let after = spots(s);
|
||||
let moved = |c: u64| moves.iter().any(|m| m.0.0 == c);
|
||||
let new = |c: u64| !before.iter().any(|b| b.0 == c);
|
||||
let mut overwritten = Vec::new();
|
||||
for &(c, track, start, end, item) in before.iter().filter(|b| !moved(b.0)) {
|
||||
let kept = after.iter().find(|a| a.0 == c);
|
||||
if ins || kept.is_some_and(|k| (k.2, k.3) == (start, end)) {
|
||||
continue; // untouched (an insert only shifts clips, it never covers one)
|
||||
}
|
||||
let pieces: Vec<_> =
|
||||
after.iter().filter(|a| a.0 == c || (new(a.0) && !moved(a.0) && a.1 == track && a.4 == item && a.2 >= start && a.3 <= end)).collect();
|
||||
let now: i64 = pieces.iter().map(|a| (a.3 - a.2).0).sum();
|
||||
overwritten.push(json!({"clip": c, "was": (end - start).0, "now": now, "pieces": pieces.iter().map(|a| a.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>(), "overwritten": overwritten}))
|
||||
}
|
||||
|
||||
/// Expand a clip selection with linked partners (when linked selection is on).
|
||||
@@ -419,6 +445,35 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
|
||||
Ok(json!({"item": id.0}))
|
||||
}
|
||||
|
||||
/// `project.matteColor`: change a Color Matte's color (`item`, or the one selected in the Project
|
||||
/// panel). Every clip of the matte follows; undo brings the old color back.
|
||||
fn set_matte_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "project.matteColor";
|
||||
let color = str_p(p, "color").and_then(filmcraft_color::parse_hex).ok_or_else(|| bad(CMD, "`color` must be #rrggbb"))?;
|
||||
let is_matte = |s: &Session, id: ItemId| {
|
||||
s.project.item(id).is_some_and(|it| matches!(&it.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::Generator(Generator::ColorMatte { .. }))))
|
||||
};
|
||||
let item = match item_p(p, "item") {
|
||||
Some(id) => id,
|
||||
None => match s.state.project_selection.as_slice() {
|
||||
[id] => *id,
|
||||
_ => return Err(bad(CMD, "select one Color Matte (or pass `item`)")),
|
||||
},
|
||||
};
|
||||
if !is_matte(s, item) {
|
||||
return Err(bad(CMD, format!("item {} is not a Color Matte", item.0)));
|
||||
}
|
||||
s.edit("Color Matte Color", |pr, _| {
|
||||
if let Some(ItemKind::Media(m)) = pr.item_mut(item).map(|it| &mut it.kind)
|
||||
&& let MediaRef::Generator(Generator::ColorMatte { color: c }) = &mut m.media
|
||||
{
|
||||
*c = color;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"item": item.0, "color": filmcraft_color::to_hex(color)}))
|
||||
}
|
||||
|
||||
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
|
||||
pub(crate) fn place_item(
|
||||
s: &mut Session,
|
||||
@@ -432,6 +487,10 @@ pub(crate) fn place_item(
|
||||
audio_split: Option<(TimeRange, Tick)>,
|
||||
) -> Result<Vec<ClipId>> {
|
||||
let pi = s.project.item(item).ok_or_else(|| bad(label, "no such item"))?.clone();
|
||||
// the nest toggle off: a sequence edits in as its clips (a multi-camera source stays one clip)
|
||||
if s.state.sequences_as_clips && pi.as_sequence().is_some_and(|q| q.multicam.is_none()) {
|
||||
return crate::sequence_tools::place_sequence_clips(s, item, range, at, vdest, adest, insert, label);
|
||||
}
|
||||
let has_v = pi.has_video() && vdest.is_some();
|
||||
let has_a = pi.has_audio() && adest.is_some();
|
||||
if !has_v && !has_a {
|
||||
@@ -693,6 +752,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let c = str_p(p, "color").and_then(filmcraft_color::parse_hex).unwrap_or([0.1, 0.1, 0.1, 1.0]);
|
||||
new_generator(s, Generator::ColorMatte { color: c }, "Color Matte", Label::Lavender, p)
|
||||
}),
|
||||
cmd!("project.matteColor", "Color Matte Color…", [], None, r##"{"item":id?,"color":"#rrggbb"}"##, always, set_matte_color),
|
||||
cmd!("file.newCountingLeader", "Universal Counting Leader…", ["File", "New"], None, "{}", always, |s, p| new_generator(
|
||||
s,
|
||||
Generator::CountingLeader,
|
||||
@@ -798,16 +858,17 @@ fn build() -> Vec<CommandSpec> {
|
||||
let item_ids: Vec<ItemId> = ids.iter().filter(|i| !image_sequences.iter().any(|q| q["item"].as_u64() == Some(**i))).map(|i| ItemId(*i)).collect();
|
||||
// Settings ▸ Media Analysis & Transcription ▸ Automatically transcribe clips
|
||||
let ma = &s.prefs.media_analysis;
|
||||
if ma.auto_transcribe
|
||||
&& ma.auto_transcribe_scope == "allImported"
|
||||
&& !ids.is_empty()
|
||||
&& (s.transcriber.is_some() || crate::transcript::speech_available())
|
||||
{
|
||||
if ma.auto_transcribe && ma.auto_transcribe_scope == "allImported" && !ids.is_empty() {
|
||||
let audio: Vec<u64> = ids.iter().copied().filter(|i| s.project.item(ItemId(*i)).is_some_and(|it| it.has_audio())).collect();
|
||||
if !audio.is_empty()
|
||||
&& let Err(e) = s.execute("transcript.generate", json!({"items": audio}))
|
||||
{
|
||||
errors.push(format!("transcription: {e}"));
|
||||
if !audio.is_empty() {
|
||||
// without speech-to-text the setting can't act: say so instead of importing
|
||||
// silently untranscribed (#89)
|
||||
let r = crate::transcript::can_transcribe(s)
|
||||
.map_err(EngineError::Other)
|
||||
.and_then(|()| s.execute("transcript.generate", json!({"items": audio})));
|
||||
if let Err(e) = r {
|
||||
errors.push(format!("transcription: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ingest = match crate::proxies::ingest(s, &item_ids) {
|
||||
@@ -827,6 +888,14 @@ fn build() -> Vec<CommandSpec> {
|
||||
if !image_sequences.is_empty() {
|
||||
out["imageSequences"] = json!(image_sequences);
|
||||
}
|
||||
// Newly imported files may resolve paths the project already listed as offline
|
||||
// (issue #110): rebuild s.offline.missing so the "Media missing" badge clears, and drop
|
||||
// the cached slate of every item that came back so the monitors show the file again.
|
||||
let was_missing = s.offline.missing.clone();
|
||||
crate::relink::refresh(s);
|
||||
for item in was_missing.iter().filter(|i| !s.offline.missing.contains(i)) {
|
||||
s.media.remove(*item);
|
||||
}
|
||||
Ok(out)
|
||||
}),
|
||||
cmd!(
|
||||
@@ -987,7 +1056,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
@@ -1236,6 +1305,26 @@ fn build() -> Vec<CommandSpec> {
|
||||
crate::clip_ops::add_frame_hold(s, p)
|
||||
}),
|
||||
cmd!("clip.nest", "Nest…", ["Clip"], None, r#"{"name":str}"#, has_selection, |s, p| nest(s, p)),
|
||||
// Reveal in Project (clip context menu) and Reveal Sequence in Project (Timeline tab menu):
|
||||
// select the item in the Project panel and show it there
|
||||
cmd!("clip.revealInProject", "Reveal in Project", [], None, r#"{"clip":id?}"#, has_seq, |s, p| {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let t = s.playhead();
|
||||
let tg = s.targeting().targeted;
|
||||
// the named or first selected clip, else the topmost clip under the playhead on a targeted track
|
||||
let item = clips_p(s, p)
|
||||
.iter()
|
||||
.find_map(|c| seq.find_item(*c).map(|(_, i)| i.item))
|
||||
.or_else(|| {
|
||||
seq.video_tracks.iter().rev().chain(seq.audio_tracks.iter()).filter(|tr| tg.contains(&tr.id)).find_map(|tr| tr.item_at(t).map(|i| i.item))
|
||||
})
|
||||
.ok_or_else(|| EngineError::Other("select a clip to reveal".into()))?;
|
||||
reveal_in_project(s, item)
|
||||
}),
|
||||
cmd!("sequence.revealInProject", "Reveal Sequence in Project", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
|
||||
let item = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
||||
reveal_in_project(s, item)
|
||||
}),
|
||||
// ================= Sequence =================
|
||||
cmd!("sequence.open", "Open in Timeline", [], None, r#"{"item":id}"#, always, |s, p| {
|
||||
let id = item_p(p, "item").ok_or_else(|| bad("sequence.open", "need `item`"))?;
|
||||
@@ -1256,10 +1345,43 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("sequence.closeOthers", "Close Other Timeline Panels", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
|
||||
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
||||
if !s.state.open_sequences.contains(&id) {
|
||||
return Err(bad("sequence.closeOthers", "that sequence is not open"));
|
||||
}
|
||||
let closed = s.state.open_sequences.len().saturating_sub(1);
|
||||
s.state.open_sequences = vec![id];
|
||||
if s.state.active_sequence != Some(id) {
|
||||
s.state.active_sequence = Some(id);
|
||||
s.state.selection.clear();
|
||||
s.events.push(crate::Event::OpenSequence(id));
|
||||
}
|
||||
Ok(json!({"closed": closed}))
|
||||
}),
|
||||
cmd!("sequence.moveTab", "Move Sequence Tab", [], None, r#"{"item":id?,"index":int}"#, has_seq, |s, p| {
|
||||
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
||||
let to = u64_p(p, "index").ok_or_else(|| bad("sequence.moveTab", "need `index`"))?;
|
||||
let from = s.state.open_sequences.iter().position(|x| *x == id).ok_or_else(|| bad("sequence.moveTab", "that sequence is not open"))?;
|
||||
// past the end is the end
|
||||
let to = usize::try_from(to).unwrap_or(usize::MAX).min(s.state.open_sequences.len().saturating_sub(1));
|
||||
let tab = s.state.open_sequences.remove(from);
|
||||
s.state.open_sequences.insert(to, tab);
|
||||
Ok(json!({"index": to}))
|
||||
}),
|
||||
cmd!("sequence.addEdit", "Add Edit", ["Sequence"], Some("Cmd+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let tg = s.targeting().targeted;
|
||||
let n = s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor(q, &tg, t, ctx)))?;
|
||||
// Like Premiere: selected clips under the playhead are cut, and only they (#164);
|
||||
// with none, the targeted tracks are.
|
||||
let sel = s.state.selection.clone();
|
||||
let selected_here =
|
||||
s.active_sequence().is_some_and(|q| sel.iter().any(|c| q.find_item(*c).is_some_and(|(_, it)| it.start < t && t < it.start + it.duration)));
|
||||
let n = if selected_here {
|
||||
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor_items(q, &sel, t, ctx)))?
|
||||
} else {
|
||||
let tg = s.targeting().targeted;
|
||||
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor(q, &tg, t, ctx)))?
|
||||
};
|
||||
Ok(json!({"cuts": n.len()}))
|
||||
}),
|
||||
cmd!("sequence.addEditAllTracks", "Add Edit to All Tracks", ["Sequence"], Some("Cmd+Shift+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
|
||||
@@ -1348,22 +1470,20 @@ fn build() -> Vec<CommandSpec> {
|
||||
s.state.linked_selection = bool_p(p, "on").unwrap_or(!s.state.linked_selection);
|
||||
Ok(json!({"linkedSelection": s.state.linked_selection}))
|
||||
}),
|
||||
cmd!("sequence.addTracks", "Add Tracks…", ["Sequence"], None, r#"{"video":n=1,"audio":n=0}"#, has_seq, |s, p| {
|
||||
let nv = u64_p(p, "video").unwrap_or(1) as usize;
|
||||
let na = u64_p(p, "audio").unwrap_or(0) as usize;
|
||||
s.edit_sequence("Add Tracks", |q, ctx, _| {
|
||||
for _ in 0..nv {
|
||||
let n = q.video_tracks.len() + 1;
|
||||
q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), TrackKind::Video, format!("Video {n}")));
|
||||
}
|
||||
for _ in 0..na {
|
||||
let n = q.audio_tracks.len() + 1;
|
||||
q.audio_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), TrackKind::Audio, format!("Audio {n}")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
cmd!("sequence.nestSequences", "Insert and overwrite sequences as nests or individual clips", [], None, r#"{"on":bool?}"#, always, |s, p| {
|
||||
let nest = bool_p(p, "on").unwrap_or(s.state.sequences_as_clips);
|
||||
s.state.sequences_as_clips = !nest;
|
||||
Ok(json!({"nest": nest}))
|
||||
}),
|
||||
cmd!(
|
||||
"sequence.addTracks",
|
||||
"Add Tracks…",
|
||||
["Sequence"],
|
||||
None,
|
||||
r#"{"video":n=1,"audio":n=0,"submix":n=0,"videoAfter":"first"|"V2"|n?,"audioAfter":"first"|"A2"|n?,"submixAfter":"first"|"S1"|n?,"audioType":"standard|5.1|adaptive|mono"?,"submixType":"stereo|5.1|adaptive|mono"?}"#,
|
||||
has_seq,
|
||||
crate::sequence_tools::add_tracks
|
||||
),
|
||||
cmd!("sequence.deleteTrack", "Delete Track", [], None, r#"{"track":"V3"|id}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track", "sequence.deleteTrack")?.ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
|
||||
s.edit_sequence("Delete Track", |q, _, _| {
|
||||
@@ -2219,12 +2339,33 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("project.delete", "Clear", [], Some("Delete"), r#"{"items":[id]?}"#, has_project_selection, |s, p| {
|
||||
let items: Vec<ItemId> = p
|
||||
let asked: Vec<ItemId> = p
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect())
|
||||
.unwrap_or_else(|| s.state.project_selection.clone());
|
||||
// A bin named here goes with everything in it, as in the Project panel of any editor.
|
||||
let root = s.project.root.id;
|
||||
let named: Vec<filmcraft_project::BinId> =
|
||||
asked.iter().map(|i| filmcraft_project::BinId(i.0)).filter(|b| *b != root && s.project.root.find_bin(*b).is_some()).collect();
|
||||
// a bin inside another named bin goes with that one
|
||||
let bins: Vec<filmcraft_project::BinId> = named
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|b| !named.iter().any(|o| o != b && s.project.root.find_bin(*o).is_some_and(|o| o.find_bin(*b).is_some())))
|
||||
.collect();
|
||||
let mut items: Vec<ItemId> = asked.iter().copied().filter(|i| !named.contains(&filmcraft_project::BinId(i.0))).collect();
|
||||
for b in &bins {
|
||||
if let Some(bin) = s.project.root.find_bin(*b) {
|
||||
bin.all_items(&mut items);
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|i| i.0);
|
||||
items.dedup();
|
||||
s.edit("Clear", |pr, st| {
|
||||
for b in &bins {
|
||||
pr.root.remove_bin(*b);
|
||||
}
|
||||
for i in &items {
|
||||
pr.items.remove(i);
|
||||
pr.root.remove_item(*i);
|
||||
@@ -2246,7 +2387,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
s.media.remove(*i);
|
||||
}
|
||||
s.fix_state();
|
||||
Ok(Value::Null)
|
||||
Ok(json!({"items": items.len(), "bins": bins.len()}))
|
||||
}),
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
|
||||
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
|
||||
@@ -2281,6 +2422,19 @@ fn build() -> Vec<CommandSpec> {
|
||||
if let Some(v) = p.get("out") {
|
||||
m.mark_out = v.as_i64().map(Tick);
|
||||
}
|
||||
} else if let Some(q) = it.as_sequence_mut() {
|
||||
// a sequence loaded in the Source Monitor is marked like a clip: these are the
|
||||
// sequence's own In and Out
|
||||
if let Some(v) = p.get("in") {
|
||||
q.mark_in = v.as_i64().map(Tick);
|
||||
q.split.video_in = None;
|
||||
q.split.audio_in = None;
|
||||
}
|
||||
if let Some(v) = p.get("out") {
|
||||
q.mark_out = v.as_i64().map(Tick);
|
||||
q.split.video_out = None;
|
||||
q.split.audio_out = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -2554,17 +2708,40 @@ fn paste(s: &mut Session, insert: bool) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Select `item` in the Project panel and ask the UI to show it there.
|
||||
fn reveal_in_project(s: &mut Session, item: ItemId) -> Result<Value> {
|
||||
s.project.item(item).ok_or_else(|| EngineError::Other("that clip's source is not in the project".into()))?;
|
||||
s.state.project_selection = vec![item];
|
||||
s.events.push(crate::Event::RevealInProject(item));
|
||||
Ok(json!({"item": item.0}))
|
||||
}
|
||||
|
||||
/// The name Nest… offers: the first unused "Nested Sequence NN".
|
||||
pub fn next_nested_name(s: &Session) -> String {
|
||||
(1..=s.project.items.len().saturating_add(1))
|
||||
.map(|k| format!("Nested Sequence {k:02}"))
|
||||
.find(|n| !s.project.items.values().any(|i| &i.name == n))
|
||||
.unwrap_or_else(|| "Nested Sequence".to_string())
|
||||
}
|
||||
|
||||
fn nest(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let sel = with_links(s, &s.state.selection.clone());
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?.clone();
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| "Nested Sequence 01".to_string());
|
||||
let name = str_p(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or_else(|| next_nested_name(s));
|
||||
let mut items: Vec<(TrackKind, usize, filmcraft_project::TrackItem)> = Vec::new();
|
||||
// transitions go into the nest with their clips: those whose clips are all nested
|
||||
let mut transitions: Vec<(TrackKind, usize, filmcraft_project::Transition)> = Vec::new();
|
||||
for (k, ts) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||||
for (ti, t) in ts.iter().enumerate() {
|
||||
// clips on a locked track stay where they are (they cannot be removed from it)
|
||||
for (ti, t) in ts.iter().enumerate().filter(|(_, t)| !t.locked) {
|
||||
for i in t.items.iter().filter(|i| sel.contains(&i.id)) {
|
||||
items.push((k, ti, i.clone()));
|
||||
}
|
||||
let nested = |c: Option<ClipId>| c.is_none_or(|c| sel.contains(&c));
|
||||
for trn in t.transitions.iter().filter(|x| (x.from.is_some() || x.to.is_some()) && nested(x.from) && nested(x.to)) {
|
||||
transitions.push((k, ti, trn.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
@@ -2572,55 +2749,133 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let start = items.iter().map(|i| i.2.start).min().unwrap_or_default();
|
||||
let end = items.iter().map(|i| i.2.end()).max().unwrap_or_default();
|
||||
let lowest_v = items.iter().filter(|i| i.0 == TrackKind::Video).map(|i| i.1).min();
|
||||
let lowest_a = items.iter().filter(|i| i.0 == TrackKind::Audio).map(|i| i.1).min();
|
||||
s.edit("Nest", |pr, st| {
|
||||
let nid = pr.new_sequence(&name, q.settings.clone(), q.video_tracks.len(), q.audio_tracks.len(), None);
|
||||
let span = TimeRange::new(start, end - start);
|
||||
let tracks_of = |kind: TrackKind| -> Vec<usize> {
|
||||
let mut v: Vec<usize> = items.iter().filter(|i| i.0 == kind).map(|i| i.1).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let (video_tracks, audio_tracks) = (tracks_of(TrackKind::Video), tracks_of(TrackKind::Audio));
|
||||
// Premiere (26.5.2): the nest is a linked picture + sound pair only when the selected sound is
|
||||
// on one track and that track is free for the nest's whole length. With sound on several
|
||||
// tracks, or another clip in the way on its track, the nest is picture only: the selected sound
|
||||
// stays where it is in this sequence, and a copy of it goes into the nest.
|
||||
let sound_blocked = |ai: usize| q.audio_tracks.get(ai).is_none_or(|t| t.items.iter().any(|i| !sel.contains(&i.id) && i.range().overlaps(&span)));
|
||||
let video_only = !video_tracks.is_empty() && (audio_tracks.len() > 1 || audio_tracks.first().is_some_and(|ai| sound_blocked(*ai)));
|
||||
let (lowest_v, lowest_a) = (video_tracks.first().copied(), audio_tracks.first().copied().filter(|_| !video_only));
|
||||
// In the nest, picture tracks keep their positions (V1 up to the highest one used). Sound
|
||||
// tracks do not: A1 is always there, and the other tracks used follow it in order
|
||||
// (A1 + A3 become A1, A2; A3 alone becomes an empty A1 and A2).
|
||||
let nv = video_tracks.last().map_or(1, |i| i + 1);
|
||||
let audio_at: std::collections::HashMap<usize, usize> =
|
||||
audio_tracks.iter().filter(|i| **i != 0).enumerate().map(|(n, i)| (*i, n + 1)).chain(std::iter::once((0, 0))).collect();
|
||||
let na = audio_at.values().max().map_or(1, |i| i + 1);
|
||||
let nested_track = |k: TrackKind, ti: usize| if k == TrackKind::Audio { audio_at.get(&ti).copied().unwrap_or(ti) } else { ti };
|
||||
// what leaves this sequence: everything nested, less the sound that stays behind
|
||||
let removed: Vec<ClipId> = items.iter().filter(|i| !(video_only && i.0 == TrackKind::Audio)).map(|i| i.2.id).collect();
|
||||
let stays = |k: TrackKind| video_only && k == TrackKind::Audio;
|
||||
let r = s.edit("Nest", |pr, st| {
|
||||
let nid = pr.new_sequence(&name, q.settings.clone(), nv, na, None);
|
||||
// copies of clips and transitions that also stay behind need ids of their own
|
||||
let fresh_clips: std::collections::HashMap<ClipId, ClipId> = items.iter().filter(|i| stays(i.0)).map(|i| (i.2.id, ClipId(pr.alloc_id()))).collect();
|
||||
let fresh_transitions: Vec<Option<u64>> = transitions.iter().map(|t| stays(t.0).then(|| pr.alloc_id())).collect();
|
||||
let copy = |c: ClipId| fresh_clips.get(&c).copied().unwrap_or(c);
|
||||
{
|
||||
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
|
||||
// tracks take the name and channel layout of the track their clips came from
|
||||
for (tr, src) in nq.video_tracks.iter_mut().zip(&q.video_tracks) {
|
||||
tr.name = src.name.clone();
|
||||
}
|
||||
for (from, to) in &audio_at {
|
||||
if let (Some(tr), Some(src)) = (nq.audio_tracks.get_mut(*to), q.audio_tracks.get(*from)) {
|
||||
tr.channels = src.channels;
|
||||
if from == to {
|
||||
tr.name = src.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, ti, it) in &items {
|
||||
let mut it = it.clone();
|
||||
it.id = copy(it.id);
|
||||
it.start -= start;
|
||||
nq.tracks_mut(*k)[*ti].items.push(it);
|
||||
nq.tracks_mut(*k).get_mut(nested_track(*k, *ti)).ok_or(EngineError::NoSequence)?.items.push(it);
|
||||
}
|
||||
for ((k, ti, trn), fresh) in transitions.iter().zip(&fresh_transitions) {
|
||||
let mut trn = trn.clone();
|
||||
if let Some(id) = fresh {
|
||||
trn.id = filmcraft_project::TransitionId(*id);
|
||||
}
|
||||
(trn.from, trn.to) = (trn.from.map(copy), trn.to.map(copy));
|
||||
trn.start -= start;
|
||||
nq.tracks_mut(*k).get_mut(nested_track(*k, *ti)).ok_or(EngineError::NoSequence)?.transitions.push(trn);
|
||||
}
|
||||
for t in nq.all_tracks_mut() {
|
||||
t.sort();
|
||||
edit::remove_orphan_transitions(t);
|
||||
}
|
||||
nq.check().map_err(EngineError::Other)?;
|
||||
}
|
||||
let rate = q.settings.frame_rate;
|
||||
let mut v = pr
|
||||
.make_track_item(nid, TrackKind::Video, start, TimeRange::new(Tick::ZERO, end - start), rate)
|
||||
.ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
|
||||
// the nest shows its sequence from the beginning, for the length of what was nested
|
||||
let whole = TimeRange::new(Tick::ZERO, span.duration);
|
||||
let mut v =
|
||||
pr.make_track_item(nid, TrackKind::Video, start, whole, rate).ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
|
||||
for e in &mut v.effects {
|
||||
resolve_auto_points(e, (q.settings.width, q.settings.height), (q.settings.width, q.settings.height));
|
||||
}
|
||||
let a = pr
|
||||
.make_track_item(nid, TrackKind::Audio, start, TimeRange::new(Tick::ZERO, end - start), rate)
|
||||
.ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
|
||||
let link = pr.alloc_id();
|
||||
let a = pr.make_track_item(nid, TrackKind::Audio, start, whole, rate).ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
|
||||
let (link, new_video, new_audio) = (pr.alloc_id(), pr.alloc_id(), pr.alloc_id());
|
||||
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
edit::delete_items(seq, &sel);
|
||||
edit::delete_items(seq, &removed);
|
||||
// sound left behind has lost its picture: it is no longer linked
|
||||
if video_only {
|
||||
let links: Vec<u64> = items.iter().filter(|i| i.0 == TrackKind::Video).filter_map(|i| i.2.link).collect();
|
||||
for it in seq.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) {
|
||||
if it.link.is_some_and(|l| links.contains(&l)) {
|
||||
it.link = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where the nest goes, as Premiere Pro 26.5.2 places it (about 35 nests observed). With L
|
||||
// the lowest video track of the selection and `a` the number of the highest audio track
|
||||
// holding selected sound, the picture takes the lowest track T from L up such that
|
||||
// 1. T is free of other clips for the nest's whole length, and
|
||||
// 2. so is every video track numbered from T up to a - 1 that the selection had clips on.
|
||||
// If no track will do, a new one is added on top. So the nest never lands on another clip,
|
||||
// and it can be pushed above an unselected clip that shares a track with the selection
|
||||
// when the sound sits on a higher-numbered track. The sound of a linked nest is on the one
|
||||
// track its clips came from, which is free (a selection of sound alone moves up to the
|
||||
// first free track).
|
||||
let highest_sound = audio_tracks.last().map_or(0, |i| i + 1);
|
||||
let mut ids = Vec::new();
|
||||
if let Some(vi) = lowest_v {
|
||||
let mut v = v;
|
||||
v.link = lowest_a.map(|_| link);
|
||||
ids.push(v.id);
|
||||
let t = &mut seq.video_tracks[vi];
|
||||
t.items.push(v);
|
||||
for (kind, lowest, mut clip, new_track) in [(TrackKind::Video, lowest_v, v, new_video), (TrackKind::Audio, lowest_a, a, new_audio)] {
|
||||
let Some(lowest) = lowest else { continue };
|
||||
let tracks = seq.tracks_mut(kind);
|
||||
let clear = |i: usize| tracks.get(i).is_some_and(|t| !t.locked && edit::track_range_empty(t, span));
|
||||
let selection_clear = |t: usize| kind != TrackKind::Video || video_tracks.iter().filter(|i| **i >= t && **i + 1 < highest_sound).all(|i| clear(*i));
|
||||
let free = (lowest..tracks.len()).find(|t| clear(*t) && selection_clear(*t));
|
||||
let at = match free {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let (word, n) = (if kind == TrackKind::Video { "Video" } else { "Audio" }, tracks.len() + 1);
|
||||
tracks.push(filmcraft_project::Track::new(TrackId(new_track), kind, format!("{word} {n}")));
|
||||
tracks.len() - 1
|
||||
}
|
||||
};
|
||||
clip.link = (lowest_v.is_some() && lowest_a.is_some()).then_some(link);
|
||||
ids.push(clip.id);
|
||||
let t = tracks.get_mut(at).ok_or(EngineError::NoSequence)?;
|
||||
t.items.push(clip);
|
||||
t.sort();
|
||||
}
|
||||
if let Some(ai) = lowest_a {
|
||||
let mut a = a;
|
||||
a.link = lowest_v.map(|_| link);
|
||||
ids.push(a.id);
|
||||
let t = &mut seq.audio_tracks[ai];
|
||||
t.items.push(a);
|
||||
t.sort();
|
||||
}
|
||||
st.selection = ids;
|
||||
Ok(nid)
|
||||
})
|
||||
.map(|n| json!({"sequence": n.0}))
|
||||
seq.check().map_err(EngineError::Other)?;
|
||||
// like Premiere: nothing stays selected in the timeline; the new sequence is selected in the Project panel
|
||||
st.selection.clear();
|
||||
st.project_selection = vec![nid];
|
||||
Ok((nid, ids))
|
||||
});
|
||||
r.map(|(n, ids)| json!({"sequence": n.0, "clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// `effects.list`: every effect definition with its Effects-panel folder (optionally filtered by
|
||||
@@ -2894,7 +3149,9 @@ fn write_project(s: &mut Session, path: &str, adopt: bool) -> Result<Value> {
|
||||
{
|
||||
name_project(std::sync::Arc::make_mut(&mut s.project), name);
|
||||
}
|
||||
let bytes = filmcraft_format::encode(&s.project, false);
|
||||
// what is open (sequence tabs, how each is shown) goes into the file beside the project
|
||||
let view = s.project_view();
|
||||
let bytes = filmcraft_format::encode_with_view(&s.project, Some(&view), false);
|
||||
let mut backup = None;
|
||||
if adopt && s.path.as_deref() == Some(path) && s.loaded_schema < filmcraft_format::SCHEMA_VERSION {
|
||||
let b = schema_backup_path(path, s.loaded_schema);
|
||||
@@ -2952,6 +3209,13 @@ fn open_project(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let loaded = filmcraft_format::decode(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (from, migrated) = (loaded.schema_version, loaded.migrated());
|
||||
install_project(s, loaded.project, Some(path.to_string()), true);
|
||||
if let Some(view) = loaded.view.filter(|_| s.prefs.timeline.restore_open_sequences) {
|
||||
s.restore_project_view(view);
|
||||
}
|
||||
// the frontend shows the project's active sequence (with its own view, when one was restored)
|
||||
if let Some(seq) = s.state.active_sequence {
|
||||
s.events.push(crate::Event::OpenSequence(seq));
|
||||
}
|
||||
s.note_recent_project();
|
||||
s.loaded_schema = from;
|
||||
if migrated {
|
||||
@@ -2972,6 +3236,9 @@ fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
|
||||
let saved_at = per.local_time(c.meta.saved_unix);
|
||||
let loaded = crate::autosave::load_candidate(&c).map_err(EngineError::Other)?;
|
||||
install_project(s, loaded.project, c.meta.project_path.clone(), false);
|
||||
if let Some(seq) = s.state.active_sequence {
|
||||
s.events.push(crate::Event::OpenSequence(seq));
|
||||
}
|
||||
crate::relink::on_open(s);
|
||||
// Our own journal must hold the recovered state before the old one is deleted.
|
||||
s.sync_persistence();
|
||||
|
||||
+14
-18
@@ -21,24 +21,20 @@ pub fn add_generator(p: &mut Project, pool: &MediaPool, src: GeneratorSource, na
|
||||
i.name = name.to_string();
|
||||
i
|
||||
};
|
||||
let g = src.generator.clone();
|
||||
let id = p.add_item(
|
||||
name,
|
||||
label,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::Generator(g),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
bin,
|
||||
);
|
||||
pool.insert(id, Arc::new(src.with_name(name)));
|
||||
let clip = MediaClip {
|
||||
media: MediaRef::Generator(src.generator.clone()),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
};
|
||||
let key = crate::media_pool::media_key(&clip);
|
||||
let id = p.add_item(name, label, ItemKind::Media(clip), bin);
|
||||
pool.insert_keyed(id, key, Arc::new(src.with_name(name)));
|
||||
id
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,24 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
||||
assert!((vbr / (mbps * px) - 1.0).abs() < 0.25, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
"apv" => {
|
||||
assert!(fmt_name.contains("mov"), "{name}: {fmt_name}");
|
||||
assert_eq!(v["codec_name"], "apv", "{name}");
|
||||
let p = settings["apvProfile"].as_str().unwrap();
|
||||
let idc = match p {
|
||||
"422-10" => "33",
|
||||
"422-12" => "44",
|
||||
"444-10" => "55",
|
||||
"444-12" => "66",
|
||||
"4444-10" => "77",
|
||||
"4444-12" => "88",
|
||||
"400-10" => "99",
|
||||
other => other,
|
||||
};
|
||||
let got_profile = v["profile"].as_str().unwrap_or("");
|
||||
assert!(got_profile == p || got_profile == idc, "{name}: profile {got_profile} != {p} ({idc})");
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
"mxf-op1a" => {
|
||||
assert_eq!(fmt_name, "mxf", "{name}");
|
||||
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
|
||||
@@ -345,6 +363,67 @@ fn queue_cancels_a_running_export_and_retries_a_failed_one() {
|
||||
assert!(queue(&mut s).iter().all(|i| i["id"] != bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_and_queue_items_report_the_time_they_have_left() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::export_tools::QueueStatus;
|
||||
let mut s = demo();
|
||||
let dir = Scratch::new("eta");
|
||||
let job = crate::Job { id: 77, label: "Exporting".into(), progress: Default::default(), result: Default::default() };
|
||||
job.progress.total.store(1000, Ordering::Relaxed);
|
||||
assert!(job.to_json()["etaSeconds"].is_null(), "no speed measured yet");
|
||||
// 50 units a second for 5 s, read from the near future (the JSON reads the real clock, which is then behind them)
|
||||
let t0 = web_time::Instant::now() + Duration::from_secs(60);
|
||||
for i in 0..=50u64 {
|
||||
job.progress.done.store(i * 5, Ordering::Relaxed);
|
||||
job.progress.eta_at(t0 + Duration::from_millis(i * 100));
|
||||
}
|
||||
let eta = job.to_json()["etaSeconds"].as_f64().expect("a number once the job has a speed");
|
||||
assert!((eta - 15.0).abs() < 0.5, "750 units left at 50 a second: {eta}");
|
||||
s.jobs.push(job);
|
||||
|
||||
// a queue item encoding that job shows it; one that waits shows nothing
|
||||
for name in ["a.wav", "b.wav"] {
|
||||
s.execute(
|
||||
"export.queue.add",
|
||||
json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": dir.path(name), "range": "custom", "startSeconds": 0, "endSeconds": 0.25}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
s.export_queue.items[0].status = QueueStatus::Encoding;
|
||||
s.export_queue.items[0].job = Some(77);
|
||||
let items = queue(&mut s);
|
||||
assert!((items[0]["etaSeconds"].as_f64().expect("encoding") - 15.0).abs() < 0.5, "{}", items[0]);
|
||||
assert_eq!(items[0]["status"], "encoding");
|
||||
assert!(items[1]["etaSeconds"].is_null(), "{}", items[1]);
|
||||
// and `jobs.list` carries it too
|
||||
let jobs = s.execute("jobs.list", json!({})).unwrap();
|
||||
let listed = jobs["jobs"].as_array().or(jobs.as_array()).expect("a list of jobs").iter().find(|j| j["id"] == 77).cloned().expect("the job is listed");
|
||||
assert!(listed["etaSeconds"].as_f64().is_some(), "{listed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_encoding_is_off_unless_asked_for() {
|
||||
use filmcraft_export::HardwareEncoding::{Auto, Off};
|
||||
let s = demo();
|
||||
let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| st.hardware_encoding);
|
||||
assert_eq!(setting(json!({"path": "x.mp4"})).unwrap(), Off);
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "hardwareEncoding": "auto"})).unwrap(), Auto);
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "hardwareEncoding": "off"})).unwrap(), Off);
|
||||
// a boolean is understood too: true is auto
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "hardwareEncoding": true})).unwrap(), Auto);
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "hardwareEncoding": false})).unwrap(), Off);
|
||||
// anything else names the choices
|
||||
let e = setting(json!({"path": "x.mp4", "hardwareEncoding": "gpu"})).unwrap_err().to_string();
|
||||
assert!(e.contains("off | auto"), "{e}");
|
||||
// and it travels in the settings object, with the default filling in when it is missing
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "settings": {"hardwareEncoding": "auto"}})).unwrap(), Auto);
|
||||
let old: filmcraft_export::ExportSettings = serde_json::from_value(json!({"format": "h264"})).unwrap();
|
||||
assert_eq!(old.hardware_encoding, Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_exports_several_sequences_and_ranges() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use filmcraft_export::presets::{DEFAULT_PRESET, preset_key};
|
||||
use filmcraft_export::{ExportPreset, ExportSettings, Format};
|
||||
use filmcraft_export::{ExportPreset, ExportSettings, Format, HardwareEncoding};
|
||||
use filmcraft_project::{ItemId, Project};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -200,6 +200,18 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
||||
if let Some(v) = u64_p(p, "keyframeDistance") {
|
||||
settings.keyframe_distance = Some(v as u32);
|
||||
}
|
||||
if let Some(v) = p.get("hardwareEncoding") {
|
||||
settings.hardware_encoding = match v {
|
||||
Value::Bool(on) => {
|
||||
if *on {
|
||||
HardwareEncoding::Auto
|
||||
} else {
|
||||
HardwareEncoding::Off
|
||||
}
|
||||
}
|
||||
other => serde_json::from_value(other.clone()).map_err(|_| bad(cmd, "hardwareEncoding: off | auto"))?,
|
||||
};
|
||||
}
|
||||
if let Some(v) = bool_p(p, "burnCaptions") {
|
||||
settings.burn_captions = v;
|
||||
}
|
||||
@@ -209,6 +221,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
||||
if let Some(v) = str_p(p, "dnxProfile") {
|
||||
settings.dnx_profile = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "apvProfile") {
|
||||
settings.apv_profile = v.to_string();
|
||||
}
|
||||
if let Some(v) = str_p(p, "mxfVideoCodec") {
|
||||
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
|
||||
}
|
||||
@@ -513,6 +528,7 @@ fn item_json(s: &Session, it: &QueueItem) -> Value {
|
||||
"range": it.settings.range,
|
||||
"status": it.status,
|
||||
"progress": progress,
|
||||
"etaSeconds": job.filter(|_| it.status == QueueStatus::Encoding).and_then(|j| j.progress.eta()).map(|d| d.as_secs_f64()),
|
||||
"statusText": status_text,
|
||||
"job": it.job,
|
||||
"error": it.error,
|
||||
|
||||
@@ -363,3 +363,20 @@ fn caption_formats_through_the_engine() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #188: a copied Graphic clip pastes at the playhead as a second Graphic clip.
|
||||
#[test]
|
||||
fn copy_paste_duplicates_a_graphic_clip() {
|
||||
let mut s = demo("gt-paste");
|
||||
let c = clip_of(&s.execute("graphics.newText", json!({"text": "Lyric", "seconds": 2})).unwrap());
|
||||
s.execute("timeline.select", json!({"clips": [c.0]})).unwrap();
|
||||
let v = s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(v["copied"], 1, "{v}");
|
||||
s.execute("playhead.set", json!({"seconds": 620})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let pasted: Vec<&TrackItem> = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| i.id != c && i.item == item(&s, c).item).collect();
|
||||
assert_eq!(pasted.len(), 1, "one copy");
|
||||
assert!((pasted[0].start.seconds() - 620.0).abs() < 0.05, "at the playhead: {:?}", pasted[0].start);
|
||||
assert_eq!(texts(&s, pasted[0].id), vec!["Lyric".to_string()]);
|
||||
}
|
||||
+153
-28
@@ -9,8 +9,8 @@
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, ParamValue, Sequence, TrackKind};
|
||||
use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad};
|
||||
use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind};
|
||||
use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -124,13 +124,15 @@ pub(crate) fn place_video_clip(
|
||||
(q.settings.width, q.settings.height, q.settings.frame_rate)
|
||||
};
|
||||
let (src, dur) = source(pr, (w, h, rate));
|
||||
// an imported picture (`graphics.newFromFile`) keeps its own size: centre its anchor in it
|
||||
let src_size = pr.source_size(src).unwrap_or((w, h));
|
||||
let t = rate.snap(t);
|
||||
let dur = dur.max(rate.frame_duration());
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
|
||||
ti.name = name;
|
||||
ti.effects.extend(extra);
|
||||
for e in &mut ti.effects {
|
||||
filmcraft_project::resolve_auto_points(e, (w, h), (w, h));
|
||||
filmcraft_project::resolve_auto_points(e, (w, h), src_size);
|
||||
}
|
||||
let id = ti.id;
|
||||
let track_id = filmcraft_project::TrackId(pr.alloc_id());
|
||||
@@ -191,6 +193,7 @@ fn param_id(k: &str) -> &str {
|
||||
"fauxBold" => "faux_bold",
|
||||
"fauxItalic" => "faux_italic",
|
||||
"boxWidth" => "box_width",
|
||||
"boxHeight" => "box_height",
|
||||
"anchorPoint" => "anchor",
|
||||
"cornerRadius" => "corner_radius",
|
||||
other => other,
|
||||
@@ -214,6 +217,38 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
|
||||
crate::commands::json_to_param(template, v)
|
||||
}
|
||||
|
||||
/// Parameter `id` of a layer, created from the layer definition when the layer was saved before
|
||||
/// the parameter existed.
|
||||
fn param_entry<'a>(e: &'a mut EffectInstance, id: &str) -> Option<&'a mut Param> {
|
||||
if !e.params.contains_key(id) {
|
||||
let d = filmcraft_project::find_effect(&e.effect)?.param(id)?.default.clone();
|
||||
e.params.insert(id.to_string(), Param::new(d));
|
||||
}
|
||||
e.params.get_mut(id)
|
||||
}
|
||||
|
||||
/// Set properties `props` on layer `e` (keyframe-aware at media time `mt`). Changing the text
|
||||
/// keeps per-character styles on their characters.
|
||||
fn apply_props(e: &mut EffectInstance, props: &serde_json::Map<String, Value>, mt: Tick) -> Result<()> {
|
||||
for (k, v) in props {
|
||||
if k == "enabled" {
|
||||
e.enabled = v.as_bool().unwrap_or(true);
|
||||
continue;
|
||||
}
|
||||
let id = param_id(k);
|
||||
let prm = param_entry(e, id).ok_or_else(|| bad("graphics.set", format!("no property `{k}`")))?;
|
||||
let pv = to_param(&prm.value, id, v).ok_or_else(|| bad("graphics.set", format!("`{k}`: value has the wrong type")))?;
|
||||
let old = prm.value_at(mt);
|
||||
prm.set_at(mt, pv.clone());
|
||||
if let (ParamValue::Text(a), ParamValue::Text(b), "text") = (&old, &pv, id)
|
||||
&& let Some(x) = e.layer.as_mut()
|
||||
{
|
||||
x.runs = filmcraft_project::graphic_design::adjust_runs(a, b, &x.runs);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set properties `props` on a layer (keyframe-aware at time `tl`). Changing the text keeps
|
||||
/// per-character styles on their characters.
|
||||
pub(crate) fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map<String, Value>, tl: Tick, label: &str) -> Result<()> {
|
||||
@@ -222,26 +257,91 @@ pub(crate) fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serd
|
||||
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
|
||||
let e = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?;
|
||||
for (k, v) in &props {
|
||||
if k == "enabled" {
|
||||
e.enabled = v.as_bool().unwrap_or(true);
|
||||
continue;
|
||||
}
|
||||
let id = param_id(k);
|
||||
let prm = e.params.get_mut(id).ok_or_else(|| bad("graphics.set", format!("no property `{k}`")))?;
|
||||
let pv = to_param(&prm.value, id, v).ok_or_else(|| bad("graphics.set", format!("`{k}`: value has the wrong type")))?;
|
||||
let old = prm.value_at(mt);
|
||||
prm.set_at(mt, pv.clone());
|
||||
if let (ParamValue::Text(a), ParamValue::Text(b), "text") = (&old, &pv, id)
|
||||
&& let Some(x) = e.layer.as_mut()
|
||||
{
|
||||
x.runs = filmcraft_project::graphic_design::adjust_runs(a, b, &x.runs);
|
||||
}
|
||||
}
|
||||
apply_props(e, &props, mt)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn a text layer into point text (no box; the lines the box wrapped become real lines) or
|
||||
/// paragraph text (a box fitted to the text). The text stays where it is: the layer's origin
|
||||
/// moves between the first baseline and the box's top-left corner, and the anchor point is
|
||||
/// renumbered to stay on the same spot.
|
||||
fn set_text_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "graphics.setTextType";
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad(ID, "no graphic clip"))?;
|
||||
let (layer, ei) = layer_effect_index(s, clip, p)?;
|
||||
let to_paragraph = match str_p(p, "type").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("paragraph" | "paragraph text") => true,
|
||||
Some("point" | "point text") => false,
|
||||
_ => return Err(bad(ID, "need `type`: point or paragraph")),
|
||||
};
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = seq.find_item(clip).ok_or_else(|| bad(ID, "no such clip"))?;
|
||||
let size = match s.project.item(it.item).map(|p| &p.kind) {
|
||||
Some(ItemKind::Graphic { width, height, .. }) => (*width, *height),
|
||||
_ => (seq.settings.width, seq.settings.height),
|
||||
};
|
||||
let ph = s.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let spec = it.effects.get(ei).and_then(|e| eval_layer(e, mt, size)).ok_or_else(|| bad(ID, "no such layer"))?;
|
||||
let LayerContent::Text(tp) = &spec.content else { return Err(bad(ID, "not a text layer")) };
|
||||
if tp.vertical {
|
||||
return Err(bad(ID, "vertical text is always point text"));
|
||||
}
|
||||
let name = |paragraph: bool| if paragraph { "paragraph" } else { "point" };
|
||||
if (tp.box_width > 0.0) == to_paragraph {
|
||||
return Ok(json!({"clip": clip.0, "layer": layer, "type": name(to_paragraph), "changed": false}));
|
||||
}
|
||||
// where point text's origin (its alignment point on the first baseline) sits in a box `w` wide
|
||||
let origin_x = |w: f32| match tp.align {
|
||||
1 => w / 2.0,
|
||||
2 => w,
|
||||
_ => 0.0,
|
||||
};
|
||||
let mut props = serde_json::Map::new();
|
||||
// what to add to the anchor so that it stays on the same spot of the picture
|
||||
let shift = if to_paragraph {
|
||||
let widest = text_layout(tp).lines.iter().map(|l| l.width).fold(0.0, f32::max);
|
||||
// a little slack, so that no line wraps
|
||||
let w = (widest.ceil() + 1.0).clamp(1.0, 100_000.0);
|
||||
let boxed = text_layout(&graphic::TextProps { box_width: w, box_height: 0.0, ..tp.clone() });
|
||||
let h = (boxed.bounds[3].ceil() + 1.0).clamp(1.0, 100_000.0);
|
||||
props.insert("box_width".into(), json!(w));
|
||||
props.insert("box_height".into(), json!(h));
|
||||
(origin_x(w), boxed.lines.first().map_or(0.0, |l| l.baseline))
|
||||
} else {
|
||||
// every line, also those the box hides
|
||||
let all = text_layout(&graphic::TextProps { box_height: 0.0, ..tp.clone() });
|
||||
let mut text = String::with_capacity(tp.text.len());
|
||||
for (i, l) in all.lines.iter().enumerate() {
|
||||
let line = tp.text.get(l.range.clone()).unwrap_or_default();
|
||||
// a wrapped line ends where the next begins: its trailing space becomes the line break
|
||||
let wrapped = all.lines.get(i + 1).is_some_and(|n| n.range.start == l.range.end);
|
||||
text.push_str(if wrapped { line.trim_end() } else { line });
|
||||
if i + 1 < all.lines.len() {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
props.insert("text".into(), json!(text));
|
||||
props.insert("box_width".into(), json!(0.0));
|
||||
props.insert("box_height".into(), json!(0.0));
|
||||
(-origin_x(tp.box_width), -all.lines.first().map_or(0.0, |l| l.baseline))
|
||||
};
|
||||
s.edit_sequence("Text Layer Type", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let e = it.effects.get_mut(ei).ok_or_else(|| bad(ID, "no such layer"))?;
|
||||
apply_props(e, &props, mt)?;
|
||||
if let Some(a) = param_entry(e, "anchor") {
|
||||
a.map_values(|v| match v.as_vec2() {
|
||||
Some(a) => ParamValue::Vec2(Vec2::new(a.x + shift.0 as f64, a.y + shift.1 as f64)),
|
||||
None => v,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"clip": clip.0, "layer": layer, "type": name(to_paragraph), "changed": true}))
|
||||
}
|
||||
|
||||
/// Layers of a graphic clip with their evaluated bounds (sequence/canvas pixels).
|
||||
fn list_layers(s: &Session, clip: ClipId) -> Result<Value> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
@@ -265,6 +365,14 @@ fn list_layers(s: &Session, clip: ClipId) -> Result<Value> {
|
||||
LayerContent::Text(t) => ("text", Some(t.text.clone())),
|
||||
LayerContent::Shape(sh) => (SHAPE_OPTS.get(sh.shape as usize).copied().unwrap_or("Shape"), None),
|
||||
};
|
||||
// point text has no box; paragraph text wraps in one and hides what does not fit
|
||||
let (text_type, text_box, overflow) = match &sp.content {
|
||||
LayerContent::Text(t) if t.box_width > 0.0 && !t.vertical => {
|
||||
(Some("paragraph"), Some([t.box_width, t.box_height]), Some(text_layout(t).overflow))
|
||||
}
|
||||
LayerContent::Text(_) => (Some("point"), None, None),
|
||||
LayerContent::Shape(_) => (None, None, None),
|
||||
};
|
||||
Some(json!({
|
||||
"layer": i,
|
||||
"effectIndex": ei,
|
||||
@@ -272,7 +380,13 @@ fn list_layers(s: &Session, clip: ClipId) -> Result<Value> {
|
||||
"kind": kind,
|
||||
"text": text,
|
||||
"enabled": e.enabled,
|
||||
"textType": text_type,
|
||||
"box": text_box,
|
||||
"overflow": overflow,
|
||||
"position": [sp.transform.position.x, sp.transform.position.y],
|
||||
"anchor": [sp.transform.anchor.x, sp.transform.anchor.y],
|
||||
"scale": sp.transform.scale.y * 100.0,
|
||||
"scaleWidth": sp.transform.scale.x * 100.0,
|
||||
"uid": extra.map_or(0, |x| x.uid),
|
||||
"pin": extra.and_then(|x| x.pin.as_ref()).map(|p| serde_json::to_value(p).unwrap_or_default()),
|
||||
"styles": extra.map(|x| serde_json::to_value(&x.runs).unwrap_or_default()).unwrap_or(json!([])),
|
||||
@@ -751,7 +865,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
"Text",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+T"),
|
||||
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"vertical":bool=false,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
r#"{"text":str="New Text","position":[x,y]? (point text: its alignment point on the first baseline; with `box`: the box's top-left corner),"box":[w,h]? (paragraph text wrapped in a box this size),"clip":id?,"newClip":bool?,"vertical":bool=false,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let text = str_p(p, "text").unwrap_or("New Text").to_string();
|
||||
@@ -761,18 +875,20 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
|
||||
let size = f64_p(p, "size").unwrap_or(100.0);
|
||||
let mut layer = if p.get("vertical").and_then(Value::as_bool) == Some(true) {
|
||||
graphic::new_vertical_text_layer(&text, pos, size)
|
||||
} else {
|
||||
new_text_layer(&text, pos, size)
|
||||
};
|
||||
layer.params.insert("ligatures".into(), filmcraft_project::Param::new(ParamValue::Bool(gp.ligatures)));
|
||||
let vertical = p.get("vertical").and_then(Value::as_bool) == Some(true);
|
||||
let mut layer = if vertical { graphic::new_vertical_text_layer(&text, pos, size) } else { new_text_layer(&text, pos, size) };
|
||||
if let Some(b) = vec2_p(p, "box").filter(|_| !vertical) {
|
||||
for (id, v) in [("box_width", b.x), ("box_height", b.y)] {
|
||||
layer.params.insert(id.into(), Param::new(ParamValue::Float(v.clamp(1.0, 100_000.0))));
|
||||
}
|
||||
}
|
||||
layer.params.insert("ligatures".into(), Param::new(ParamValue::Bool(gp.ligatures)));
|
||||
if !gp.default_font.trim().is_empty() {
|
||||
layer.params.insert("font".into(), filmcraft_project::Param::new(ParamValue::Text(gp.default_font.trim().into())));
|
||||
layer.params.insert("font".into(), Param::new(ParamValue::Text(gp.default_font.trim().into())));
|
||||
}
|
||||
for (k, id) in [("font", "font"), ("fontStyle", "font_style")] {
|
||||
if let Some(v) = str_p(p, k) {
|
||||
layer.params.insert(id.into(), filmcraft_project::Param::new(ParamValue::Text(v.into())));
|
||||
layer.params.insert(id.into(), Param::new(ParamValue::Text(v.into())));
|
||||
}
|
||||
}
|
||||
let into = if p.get("newClip").and_then(Value::as_bool) == Some(true) { None } else { u64_p(p, "clip").map(ClipId) };
|
||||
@@ -859,6 +975,15 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
Ok(Value::Null)
|
||||
},
|
||||
),
|
||||
spec(
|
||||
"graphics.setTextType",
|
||||
"Text Layer Type",
|
||||
&[],
|
||||
None,
|
||||
r#"{"clip":id?,"layer":n|name?,"type":"point|paragraph" (point: no box, handles scale the text; paragraph: the text wraps in a box that handles resize)}"#,
|
||||
has_graphic,
|
||||
set_text_type,
|
||||
),
|
||||
spec("graphics.selectLayer", "Select Graphic Layer", &[], None, r#"{"clip":id?,"layers":[n]}"#, has_graphic, |s, p| {
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?;
|
||||
let layers: Vec<usize> =
|
||||
|
||||
@@ -369,7 +369,171 @@ fn new_layer_from_file_places_the_image_above() {
|
||||
assert_eq!(it.start, q.settings.frame_rate.snap(t));
|
||||
let ti = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap();
|
||||
assert!(q.video_tracks[..ti].iter().any(|tr| tr.item_at(t).is_some()), "above the footage");
|
||||
// the anchor is the picture's centre, not the frame's, so `position` places the picture
|
||||
let anchor = it.effect("motion").unwrap().vec2_at("anchor", Tick::ZERO);
|
||||
assert_eq!((anchor.x, anchor.y), (w as f64 / 2.0, h as f64 / 2.0));
|
||||
assert_eq!(s.state.selection, vec![clip]);
|
||||
assert!(s.execute("graphics.newFromFile", json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Where each line of text layer `layer` starts on the canvas (the left end of its baseline),
|
||||
/// with the line's text.
|
||||
fn line_starts(s: &Session, clip: ClipId, layer: usize) -> Vec<(String, f64, f64)> {
|
||||
let spec = layers(s, clip).remove(layer);
|
||||
let LayerContent::Text(t) = &spec.content else { panic!("not text") };
|
||||
let l = filmcraft_render::graphic_clip::text_layout(t);
|
||||
let m = filmcraft_render::graphic_clip::layer_matrix(&spec);
|
||||
l.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
let p = m.apply(filmcraft_geom::Vec2::new(line.x as f64, line.baseline as f64));
|
||||
(t.text[line.range.clone()].trim_end().to_string(), p.x, p.y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_places(a: &[(String, f64, f64)], b: &[(String, f64, f64)]) {
|
||||
assert_eq!(a.len(), b.len(), "{a:?} vs {b:?}");
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert_eq!(x.0, y.0);
|
||||
assert!((x.1 - y.1).abs() < 0.05 && (x.2 - y.2).abs() < 0.05, "line `{}` moved: {x:?} -> {y:?}", x.0);
|
||||
}
|
||||
}
|
||||
|
||||
const FOX: &str = "The quick brown fox jumps over the lazy dog";
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_wraps_in_its_box_and_hides_what_does_not_fit() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": FOX, "position": [270, 583], "box": [500, 150], "size": 60})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
|
||||
let l = &list["layers"][0];
|
||||
assert_eq!(l["textType"], "paragraph");
|
||||
assert_eq!(l["box"], json!([500.0, 150.0]));
|
||||
assert_eq!(l["overflow"], true, "three lines of 60 px text do not fit 150 px");
|
||||
assert_eq!(l["localBounds"], json!([0.0, 0.0, 500.0, 150.0]), "the box is the layer's bounds");
|
||||
assert_eq!(l["quad"][0], json!([270.0, 583.0]), "the position is the box's top-left corner");
|
||||
assert_eq!(l["quad"][2], json!([770.0, 733.0]));
|
||||
let shown = line_starts(&s, clip, 0);
|
||||
assert_eq!(shown.len(), 2, "{shown:?}");
|
||||
assert!(shown.iter().all(|(_, x, _)| (*x - 270.0).abs() < 8.0), "lines start at the box's left edge: {shown:?}");
|
||||
|
||||
// a taller box shows everything; the font size and scale are untouched
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"boxHeight": 400}})).unwrap();
|
||||
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
|
||||
assert_eq!(list["layers"][0]["overflow"], false);
|
||||
assert_eq!(list["layers"][0]["scale"], 100.0);
|
||||
assert!(line_starts(&s, clip, 0).len() >= 3);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["overflow"], true);
|
||||
|
||||
// point text has no box, and a vertical layer never gets one
|
||||
let r = s.execute("graphics.newText", json!({"text": "Title", "newClip": true, "track": 3})).unwrap();
|
||||
let point = s.execute("graphics.list", json!({"clip": r["clip"]})).unwrap();
|
||||
assert_eq!(point["layers"][0]["textType"], "point");
|
||||
assert_eq!(point["layers"][0]["box"], json!(null));
|
||||
let r = s.execute("graphics.newText", json!({"text": "Tate", "vertical": true, "box": [300, 300], "newClip": true, "track": 4})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": r["clip"]})).unwrap()["layers"][0]["textType"], "point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_type_converts_both_ways_and_keeps_the_text_in_place() {
|
||||
for align in ["left", "center", "right"] {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": FOX, "position": [270, 400], "box": [500, 150], "size": 60})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
// scaled and turned, so that the anchor arithmetic matters
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"align": align, "scale": 150, "scale_width": 150, "rotation": 20}})).unwrap();
|
||||
let hidden = line_starts(&s, clip, 0);
|
||||
assert_eq!(hidden.len(), 2, "the box hides the third line");
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"boxHeight": 0}})).unwrap();
|
||||
let before = line_starts(&s, clip, 0);
|
||||
assert!(before.len() >= 3);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let position = layers(&s, clip)[0].transform.position;
|
||||
|
||||
// paragraph → point: every line stays where it was, also the hidden one
|
||||
let r = s.execute("graphics.setTextType", json!({"clip": clip.0, "type": "point"})).unwrap();
|
||||
assert_eq!(r, json!({"clip": clip.0, "layer": 0, "type": "point", "changed": true}));
|
||||
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
|
||||
assert_eq!(list["layers"][0]["textType"], "point", "{align}");
|
||||
let spec = layers(&s, clip).remove(0);
|
||||
assert_eq!(text_of(&spec).lines().count(), before.len(), "the wrapped lines became real lines: {:?}", text_of(&spec));
|
||||
assert_eq!(text_of(&spec).replace('\n', " "), FOX, "nothing but the line breaks changed");
|
||||
assert_eq!(spec.transform.position, position, "the layer's position is untouched ({align})");
|
||||
assert!(spec.transform.anchor.y < -1.0, "the anchor, still on the box's old corner, is above the first baseline: {:?}", spec.transform.anchor);
|
||||
same_places(&before, &line_starts(&s, clip, 0));
|
||||
// again: nothing to do
|
||||
assert_eq!(s.execute("graphics.setTextType", json!({"clip": clip.0, "type": "Point Text"})).unwrap()["changed"], false);
|
||||
|
||||
// point → paragraph: a box fitted to the text, which still does not move
|
||||
s.execute("graphics.setTextType", json!({"clip": clip.0, "type": "paragraph"})).unwrap();
|
||||
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
|
||||
let l = &list["layers"][0];
|
||||
assert_eq!(l["textType"], "paragraph");
|
||||
assert_eq!(l["overflow"], false, "the fitted box shows every line");
|
||||
let widest = 500.0;
|
||||
assert!(l["box"][0].as_f64().unwrap() <= widest + 2.0 && l["box"][0].as_f64().unwrap() > 200.0, "{}", l["box"]);
|
||||
let spec = layers(&s, clip).remove(0);
|
||||
assert_eq!(spec.transform.position, position);
|
||||
// the anchor is level with the box's top again; the fitted box is narrower than the old
|
||||
// one, so centred and right-aligned text leave the anchor to its left
|
||||
let a = spec.transform.anchor;
|
||||
assert!(a.y.abs() < 1e-3 && a.x < 1e-3 && (align != "left" || a.x.abs() < 1e-3), "{align}: {a:?}");
|
||||
same_places(&before, &line_starts(&s, clip, 0));
|
||||
|
||||
// each conversion is one undo step
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["textType"], "point");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["textType"], "paragraph");
|
||||
same_places(&hidden, &line_starts(&s, clip, 0));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
same_places(&before, &line_starts(&s, clip, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_type_refuses_what_it_cannot_convert() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("graphics.setTextType", json!({"type": "point"})).is_err(), "no sequence");
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": "Title"})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
assert!(s.execute("graphics.setTextType", json!({"clip": clip})).is_err(), "no type");
|
||||
assert!(s.execute("graphics.setTextType", json!({"clip": clip, "type": "wavy"})).is_err());
|
||||
s.execute("graphics.newShape", json!({"shape": "ellipse", "clip": clip})).unwrap();
|
||||
let e = s.execute("graphics.setTextType", json!({"clip": clip, "layer": 1, "type": "paragraph"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not a text layer"), "{e}");
|
||||
s.execute("graphics.newText", json!({"text": "Tate", "vertical": true, "clip": clip})).unwrap();
|
||||
let e = s.execute("graphics.setTextType", json!({"clip": clip, "layer": 2, "type": "paragraph"})).unwrap_err();
|
||||
assert!(e.to_string().contains("vertical"), "{e}");
|
||||
// a point-text layer keeps its own line breaks when it gets a box
|
||||
s.execute("graphics.setText", json!({"clip": clip, "layer": 0, "text": "one\ntwo words\n\nfour"})).unwrap();
|
||||
let before = line_starts(&s, ClipId(clip), 0);
|
||||
s.execute("graphics.setTextType", json!({"clip": clip, "layer": 0, "type": "paragraph"})).unwrap();
|
||||
same_places(&before, &line_starts(&s, ClipId(clip), 0));
|
||||
s.execute("graphics.setTextType", json!({"clip": clip, "layer": 0, "type": "point"})).unwrap();
|
||||
assert_eq!(text_of(&layers(&s, ClipId(clip))[0]), "one\ntwo words\n\nfour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_saved_before_the_box_height_existed_can_still_get_one() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": FOX, "box": [500, 150], "size": 60})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.edit_sequence("old file", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(clip).unwrap();
|
||||
let ei = layer_indices(&it.effects)[0];
|
||||
it.effects[ei].params.remove("box_height");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap();
|
||||
assert_eq!(list["layers"][0]["box"], json!([500.0, 0.0]), "as tall as its text");
|
||||
assert_eq!(list["layers"][0]["overflow"], false);
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"box_height": 100}})).unwrap();
|
||||
assert_eq!(s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"][0]["box"], json!([500.0, 100.0]));
|
||||
}
|
||||
+105
-14
@@ -234,8 +234,17 @@ pub struct EditorState {
|
||||
pub targeting: std::collections::BTreeMap<ItemId, Targeting>,
|
||||
pub snapping: bool,
|
||||
pub linked_selection: bool,
|
||||
/// The Timeline's "Insert and overwrite sequences as nests or individual clips" toggle, turned
|
||||
/// off: a sequence edits in as its clips instead of one nested clip.
|
||||
#[serde(default)]
|
||||
pub sequences_as_clips: bool,
|
||||
/// Open sequences (timeline tabs), in tab order.
|
||||
pub open_sequences: Vec<ItemId>,
|
||||
/// How each sequence is shown in the Timeline panel (zoom, scroll, track heights): every
|
||||
/// sequence keeps its own. The frontend writes the active sequence's view here as it changes
|
||||
/// and takes a sequence's view from here when it becomes the active one.
|
||||
#[serde(default)]
|
||||
pub timeline_views: std::collections::BTreeMap<ItemId, filmcraft_project::SequenceView>,
|
||||
/// Timeline clipboard (serialized track items with their relative track index).
|
||||
#[serde(skip)]
|
||||
pub clipboard: Vec<(TrackKind, usize, filmcraft_project::TrackItem)>,
|
||||
@@ -295,10 +304,17 @@ pub struct EditorState {
|
||||
/// Events for frontends (drained each frame).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub enum Event {
|
||||
ProjectChanged { revision: u64 },
|
||||
Toast { message: String, error: bool },
|
||||
ProjectChanged {
|
||||
revision: u64,
|
||||
},
|
||||
Toast {
|
||||
message: String,
|
||||
error: bool,
|
||||
},
|
||||
OpenSequence(ItemId),
|
||||
OpenSource(ItemId),
|
||||
/// Show this item in the Project panel (Reveal in Project).
|
||||
RevealInProject(ItemId),
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
@@ -388,6 +404,8 @@ impl Job {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"progress": self.progress.fraction(),
|
||||
// seconds left at the job's recent speed; null until it can tell
|
||||
"etaSeconds": self.progress.eta().map(|d| d.as_secs_f64()),
|
||||
"done": self.progress.done.load(Ordering::Relaxed),
|
||||
"total": self.progress.total.load(Ordering::Relaxed),
|
||||
"status": self.progress.status.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
@@ -681,11 +699,55 @@ impl Session {
|
||||
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
|
||||
}
|
||||
|
||||
/// What is open, to store beside the project when it is saved.
|
||||
pub fn project_view(&self) -> filmcraft_project::ProjectView {
|
||||
let is_seq = |id: &ItemId| self.project.sequence(*id).is_some();
|
||||
filmcraft_project::ProjectView {
|
||||
open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(),
|
||||
active_sequence: self.state.active_sequence.filter(is_seq),
|
||||
sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are
|
||||
/// not sequences of this project are dropped (also a second mention of the same sequence),
|
||||
/// and numbers are brought into range. A view without any open sequence leaves the project
|
||||
/// on its first sequence: a project saved by a session that never showed one (a script, the
|
||||
/// CLI) should not open on an empty Timeline.
|
||||
pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_sequences {
|
||||
if self.project.sequence(id).is_some() && !open.contains(&id) {
|
||||
open.push(id);
|
||||
}
|
||||
}
|
||||
if !open.is_empty() {
|
||||
self.state.active_sequence = view.active_sequence.filter(|id| open.contains(id)).or(open.first().copied());
|
||||
self.state.open_sequences = open;
|
||||
}
|
||||
self.state.timeline_views =
|
||||
view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect();
|
||||
}
|
||||
|
||||
/// Every edit passes through here: one that would put a sequence inside itself (directly or
|
||||
/// through another nested sequence) is refused, whichever command asked for it. A project that
|
||||
/// was opened with such a sequence already in it can still be edited (and repaired).
|
||||
fn refuse_self_nesting(&self, edited: &Project) -> Result<()> {
|
||||
match edited.nest_cycle() {
|
||||
Some(id) if self.project.nest_cycle().is_none() => {
|
||||
let name = edited.item(id).map(|i| i.name.as_str()).unwrap_or_default();
|
||||
Err(EngineError::Other(format!("a sequence cannot be nested inside itself (\u{201c}{name}\u{201d})")))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an undoable project edit. The closure gets a mutable copy; on error nothing changes.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<R>) -> Result<R> {
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
let r = f(&mut p, &mut st)?;
|
||||
self.refuse_self_nesting(&p)?;
|
||||
let old = std::mem::replace(&mut self.project, Arc::new(p));
|
||||
self.history.undo.push((label.to_string(), old));
|
||||
if self.history.undo.len() > self.history.limit {
|
||||
@@ -710,6 +772,7 @@ impl Session {
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
let r = f(&mut p, &mut st)?;
|
||||
self.refuse_self_nesting(&p)?;
|
||||
self.project = Arc::new(p);
|
||||
self.state = st;
|
||||
self.bump();
|
||||
@@ -776,6 +839,7 @@ impl Session {
|
||||
self.state.active_sequence = p.sequences().next().map(|i| i.id);
|
||||
}
|
||||
self.state.open_sequences.retain(|s| p.sequence(*s).is_some());
|
||||
self.state.timeline_views.retain(|s, _| p.sequence(*s).is_some());
|
||||
if let Some(s) = self.state.source_item
|
||||
&& p.item(s).is_none()
|
||||
{
|
||||
@@ -867,10 +931,17 @@ impl Session {
|
||||
|
||||
/// Render the active sequence at the playhead (CPU reference path).
|
||||
pub fn render_program(&self, scale: f32) -> Option<filmcraft_render::Image> {
|
||||
self.render_program_at(scale, self.playhead())
|
||||
}
|
||||
|
||||
/// Render the active sequence at `t` (snapped to its frame, as the playhead would be) without
|
||||
/// moving the playhead (CPU reference path).
|
||||
pub fn render_program_at(&self, scale: f32, t: Tick) -> Option<filmcraft_render::Image> {
|
||||
let seq = self.state.active_sequence?;
|
||||
let t = self.sequence_rate().snap(t.max(Tick::ZERO));
|
||||
let provider = self.media.provider(self.project.clone(), self.services.clone());
|
||||
let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() };
|
||||
Some(filmcraft_render::render_sequence(&self.project, seq, self.playhead(), opts, &provider))
|
||||
Some(filmcraft_render::render_sequence(&self.project, seq, t, opts, &provider))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,20 +956,32 @@ pub fn media_start(p: &Project, id: ItemId) -> Tick {
|
||||
|
||||
/// Media duration of an item (None for stills/adjustment layers = unlimited handles).
|
||||
pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick> {
|
||||
let it = p.item(id)?;
|
||||
match &it.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => match m.info.kind {
|
||||
filmcraft_media::MediaKind::Still | filmcraft_media::MediaKind::Synthetic => None,
|
||||
_ => Some(m.info.duration),
|
||||
},
|
||||
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
|
||||
// a subclip that restricts trims ends at its Out point; otherwise its parent's media is the limit
|
||||
filmcraft_project::ItemKind::Subclip { range, restrict_trims: true, .. } => Some(range.end()),
|
||||
filmcraft_project::ItemKind::Subclip { parent, .. } => media_duration(p, _pool, *parent),
|
||||
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
|
||||
// a subclip's limit is its parent's: follow the chain, bounded (a damaged project file can make
|
||||
// it cyclic or arbitrarily deep)
|
||||
let mut id = id;
|
||||
for _ in 0..=MAX_SUBCLIP_CHAIN {
|
||||
let it = p.item(id)?;
|
||||
return match &it.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => match m.info.kind {
|
||||
filmcraft_media::MediaKind::Still | filmcraft_media::MediaKind::Synthetic => None,
|
||||
_ => Some(m.info.duration),
|
||||
},
|
||||
filmcraft_project::ItemKind::Sequence(s) => Some(s.duration()),
|
||||
// a subclip that restricts trims ends at its Out point; otherwise its parent's media is the limit
|
||||
filmcraft_project::ItemKind::Subclip { range, restrict_trims: true, .. } => Some(range.end()),
|
||||
filmcraft_project::ItemKind::Subclip { parent, .. } => {
|
||||
id = *parent;
|
||||
continue;
|
||||
}
|
||||
filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Subclips of subclips followed before giving up (real projects nest one or two deep).
|
||||
const MAX_SUBCLIP_CHAIN: usize = 16;
|
||||
|
||||
#[cfg(test)]
|
||||
mod aaf_omf_tests;
|
||||
#[cfg(test)]
|
||||
@@ -936,6 +1019,12 @@ mod mixer_tests;
|
||||
#[cfg(test)]
|
||||
mod multicam_tests;
|
||||
#[cfg(test)]
|
||||
mod nest_editing_tests;
|
||||
#[cfg(test)]
|
||||
mod nest_fidelity_tests;
|
||||
#[cfg(test)]
|
||||
mod nesting_tests;
|
||||
#[cfg(test)]
|
||||
mod panels_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
@@ -958,6 +1047,8 @@ mod scopes_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_inspect_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_tabs_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
|
||||
@@ -476,13 +476,17 @@ fn open_in_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"item": item.0}))
|
||||
}
|
||||
|
||||
/// Media properties of a file (cached per path).
|
||||
/// Media properties of a file (cached per path). Only the container's index is read; a format
|
||||
/// without a streaming reader is read whole only when small (see [`MediaPool::probe_file`]), so
|
||||
/// browsing a folder of large AVI or WAV files doesn't read all of them (#157).
|
||||
///
|
||||
/// [`MediaPool::probe_file`]: crate::media_pool::MediaPool::probe_file
|
||||
pub fn probe(s: &mut Session, path: &str) -> Option<MediaInfo> {
|
||||
if let Some(v) = s.browser.probes.get(path) {
|
||||
return v.clone();
|
||||
}
|
||||
let info = if kind_of(path).is_some_and(|k| matches!(k, "video" | "audio" | "image")) {
|
||||
s.media.open_file(path, &*s.services).ok().map(|src| src.info().clone())
|
||||
s.media.probe_file(path, &*s.services).ok().map(|src| src.info().clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -248,3 +248,31 @@ fn path_helpers() {
|
||||
assert_eq!(media_browser::parent("C:\\Media"), Some("C:\\".into()));
|
||||
assert_eq!(media_browser::base_name("/home/me/Movies/"), "Movies");
|
||||
}
|
||||
|
||||
/// #157: browsing a folder read every file no streaming reader takes (an AVI, a WAV) whole into
|
||||
/// memory, just to show its properties or to find out it isn't supported. Large ones are now
|
||||
/// refused after the head; small ones still show their properties; MP4 is read through its index.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn probing_large_unstreamable_files_does_not_read_them_whole() {
|
||||
let dir = crate::media_test_util::tmp_dir("browser-probe-large");
|
||||
// a 100 MB "AVI" (sparse: nothing is written past the header)
|
||||
let avi = dir.join("camera.avi");
|
||||
let mut head = vec![0u8; 64];
|
||||
head[..4].copy_from_slice(b"RIFF");
|
||||
head[8..12].copy_from_slice(b"AVI ");
|
||||
std::fs::write(&avi, &head).unwrap();
|
||||
std::fs::OpenOptions::new().write(true).open(&avi).unwrap().set_len(crate::media_pool::PROBE_WHOLE_FILE_MAX + 36 * 1024 * 1024).unwrap();
|
||||
let wav = dir.join("voice.wav");
|
||||
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 9_600], 2, 48_000)).unwrap();
|
||||
let mov = dir.join("clip.mov");
|
||||
crate::media_test_util::make_movie(&mov, filmcraft_media::DemoScene::OceanSunset, 64, 36, 24);
|
||||
let mut s = Session::default();
|
||||
assert!(media_browser::probe(&mut s, &avi.to_string_lossy()).is_none());
|
||||
assert_eq!(media_browser::probe(&mut s, &wav.to_string_lossy()).unwrap().audio.unwrap().channels, 2);
|
||||
assert!(media_browser::probe(&mut s, &mov.to_string_lossy()).unwrap().video.is_some());
|
||||
// importing is unchanged: it still opens files through the full path
|
||||
let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -53,13 +53,18 @@ impl Default for MediaPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key of a media clip's full-resolution reference ("" matches anything: generators and
|
||||
/// sources inserted without a key).
|
||||
/// Largest file [`MediaPool::probe_file`] reads whole when no streaming reader takes it (WAV,
|
||||
/// MP3 and other byte-opened formats, or an unsupported container such as AVI).
|
||||
pub const PROBE_WHOLE_FILE_MAX: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Cache key of a media clip's full-resolution reference ("" matches anything: sources inserted
|
||||
/// without a key). A generator's key holds its parameters, so a Color Matte whose color changes
|
||||
/// (or comes back with undo) is generated again.
|
||||
pub fn media_key(m: &MediaClip) -> String {
|
||||
match &m.media {
|
||||
MediaRef::File { path } if m.offline => format!("offline:{path}"),
|
||||
MediaRef::File { path } => format!("file:{path}"),
|
||||
MediaRef::Generator(_) => String::new(),
|
||||
MediaRef::Generator(g) => format!("generator:{g:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +132,11 @@ impl MediaPool {
|
||||
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (String::new(), src));
|
||||
}
|
||||
|
||||
/// Cache a source for an item under its [`media_key`].
|
||||
pub fn insert_keyed(&self, item: ItemId, key: String, src: SharedSource) {
|
||||
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src));
|
||||
}
|
||||
|
||||
/// Cache a source opened from `path` for an item.
|
||||
pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) {
|
||||
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src));
|
||||
@@ -186,6 +196,16 @@ impl MediaPool {
|
||||
self.open_bytes(&file_name(path), bytes.into())
|
||||
}
|
||||
|
||||
/// Open a file to look at it (Media Browser properties and thumbnails), not to import it: a
|
||||
/// format without a streaming reader is read whole only up to [`PROBE_WHOLE_FILE_MAX`] bytes
|
||||
/// (#157). Hosts without random-access readers open the file as [`Self::open_file`] does.
|
||||
pub fn probe_file(&self, path: &str, services: &dyn Services) -> Result<SharedSource, MediaError> {
|
||||
let Some(r) = services.reader(path) else { return self.open_file(path, services) };
|
||||
let r = r.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) })?;
|
||||
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
filmcraft_media::reader::open_reader_within(&file_name(path), r, &filmcraft_codecs::reader_openers(), &openers, PROBE_WHOLE_FILE_MAX)
|
||||
}
|
||||
|
||||
/// Resolve (and cache) the source for a project item, using proxies when they are enabled.
|
||||
pub fn source_for(&self, p: &Project, item: ItemId, services: &dyn Services) -> Option<SharedSource> {
|
||||
self.resolve(p, item, services, self.use_proxies())
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Editing with nested sequences: the nest toggle ("Insert and overwrite sequences as nests or
|
||||
//! individual clips"), a sequence as a source, Match Frame on a nest, and Reveal in Project.
|
||||
//! Premiere's behaviour was observed in Premiere Pro 26.5.2.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::{TrackItem, TrackKind};
|
||||
use filmcraft_time::TimeRange;
|
||||
use serde_json::json;
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn clips(s: &Session, kind: TrackKind, idx: usize) -> Vec<TrackItem> {
|
||||
s.active_sequence().unwrap().tracks(kind)[idx].items.clone()
|
||||
}
|
||||
|
||||
/// A subsequence of the first three V1 clips of the demo (with their sound and the transitions
|
||||
/// between them), and a new empty sequence, which is left active.
|
||||
fn source_and_empty(s: &mut Session) -> (ItemId, ItemId) {
|
||||
let v1: Vec<u64> = clips(s, TrackKind::Video, 0).iter().take(3).map(|c| c.id.0).collect();
|
||||
s.execute("timeline.select", json!({"clips": v1})).unwrap();
|
||||
let source = ItemId(s.execute("sequence.makeSubsequence", json!({"name": "Source"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
let empty = ItemId(s.execute("file.newSequence", json!({"name": "Target"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
s.execute("sequence.open", json!({"item": empty.0})).unwrap();
|
||||
(source, empty)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_nest_toggle_is_on_by_default_and_can_be_set() {
|
||||
let mut s = demo();
|
||||
assert!(!s.state.sequences_as_clips);
|
||||
assert_eq!(s.execute("sequence.nestSequences", json!({})).unwrap()["nest"], false);
|
||||
assert!(s.state.sequences_as_clips);
|
||||
assert_eq!(s.execute("sequence.nestSequences", json!({})).unwrap()["nest"], true);
|
||||
assert_eq!(s.execute("sequence.nestSequences", json!({"on": false})).unwrap()["nest"], false);
|
||||
assert_eq!(s.execute("sequence.nestSequences", json!({"on": false})).unwrap()["nest"], false);
|
||||
assert!(s.state.sequences_as_clips);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_toggle_on_a_sequence_edits_in_as_one_nest() {
|
||||
let mut s = demo();
|
||||
let (source, _) = source_and_empty(&mut s);
|
||||
let r = s.execute("timeline.place", json!({"item": source.0, "seconds": 0.0})).unwrap();
|
||||
assert_eq!(r["clips"].as_array().unwrap().len(), 2, "one picture and one sound clip");
|
||||
let v1 = clips(&s, TrackKind::Video, 0);
|
||||
assert_eq!(v1.len(), 1);
|
||||
assert_eq!(v1[0].item, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_the_toggle_off_a_sequence_edits_in_as_its_clips_with_their_transitions() {
|
||||
let mut s = demo();
|
||||
let (source, _) = source_and_empty(&mut s);
|
||||
let src = s.project.sequence(source).unwrap().clone();
|
||||
let (src_v, src_a) = (src.video_tracks[0].clone(), src.audio_tracks[0].clone());
|
||||
assert!(src_v.items.len() == 3 && !src_v.transitions.is_empty(), "the source has transitions to carry");
|
||||
s.execute("sequence.nestSequences", json!({"on": false})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
let at = s.sequence_rate().tick_of(48);
|
||||
s.execute("timeline.place", json!({"item": source.0, "time": at.0})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
let (v, a) = (&q.video_tracks[0], &q.audio_tracks[0]);
|
||||
// the same clips, at the same places from `at`, as copies with ids of their own
|
||||
assert_eq!(v.items.len(), 3);
|
||||
for (new, old) in v.items.iter().zip(&src_v.items).chain(a.items.iter().zip(&src_a.items)) {
|
||||
assert_eq!((new.item, new.start, new.duration, new.source_in), (old.item, old.start + at, old.duration, old.source_in));
|
||||
assert_ne!(new.id, old.id);
|
||||
}
|
||||
assert!(v.items.iter().all(|i| s.project.sequence(i.item).is_none()), "no nest");
|
||||
// picture and sound stay linked pair by pair, with links of their own
|
||||
for (pic, snd) in v.items.iter().zip(&a.items) {
|
||||
assert!(pic.link.is_some() && pic.link == snd.link);
|
||||
}
|
||||
assert!(v.items.iter().zip(&src_v.items).all(|(n, o)| n.link != o.link));
|
||||
// the transitions came along, between the copies
|
||||
assert_eq!((v.transitions.len(), a.transitions.len()), (src_v.transitions.len(), src_a.transitions.len()));
|
||||
for (new, old) in v.transitions.iter().zip(&src_v.transitions) {
|
||||
assert_eq!((new.start, new.duration), (old.start + at, old.duration));
|
||||
assert!(new.id != old.id && new.from.is_none_or(|c| v.item(c).is_some()) && new.to.is_none_or(|c| v.item(c).is_some()));
|
||||
}
|
||||
// the copies are what is selected, and the source sequence is untouched
|
||||
assert_eq!(s.state.selection.len(), v.items.len() + a.items.len());
|
||||
assert_eq!(s.project.sequence(source).unwrap(), &src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tracks_with_clips_go_to_consecutive_tracks_and_missing_tracks_are_added() {
|
||||
let mut s = demo();
|
||||
let (source, target) = source_and_empty(&mut s);
|
||||
// in the source, put the third clip's picture on V3 (V2 stays empty)
|
||||
s.execute("sequence.open", json!({"item": source.0})).unwrap();
|
||||
let third = clips(&s, TrackKind::Video, 0)[2].clone();
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": third.id.0, "track": "V3", "time": third.start.0}], "linked": false})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1);
|
||||
s.execute("sequence.open", json!({"item": target.0})).unwrap();
|
||||
s.execute("sequence.nestSequences", json!({"on": false})).unwrap();
|
||||
// dropped on V1: source V1 and V3 land on V1 and V2
|
||||
s.execute("timeline.place", json!({"item": source.0, "track": "V1", "seconds": 0.0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks[0].items.len(), q.video_tracks[1].items.len(), q.video_tracks[2].items.len()), (2, 1, 0));
|
||||
assert_eq!(q.video_tracks[1].items[0].item, third.item);
|
||||
// dropped on the top track, V3: the second lane needs a V4, which is added
|
||||
assert_eq!(q.video_tracks.len(), 3);
|
||||
s.execute("timeline.place", json!({"item": source.0, "track": "V3", "seconds": 60.0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert_eq!(q.video_tracks.len(), 4);
|
||||
assert_eq!((q.video_tracks[2].items.len(), q.video_tracks[3].items.len()), (2, 1));
|
||||
assert_eq!(q.video_tracks[3].name, "Video 4");
|
||||
// one undo removes the clips and the track again
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_the_source_monitor_the_marked_part_of_a_sequence_is_edited_in() {
|
||||
let mut s = demo();
|
||||
let (source, _) = source_and_empty(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let src_v = s.project.sequence(source).unwrap().video_tracks[0].clone();
|
||||
// In inside the first clip, Out inside the second
|
||||
let (mark_in, mark_out) = (src_v.items[0].start + rate.tick_of(12), src_v.items[1].start + rate.tick_of(23));
|
||||
s.execute("source.open", json!({"item": source.0})).unwrap();
|
||||
s.execute("project.setMarks", json!({"item": source.0, "in": mark_in.0, "out": mark_out.0})).unwrap();
|
||||
let marked = TimeRange::from_bounds(mark_in, mark_out + rate.frame_duration());
|
||||
|
||||
// as a nest: one clip showing that part of the sequence
|
||||
s.execute("source.overwrite", json!({})).unwrap();
|
||||
let nest = clips(&s, TrackKind::Video, 0)[0].clone();
|
||||
assert_eq!((nest.item, nest.source_in, nest.duration), (source, marked.start, marked.duration));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// as clips: the two clips the marks cut through, trimmed to them
|
||||
s.execute("sequence.nestSequences", json!({"on": false})).unwrap();
|
||||
s.set_playhead(Tick::ZERO);
|
||||
s.execute("source.overwrite", json!({})).unwrap();
|
||||
let v = clips(&s, TrackKind::Video, 0);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!((v[0].start, v[0].source_in), (Tick::ZERO, src_v.items[0].source_in + rate.tick_of(12)));
|
||||
assert_eq!(v[0].end(), v[1].start);
|
||||
assert_eq!(v[1].end(), marked.duration);
|
||||
assert_eq!(v[1].source_in, src_v.items[1].source_in);
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_can_be_edited_into_itself_as_clips_but_not_as_a_nest() {
|
||||
let mut s = demo();
|
||||
let main = s.state.active_sequence.unwrap();
|
||||
let before = clips(&s, TrackKind::Video, 0).len();
|
||||
assert!(s.execute("timeline.place", json!({"item": main.0, "seconds": 120.0})).is_err());
|
||||
s.execute("sequence.nestSequences", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": main.0, "seconds": 120.0})).expect("its clips are just clips");
|
||||
assert_eq!(clips(&s, TrackKind::Video, 0).len(), before * 2);
|
||||
assert_eq!(s.project.nest_cycle(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_frame_on_a_nest_loads_the_nested_sequence_at_the_frame_it_shows() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let v = clips(&s, TrackKind::Video, 0)[1].id;
|
||||
s.execute("timeline.select", json!({"clips": [v.0]})).unwrap();
|
||||
let r = s.execute("clip.nest", json!({"name": "Inner"})).unwrap();
|
||||
let (nested, nest) = (ItemId(r["sequence"].as_u64().unwrap()), ClipId(r["clips"][0].as_u64().unwrap()));
|
||||
let clip = |s: &Session| s.active_sequence().unwrap().find_item(nest).unwrap().1.clone();
|
||||
// plain: 9 frames into the nest is 9 frames into its sequence
|
||||
s.set_playhead(clip(&s).start + rate.tick_of(9));
|
||||
s.execute("sequence.matchFrame", json!({})).unwrap();
|
||||
assert_eq!((s.state.source_item, s.state.source_playhead), (Some(nested), rate.tick_of(9)));
|
||||
// trimmed in by 5 frames: the same timeline frame now shows frame 9 still, 4 frames into the clip
|
||||
s.execute("timeline.trim", json!({"clip": nest.0, "edge": "in", "deltaFrames": 5})).unwrap();
|
||||
s.set_playhead(clip(&s).start + rate.tick_of(4));
|
||||
s.execute("sequence.matchFrame", json!({})).unwrap();
|
||||
assert_eq!(s.state.source_playhead, rate.tick_of(9));
|
||||
// at double speed, 4 frames in is 8 frames further into the sequence
|
||||
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"speed": 200.0})).unwrap();
|
||||
s.set_playhead(clip(&s).start + rate.tick_of(4));
|
||||
s.execute("sequence.matchFrame", json!({})).unwrap();
|
||||
assert_eq!((s.state.source_item, s.state.source_playhead), (Some(nested), rate.tick_of(5 + 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_in_project_selects_the_clips_item_and_asks_the_ui_to_show_it() {
|
||||
let mut s = demo();
|
||||
let v1 = clips(&s, TrackKind::Video, 0);
|
||||
s.drain_events();
|
||||
// the selected clip
|
||||
s.execute("timeline.select", json!({"clips": [v1[2].id.0]})).unwrap();
|
||||
assert_eq!(s.execute("clip.revealInProject", json!({})).unwrap()["item"], v1[2].item.0);
|
||||
assert_eq!(s.state.project_selection, vec![v1[2].item]);
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::RevealInProject(i) if *i == v1[2].item)));
|
||||
// a named clip
|
||||
s.execute("clip.revealInProject", json!({"clip": v1[0].id.0})).unwrap();
|
||||
assert_eq!(s.state.project_selection, vec![v1[0].item]);
|
||||
// nothing selected: the clip under the playhead on a targeted track
|
||||
s.execute("timeline.select", json!({"clips": []})).unwrap();
|
||||
s.set_playhead(v1[3].start + Tick(v1[3].duration.0 / 2));
|
||||
s.execute("clip.revealInProject", json!({})).unwrap();
|
||||
assert_eq!(s.state.project_selection, vec![v1[3].item]);
|
||||
// the sequence in the Timeline
|
||||
let main = s.state.active_sequence.unwrap();
|
||||
s.execute("sequence.revealInProject", json!({})).unwrap();
|
||||
assert_eq!(s.state.project_selection, vec![main]);
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::RevealInProject(i) if *i == main)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_in_and_out_in_the_source_monitor_mark_a_sequence_like_a_clip() {
|
||||
// marks on a sequence loaded in the Source Monitor used to be dropped silently
|
||||
let mut s = demo();
|
||||
let (source, _) = source_and_empty(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("source.open", json!({"item": source.0})).unwrap();
|
||||
s.execute("markers.markIn", json!({"target": "source", "time": rate.tick_of(10).0})).unwrap();
|
||||
s.execute("markers.markOut", json!({"target": "source", "time": rate.tick_of(40).0})).unwrap();
|
||||
let q = s.project.sequence(source).unwrap();
|
||||
assert_eq!((q.mark_in, q.mark_out), (Some(rate.tick_of(10)), Some(rate.tick_of(40))));
|
||||
// and can be cleared again
|
||||
s.execute("project.setMarks", json!({"item": source.0, "in": null, "out": null})).unwrap();
|
||||
let q = s.project.sequence(source).unwrap();
|
||||
assert_eq!((q.mark_in, q.mark_out), (None, None));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Nests whose sequence is another size than the sequence they are in: how they come in, and
|
||||
//! Scale to Frame Size / Fit to frame / Fill frame on them. Premiere's behaviour was observed in
|
||||
//! Premiere Pro 26.5.2 (a 1280x720 nest in a 1920x1080 sequence).
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::TrackItem;
|
||||
use serde_json::json;
|
||||
|
||||
/// The demo's 1920x1080 sequence with a 1280x720 sequence nested on V3 after its last clip.
|
||||
/// Returns the session and the nest's picture clip.
|
||||
fn small_nest() -> (Session, ClipId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let main = s.state.active_sequence.unwrap();
|
||||
assert_eq!((s.active_sequence().unwrap().settings.width, s.active_sequence().unwrap().settings.height), (1920, 1080));
|
||||
let footage = s.active_sequence().unwrap().video_tracks[0].items[0].item;
|
||||
let small = ItemId(s.execute("file.newSequence", json!({"name": "Small", "width": 1280, "height": 720})).unwrap()["sequence"].as_u64().unwrap());
|
||||
s.execute("sequence.open", json!({"item": small.0})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": footage.0, "seconds": 0.0})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": main.0})).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": small.0, "track": "V3", "seconds": 120.0})).unwrap();
|
||||
let nest = ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(nest).unwrap().1.item, small);
|
||||
(s, nest)
|
||||
}
|
||||
|
||||
fn clip(s: &Session, id: ClipId) -> TrackItem {
|
||||
s.active_sequence().unwrap().find_item(id).unwrap().1.clone()
|
||||
}
|
||||
|
||||
fn motion(it: &TrackItem, param: &str) -> f64 {
|
||||
it.effect("motion").unwrap().param(param).unwrap().value.as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nest_of_another_size_comes_in_at_its_own_size() {
|
||||
let (s, nest) = small_nest();
|
||||
let it = clip(&s, nest);
|
||||
assert!((motion(&it, "scale") - 100.0).abs() < 1e-9 && !it.scale_to_frame);
|
||||
assert_eq!(filmcraft_render::source_size(&s.project, it.item), Some((1280, 720)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_fill_frame_scale_a_nest_to_the_sequence() {
|
||||
let (mut s, nest) = small_nest();
|
||||
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
|
||||
// 1280x720 into 1920x1080: 150% either way (the same shape)
|
||||
s.execute("clip.fitToFrame", json!({})).unwrap();
|
||||
assert!((motion(&clip(&s, nest), "scale") - 150.0).abs() < 1e-6);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!((motion(&clip(&s, nest), "scale") - 100.0).abs() < 1e-9);
|
||||
s.execute("clip.fillFrame", json!({})).unwrap();
|
||||
assert!((motion(&clip(&s, nest), "scale") - 150.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_to_frame_size_on_a_nest_fills_the_frame_and_leaves_scale_alone() {
|
||||
let (mut s, nest) = small_nest();
|
||||
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
|
||||
s.execute("clip.scaleToFrameSize", json!({})).unwrap();
|
||||
let it = clip(&s, nest);
|
||||
assert!(it.scale_to_frame && (motion(&it, "scale") - 100.0).abs() < 1e-9);
|
||||
// the picture: the nest's corner is now the frame's corner
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
let opts = filmcraft_render::RenderOptions { scale: 0.25, ..Default::default() };
|
||||
let t = it.start + Tick(it.duration.0 / 2);
|
||||
let filled = filmcraft_render::render_sequence(&s.project, s.state.active_sequence.unwrap(), t, opts, &provider);
|
||||
assert!(filled.get(2, 2)[3] > 0.98 && filled.get(filled.w - 3, filled.h - 3)[3] > 0.98);
|
||||
// a toggle: off again, the nest is back in the middle with nothing in the corners
|
||||
s.execute("clip.scaleToFrameSize", json!({})).unwrap();
|
||||
let centred = filmcraft_render::render_sequence(&s.project, s.state.active_sequence.unwrap(), t, opts, &provider);
|
||||
assert!(centred.get(2, 2)[3] < 0.02 && centred.get(centred.w / 2, centred.h / 2)[3] > 0.98);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Nested sequences: a sequence can never contain itself, whichever
|
||||
//! command is asked to put it there, and Nest… keeps what was selected.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::TrackKind;
|
||||
use filmcraft_time::TimeRange;
|
||||
use serde_json::json;
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn clip_ids(s: &Session, kind: TrackKind, idx: usize) -> Vec<u64> {
|
||||
s.active_sequence().unwrap().tracks(kind)[idx].items.iter().map(|i| i.id.0).collect()
|
||||
}
|
||||
|
||||
/// Nest the second V1 clip of the active sequence; returns (outer sequence, nested sequence).
|
||||
fn nest_one(s: &mut Session, name: &str) -> (ItemId, ItemId) {
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
let v = clip_ids(s, TrackKind::Video, 0)[1];
|
||||
s.execute("timeline.select", json!({"clips": [v]})).unwrap();
|
||||
let r = s.execute("clip.nest", json!({"name": name})).unwrap();
|
||||
(outer, ItemId(r["sequence"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
/// The command fails with the self-nesting message and changes nothing (project and undo history).
|
||||
fn refused(s: &mut Session, command: &str, params: serde_json::Value) {
|
||||
let (before, undo) = (s.project.clone(), s.history.undo.len());
|
||||
let e = s.execute(command, params).expect_err(command).to_string();
|
||||
assert!(e.contains("cannot be nested inside itself"), "{command}: {e}");
|
||||
assert!(Arc::ptr_eq(&before, &s.project), "{command} left the project alone");
|
||||
assert_eq!(s.history.undo.len(), undo, "{command} added no undo step");
|
||||
assert_eq!(s.project.nest_cycle(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_cannot_be_placed_in_itself() {
|
||||
let mut s = demo();
|
||||
let a = s.state.active_sequence.unwrap();
|
||||
refused(&mut s, "timeline.place", json!({"item": a.0, "seconds": 1.0}));
|
||||
refused(&mut s, "timeline.place", json!({"item": a.0, "seconds": 1.0, "insert": true}));
|
||||
// from the Source monitor as well
|
||||
s.execute("source.open", json!({"item": a.0})).unwrap();
|
||||
refused(&mut s, "source.insert", json!({}));
|
||||
refused(&mut s, "source.overwrite", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_cannot_be_placed_in_a_sequence_it_contains() {
|
||||
let mut s = demo();
|
||||
let (outer, nested) = nest_one(&mut s, "Inner");
|
||||
// outer holds Inner: Inner cannot take outer, nor itself
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
refused(&mut s, "timeline.place", json!({"item": outer.0, "seconds": 0.0}));
|
||||
refused(&mut s, "timeline.place", json!({"item": nested.0, "seconds": 0.0}));
|
||||
// one level further: a third sequence that holds outer cannot go into Inner either
|
||||
s.execute("sequence.open", json!({"item": outer.0})).unwrap();
|
||||
let third = ItemId(s.execute("file.newSequence", json!({"name": "Third"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
s.execute("sequence.open", json!({"item": third.0})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": outer.0, "seconds": 0.0})).expect("a nest of a nest is fine");
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
refused(&mut s, "timeline.place", json!({"item": third.0, "seconds": 0.0}));
|
||||
// the other direction stays allowed
|
||||
s.execute("sequence.open", json!({"item": third.0})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": nested.0, "seconds": 30.0})).expect("Inner may be used twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_a_nest_into_its_own_sequence_is_refused() {
|
||||
let mut s = demo();
|
||||
let (_, nested) = nest_one(&mut s, "Inner");
|
||||
// copy the nest clip, open the nested sequence and paste it there
|
||||
let nest = s.active_sequence().unwrap().video_tracks[0].items.iter().find(|i| i.item == nested).unwrap().id;
|
||||
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
refused(&mut s, "edit.paste", json!({}));
|
||||
refused(&mut s, "edit.pasteInsert", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_opened_with_a_cycle_can_still_be_edited_and_repaired() {
|
||||
let mut s = demo();
|
||||
let a = s.state.active_sequence.unwrap();
|
||||
// what a damaged project file would hold: the sequence on its own track
|
||||
let mut p = (*s.project).clone();
|
||||
let rate = p.sequence(a).unwrap().settings.frame_rate;
|
||||
let it = p.make_track_item(a, TrackKind::Video, Tick(900 * filmcraft_time::TICKS_PER_SECOND), TimeRange::new(Tick::ZERO, rate.tick_of(24)), rate).unwrap();
|
||||
let bad = it.id;
|
||||
p.sequence_mut(a).unwrap().video_tracks[0].items.push(it);
|
||||
s.project = Arc::new(p);
|
||||
assert_eq!(s.project.nest_cycle(), Some(a));
|
||||
// unrelated edits still work
|
||||
let first = clip_ids(&s, TrackKind::Video, 0)[0];
|
||||
s.execute("timeline.select", json!({"clips": [first]})).unwrap();
|
||||
s.execute("clip.enable", json!({})).expect("edits are not locked out");
|
||||
// and removing the clip repairs it, after which the guard is back
|
||||
s.execute("timeline.select", json!({"clips": [bad.0]})).unwrap();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(s.project.nest_cycle(), None);
|
||||
refused(&mut s, "timeline.place", json!({"item": a.0, "seconds": 1.0}));
|
||||
}
|
||||
|
||||
/// The first V1 transition that joins two clips: (transition, outgoing clip, incoming clip).
|
||||
fn joining_transition(s: &Session) -> (filmcraft_project::Transition, ClipId, ClipId) {
|
||||
let v1 = &s.active_sequence().unwrap().video_tracks[0];
|
||||
let t = v1.transitions.iter().find(|t| t.from.is_some() && t.to.is_some()).expect("the demo has a transition between two clips").clone();
|
||||
let (from, to) = (t.from.unwrap(), t.to.unwrap());
|
||||
(t, from, to)
|
||||
}
|
||||
|
||||
fn frame_at(s: &Session, t: Tick) -> filmcraft_render::Image {
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
let opts = filmcraft_render::RenderOptions { scale: 0.25, ..Default::default() };
|
||||
filmcraft_render::render_sequence(&s.project, s.state.active_sequence.unwrap(), t, opts, &provider)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_keeps_transitions_track_names_and_channel_layouts() {
|
||||
let mut s = demo();
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
s.edit_sequence("setup", |q, _, _| {
|
||||
q.video_tracks[0].name = "Picture".into();
|
||||
q.audio_tracks[0].name = "Dialogue".into();
|
||||
q.audio_tracks[0].channels = filmcraft_project::AudioChannels::Mono;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (trn, from, to) = joining_transition(&s);
|
||||
let mid = trn.start + Tick(trn.duration.0 / 2);
|
||||
let before = frame_at(&s, mid);
|
||||
let start = s.active_sequence().unwrap().find_item(from).unwrap().1.start;
|
||||
let audio_transitions = s.active_sequence().unwrap().audio_tracks[0].transitions.len();
|
||||
s.execute("timeline.select", json!({"clips": [from.0, to.0]})).unwrap();
|
||||
let linked: Vec<ClipId> = s.state.selection.clone();
|
||||
let nested = ItemId(s.execute("clip.nest", json!({"name": "Inner"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
|
||||
let q = s.project.sequence(nested).unwrap();
|
||||
q.check().unwrap();
|
||||
// the transition came along, on the same track, at the same place relative to its clips
|
||||
let inner = q.video_tracks[0].transitions.iter().find(|t| t.id == trn.id).expect("the transition is inside the nest");
|
||||
assert_eq!((inner.start, inner.duration, inner.from, inner.to), (trn.start - start, trn.duration, Some(from), Some(to)));
|
||||
// so did the one between the linked sound clips, if the demo has it
|
||||
let inner_audio =
|
||||
q.audio_tracks[0].transitions.iter().filter(|t| t.from.is_some_and(|c| linked.contains(&c)) && t.to.is_some_and(|c| linked.contains(&c))).count();
|
||||
let outer_q = s.project.sequence(outer).unwrap();
|
||||
assert_eq!(outer_q.audio_tracks[0].transitions.len() + inner_audio, audio_transitions, "audio transitions moved, none lost");
|
||||
// tracks are laid out like the parent's
|
||||
assert_eq!(q.video_tracks[0].name, "Picture");
|
||||
assert_eq!(q.audio_tracks[0].name, "Dialogue");
|
||||
assert_eq!(q.audio_tracks[0].channels, filmcraft_project::AudioChannels::Mono);
|
||||
// and there are only as many as the nested clips need (Premiere: V1 + A1 here, not the parent's three each)
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (1, 1));
|
||||
// the parent no longer holds the transition, and shows the same picture through the nest
|
||||
assert!(outer_q.video_tracks[0].transitions.iter().all(|t| t.id != trn.id));
|
||||
outer_q.check().unwrap();
|
||||
let after = frame_at(&s, mid);
|
||||
assert_eq!((before.w, before.h), (after.w, after.h));
|
||||
let worst = before.px.iter().zip(&after.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max);
|
||||
assert!(worst < 0.01, "the frame inside the transition is unchanged by nesting (worst channel difference {worst})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_leaves_transitions_to_clips_outside_the_selection_out() {
|
||||
let mut s = demo();
|
||||
let (trn, from, _to) = joining_transition(&s);
|
||||
// only the outgoing clip is nested: the transition needs both, so it is not carried
|
||||
s.execute("timeline.select", json!({"clips": [from.0]})).unwrap();
|
||||
let nested = ItemId(s.execute("clip.nest", json!({"name": "Half"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
let q = s.project.sequence(nested).unwrap();
|
||||
q.check().unwrap();
|
||||
assert!(q.all_tracks().all(|t| t.transitions.iter().all(|t| t.id != trn.id)));
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
/// Nest the 4th and 5th V1 clips (with their sound) as "Inner". Returns the nested sequence, the
|
||||
/// nest's video clip in the outer sequence, and the two nested clips' ids and lengths.
|
||||
fn nest_two(s: &mut Session) -> (ItemId, ClipId, [(ClipId, Tick); 2]) {
|
||||
let v1 = &s.active_sequence().unwrap().video_tracks[0];
|
||||
let pair = [(v1.items[3].id, v1.items[3].duration), (v1.items[4].id, v1.items[4].duration)];
|
||||
s.execute("timeline.select", json!({"clips": [pair[0].0.0, pair[1].0.0]})).unwrap();
|
||||
let nested = ItemId(s.execute("clip.nest", json!({"name": "Inner"})).unwrap()["sequence"].as_u64().unwrap());
|
||||
let nest = s.active_sequence().unwrap().video_tracks[0].items.iter().find(|i| i.item == nested).unwrap().id;
|
||||
(nested, nest, pair)
|
||||
}
|
||||
|
||||
fn nest_clip(s: &Session, id: ClipId) -> filmcraft_project::TrackItem {
|
||||
s.active_sequence().unwrap().find_item(id).unwrap().1.clone()
|
||||
}
|
||||
|
||||
fn mix_at(s: &Session, t: Tick, frames: usize) -> filmcraft_frame::AudioBuffer {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let sr = q.settings.sample_rate as i64;
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
filmcraft_render::audio::mix_sequence(&s.project, q, t.to_units_floor(sr), frames, &provider)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nest_keeps_its_length_when_its_sequence_gets_shorter() {
|
||||
let mut s = demo();
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
let (nested, nest, [(_, first), (second_id, second)]) = nest_two(&mut s);
|
||||
let whole = nest_clip(&s, nest);
|
||||
assert_eq!(whole.duration, first + second);
|
||||
assert_eq!(s.project.nest_overhang(&whole), None);
|
||||
// remove the second clip inside the nest
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [second_id.0]})).unwrap();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().duration(), first);
|
||||
s.execute("sequence.open", json!({"item": outer.0})).unwrap();
|
||||
// the nest is as long as before; its end is now past the contents
|
||||
let c = nest_clip(&s, nest);
|
||||
assert_eq!(c.range(), whole.range());
|
||||
let empty = s.project.nest_overhang(&c).expect("the end of the nest is empty");
|
||||
assert_eq!(empty, TimeRange::new(c.start + first, second));
|
||||
// there it shows and plays what the sequence would without the nest
|
||||
let t = empty.start + Tick(empty.duration.0 / 2);
|
||||
let (with_frame, with_mix) = (frame_at(&s, t), mix_at(&s, t, 4800));
|
||||
s.execute("timeline.select", json!({"clips": [nest.0]})).unwrap();
|
||||
s.execute("clip.enable", json!({})).unwrap();
|
||||
let (without_frame, without_mix) = (frame_at(&s, t), mix_at(&s, t, 4800));
|
||||
assert!(with_frame.px == without_frame.px, "nothing to see past the contents");
|
||||
assert_eq!(with_mix.channels, without_mix.channels, "nothing to hear past the contents");
|
||||
s.execute("clip.enable", json!({})).unwrap();
|
||||
// it cannot be trimmed out further, and trimming the empty part off leaves a sound nest
|
||||
s.execute("timeline.trim", json!({"clip": nest.0, "edge": "out", "deltaFrames": 12})).ok();
|
||||
assert!(nest_clip(&s, nest).duration <= whole.duration, "no more empty time is added");
|
||||
let back = nest_clip(&s, nest).duration - first;
|
||||
s.execute("timeline.trim", json!({"clip": nest.0, "edge": "out", "delta": -back.0})).unwrap();
|
||||
let c = nest_clip(&s, nest);
|
||||
assert_eq!(c.duration, first);
|
||||
assert_eq!(s.project.nest_overhang(&c), None);
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nest_can_be_trimmed_out_to_reveal_what_its_sequence_gained() {
|
||||
let mut s = demo();
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let (nested, nest, [(_, first), (_, second)]) = nest_two(&mut s);
|
||||
let whole = nest_clip(&s, nest);
|
||||
// make room after the nest in the outer sequence: drop everything that follows it on V1/A1
|
||||
let later: Vec<u64> = s.active_sequence().unwrap().video_tracks[0].items.iter().filter(|i| i.start >= whole.end()).map(|i| i.id.0).collect();
|
||||
s.execute("timeline.select", json!({"clips": later})).unwrap();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
// add 48 frames of media to the end of the nested sequence
|
||||
let media = s.project.items.values().find(|i| i.name == "Desert_Dunes.mp4").unwrap().id;
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
s.execute("timeline.place", json!({"item": media.0, "time": (first + second).0, "duration": rate.tick_of(48).0})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().duration(), first + second + rate.tick_of(48));
|
||||
s.execute("sequence.open", json!({"item": outer.0})).unwrap();
|
||||
// the nest did not grow by itself
|
||||
assert_eq!(nest_clip(&s, nest).range(), whole.range());
|
||||
// trimming reveals the new material, up to the end of the contents and no further
|
||||
s.execute("timeline.trim", json!({"clip": nest.0, "edge": "out", "deltaFrames": 10})).unwrap();
|
||||
assert_eq!(nest_clip(&s, nest).duration, whole.duration + rate.tick_of(10));
|
||||
s.execute("timeline.trim", json!({"clip": nest.0, "edge": "out", "deltaFrames": 500})).ok();
|
||||
let c = nest_clip(&s, nest);
|
||||
assert_eq!(c.duration, whole.duration + rate.tick_of(48));
|
||||
assert_eq!(s.project.nest_overhang(&c), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_sequence_shows_changes_made_inside_a_nest() {
|
||||
let mut s = demo();
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
let (nested, nest, [(first_id, first), _]) = nest_two(&mut s);
|
||||
let t = nest_clip(&s, nest).start + Tick(first.0 / 2);
|
||||
let before = frame_at(&s, t);
|
||||
let revision = s.revision;
|
||||
// disable the clip that is on screen, inside the nest
|
||||
s.execute("sequence.open", json!({"item": nested.0})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [first_id.0]})).unwrap();
|
||||
s.execute("clip.enable", json!({})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": outer.0})).unwrap();
|
||||
assert_ne!(s.revision, revision, "frames cached for the outer sequence are stale");
|
||||
let after = frame_at(&s, t);
|
||||
assert!(before.px != after.px, "the outer sequence shows the nest's new contents");
|
||||
}
|
||||
|
||||
// ---- Nest… as Premiere Pro 26.5.2 does it (observed in the app, 2026-10-06)
|
||||
|
||||
#[test]
|
||||
fn after_nest_nothing_is_selected_and_the_new_sequence_is_selected_in_the_project() {
|
||||
let mut s = demo();
|
||||
let v = clip_ids(&s, TrackKind::Video, 0)[1];
|
||||
s.execute("timeline.select", json!({"clips": [v]})).unwrap();
|
||||
let r = s.execute("clip.nest", json!({"name": "Inner"})).unwrap();
|
||||
let nested = ItemId(r["sequence"].as_u64().unwrap());
|
||||
assert!(s.state.selection.is_empty());
|
||||
assert_eq!(s.state.project_selection, vec![nested]);
|
||||
// the result names the nest's clips: one picture and one sound clip, linked, where the clips were
|
||||
let clips: Vec<ClipId> = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (vt, vc) = q.find_item(clips[0]).unwrap();
|
||||
let (at, ac) = q.find_item(clips[1]).unwrap();
|
||||
assert_eq!((vt, at), (q.video_tracks[0].id, q.audio_tracks[0].id));
|
||||
assert!(vc.item == nested && ac.item == nested && vc.link.is_some() && vc.link == ac.link);
|
||||
// each shows the nested sequence from its start, for the length of what was nested
|
||||
let inner = s.project.sequence(nested).unwrap().duration();
|
||||
assert_eq!((vc.source_in, vc.duration, ac.source_in, ac.duration), (Tick::ZERO, inner, Tick::ZERO, inner));
|
||||
assert_eq!(clips.len(), 2);
|
||||
}
|
||||
|
||||
/// Move the sound of V1 clip `n` alone to audio track `track` ("A3"), keeping its time.
|
||||
fn move_sound(s: &mut Session, n: usize, track: &str) -> (filmcraft_project::TrackItem, filmcraft_project::TrackItem) {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let picture = q.video_tracks[0].items[n].clone();
|
||||
let sound = q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.id != picture.id && i.link == picture.link).unwrap().clone();
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": sound.id.0, "track": track, "time": sound.start.0}], "linked": false})).unwrap();
|
||||
let sound = s.active_sequence().unwrap().find_item(sound.id).unwrap().1.clone();
|
||||
(picture, sound)
|
||||
}
|
||||
|
||||
fn nest_of(s: &mut Session, clips: &[ClipId], name: &str) -> (ItemId, Vec<ClipId>) {
|
||||
s.execute("timeline.select", json!({"clips": clips.iter().map(|c| c.0).collect::<Vec<_>>()})).unwrap();
|
||||
let r = s.execute("clip.nest", json!({"name": name})).unwrap();
|
||||
(ItemId(r["sequence"].as_u64().unwrap()), r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect())
|
||||
}
|
||||
|
||||
fn track_index(s: &Session, kind: TrackKind, clip: ClipId) -> usize {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (track, _) = q.find_item(clip).unwrap();
|
||||
q.tracks(kind).iter().position(|t| t.id == track).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clips_on_two_video_tracks_with_their_sound_on_one_track_nest_as_a_linked_pair() {
|
||||
let mut s = demo();
|
||||
// two neighbouring V1 clips with their sound on A1; the second one's picture goes up to V2
|
||||
let (first, second) = {
|
||||
let v1 = &s.active_sequence().unwrap().video_tracks[0].items;
|
||||
(v1[4].clone(), v1[5].clone())
|
||||
};
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": second.id.0, "track": "V2", "time": second.start.0}], "linked": false})).unwrap();
|
||||
assert_eq!((track_index(&s, TrackKind::Video, first.id), track_index(&s, TrackKind::Video, second.id)), (0, 1));
|
||||
let (nested, clips) = nest_of(&mut s, &[first.id, second.id], "Stack");
|
||||
assert_eq!(clips.len(), 2, "picture and sound");
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
let (picture, sound) = (q.find_item(clips[0]).unwrap().1, q.find_item(clips[1]).unwrap().1);
|
||||
assert!(picture.item == nested && sound.item == nested && picture.link.is_some() && picture.link == sound.link);
|
||||
assert_eq!((track_index(&s, TrackKind::Video, clips[0]), track_index(&s, TrackKind::Audio, clips[1])), (0, 0));
|
||||
// inside: the pictures on V1 and V2, both sounds on A1
|
||||
let inner = s.project.sequence(nested).unwrap();
|
||||
assert_eq!((inner.video_tracks.len(), inner.audio_tracks.len()), (2, 1));
|
||||
assert!(inner.video_tracks[0].item(first.id).is_some() && inner.video_tracks[1].item(second.id).is_some());
|
||||
assert_eq!(inner.audio_tracks[0].items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clips_with_their_sound_on_several_tracks_nest_as_picture_only_and_the_sound_stays() {
|
||||
let mut s = demo();
|
||||
// two neighbouring V1 clips; the second one's sound goes to A3
|
||||
let first = s.active_sequence().unwrap().video_tracks[0].items[3].clone();
|
||||
let first_sound = s.active_sequence().unwrap().audio_tracks[0].items.iter().find(|i| i.link == first.link).unwrap().clone();
|
||||
let (second, second_sound) = move_sound(&mut s, 4, "A3");
|
||||
assert_eq!(track_index(&s, TrackKind::Audio, second_sound.id), 2);
|
||||
let (nested, clips) = nest_of(&mut s, &[first.id, second.id], "Picture");
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
// one clip came back: the picture, unlinked, on V1
|
||||
assert_eq!(clips.len(), 1);
|
||||
let nest = q.find_item(clips[0]).unwrap().1;
|
||||
assert!(nest.item == nested && nest.link.is_none());
|
||||
assert_eq!(track_index(&s, TrackKind::Video, clips[0]), 0);
|
||||
assert!(q.audio_tracks.iter().all(|t| t.items.iter().all(|i| i.item != nested)), "no sound clip for the nest");
|
||||
// the sound is where it was, the same clips, no longer linked to a picture
|
||||
for (sound, at) in [(&first_sound, 0), (&second_sound, 2)] {
|
||||
let left = q.audio_tracks[at].item(sound.id).expect("the sound stays in the sequence");
|
||||
assert_eq!((left.range(), left.link), (sound.range(), None));
|
||||
}
|
||||
// inside: both pictures on V1; copies of the sound, linked to their pictures, on A1 and A2
|
||||
// (the tracks used follow A1 without the gap)
|
||||
let inner = s.project.sequence(nested).unwrap();
|
||||
inner.check().unwrap();
|
||||
assert_eq!((inner.video_tracks.len(), inner.audio_tracks.len()), (1, 2));
|
||||
for (track, sound, picture) in [(0, &first_sound, &first), (1, &second_sound, &second)] {
|
||||
let copy = &inner.audio_tracks[track].items[0];
|
||||
assert!(copy.id != sound.id && copy.item == sound.item && copy.link == picture.link, "track {track}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nest_whose_sound_track_is_taken_is_picture_only_and_moves_up_a_video_track() {
|
||||
let mut s = demo();
|
||||
// the first and third V1 clips, but not the second: the nest spans all three, and the second
|
||||
// clip and its sound are in the way on V1 and A1
|
||||
let (first, between, third) = {
|
||||
let v1 = &s.active_sequence().unwrap().video_tracks[0].items;
|
||||
(v1[0].clone(), v1[1].clone(), v1[2].clone())
|
||||
};
|
||||
let sounds: Vec<filmcraft_project::TrackItem> =
|
||||
s.active_sequence().unwrap().audio_tracks[0].items.iter().filter(|i| i.link == first.link || i.link == third.link).cloned().collect();
|
||||
let before = (*s.project).clone();
|
||||
let (nested, clips) = nest_of(&mut s, &[first.id, third.id], "Around");
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().expect("no two clips overlap on a track");
|
||||
assert_eq!(clips.len(), 1, "picture only");
|
||||
let nest = q.find_item(clips[0]).unwrap().1.clone();
|
||||
assert!(nest.item == nested && nest.link.is_none());
|
||||
// V1 still holds the clip in between, so the nest took the first track above that was free for its whole length
|
||||
let index = track_index(&s, TrackKind::Video, clips[0]);
|
||||
assert!(index > 0 && q.video_tracks[0].item(between.id).is_some());
|
||||
let was = before.sequence(s.state.active_sequence.unwrap()).unwrap();
|
||||
let free = |i: usize| was.video_tracks.get(i).is_none_or(|t| !t.items.iter().any(|x| x.range().overlaps(&nest.range())));
|
||||
assert!(free(index) && (1..index).all(|i| !free(i)), "track {index}");
|
||||
// the sound stays on A1
|
||||
for sound in &sounds {
|
||||
assert_eq!(q.audio_tracks[0].item(sound.id).map(|i| (i.range(), i.link)), Some((sound.range(), None)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_on_a_track_other_than_a1_keeps_an_empty_a1_in_the_nest() {
|
||||
let mut s = demo();
|
||||
// one clip whose sound is on A3: a linked nest with its sound still on A3
|
||||
let (picture, sound) = move_sound(&mut s, 1, "A3");
|
||||
let (nested, clips) = nest_of(&mut s, &[picture.id], "Third");
|
||||
assert_eq!(clips.len(), 2);
|
||||
assert_eq!((track_index(&s, TrackKind::Video, clips[0]), track_index(&s, TrackKind::Audio, clips[1])), (0, 2));
|
||||
// inside: A1 is there and empty, the sound is on A2
|
||||
let inner = s.project.sequence(nested).unwrap();
|
||||
assert_eq!((inner.video_tracks.len(), inner.audio_tracks.len()), (1, 2));
|
||||
assert!(inner.audio_tracks[0].items.is_empty());
|
||||
assert!(inner.audio_tracks[1].item(sound.id).is_some());
|
||||
}
|
||||
|
||||
/// The arrangement Premiere was probed with: the 4th V1 clip stays on V1 with its sound on
|
||||
/// `sound_track`; the 5th and 6th clips' pictures go up to V2, unlinked from their sound. Nesting
|
||||
/// the 4th and 6th leaves the 5th's picture on V2, a track the selection has a clip on, inside the
|
||||
/// nest's span. Returns the clips to nest and the one in the way.
|
||||
fn stacked(s: &mut Session, sound_track: &str) -> ([ClipId; 2], ClipId) {
|
||||
let (first, between, last) = {
|
||||
let v1 = &s.active_sequence().unwrap().video_tracks[0].items;
|
||||
(v1[3].id, v1[4].clone(), v1[5].clone())
|
||||
};
|
||||
if sound_track != "A1" {
|
||||
move_sound(s, 3, sound_track);
|
||||
}
|
||||
for clip in [&between, &last] {
|
||||
s.execute("timeline.select", json!({"clips": [clip.id.0]})).unwrap();
|
||||
s.execute("clip.link", json!({})).unwrap();
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": clip.id.0, "track": "V2", "time": clip.start.0}], "linked": false})).unwrap();
|
||||
}
|
||||
assert_eq!((track_index(s, TrackKind::Video, between.id), track_index(s, TrackKind::Video, last.id)), (1, 1));
|
||||
([first, last.id], between.id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_sharing_a_track_with_the_selection_pushes_the_nest_above_it_when_the_sound_is_on_a_higher_track() {
|
||||
// sound on A3 (free): a linked nest, on V3 because V2 holds another clip and is numbered below the sound's track
|
||||
let mut s = demo();
|
||||
let (picked, in_the_way) = stacked(&mut s, "A3");
|
||||
let (nested, clips) = nest_of(&mut s, &picked, "Above");
|
||||
assert_eq!(clips.len(), 2, "linked: the sound track is free");
|
||||
assert_eq!((track_index(&s, TrackKind::Video, clips[0]), track_index(&s, TrackKind::Audio, clips[1])), (2, 2));
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert!(q.video_tracks[0].items.iter().all(|i| !i.range().overlaps(&q.find_item(clips[0]).unwrap().1.range())), "V1 was free all along");
|
||||
assert_eq!(track_index(&s, TrackKind::Video, in_the_way), 1);
|
||||
assert_eq!(s.project.sequence(nested).unwrap().video_tracks.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_on_a_selected_track_numbered_at_or_above_the_sound_track_does_not_push_the_nest() {
|
||||
// sound on A2 (taken by the score, so picture only): V2 is not below the sound's track, the nest stays on V1
|
||||
let mut s = demo();
|
||||
let (picked, in_the_way) = stacked(&mut s, "A2");
|
||||
let (_, clips) = nest_of(&mut s, &picked, "Second");
|
||||
assert_eq!(clips.len(), 1, "picture only: A2 is taken");
|
||||
assert_eq!(track_index(&s, TrackKind::Video, clips[0]), 0);
|
||||
assert_eq!(track_index(&s, TrackKind::Video, in_the_way), 1);
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
|
||||
// sound on A1 (taken by the clip in between's sound): the same
|
||||
let mut s = demo();
|
||||
let (picked, _) = stacked(&mut s, "A1");
|
||||
let (_, clips) = nest_of(&mut s, &picked, "First");
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(track_index(&s, TrackKind::Video, clips[0]), 0);
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nest_with_no_track_to_go_on_gets_a_new_one() {
|
||||
// as the first case, with V3 taken as well: a fourth video track is added for the nest
|
||||
let mut s = demo();
|
||||
let (picked, _) = stacked(&mut s, "A3");
|
||||
let (overlay, at) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.video_tracks[1].items[0].id, q.find_item(picked[0]).unwrap().1.start)
|
||||
};
|
||||
s.execute("timeline.move", json!({"moves": [{"clip": overlay.0, "track": "V3", "time": at.0}], "linked": false})).unwrap();
|
||||
assert_eq!(track_index(&s, TrackKind::Video, overlay), 2);
|
||||
let tracks = s.active_sequence().unwrap().video_tracks.len();
|
||||
let (_, clips) = nest_of(&mut s, &picked, "Top");
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.check().unwrap();
|
||||
assert_eq!((tracks, q.video_tracks.len()), (3, 4));
|
||||
assert_eq!(track_index(&s, TrackKind::Video, clips[0]), 3);
|
||||
// one undo takes the nest and its track away again
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_subsequence_loads_the_new_sequence_in_the_source_monitor() {
|
||||
let mut s = demo();
|
||||
let outer = s.state.active_sequence.unwrap();
|
||||
let v = clip_ids(&s, TrackKind::Video, 0)[1];
|
||||
s.execute("timeline.select", json!({"clips": [v]})).unwrap();
|
||||
let sub = ItemId(s.execute("sequence.makeSubsequence", json!({})).unwrap()["sequence"].as_u64().unwrap());
|
||||
assert_eq!(s.state.source_item, Some(sub));
|
||||
assert_eq!(s.state.project_selection, vec![sub]);
|
||||
// it is not opened in the Timeline, and the selection is left alone
|
||||
assert_eq!(s.state.active_sequence, Some(outer));
|
||||
assert!(!s.state.open_sequences.contains(&sub));
|
||||
assert!(s.state.selection.contains(&ClipId(v)));
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::OpenSource(i) if *i == sub)));
|
||||
}
|
||||
@@ -37,6 +37,8 @@ pub fn decode_json() -> Value {
|
||||
// hardware decoders created, streams handed to software up front, mid-stream fallbacks.
|
||||
"hardware": {
|
||||
"enabled": filmcraft_codecs::hw::hardware_decoding(),
|
||||
// the OS decoder backend registered at startup (null where there is none)
|
||||
"backend": filmcraft_codecs::hw::hw_backend(),
|
||||
"frames": hw.frames,
|
||||
"softwareFrames": g.frames.saturating_sub(hw.frames),
|
||||
"sessions": hw.sessions,
|
||||
@@ -46,11 +48,18 @@ pub fn decode_json() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Export counters: pictures encoded by hardware encoders, sessions, declined requests.
|
||||
pub fn export_json() -> Value {
|
||||
let hw = filmcraft_export::hw_encode_stats();
|
||||
json!({"hardware": {"frames": hw.frames, "sessions": hw.sessions, "declined": hw.declined}})
|
||||
}
|
||||
|
||||
/// The engine's `perf.stats`.
|
||||
pub fn stats(s: &Session) -> Value {
|
||||
let running = s.jobs.iter().filter(|j| j.result.lock().map(|r| r.is_none()).unwrap_or(false)).count();
|
||||
json!({
|
||||
"decode": decode_json(),
|
||||
"export": export_json(),
|
||||
"media": {"openSources": s.media.open_sources()},
|
||||
"jobs": {"total": s.jobs.len(), "running": running},
|
||||
})
|
||||
@@ -92,6 +101,10 @@ mod tests {
|
||||
assert!(v["decode"]["hardware"][k].is_number(), "decode.hardware.{k} in {v}");
|
||||
}
|
||||
assert!(v["decode"]["hardware"]["enabled"].is_boolean());
|
||||
assert!(v["decode"]["hardware"]["backend"].is_null() || v["decode"]["hardware"]["backend"].is_string());
|
||||
for k in ["frames", "sessions", "declined"] {
|
||||
assert!(v["export"]["hardware"][k].is_number(), "export.hardware.{k} in {v}");
|
||||
}
|
||||
assert!(v["media"]["openSources"].is_number());
|
||||
assert_eq!(v["jobs"]["running"], json!(0));
|
||||
assert_eq!(s.history.undo.len(), undo, "a query adds no undo step");
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! | `project.freeform.saveArrangement` / `.restoreArrangement` / `.arrangements` / `.deleteArrangement` | saved arrangements |
|
||||
//! | `project.freeform.options` | Freeform View Options… (grid, snap, names, durations) |
|
||||
//! | `project.renameBin` | rename a bin (inline rename, undoable) |
|
||||
//! | `project.selectAll` / `project.deselectAll` | select every item in the project / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) |
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -1222,5 +1223,13 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
freeform_options,
|
||||
),
|
||||
c("project.renameBin", "Rename Bin", r#"{"bin":binId,"name":str}"#, always, rename_bin),
|
||||
c("project.selectAll", "Select All Project Items", "{}", always, |s, _| {
|
||||
s.state.project_selection = s.project.items.keys().copied().collect();
|
||||
Ok(json!({"selected": s.state.project_selection.len()}))
|
||||
}),
|
||||
c("project.deselectAll", "Deselect All Project Items", "{}", always, |s, _| {
|
||||
s.state.project_selection.clear();
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -286,3 +286,22 @@ fn usage_counts_sequence_uses() {
|
||||
assert_eq!(text, (if v > 0 { v } else { a }).to_string());
|
||||
assert!(!matches!(it.kind, ItemKind::Graphic { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_selects_every_project_item() {
|
||||
let mut s = demo();
|
||||
assert!(s.project.items.len() >= 3);
|
||||
s.state.project_selection.clear();
|
||||
let r = s.execute("project.selectAll", json!({})).unwrap();
|
||||
assert_eq!(r["selected"].as_u64(), Some(s.project.items.len() as u64));
|
||||
assert_eq!(s.state.project_selection.len(), s.project.items.len());
|
||||
assert!(s.project.items.keys().all(|i| s.state.project_selection.contains(i)));
|
||||
s.execute("project.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.project_selection.is_empty());
|
||||
// the timeline Select All still selects timeline clips and leaves the Project panel alone
|
||||
s.execute("project.selectAll", json!({})).unwrap();
|
||||
let before = s.state.project_selection.clone();
|
||||
s.execute("edit.selectAll", json!({})).unwrap();
|
||||
assert!(!s.state.selection.is_empty());
|
||||
assert_eq!(s.state.project_selection, before);
|
||||
}
|
||||
@@ -401,3 +401,45 @@ fn system_report_lists_the_build() {
|
||||
assert!(r["decoders"].as_array().unwrap().iter().any(|d| d.as_str().unwrap().contains("H.264")));
|
||||
assert_eq!(r["exportFormats"].as_array().unwrap().len(), filmcraft_export::Format::ALL.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_deletes_a_bin_with_everything_in_it() {
|
||||
let mut s = demo();
|
||||
let before = s.project.items.len();
|
||||
let outer = s.execute("file.newBin", json!({"name": "Old footage"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let inner = s.execute("file.newBin", json!({"name": "Inside", "parent": outer})).unwrap()["bin"].as_u64().unwrap();
|
||||
let empty = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.moveToBin", json!({"items": [ocean.0], "bin": inner})).unwrap();
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
let select = |s: &mut Session| s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
|
||||
select(&mut s);
|
||||
// an empty bin, by its id: gone, nothing else touched
|
||||
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 0, "bins": 1}));
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
// a bin holding a sub-bin holding a clip: all three go
|
||||
select(&mut s);
|
||||
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 1}));
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none());
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none());
|
||||
assert!(s.project.item(ocean).is_none());
|
||||
assert_eq!(s.project.items.len(), before - 1);
|
||||
// undo puts the bins and the clip back where they were
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_some());
|
||||
assert_eq!(s.project.root.parent_of(ocean), Some(filmcraft_project::BinId(inner)));
|
||||
// the clip, its bin and the bin around it all named: each counted once
|
||||
select(&mut s);
|
||||
let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 1}));
|
||||
assert_eq!(s.project.items.len(), before - 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// the project's own top bin is never cleared
|
||||
let root = s.project.root.id.0;
|
||||
select(&mut s);
|
||||
s.execute("project.delete", json!({"items": [root]})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
}
|
||||
@@ -145,3 +145,35 @@ fn make_offline_shows_the_slate_until_relinked() {
|
||||
assert_eq!(frame_rgba(&mut s, 3, 1.0), online);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// Regression test for issue #110: after `file.import` re-imports a file at a path the project
|
||||
// already listed as offline, `s.offline.missing` must drop that item so the slate and the
|
||||
// "Media missing" badge clear without reopening the project. Before the fix (the patch in
|
||||
// `file.import` that calls `crate::relink::refresh(s)`), the slate stayed stale until reopen
|
||||
// or per-item relink.
|
||||
#[test]
|
||||
fn reimport_clears_offline_list_after_path_resolves() {
|
||||
let root = tmp_dir("relink-reimport");
|
||||
let (path, items) = saved_project(&root);
|
||||
let a = root.join("Media/a.mov");
|
||||
|
||||
// Delete a.mov so the saved project now points at a missing file, then reopen:
|
||||
// `on_open` → `refresh` → `scan` registers items[0] as missing.
|
||||
let online = frame_rgba(&mut open(&path), 3, 1.0);
|
||||
std::fs::remove_file(&a).unwrap();
|
||||
let mut s = open(&path);
|
||||
assert!(s.offline.missing.contains(&items[0]), "setup: a.mov missing on reopen");
|
||||
assert_ne!(frame_rgba(&mut s, 3, 1.0), online, "setup: the slate shows while a.mov is missing");
|
||||
|
||||
// Recreate the file at the exact path the project stores, then import. `file.import`
|
||||
// must drop items[0] from `s.offline.missing`. Without the fix, the entry stays stale
|
||||
// and the user has to reopen or `media.relink` each item.
|
||||
make_movie(&a, DemoScene::Aurora, W, H, 12);
|
||||
s.execute("file.import", json!({"paths": [a.to_string_lossy()]})).unwrap();
|
||||
assert!(!s.offline.missing.contains(&items[0]), "issue #110 — file.import did not refresh s.offline.missing (still contains {:?})", s.offline.missing);
|
||||
assert_eq!(s.execute("media.findMissing", json!({})).unwrap()["missing"].as_array().unwrap().len(), 0);
|
||||
// and the monitors show the file again, not the cached slate
|
||||
assert_eq!(frame_rgba(&mut s, 3, 1.0), online);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
&["Sequence"],
|
||||
None,
|
||||
r#"{"track":"mix"|"A1"|id?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?,"model":str?}"#,
|
||||
has_audio_clips,
|
||||
can_transcribe_sequence,
|
||||
transcribe,
|
||||
),
|
||||
spec(
|
||||
@@ -105,6 +105,13 @@ fn has_audio_clips(s: &Session) -> std::result::Result<(), String> {
|
||||
if q.audio_tracks.iter().any(|t| !t.items.is_empty()) { Ok(()) } else { Err("the sequence has no audio clips".into()) }
|
||||
}
|
||||
|
||||
/// Transcribe Sequence runs `transcript.generate`: disabled like it in a build without
|
||||
/// speech-to-text (#97), and when the sequence has no sound.
|
||||
fn can_transcribe_sequence(s: &Session) -> std::result::Result<(), String> {
|
||||
crate::transcript::can_transcribe(s)?;
|
||||
has_audio_clips(s)
|
||||
}
|
||||
|
||||
fn has_caption_track(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty()) { Ok(()) } else { Err("the sequence has no caption track".into()) }
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of
|
||||
//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track
|
||||
//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::SequenceView;
|
||||
use serde_json::json;
|
||||
|
||||
/// The demo project with two more sequences open: tabs [main, b, c], `c` active.
|
||||
fn three_tabs() -> (Session, [ItemId; 3]) {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let main = s.state.active_sequence.unwrap();
|
||||
let mut new = |name: &str| ItemId(s.execute("file.newSequence", json!({"name": name})).unwrap()["sequence"].as_u64().unwrap());
|
||||
let (b, c) = (new("B"), new("C"));
|
||||
assert_eq!(s.state.open_sequences, [main, b, c]);
|
||||
assert_eq!(s.state.active_sequence, Some(c));
|
||||
(s, [main, b, c])
|
||||
}
|
||||
|
||||
fn view(pps: f64, scroll: f64) -> SequenceView {
|
||||
SequenceView { pps, scroll, v_scroll: 12.0, a_scroll: 7.0, video_track_h: 90.0, audio_track_h: 40.0 }
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-tabs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_others_keeps_one_tab_and_shows_it() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
// from the active tab
|
||||
assert_eq!(s.execute("sequence.closeOthers", json!({})).unwrap()["closed"], json!(2));
|
||||
assert_eq!((s.state.open_sequences.clone(), s.state.active_sequence), (vec![c], Some(c)));
|
||||
// from another tab: that one becomes the active one
|
||||
let (mut s, _) = three_tabs();
|
||||
s.drain_events();
|
||||
s.execute("sequence.closeOthers", json!({"item": b.0})).unwrap();
|
||||
assert_eq!((s.state.open_sequences.clone(), s.state.active_sequence), (vec![b], Some(b)));
|
||||
assert!(s.drain_events().contains(&Event::OpenSequence(b)));
|
||||
// a sequence that is not open cannot be the one to keep
|
||||
s.execute("sequence.close", json!({})).unwrap();
|
||||
assert!(s.execute("sequence.closeOthers", json!({"item": main.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_moves_to_another_place_among_the_tabs() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
let (revision, steps) = (s.revision, s.history.undo.len());
|
||||
s.execute("sequence.moveTab", json!({"item": c.0, "index": 0})).unwrap();
|
||||
assert_eq!(s.state.open_sequences, [c, main, b]);
|
||||
assert_eq!(s.state.active_sequence, Some(c), "moving a tab does not change which is shown");
|
||||
s.execute("sequence.moveTab", json!({"item": c.0, "index": 1})).unwrap();
|
||||
assert_eq!(s.state.open_sequences, [main, c, b]);
|
||||
// past the end is the end; the active tab is the default
|
||||
assert_eq!(s.execute("sequence.moveTab", json!({"index": u64::MAX})).unwrap()["index"], json!(2));
|
||||
assert_eq!(s.state.open_sequences, [main, b, c]);
|
||||
// not an undo step, not an edit
|
||||
assert_eq!((s.revision, s.history.undo.len()), (revision, steps));
|
||||
s.execute("sequence.close", json!({"item": b.0})).unwrap();
|
||||
assert!(s.execute("sequence.moveTab", json!({"item": b.0, "index": 0})).is_err());
|
||||
assert!(s.execute("sequence.moveTab", json!({"item": c.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_project_reopens_with_its_tabs_and_their_views() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
s.execute("sequence.moveTab", json!({"item": c.0, "index": 0})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": b.0})).unwrap();
|
||||
s.state.timeline_views.insert(main, view(12.5, 3.0));
|
||||
s.state.timeline_views.insert(b, view(400.0, 0.25));
|
||||
let dir = temp_dir("reopen");
|
||||
let path = dir.join("tabs.fcproj").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(t.state.open_sequences, [c, main, b]);
|
||||
assert_eq!(t.state.active_sequence, Some(b));
|
||||
assert_eq!(t.state.timeline_views.get(&main), Some(&view(12.5, 3.0)));
|
||||
assert_eq!(t.state.timeline_views.get(&b), Some(&view(400.0, 0.25)));
|
||||
assert_eq!(t.state.timeline_views.get(&c), None, "a sequence that was never shown has no view");
|
||||
assert!(t.drain_events().contains(&Event::OpenSequence(b)));
|
||||
assert!(!t.is_dirty());
|
||||
|
||||
// with the preference off, a project opens on its first sequence as before
|
||||
let mut u = Session::default();
|
||||
u.prefs.timeline.restore_open_sequences = false;
|
||||
u.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(u.state.open_sequences, [main]);
|
||||
assert_eq!(u.state.active_sequence, Some(main));
|
||||
assert!(u.state.timeline_views.is_empty());
|
||||
|
||||
// saved with every tab closed (or by a session that never showed a sequence): the project
|
||||
// opens on its first sequence, not on an empty Timeline, and still knows the views
|
||||
s.execute("sequence.closeOthers", json!({})).unwrap();
|
||||
s.execute("sequence.close", json!({})).unwrap();
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
let mut w = Session::default();
|
||||
w.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!((w.state.open_sequences.clone(), w.state.active_sequence), (vec![main], Some(main)));
|
||||
assert_eq!(w.state.timeline_views.get(&b), Some(&view(400.0, 0.25)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The view in a project file is not trusted: ids of things that are not sequences, repeats and
|
||||
/// numbers out of range are dropped or brought into range, and a view that cannot be read at all
|
||||
/// does not stop the project from opening.
|
||||
#[test]
|
||||
fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
let footage = s.project.sequence(main).unwrap().video_tracks[0].items[0].item;
|
||||
let dir = temp_dir("damaged");
|
||||
let path = dir.join("tabs.fcproj").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
let with_view = |view: serde_json::Value| {
|
||||
let mut doc = saved.clone();
|
||||
doc["view"] = view;
|
||||
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
t
|
||||
};
|
||||
|
||||
let t = with_view(json!({
|
||||
"open_sequences": [c.0, 999_999, footage.0, c.0, b.0],
|
||||
"active_sequence": 999_999,
|
||||
"sequences": {
|
||||
b.0.to_string(): {"pps": 1e300, "scroll": -5.0, "v_scroll": 1e30, "a_scroll": -1.0, "video_track_h": 0.0, "audio_track_h": 1e9},
|
||||
"999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
},
|
||||
}));
|
||||
assert_eq!(t.state.open_sequences, [c, b]);
|
||||
assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab");
|
||||
assert_eq!(t.state.timeline_views.len(), 1);
|
||||
let v = t.state.timeline_views[&b];
|
||||
assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0);
|
||||
assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0);
|
||||
|
||||
// not a view at all: the project opens as it does without one
|
||||
for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] {
|
||||
let t = with_view(junk.clone());
|
||||
assert_eq!((t.state.open_sequences.clone(), t.state.active_sequence), (vec![main], Some(main)), "{junk}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_of_a_deleted_sequence_is_forgotten() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
for id in [main, b, c] {
|
||||
s.state.timeline_views.insert(id, view(40.0, 0.0));
|
||||
}
|
||||
s.execute("project.delete", json!({"items": [b.0]})).unwrap();
|
||||
assert_eq!(s.state.timeline_views.keys().copied().collect::<Vec<_>>(), [main, c]);
|
||||
assert_eq!(s.state.open_sequences, [main, c]);
|
||||
// the saved view never names it either
|
||||
assert_eq!(s.project_view().open_sequences, [main, c]);
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
//! takes its own range and lands offset by the difference of the In points (J- and L-cuts).
|
||||
|
||||
use filmcraft_edit as edit;
|
||||
use filmcraft_project::{ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
|
||||
use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -348,6 +348,18 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the
|
||||
/// selected clips, or of everything on targeted tracks between In and Out, trimmed to In/Out when
|
||||
/// they are set. The new sequence is added to the project and selected; the original is unchanged.
|
||||
/// Give a sequence made from part of `from` (Nest…, Make Subsequence) the track names and audio
|
||||
/// channel layouts of `from`'s tracks, track for track.
|
||||
pub(crate) fn lay_out_tracks_like(new: &mut filmcraft_project::Sequence, from: &filmcraft_project::Sequence) {
|
||||
for (tr, src) in new.video_tracks.iter_mut().zip(&from.video_tracks) {
|
||||
tr.name = src.name.clone();
|
||||
}
|
||||
for (tr, src) in new.audio_tracks.iter_mut().zip(&from.audio_tracks) {
|
||||
tr.name = src.name.clone();
|
||||
tr.channels = src.channels;
|
||||
}
|
||||
}
|
||||
|
||||
fn make_subsequence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?.clone();
|
||||
@@ -420,13 +432,7 @@ fn make_subsequence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
|
||||
for (i, tr) in nq.video_tracks.iter_mut().enumerate() {
|
||||
tr.name = q.video_tracks[i].name.clone();
|
||||
}
|
||||
for (i, tr) in nq.audio_tracks.iter_mut().enumerate() {
|
||||
tr.name = q.audio_tracks[i].name.clone();
|
||||
tr.channels = q.audio_tracks[i].channels;
|
||||
}
|
||||
lay_out_tracks_like(nq, &q);
|
||||
for (kind, ti, it) in placed {
|
||||
nq.tracks_mut(kind)[ti].items.push(it);
|
||||
}
|
||||
@@ -439,15 +445,253 @@ fn make_subsequence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
nq.check().map_err(EngineError::Other)?;
|
||||
st.project_selection = vec![nid];
|
||||
// like Premiere: the subsequence is loaded in the Source Monitor, ready to edit from
|
||||
st.source_item = Some(nid);
|
||||
st.source_playhead = Tick::ZERO;
|
||||
Ok(nid)
|
||||
})?;
|
||||
s.events.push(crate::Event::OpenSource(id));
|
||||
Ok(json!({"sequence": id.0, "name": name}))
|
||||
}
|
||||
|
||||
// ---------- a sequence edited in as its clips ----------
|
||||
|
||||
/// The nest toggle off ("Insert and overwrite sequences as nests or individual clips"): edit the
|
||||
/// clips of sequence `item` that lie in `range` of it into the active sequence at `at`, instead of
|
||||
/// one nested clip. As in Premiere, the clips come with their transitions and links; the source's
|
||||
/// tracks that hold clips go to consecutive tracks from `vdest` / `adest` up (source V1 and V3
|
||||
/// land on the destination track and the one above it), and tracks that are missing are added.
|
||||
/// A kind without a destination track is left out.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn place_sequence_clips(
|
||||
s: &mut Session,
|
||||
item: ItemId,
|
||||
range: TimeRange,
|
||||
at: Tick,
|
||||
vdest: Option<TrackId>,
|
||||
adest: Option<TrackId>,
|
||||
insert: bool,
|
||||
label: &str,
|
||||
) -> Result<Vec<ClipId>> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let src = s.project.sequence(item).ok_or_else(|| bad(label, "not a sequence"))?.clone();
|
||||
let dst = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
// (kind, how many tracks above the destination track, the clip trimmed to the range)
|
||||
let mut items: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||||
// source track index → tracks above the destination track, per kind
|
||||
// (TrackKind has no Hash: the maps are keyed by "is audio")
|
||||
let audio = |k: TrackKind| k == TrackKind::Audio;
|
||||
let mut lanes: std::collections::HashMap<(bool, usize), usize> = std::collections::HashMap::new();
|
||||
let mut base: std::collections::HashMap<bool, usize> = std::collections::HashMap::new();
|
||||
for (kind, tracks, dest) in [(TrackKind::Video, &src.video_tracks, vdest), (TrackKind::Audio, &src.audio_tracks, adest)] {
|
||||
let Some(first) = dest.and_then(|d| dst.tracks(kind).iter().position(|t| t.id == d)) else { continue };
|
||||
base.insert(audio(kind), first);
|
||||
let mut lane = 0;
|
||||
for (ti, tr) in tracks.iter().enumerate() {
|
||||
let clips: Vec<TrackItem> = tr.items.iter().filter_map(|it| edit::through::clip_item_to(it, range)).collect();
|
||||
if clips.is_empty() {
|
||||
continue;
|
||||
}
|
||||
lanes.insert((audio(kind), ti), lane);
|
||||
items.extend(clips.into_iter().map(|c| (kind, lane, c)));
|
||||
lane += 1;
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(EngineError::Other("no destination track for this sequence's clips (check source patching)".into()));
|
||||
}
|
||||
let media = s.media.clone();
|
||||
s.edit(label, |p, st| {
|
||||
// copies with ids, links and transitions of their own, moved to `at`
|
||||
let mut ids = std::collections::HashMap::new();
|
||||
let mut links = std::collections::HashMap::new();
|
||||
let mut placed: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||||
for (kind, lane, mut it) in items.clone() {
|
||||
let id = ClipId(p.alloc_id());
|
||||
ids.insert(it.id, id);
|
||||
it.id = id;
|
||||
it.start = at + (it.start - range.start);
|
||||
if let Some(l) = it.link {
|
||||
it.link = Some(*links.entry(l).or_insert_with(|| p.alloc_id()));
|
||||
}
|
||||
placed.push((kind, lane, it));
|
||||
}
|
||||
let mut transitions = Vec::new();
|
||||
for (kind, tracks) in [(TrackKind::Video, &src.video_tracks), (TrackKind::Audio, &src.audio_tracks)] {
|
||||
for (ti, tr) in tracks.iter().enumerate() {
|
||||
let Some(lane) = lanes.get(&(audio(kind), ti)).copied() else { continue };
|
||||
for trn in &tr.transitions {
|
||||
let from = trn.from.map(|c| ids.get(&c).copied());
|
||||
let to = trn.to.map(|c| ids.get(&c).copied());
|
||||
if trn.start >= range.start && trn.end() <= range.end() && from != Some(None) && to != Some(None) {
|
||||
let mut t = trn.clone();
|
||||
t.id = filmcraft_project::TransitionId(p.alloc_id());
|
||||
(t.from, t.to) = (from.flatten(), to.flatten());
|
||||
t.start = at + (t.start - range.start);
|
||||
transitions.push((kind, lane, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// tracks for every lane, added on top when the sequence has too few
|
||||
let mut dest: std::collections::HashMap<(bool, usize), TrackId> = std::collections::HashMap::new();
|
||||
for (is_audio, lane) in placed.iter().map(|x| (audio(x.0), x.1)).collect::<std::collections::BTreeSet<_>>() {
|
||||
let kind = if is_audio { TrackKind::Audio } else { TrackKind::Video };
|
||||
let index = base.get(&is_audio).copied().unwrap_or(0).saturating_add(lane);
|
||||
while p.sequence(seq_id).ok_or(EngineError::NoSequence)?.tracks(kind).len() <= index {
|
||||
let id = TrackId(p.alloc_id());
|
||||
let tracks = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?.tracks_mut(kind);
|
||||
let (word, n) = (if kind == TrackKind::Video { "Video" } else { "Audio" }, tracks.len() + 1);
|
||||
tracks.push(filmcraft_project::Track::new(id, kind, format!("{word} {n}")));
|
||||
}
|
||||
let id = p.sequence(seq_id).and_then(|q| q.tracks(kind).get(index)).map(|t| t.id).ok_or(EngineError::NoSequence)?;
|
||||
dest.insert((is_audio, lane), id);
|
||||
}
|
||||
let placements: Vec<(TrackId, TrackItem)> = placed.into_iter().filter_map(|(k, lane, it)| Some((*dest.get(&(audio(k), lane))?, it))).collect();
|
||||
let span = placements.iter().map(|x| x.1.start).min().zip(placements.iter().map(|x| x.1.end()).max());
|
||||
let snapshot = std::sync::Arc::new(p.clone());
|
||||
let snap = snapshot.clone();
|
||||
let durations = move |id: ItemId| crate::media_duration(&snapshot, &media, id);
|
||||
let starts = move |id: ItemId| crate::media_start(&snap, id);
|
||||
let mut next = p.next_id;
|
||||
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let min = seq.settings.frame_rate.frame_duration();
|
||||
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
|
||||
let new = if insert { edit::insert(seq, placements, &mut ctx)? } else { edit::overwrite(seq, placements, &mut ctx)? };
|
||||
for (kind, lane, t) in transitions {
|
||||
if let Some(tr) = dest.get(&(audio(kind), lane)).and_then(|id| seq.track_mut(*id)) {
|
||||
tr.transitions.push(t);
|
||||
}
|
||||
}
|
||||
for tr in seq.all_tracks_mut() {
|
||||
tr.sort();
|
||||
edit::remove_orphan_transitions(tr);
|
||||
}
|
||||
seq.check().map_err(EngineError::Other)?;
|
||||
if insert
|
||||
&& st.ripple_sequence_markers
|
||||
&& let Some((a, b)) = span
|
||||
{
|
||||
ripple_markers(&mut seq.markers, a, b - a);
|
||||
}
|
||||
p.next_id = next;
|
||||
st.selection = new.clone();
|
||||
Ok(new)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- delete tracks ----------
|
||||
|
||||
/// Sequence ▸ Delete Tracks…: delete all empty video / audio tracks (`"empty"`) or one track per
|
||||
/// kind. A sequence always keeps at least one video and one audio track.
|
||||
/// The most tracks one `sequence.addTracks` adds of a kind, and the most a sequence may then
|
||||
/// have of it. (Amounts come from dialogs, scripts and the control channel: never trusted.)
|
||||
const MAX_ADDED_TRACKS: u64 = 99;
|
||||
const MAX_TRACKS: usize = 999;
|
||||
|
||||
/// Where `sequence.addTracks` puts new tracks among the `len` tracks of their kind: the number of
|
||||
/// tracks before them. `key` holds `"first"` (Before First Track), a track of that kind by name
|
||||
/// (`"V2"`: after Video 2; `letter` is V, A or S) or a number (after that many tracks); without it
|
||||
/// they go after the last track.
|
||||
fn placement(p: &Value, key: &str, letter: char, len: usize) -> Result<usize> {
|
||||
let cmd = "sequence.addTracks";
|
||||
let after = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(len),
|
||||
Some(v) => match (v.as_u64(), v.as_str()) {
|
||||
(Some(n), _) => usize::try_from(n).ok(),
|
||||
(None, Some(x)) if matches!(x.to_ascii_lowercase().as_str(), "first" | "before" | "beforefirst") => Some(0),
|
||||
(None, Some(x)) if matches!(x.to_ascii_lowercase().as_str(), "last" | "end") => Some(len),
|
||||
(None, Some(x)) => {
|
||||
let mut rest = x.chars();
|
||||
rest.next().filter(|c| c.eq_ignore_ascii_case(&letter)).and_then(|_| rest.as_str().parse::<usize>().ok())
|
||||
}
|
||||
(None, None) => None,
|
||||
},
|
||||
};
|
||||
after.filter(|n| *n <= len).ok_or_else(|| bad(cmd, format!("`{key}` must be \"first\", a track ({letter}1–{letter}{len}) or how many tracks come before")))
|
||||
}
|
||||
|
||||
/// Give the tracks that still carry a default name (`<prefix> <number>`) the number of their
|
||||
/// place, as Premiere numbers its tracks; a track the user named keeps its name.
|
||||
fn renumber(tracks: &mut [filmcraft_project::Track], prefix: &str) {
|
||||
for (i, t) in tracks.iter_mut().enumerate() {
|
||||
let default = t.name.strip_prefix(prefix).and_then(|r| r.strip_prefix(' ')).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
if default {
|
||||
t.name = format!("{prefix} {}", i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequence ▸ Add Tracks…: `video` / `audio` / `submix` tracks, each kind at its own place
|
||||
/// (`videoAfter`, `audioAfter`, `submixAfter`; after the last track when not given), audio tracks
|
||||
/// of `audioType` (Standard unless given) and submix tracks of `submixType` (Stereo). One undo
|
||||
/// step. Tracks are numbered by their place, so default names after the new tracks move up.
|
||||
pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::commands::u64_p;
|
||||
let cmd = "sequence.addTracks";
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let amount = |key: &str, default: u64, have: usize| -> Result<usize> {
|
||||
let n = match p.get(key) {
|
||||
None | Some(Value::Null) => default,
|
||||
Some(_) => u64_p(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a number of tracks")))?,
|
||||
};
|
||||
if n > MAX_ADDED_TRACKS {
|
||||
return Err(bad(cmd, format!("`{key}`: at most {MAX_ADDED_TRACKS} tracks at a time")));
|
||||
}
|
||||
if have.saturating_add(n as usize) > MAX_TRACKS {
|
||||
return Err(bad(cmd, format!("`{key}`: a sequence has at most {MAX_TRACKS} tracks of a kind")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
};
|
||||
let (nv, na, ns) = (amount("video", 1, q.video_tracks.len())?, amount("audio", 0, q.audio_tracks.len())?, amount("submix", 0, q.submix_tracks.len())?);
|
||||
let (at_v, at_a, at_s) = (
|
||||
placement(p, "videoAfter", 'V', q.video_tracks.len())?,
|
||||
placement(p, "audioAfter", 'A', q.audio_tracks.len())?,
|
||||
placement(p, "submixAfter", 'S', q.submix_tracks.len())?,
|
||||
);
|
||||
let channels = |key: &str, default: AudioChannels| match p.get(key).and_then(Value::as_str) {
|
||||
None => Ok(default),
|
||||
Some(x) => crate::mixer::channels_from(x).ok_or_else(|| bad(cmd, format!("`{key}`: unknown track type {x:?} (standard, stereo, 5.1, adaptive, mono)"))),
|
||||
};
|
||||
let (audio_type, submix_type) = (channels("audioType", AudioChannels::Stereo)?, channels("submixType", AudioChannels::Stereo)?);
|
||||
if nv + na + ns == 0 {
|
||||
return Err(bad(cmd, "no tracks to add"));
|
||||
}
|
||||
let added = s.edit_sequence("Add Tracks", |q, ctx, _| {
|
||||
let mut new = |kind: TrackKind, prefix: &str, channels: Option<AudioChannels>| {
|
||||
let mut t = filmcraft_project::Track::new(TrackId(ctx.alloc()), kind, format!("{prefix} 0"));
|
||||
if let Some(c) = channels {
|
||||
t.channels = c;
|
||||
}
|
||||
t
|
||||
};
|
||||
let mut ids: [Vec<u64>; 3] = Default::default();
|
||||
for (k, (tracks, n, at, kind, prefix, ch)) in [
|
||||
(&mut q.video_tracks, nv, at_v, TrackKind::Video, "Video", None),
|
||||
(&mut q.audio_tracks, na, at_a, TrackKind::Audio, "Audio", Some(audio_type)),
|
||||
(&mut q.submix_tracks, ns, at_s, TrackKind::Audio, "Submix", Some(submix_type)),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = at.min(tracks.len());
|
||||
for i in 0..n {
|
||||
let t = new(kind, prefix, ch);
|
||||
if let Some(slot) = ids.get_mut(k) {
|
||||
slot.push(t.id.0);
|
||||
}
|
||||
tracks.insert(at + i, t);
|
||||
}
|
||||
if n > 0 {
|
||||
renumber(tracks, prefix);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
})?;
|
||||
let [video, audio, submix] = added;
|
||||
Ok(json!({"video": video, "audio": audio, "submix": submix}))
|
||||
}
|
||||
|
||||
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sequence / Markers menu commands (`sequence_tools`).
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::{Label, MarkerKind};
|
||||
use filmcraft_project::{AudioChannels, Label, MarkerKind, TrackKind};
|
||||
use serde_json::json;
|
||||
|
||||
fn demo() -> Session {
|
||||
@@ -189,6 +189,108 @@ fn make_subsequence_from_in_out_and_from_selection() {
|
||||
assert!(s.project.items.values().all(|i| i.name != "Shot 3"));
|
||||
}
|
||||
|
||||
/// Sequence ▸ Add Tracks…: where the new tracks go, what they are, and what the amounts may be.
|
||||
/// Premiere Pro 26.5.2: Placement is "Before First Track" or after one of the tracks (after the
|
||||
/// last by default); a track added after Video 1 is the new Video 2, and what was on Video 2 is
|
||||
/// then on Video 3; audio tracks are Standard, 5.1, Adaptive or Mono, submix tracks Stereo, 5.1,
|
||||
/// Adaptive or Mono.
|
||||
#[test]
|
||||
fn add_tracks_places_and_types_them() {
|
||||
let mut s = demo();
|
||||
let names = |s: &Session, kind: TrackKind| s.active_sequence().unwrap().tracks(kind).iter().map(|t| t.name.clone()).collect::<Vec<_>>();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3"]);
|
||||
let on_v2 = before.video_tracks[1].items[0].id;
|
||||
|
||||
// without a place: after the last track (and one video track when nothing is said, as before)
|
||||
let r = s.execute("sequence.addTracks", json!({})).unwrap();
|
||||
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 0, 0));
|
||||
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4"]);
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[3].id.0, r["video"][0].as_u64().unwrap());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// after Video 1: two new empty tracks there, the tracks above move up and are numbered again
|
||||
s.execute("sequence.addTracks", json!({"video": 2, "videoAfter": "V1"})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4", "Video 5"]);
|
||||
assert!(q.video_tracks[1].items.is_empty() && q.video_tracks[2].items.is_empty());
|
||||
assert_eq!(q.video_tracks[3].id, before.video_tracks[1].id);
|
||||
assert_eq!(q.video_tracks[3].items[0].id, on_v2, "what was on Video 2 is on Video 4");
|
||||
assert_eq!(q.audio_tracks.len(), before.audio_tracks.len(), "no audio track was asked for");
|
||||
// one undo step
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
|
||||
// before the first track; a name the user gave is kept, default names follow their place
|
||||
s.edit_sequence("Rename Track", |q, _, _| {
|
||||
q.audio_tracks[1].name = "Music".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": "first", "audioType": "mono"})).unwrap();
|
||||
assert_eq!(names(&s, TrackKind::Audio), ["Audio 1", "Audio 2", "Music", "Audio 4"]);
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.audio_tracks[0].channels, AudioChannels::Mono);
|
||||
assert_eq!(q.audio_tracks[1].id, before.audio_tracks[0].id);
|
||||
// a number counts the tracks before the new ones; "Standard" is a stereo track
|
||||
for (ty, want) in [("standard", AudioChannels::Stereo), ("5.1", AudioChannels::Surround51), ("adaptive", AudioChannels::Adaptive)] {
|
||||
let r = s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": 2, "audioType": ty})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.audio_tracks[2].id.0, q.audio_tracks[2].channels), (r["audio"][0].as_u64().unwrap(), want), "{ty}");
|
||||
}
|
||||
|
||||
// submix tracks: stereo unless said, placed among the submix tracks
|
||||
assert!(before.submix_tracks.is_empty());
|
||||
s.execute("sequence.addTracks", json!({"video": 0, "submix": 2})).unwrap();
|
||||
s.execute("sequence.addTracks", json!({"video": 0, "submix": 1, "submixAfter": "S1", "submixType": "5.1"})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(
|
||||
q.submix_tracks.iter().map(|t| (t.name.as_str(), t.channels)).collect::<Vec<_>>(),
|
||||
[("Submix 1", AudioChannels::Stereo), ("Submix 2", AudioChannels::Surround51), ("Submix 3", AudioChannels::Stereo)]
|
||||
);
|
||||
// all three kinds at once are one undo step
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
let r = s.execute("sequence.addTracks", json!({"video": 1, "audio": 1, "submix": 1})).unwrap();
|
||||
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 1, 1));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
}
|
||||
|
||||
/// Amounts and places come from a dialog, a script or the control channel: a wrong one is an
|
||||
/// error that changes nothing, and no amount can make the sequence grow without bound.
|
||||
#[test]
|
||||
fn add_tracks_refuses_what_it_cannot_do() {
|
||||
let mut s = demo();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
for bad in [
|
||||
json!({"video": 0}),
|
||||
json!({"video": 100}),
|
||||
json!({"video": u64::MAX}),
|
||||
json!({"video": 0, "audio": 1_000_000}),
|
||||
json!({"video": "many"}),
|
||||
json!({"video": 1, "videoAfter": "V9"}),
|
||||
json!({"video": 1, "videoAfter": "A1"}),
|
||||
json!({"video": 1, "videoAfter": 4}),
|
||||
json!({"video": 1, "videoAfter": "top"}),
|
||||
json!({"video": 0, "audio": 1, "audioAfter": "V1"}),
|
||||
json!({"video": 0, "audio": 1, "audioType": "quad"}),
|
||||
json!({"video": 0, "submix": 1, "submixAfter": "S1"}),
|
||||
json!({"video": 0, "submix": 1, "submixType": "standard-ish"}),
|
||||
] {
|
||||
let r = s.execute("sequence.addTracks", bad.clone());
|
||||
assert!(r.is_err(), "{bad} was accepted: {r:?}");
|
||||
assert_eq!(*s.active_sequence().unwrap(), before, "{bad} changed the sequence");
|
||||
}
|
||||
// 99 at a time, and a sequence stops at 999 tracks of a kind
|
||||
for _ in 0..10 {
|
||||
s.execute("sequence.addTracks", json!({"video": 99})).unwrap();
|
||||
}
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3 + 990);
|
||||
assert!(s.execute("sequence.addTracks", json!({"video": 7})).is_err());
|
||||
s.execute("sequence.addTracks", json!({"video": 6})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tracks_empty_and_specific() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -975,7 +975,7 @@ static CATEGORIES: &[Category] = &[
|
||||
f("timeline.audioTransitionDuration", "Audio Transition Default Duration", Kind::Duration { unit_key: "timeline.audioTransitionUnit" }, true),
|
||||
f("timeline.stillImageDuration", "Still Image Default Duration", Kind::Duration { unit_key: "timeline.stillImageUnit" }, true),
|
||||
f("timeline.autoScroll", "Timeline Playback Auto-Scrolling", Kind::Choice(AUTO_SCROLL), true),
|
||||
f("timeline.mouseScrolling", "Timeline Mouse Scrolling", Kind::Choice(MOUSE_SCROLL), false),
|
||||
f("timeline.mouseScrolling", "Timeline Mouse Scrolling", Kind::Choice(MOUSE_SCROLL), true),
|
||||
Row::Group(
|
||||
"Default Audio Tracks",
|
||||
&[
|
||||
|
||||
@@ -481,6 +481,35 @@ fn auto_transcribe_on_import_and_transcription_defaults() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// In a build without speech-to-text (the release builds), the transcript commands that can't
|
||||
/// run say so through `enabled` (#97, #98), and an import with auto-transcribe on reports why it
|
||||
/// made no transcript instead of saying nothing (#89).
|
||||
#[test]
|
||||
fn transcription_without_speech_to_text_says_so() {
|
||||
let mut s = Session::default();
|
||||
assert_eq!(s.is_enabled("transcript.generate"), crate::transcript::speech_available());
|
||||
assert_eq!(s.is_enabled("transcript.downloadModel"), cfg!(feature = "speech-download"));
|
||||
if crate::transcript::speech_available() {
|
||||
return;
|
||||
}
|
||||
let dir = tmp_dir("prefs-no-speech");
|
||||
let mov = dir.join("talk.mov");
|
||||
crate::media_test_util::make_movie(&mov, filmcraft_media::DemoScene::OceanSunset, 64, 36, 24);
|
||||
// off: an import reports nothing
|
||||
let r = s.execute("file.import", json!({"paths": [mov.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]));
|
||||
set(&mut s, "mediaAnalysis.autoTranscribe", json!(true));
|
||||
set(&mut s, "mediaAnalysis.autoTranscribeScope", json!("allImported"));
|
||||
let r = s.execute("file.import", json!({"paths": [mov.to_string_lossy()]})).unwrap();
|
||||
let errors = r["errors"].as_array().unwrap();
|
||||
assert!(errors.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with("transcription:") && e.contains("not available in this build"))), "{errors:?}");
|
||||
assert!(!s.project.transcripts.contains_key(&ItemId(r["items"][0].as_u64().unwrap())));
|
||||
// with a transcriber installed (a host's), the command is enabled again
|
||||
s.transcriber = Some(std::sync::Arc::new(filmcraft_speech::FixedTranscriber { transcript: Default::default(), id: "fixed".into() }));
|
||||
assert!(s.is_enabled("transcript.generate"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_decoding_setting_drives_the_decoder_switch() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -85,3 +85,82 @@ fn ripple_trim_is_refused_when_the_early_sound_has_no_room() {
|
||||
assert_eq!(*s.project, *before, "a refused trim changes nothing");
|
||||
assert_eq!(start(&s, second) - start(&s, sound), rate.tick_of(12));
|
||||
}
|
||||
|
||||
/// The mirror, an L cut: the first clip's sound runs 6 frames on into the second picture. Shortening
|
||||
/// the second clip's head was refused as a sync-lock conflict, because the first clip's sound crossed
|
||||
/// the cut. The first clip never moves, so its sound stays and the L cut still ends where it did.
|
||||
#[test]
|
||||
fn shortening_the_head_after_an_l_cut_keeps_the_earlier_sound_in_place() {
|
||||
let mut s = Session::default();
|
||||
let (first, second, sound) = j_cut(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
// undo the J cut, then run the first clip's sound (A1) 6 frames past its picture
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": sound.0, "edge": "in", "mode": "regular", "deltaFrames": 12})).unwrap();
|
||||
let first_sound = s.active_sequence().unwrap().audio_tracks[0].items[0].id;
|
||||
s.execute("timeline.trim", json!({"clip": first_sound.0, "edge": "out", "mode": "regular", "deltaFrames": 6})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
let end = |s: &Session, c: ClipId| s.active_sequence().unwrap().find_item(c).map(|(_, i)| i.end()).unwrap();
|
||||
assert_eq!(end(&s, first_sound), start(&s, second) + rate.tick_of(6), "the L cut runs 6 frames into the second picture");
|
||||
let (l_cut_ends, picture_at, picture_ends) = (end(&s, first_sound), start(&s, second), end(&s, second));
|
||||
let before = s.project.clone();
|
||||
s.execute("timeline.trim", json!({"clip": second.0, "edge": "in", "mode": "ripple", "deltaFrames": 3})).unwrap();
|
||||
assert_eq!(end(&s, first_sound), l_cut_ends, "the L cut still ends where it did");
|
||||
assert_eq!((start(&s, first), start(&s, first_sound)), (Tick::ZERO, Tick::ZERO));
|
||||
assert_eq!((start(&s, second), end(&s, second)), (picture_at, picture_ends - rate.tick_of(3)), "the second picture is 3 frames shorter");
|
||||
assert_eq!((start(&s, sound), end(&s, sound)), (start(&s, second), end(&s, second)), "its own sound with it");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step puts it back");
|
||||
}
|
||||
|
||||
/// A shorter head on the J-cut take itself: the picture and its early sound lose the same stretch
|
||||
/// and the sound still leads by 12. It was refused as a sync-lock conflict because the check looked
|
||||
/// at the stretch before the cut, where the first clip's sound sits on A1, instead of the stretch
|
||||
/// the shorter head takes away.
|
||||
#[test]
|
||||
fn shorter_head_on_a_j_cut_take_shortens_picture_and_sound_together() {
|
||||
shorter_head_on_a_j_cut_take(false);
|
||||
}
|
||||
|
||||
/// The same trim grabbed by the early sound instead of the picture gives the same result.
|
||||
#[test]
|
||||
fn shorter_head_grabbed_by_the_early_sound_gives_the_same_result() {
|
||||
shorter_head_on_a_j_cut_take(true);
|
||||
}
|
||||
|
||||
fn shorter_head_on_a_j_cut_take(grab_sound: bool) {
|
||||
let mut s = Session::default();
|
||||
let (first, second, sound) = j_cut(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let len = |s: &Session, c: ClipId| s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.duration, i.source_in)).unwrap();
|
||||
let (picture_at, sound_at) = (start(&s, second), start(&s, sound));
|
||||
let ((pic_len, pic_in), (snd_len, snd_in)) = (len(&s, second), len(&s, sound));
|
||||
let first_before = len(&s, first);
|
||||
let before = s.project.clone();
|
||||
let r = s.execute("timeline.trim", json!({"clip": if grab_sound { sound.0 } else { second.0 }, "edge": "in", "mode": "ripple", "deltaFrames": 8})).unwrap();
|
||||
let by = rate.tick_of(8);
|
||||
assert_eq!(r["delta"], json!(by.0));
|
||||
assert_eq!((start(&s, second), start(&s, sound)), (picture_at, sound_at), "both stay where they start");
|
||||
assert_eq!(len(&s, second), (pic_len - by, pic_in + by), "the picture loses 8 from its head");
|
||||
assert_eq!(len(&s, sound), (snd_len - by, snd_in + by), "its sound loses the same 8");
|
||||
assert_eq!(start(&s, second) - start(&s, sound), rate.tick_of(12), "the sound still leads by 12");
|
||||
assert_eq!(len(&s, first), first_before, "the clip before is untouched");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step puts both back");
|
||||
}
|
||||
|
||||
/// Material on a sync-locked track inside the stretch a shorter head takes away still refuses it.
|
||||
#[test]
|
||||
fn shorter_head_is_refused_when_a_sync_locked_track_has_material_in_the_stretch() {
|
||||
let mut s = Session::default();
|
||||
let (_, second, _) = j_cut(&mut s);
|
||||
let rate = s.sequence_rate();
|
||||
let tone = s.project.items.values().find(|i| !i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
s.execute("timeline.place", json!({"item": tone.0, "audioTrack": "A1", "frame": 50, "duration": rate.tick_of(24).0})).unwrap();
|
||||
let before = s.project.clone();
|
||||
let e = s.execute("timeline.trim", json!({"clip": second.0, "edge": "in", "mode": "ripple", "deltaFrames": 8})).unwrap_err();
|
||||
assert!(e.to_string().contains("sync"), "{e}");
|
||||
assert_eq!(*s.project, *before, "a refused trim changes nothing");
|
||||
}
|
||||
@@ -202,3 +202,48 @@ fn hostile_times_are_refused_or_clamped_never_a_panic() {
|
||||
assert!(s.execute("timeline.move", json!({"moves": [{"clip": 987_654_321u64, "track": v_track.0, "time": 0}]})).is_err(), "a clip that does not exist");
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
/// Moving one narration line onto a spot another line still held shortened that line and said
|
||||
/// nothing; the result now names every clip the move changed without being asked to.
|
||||
#[test]
|
||||
fn a_move_that_lands_on_another_clip_reports_what_it_cut() {
|
||||
let mut s = demo();
|
||||
let (_, _, a_track, a1) = pair_in_an_empty_sequence(&mut s, 0.0);
|
||||
let later = a1.end().0 as f64 / filmcraft_time::TICKS_PER_SECOND as f64 + 2.0;
|
||||
let item = a1.item.0;
|
||||
s.execute("timeline.place", json!({"item": item, "seconds": later})).unwrap();
|
||||
let a2 = s.active_sequence().unwrap().track(a_track).unwrap().items[1].clone();
|
||||
let line = |c: ClipId, t: Tick| json!({"moves": [{"clip": c.0, "track": a_track.0, "time": t.0}], "linked": false});
|
||||
// onto empty space: nothing else changes, nothing is reported
|
||||
let r = s.execute("timeline.move", line(a1.id, a2.end() + Tick(1000))).unwrap();
|
||||
assert_eq!(r["overwritten"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// over the head of the next line: that line is shortened (living on under a new id), and the result says so
|
||||
let half = Tick(a1.duration.0 / 2);
|
||||
let r = s.execute("timeline.move", line(a1.id, a2.start - half)).unwrap();
|
||||
let rest = s.active_sequence().unwrap().track(a_track).unwrap().items.last().unwrap().clone();
|
||||
assert_eq!((rest.end(), rest.duration), (a2.end(), a2.duration - half), "the overwrite itself is unchanged");
|
||||
assert_eq!(r["overwritten"], json!([{"clip": a2.id.0, "was": a2.duration.0, "now": rest.duration.0, "pieces": [rest.id.0]}]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// over its tail: shortened in place, same id
|
||||
let r = s.execute("timeline.move", line(a2.id, a1.end() - half)).unwrap();
|
||||
assert_eq!(r["overwritten"], json!([{"clip": a1.id.0, "was": a1.duration.0, "now": (a1.duration - half).0, "pieces": [a1.id.0]}]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// squarely onto it: the line under it is gone, and the result names it
|
||||
let r = s.execute("timeline.move", line(a1.id, a2.start)).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(a2.id).is_none());
|
||||
assert_eq!(r["overwritten"], json!([{"clip": a2.id.0, "was": a2.duration.0, "now": 0, "pieces": []}]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// inside a longer line: it is split in two around the moved one
|
||||
s.execute("timeline.trim", json!({"clip": a1.id.0, "edge": "out", "delta": -(a1.duration.0 / 2)})).unwrap();
|
||||
let short = s.active_sequence().unwrap().find_item(a1.id).unwrap().1.duration;
|
||||
let r = s.execute("timeline.move", line(a1.id, a2.start + Tick(a2.duration.0 / 4))).unwrap();
|
||||
let o = &r["overwritten"][0];
|
||||
assert_eq!(r["overwritten"].as_array().unwrap().len(), 1, "{r}");
|
||||
assert_eq!((o["clip"].as_u64(), o["now"].as_i64()), (Some(a2.id.0), Some((a2.duration - short).0)), "{r}");
|
||||
assert_eq!(o["pieces"].as_array().unwrap().len(), 2, "{r}");
|
||||
// an insert-mode move shifts clips along and covers nothing
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": a1.id.0, "track": a_track.0, "time": a2.start.0}], "insert": true, "linked": false})).unwrap();
|
||||
assert_eq!(r["overwritten"], json!([]));
|
||||
}
|
||||
@@ -56,6 +56,25 @@ fn has_transcripts(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.project.transcripts.is_empty() { Err("there are no transcripts".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Why speech-to-text can't run in this build (no installed transcriber, built without `whisper`).
|
||||
pub(crate) const NO_SPEECH: &str =
|
||||
"speech-to-text is not available in this build (built without the `whisper` feature); import a transcript with transcript.set instead";
|
||||
|
||||
/// `transcript.generate` can run: a host installed a transcriber or the build has speech-to-text
|
||||
/// (#97: it reported enabled and then always failed).
|
||||
pub(crate) fn can_transcribe(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.transcriber.is_some() || speech_available() { Ok(()) } else { Err(NO_SPEECH.into()) }
|
||||
}
|
||||
|
||||
/// `transcript.downloadModel` can run: built with `speech-download` (#98).
|
||||
fn can_download(_: &Session) -> std::result::Result<(), String> {
|
||||
if cfg!(feature = "speech-download") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("model downloads are not available in this build (built without the `speech-download` feature)".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The media item behind a project item (subclips resolve to their parent).
|
||||
fn media_item(s: &Session, item: ItemId) -> Option<ItemId> {
|
||||
match &s.project.item(item)?.kind {
|
||||
@@ -123,9 +142,7 @@ fn transcriber(s: &Session, p: &Value) -> Result<Arc<dyn Transcriber>> {
|
||||
return Err(speech_err(SpeechError::UnknownModel(model.into())));
|
||||
}
|
||||
if !filmcraft_speech::available() {
|
||||
return Err(EngineError::Other(
|
||||
"speech-to-text is not available in this build (built without the `whisper` feature); import a transcript with transcript.set instead".into(),
|
||||
));
|
||||
return Err(EngineError::Other(NO_SPEECH.into()));
|
||||
}
|
||||
let dir = models_dir().ok_or_else(|| EngineError::Other("no data directory for speech models".into()))?;
|
||||
filmcraft_speech::load(&dir, model).map_err(speech_err)
|
||||
@@ -427,7 +444,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
"Transcribe…",
|
||||
&["Sequence", "Transcript"],
|
||||
r#"{"items":[id]?,"model":"whisper-base"?,"language":"en|auto"?,"diarize":bool?,"maxSpeakers":n?}"#,
|
||||
always,
|
||||
can_transcribe,
|
||||
generate,
|
||||
true,
|
||||
),
|
||||
@@ -444,7 +461,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
spec("transcript.inspect", "Inspect Transcript", &[], r#"{"paragraphGapSeconds":f?}"#, always, inspect, false),
|
||||
spec("transcript.search", "Search Transcript", &[], r#"{"query":str}"#, always, search, false),
|
||||
spec("transcript.models", "List Speech Models", &[], "{}", always, models, false),
|
||||
spec("transcript.downloadModel", "Download Speech Model", &[], r#"{"model":"whisper-base"?}"#, always, download_model, true),
|
||||
spec("transcript.downloadModel", "Download Speech Model", &[], r#"{"model":"whisper-base"?}"#, can_download, download_model, true),
|
||||
spec("transcript.select", "Mark Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, select, true),
|
||||
spec("transcript.extract", "Extract Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, |s, p| extract_or_lift(s, p, true), true),
|
||||
spec("transcript.lift", "Lift Selected Text", &[], r#"{"from":word,"to":word?}"#, has_transcript, |s, p| extract_or_lift(s, p, false), true),
|
||||
|
||||
@@ -163,8 +163,11 @@ fn generate_without_a_transcriber() {
|
||||
let (mut s, item, _) = session();
|
||||
s.transcriber = None;
|
||||
let e = s.execute("transcript.generate", json!({"items": [item.0], "model": "nope"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown speech model"), "{e}");
|
||||
// a build without speech-to-text says that first: the command is disabled (#97)
|
||||
let why = if filmcraft_speech::available() { "unknown speech model" } else { "not available in this build" };
|
||||
assert!(e.contains(why), "{e}");
|
||||
if !filmcraft_speech::available() {
|
||||
assert!(!s.is_enabled("sequence.transcribe"), "Transcribe Sequence follows transcript.generate");
|
||||
let e = s.execute("transcript.generate", json!({"items": [item.0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("whisper") && e.contains("not available"), "{e}");
|
||||
#[cfg(not(feature = "speech-download"))]
|
||||
@@ -172,6 +175,7 @@ fn generate_without_a_transcriber() {
|
||||
}
|
||||
// defaults to the media of the open sequence's audio clips
|
||||
s.transcriber = Some(Arc::new(FixedTranscriber { transcript: Transcript::default(), id: "empty".into() }));
|
||||
assert!(s.is_enabled("sequence.transcribe"), "an installed recogniser enables Transcribe Sequence");
|
||||
let r = s.execute("transcript.generate", json!({})).unwrap();
|
||||
assert!(r["items"].as_array().unwrap().len() >= 2, "{r}");
|
||||
}
|
||||
@@ -22,6 +22,7 @@ filmcraft-text = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-prores = { workspace = true }
|
||||
filmcraft-dnx = { workspace = true }
|
||||
filmcraft-apv = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-h264enc = { workspace = true }
|
||||
|
||||
@@ -82,7 +82,7 @@ pub struct Exporter {
|
||||
|
||||
/// Whether [`Exporter`] handles a format.
|
||||
pub fn stepped(format: Format) -> bool {
|
||||
matches!(format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||
matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||
}
|
||||
|
||||
fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result<Box<dyn VideoEncoder>> {
|
||||
@@ -102,9 +102,9 @@ impl Exporter {
|
||||
}
|
||||
settings.validate()?;
|
||||
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
|
||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR) unless SDR is asked for
|
||||
// HDR sequences export HDR (H.264 / ProRes / DNxHR / APV) unless SDR is asked for (H.265 export is 8-bit SDR)
|
||||
let pipe = q.settings.color;
|
||||
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr);
|
||||
let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv);
|
||||
let mut settings = settings.clone();
|
||||
settings.signal = match (hdr_out, pipe.working) {
|
||||
(true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ,
|
||||
@@ -128,7 +128,7 @@ impl Exporter {
|
||||
progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label()));
|
||||
}
|
||||
let venc = make_venc(&settings, pipe.w, pipe.h, pipe.rate)?;
|
||||
let brand = if settings.format == Format::H264 && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov };
|
||||
let brand = if settings.format.is_h26x() && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov };
|
||||
let audio = if settings.has_audio() { Some(AudioOut::new(project.clone(), seq, &settings, range)?) } else { None };
|
||||
let aenc: Option<Box<dyn AudioEncoder>> = match (&audio, settings.audio_codec()) {
|
||||
(Some(a), AudioCodec::Aac) => {
|
||||
|
||||
+231
-10
@@ -18,6 +18,7 @@ use std::io::Write;
|
||||
mod audio_out;
|
||||
mod job;
|
||||
mod mxf_out;
|
||||
mod pace;
|
||||
mod pcm;
|
||||
mod pipeline;
|
||||
pub mod presets;
|
||||
@@ -61,12 +62,21 @@ pub enum Format {
|
||||
#[default]
|
||||
#[serde(rename = "h264", alias = "H264")]
|
||||
H264,
|
||||
/// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no
|
||||
/// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so
|
||||
/// with [`register_format_probe`]; [`available`] is false without it.
|
||||
#[serde(rename = "hevc", alias = "Hevc")]
|
||||
Hevc,
|
||||
/// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM.
|
||||
#[serde(rename = "prores", alias = "ProRes")]
|
||||
ProRes,
|
||||
/// QuickTime, Avid DNxHR (HQ unless the settings pick another profile) + PCM.
|
||||
#[serde(rename = "dnxhr", alias = "DnxHr")]
|
||||
DnxHr,
|
||||
/// QuickTime, APV (Advanced Professional Video, RFC 9924; 422-10 unless the settings pick
|
||||
/// another profile) + PCM.
|
||||
#[serde(rename = "apv", alias = "Apv")]
|
||||
Apv,
|
||||
/// QuickTime, Motion-JPEG + PCM.
|
||||
#[serde(rename = "mjpeg", alias = "Mjpeg")]
|
||||
Mjpeg,
|
||||
@@ -98,8 +108,10 @@ impl Format {
|
||||
pub fn from_name(s: &str) -> Option<Format> {
|
||||
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
|
||||
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
||||
"hevc" | "h265" | "hvc1" | "hev1" | "x265" => Format::Hevc,
|
||||
"prores" | "mov" | "appleprores" => Format::ProRes,
|
||||
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
|
||||
"apv" | "apv1" => Format::Apv,
|
||||
"mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a,
|
||||
"mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom,
|
||||
"mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg,
|
||||
@@ -116,8 +128,10 @@ impl Format {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Format::H264 => "h264",
|
||||
Format::Hevc => "hevc",
|
||||
Format::ProRes => "prores",
|
||||
Format::DnxHr => "dnxhr",
|
||||
Format::Apv => "apv",
|
||||
Format::Mjpeg => "mjpeg",
|
||||
Format::PngSequence => "png",
|
||||
Format::TiffSequence => "tiff",
|
||||
@@ -131,8 +145,8 @@ impl Format {
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::H264 => "mp4",
|
||||
Format::ProRes | Format::DnxHr | Format::Mjpeg => "mov",
|
||||
Format::H264 | Format::Hevc => "mp4",
|
||||
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov",
|
||||
Format::PngSequence => "png",
|
||||
Format::TiffSequence => "tif",
|
||||
Format::BmpSequence => "bmp",
|
||||
@@ -145,8 +159,10 @@ impl Format {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Format::H264 => "H.264",
|
||||
Format::Hevc => "H.265 (HEVC)",
|
||||
Format::ProRes => "Apple ProRes",
|
||||
Format::DnxHr => "Avid DNxHR",
|
||||
Format::Apv => "APV",
|
||||
Format::Mjpeg => "QuickTime (Motion JPEG)",
|
||||
Format::PngSequence => "PNG",
|
||||
Format::TiffSequence => "TIFF",
|
||||
@@ -162,10 +178,21 @@ impl Format {
|
||||
pub fn is_mxf(self) -> bool {
|
||||
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
|
||||
}
|
||||
pub const ALL: [Format; 12] = [
|
||||
/// Whether the crate carries an encoder for the format; the others need one registered at runtime
|
||||
/// ([`register_format_probe`]).
|
||||
pub fn has_builtin_encoder(self) -> bool {
|
||||
self != Format::Hevc
|
||||
}
|
||||
/// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls.
|
||||
pub fn is_h26x(self) -> bool {
|
||||
matches!(self, Format::H264 | Format::Hevc)
|
||||
}
|
||||
pub const ALL: [Format; 14] = [
|
||||
Format::H264,
|
||||
Format::Hevc,
|
||||
Format::ProRes,
|
||||
Format::DnxHr,
|
||||
Format::Apv,
|
||||
Format::Mjpeg,
|
||||
Format::PngSequence,
|
||||
Format::TiffSequence,
|
||||
@@ -215,6 +242,9 @@ pub struct ExportSettings {
|
||||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||
#[serde(default)]
|
||||
pub dnx_profile: String,
|
||||
/// APV profile: `422-10`, `422-12`, `444-10` or `444-12` (empty = `422-10`).
|
||||
#[serde(default)]
|
||||
pub apv_profile: String,
|
||||
/// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply).
|
||||
#[serde(default)]
|
||||
pub mxf_video_codec: MxfVideoCodec,
|
||||
@@ -237,6 +267,11 @@ pub struct ExportSettings {
|
||||
/// stream needs is raised.
|
||||
pub h264_level: Option<u8>,
|
||||
pub bitrate_mode: BitrateMode,
|
||||
/// May H.264 be encoded by the system's hardware encoder (VideoToolbox on macOS, NVENC on
|
||||
/// Windows)? Off unless asked for: hardware output depends on the machine, so it is not
|
||||
/// byte-reproducible like the built-in encoder's (`determinism_tests`).
|
||||
#[serde(default)]
|
||||
pub hardware_encoding: HardwareEncoding,
|
||||
/// VBR maximum bitrate (None = 1.5 × target).
|
||||
pub max_bitrate_kbps: Option<u32>,
|
||||
/// Adaptive bitrate (the Match Source presets): bits per pixel per frame; replaces
|
||||
@@ -407,6 +442,7 @@ impl Default for ExportSettings {
|
||||
part_of_batch: false,
|
||||
prores_profile: String::new(),
|
||||
dnx_profile: String::new(),
|
||||
apv_profile: String::new(),
|
||||
mxf_video_codec: MxfVideoCodec::default(),
|
||||
sdr: false,
|
||||
frame_size: None,
|
||||
@@ -417,6 +453,7 @@ impl Default for ExportSettings {
|
||||
h264_profile: H264Profile::High,
|
||||
h264_level: None,
|
||||
bitrate_mode: BitrateMode::default(),
|
||||
hardware_encoding: HardwareEncoding::default(),
|
||||
max_bitrate_kbps: None,
|
||||
adaptive_bitrate: None,
|
||||
keyframe_distance: None,
|
||||
@@ -443,6 +480,9 @@ impl ExportSettings {
|
||||
if self.effects.image_overlay.enabled && self.effects.image_overlay.path.trim().is_empty() {
|
||||
return Err(ExportError::Unsupported("image overlay: no image file chosen".into()));
|
||||
}
|
||||
if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass {
|
||||
return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -458,6 +498,8 @@ pub struct Progress {
|
||||
pub error: Mutex<Option<String>>,
|
||||
/// What loudness normalization measured (when it ran).
|
||||
pub loudness: Mutex<Option<LoudnessReport>>,
|
||||
/// Readings of `done` over time, for [`Progress::eta`].
|
||||
pace: Mutex<pace::Pace>,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
@@ -465,6 +507,24 @@ impl Progress {
|
||||
let t = self.total.load(Ordering::Relaxed).max(1);
|
||||
self.done.load(Ordering::Relaxed) as f32 / t as f32
|
||||
}
|
||||
|
||||
/// The estimated time left, from the job's speed over the last 15 seconds. Each call is also a
|
||||
/// reading of that speed, so ask regularly (the UI does on every frame it draws the job).
|
||||
/// `None` until a second of readings shows progress, and once the job is done or finished.
|
||||
pub fn eta(&self) -> Option<std::time::Duration> {
|
||||
self.eta_at(web_time::Instant::now())
|
||||
}
|
||||
|
||||
/// [`Progress::eta`] at a given time (tests, and hosts with a clock of their own).
|
||||
pub fn eta_at(&self, now: web_time::Instant) -> Option<std::time::Duration> {
|
||||
let (done, total) = (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed));
|
||||
let mut pace = self.pace.lock().unwrap_or_else(|e| e.into_inner());
|
||||
pace.observe(now, done);
|
||||
if self.finished.load(Ordering::Relaxed) || done >= total {
|
||||
return None;
|
||||
}
|
||||
pace.eta(total - done)
|
||||
}
|
||||
fn set_status(&self, s: impl Into<String>) {
|
||||
*self.status.lock().unwrap_or_else(|e| e.into_inner()) = s.into();
|
||||
}
|
||||
@@ -534,24 +594,89 @@ pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u3
|
||||
|
||||
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
|
||||
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
|
||||
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, mjpeg_factory]))
|
||||
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, apv_factory, mjpeg_factory]))
|
||||
}
|
||||
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
|
||||
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
|
||||
F.get_or_init(|| RwLock::new(vec![aac_factory]))
|
||||
}
|
||||
|
||||
/// Hardware encoder counters (`perf.stats` `export.hardware`): pictures encoded by hardware
|
||||
/// encoders, encoders created, and requests a hardware encoder declined (the software encoder
|
||||
/// took them).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HwEncodeStats {
|
||||
pub frames: u64,
|
||||
pub sessions: u64,
|
||||
pub declined: u64,
|
||||
}
|
||||
|
||||
static HW_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static HW_SESSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
static HW_DECLINED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The hardware encoder counters so far.
|
||||
pub fn hw_encode_stats() -> HwEncodeStats {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
HwEncodeStats { frames: HW_FRAMES.load(Relaxed), sessions: HW_SESSIONS.load(Relaxed), declined: HW_DECLINED.load(Relaxed) }
|
||||
}
|
||||
|
||||
/// A hardware encoder encoded a picture.
|
||||
pub fn note_hw_encode_frame() {
|
||||
HW_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A hardware encoder was created.
|
||||
pub fn note_hw_encode_session() {
|
||||
HW_SESSIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A hardware encoder declined a request (the software encoder takes it).
|
||||
pub fn note_hw_encode_declined() {
|
||||
HW_DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Register a video encoder factory (tried before the built-in ones and those registered earlier).
|
||||
/// Registering the same factory twice is harmless.
|
||||
pub fn register_encoder(f: EncoderFactory) {
|
||||
video_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
|
||||
let mut g = video_factories().write().unwrap_or_else(|e| e.into_inner());
|
||||
if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) {
|
||||
g.insert(0, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `f` is among the registered video encoder factories (startup diagnostics, tests).
|
||||
pub fn encoder_registered(f: EncoderFactory) -> bool {
|
||||
video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
|
||||
}
|
||||
pub fn register_audio_encoder(f: AudioEncoderFactory) {
|
||||
audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
|
||||
}
|
||||
|
||||
/// Whether a format can currently be exported. Every [`Format`] has a built-in encoder; this stays
|
||||
/// as the hook for formats whose encoders are registered at runtime.
|
||||
type FormatProbe = (Format, fn() -> bool);
|
||||
|
||||
fn format_probes() -> &'static RwLock<Vec<FormatProbe>> {
|
||||
static P: OnceLock<RwLock<Vec<FormatProbe>>> = OnceLock::new();
|
||||
P.get_or_init(|| RwLock::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Say how to find out whether the encoder of a format without a built-in one ([`Format::has_builtin_encoder`])
|
||||
/// works on this machine: `probe` runs when [`available`] asks (it should cache its answer).
|
||||
/// Registering the same probe twice is harmless.
|
||||
pub fn register_format_probe(format: Format, probe: fn() -> bool) {
|
||||
let mut g = format_probes().write().unwrap_or_else(|e| e.into_inner());
|
||||
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
|
||||
g.push((format, probe));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a format can currently be exported: it has a built-in encoder, or a registered probe
|
||||
/// says its encoder works here.
|
||||
pub fn available(format: Format) -> bool {
|
||||
Format::ALL.contains(&format)
|
||||
if !Format::ALL.contains(&format) {
|
||||
return false;
|
||||
}
|
||||
format.has_builtin_encoder() || format_probes().read().unwrap_or_else(|e| e.into_inner()).iter().any(|(f, probe)| *f == format && probe())
|
||||
}
|
||||
|
||||
struct MjpegEncoder {
|
||||
@@ -616,7 +741,10 @@ impl AudioEncoder for AacEncoder {
|
||||
}
|
||||
|
||||
fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option<Result<Box<dyn AudioEncoder>>> {
|
||||
let bps = s.audio.bitrate_kbps.clamp(32, 512) * 1000;
|
||||
// AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so
|
||||
// 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused
|
||||
let max = (6 * sample_rate as u64 * channels.max(1) as u64).min(u32::MAX as u64) as u32;
|
||||
let bps = (s.audio.bitrate_kbps.clamp(32, 512) * 1000).min(max);
|
||||
Some(
|
||||
filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sample_rate, channels as usize, bps))
|
||||
.map(|enc| Box::new(AacEncoder { enc, rate: sample_rate, channels }) as Box<dyn AudioEncoder>)
|
||||
@@ -794,6 +922,99 @@ fn dnx_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
|
||||
})
|
||||
}
|
||||
|
||||
/// The APV profile named by [`ExportSettings::apv_profile`].
|
||||
pub fn apv_profile(name: &str) -> filmcraft_apv::Profile {
|
||||
use filmcraft_apv::Profile;
|
||||
match name.to_ascii_lowercase().replace([' ', '_'], "-").trim_start_matches("apv-").trim_start_matches("apv") {
|
||||
"422-12" | "42212" => Profile::P422_12,
|
||||
"444-10" | "44410" => Profile::P444_10,
|
||||
"444-12" | "44412" => Profile::P444_12,
|
||||
"4444-10" | "444410" => Profile::P4444_10,
|
||||
"4444-12" | "444412" => Profile::P4444_12,
|
||||
"400-10" | "40010" => Profile::P400_10,
|
||||
_ => Profile::P422_10,
|
||||
}
|
||||
}
|
||||
|
||||
/// APV encoder (RFC 9924: 10/12-bit 4:2:2 or 4:4:4 intra).
|
||||
struct ApvEncoder {
|
||||
enc: filmcraft_apv::Encoder,
|
||||
chroma: filmcraft_apv::ChromaFormat,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
signal: ColorSignal,
|
||||
}
|
||||
|
||||
impl VideoEncoder for ApvEncoder {
|
||||
fn sample_entry(&self) -> SampleEntry {
|
||||
let apvc = filmcraft_isobmff::ApvConfig::parse(&self.enc.decoder_config_record()).unwrap_or_default();
|
||||
let mut e = SampleEntry::apv(apvc, self.w as u16, self.h as u16);
|
||||
self.signal.apply_to(&mut e, true);
|
||||
e
|
||||
}
|
||||
fn timescale(&self) -> u32 {
|
||||
self.rate.num as u32
|
||||
}
|
||||
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
|
||||
let (w, h) = (f.width as usize, f.height as usize);
|
||||
let mut fr = filmcraft_apv::Frame::new(f.width, f.height, self.chroma, 10, self.chroma == filmcraft_apv::ChromaFormat::Yuv4444);
|
||||
let mut y422 = vec![0u16; w * h];
|
||||
let cw = w.div_ceil(2);
|
||||
let mut cb422 = vec![0u16; cw * h];
|
||||
let mut cr422 = vec![0u16; cw * h];
|
||||
match f.hdr {
|
||||
Some(rgb) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut y422, &mut cb422, &mut cr422);
|
||||
}
|
||||
None => rgba_to_yuv422_10(f.rgba, w, h, &mut y422, &mut cb422, &mut cr422),
|
||||
}
|
||||
fr.y = y422;
|
||||
match self.chroma {
|
||||
filmcraft_apv::ChromaFormat::Monochrome => {
|
||||
fr.cb.clear();
|
||||
fr.cr.clear();
|
||||
}
|
||||
filmcraft_apv::ChromaFormat::Yuv422 => {
|
||||
fr.cb = cb422;
|
||||
fr.cr = cr422;
|
||||
}
|
||||
filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => {
|
||||
for row in 0..h {
|
||||
for x in 0..w {
|
||||
fr.cb[row * w + x] = cb422[row * cw + x / 2];
|
||||
fr.cr[row * w + x] = cr422[row * cw + x / 2];
|
||||
}
|
||||
}
|
||||
if let Some(a) = fr.alpha.as_mut() {
|
||||
for (i, dst) in a.iter_mut().enumerate() {
|
||||
*dst = f.rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = self.enc.encode_raw_au(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<EncodedPacket>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn apv_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
|
||||
(format == Format::Apv).then(|| {
|
||||
let profile = apv_profile(&s.apv_profile);
|
||||
let mut cfg = filmcraft_apv::EncoderConfig::new(profile, w, h);
|
||||
if s.signal.is_hdr() {
|
||||
cfg.color = filmcraft_apv::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false };
|
||||
}
|
||||
let chroma = cfg.chroma;
|
||||
let enc = filmcraft_apv::Encoder::with_config(cfg).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(Box::new(ApvEncoder { enc, chroma, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||
})
|
||||
}
|
||||
|
||||
/// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the
|
||||
/// requested bitrate, length-prefixed samples with the `avcC` in the sample entry.
|
||||
struct H264Encoder {
|
||||
@@ -1117,7 +1338,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
||||
}
|
||||
(total, count)
|
||||
}
|
||||
Format::H264 | Format::ProRes | Format::DnxHr | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
|
||||
Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
|
||||
// Handled by the stepped exporter above; reaching here would be a dispatch bug.
|
||||
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Estimated time left of a job, from how fast it advanced over the last seconds.
|
||||
//!
|
||||
//! A job's `done` counter goes up in batches, and the speed of an export changes with what is on the
|
||||
//! timeline (an intro, a banner, plain camera footage), so the estimate uses the speed of a trailing
|
||||
//! window, not the average since the start, and it stays quiet until a second of readings shows
|
||||
//! progress. A stall shows as a falling speed, so the estimate grows instead of freezing.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
/// How far back the speed is measured.
|
||||
const WINDOW: Duration = Duration::from_secs(15);
|
||||
/// Readings closer together than this are one reading (the UI asks on every frame it draws).
|
||||
const MIN_GAP: Duration = Duration::from_millis(100);
|
||||
/// The estimate needs at least this much time between its first and last reading.
|
||||
const MIN_SPAN: Duration = Duration::from_secs(1);
|
||||
/// The longest estimate given: a job that all but stopped would otherwise promise millennia.
|
||||
const MAX_ETA: Duration = Duration::from_secs(100 * 3600);
|
||||
|
||||
/// Readings of a job's `done` counter over time.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Pace {
|
||||
/// (when, `done` then), oldest first.
|
||||
readings: VecDeque<(Instant, u64)>,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
/// Note that the job had done `done` units at `now`.
|
||||
pub(crate) fn observe(&mut self, now: Instant, done: u64) {
|
||||
// a count that went back (a job that started over) starts the history again
|
||||
if self.readings.back().is_some_and(|(_, d)| done < *d) {
|
||||
self.readings.clear();
|
||||
}
|
||||
if self.readings.back().is_none_or(|(t, _)| now.saturating_duration_since(*t) >= MIN_GAP) {
|
||||
self.readings.push_back((now, done));
|
||||
}
|
||||
// keep the newest reading that is a whole window old as the baseline, and nothing before it
|
||||
while self.readings.len() > 2 && self.readings.get(1).is_some_and(|(t, _)| now.saturating_duration_since(*t) >= WINDOW) {
|
||||
self.readings.pop_front();
|
||||
}
|
||||
// an idle start (a loudness pass, a slow seek) is not the job's speed: measure from the last
|
||||
// reading before it moved, not from a baseline that makes the job look slow for a whole window
|
||||
if let Some(first) = self.readings.front().map(|r| r.1)
|
||||
&& let Some(moved) = self.readings.iter().position(|(_, d)| *d != first)
|
||||
&& moved >= 2
|
||||
&& self.readings.get(moved - 1).is_some_and(|(t, _)| t.saturating_duration_since(self.readings[0].0) >= MIN_SPAN)
|
||||
{
|
||||
self.readings.drain(..moved - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The time `remaining` more units take at the pace of the readings, when they show one.
|
||||
pub(crate) fn eta(&self, remaining: u64) -> Option<Duration> {
|
||||
let (t0, d0) = *self.readings.front()?;
|
||||
let (t1, d1) = *self.readings.back()?;
|
||||
let span = t1.saturating_duration_since(t0);
|
||||
if span < MIN_SPAN || d1 <= d0 {
|
||||
return None;
|
||||
}
|
||||
let per_second = (d1 - d0) as f64 / span.as_secs_f64();
|
||||
let left = remaining as f64 / per_second;
|
||||
// `try_from_secs_f64` refuses what a `Duration` cannot hold; that is "more than the cap" here
|
||||
(left >= 0.0).then(|| Duration::try_from_secs_f64(left).map_or(MAX_ETA, |d| d.min(MAX_ETA)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(t0: Instant, secs: f64) -> Instant {
|
||||
t0 + Duration::from_secs_f64(secs)
|
||||
}
|
||||
|
||||
/// Feed `readings` (seconds, done) and ask for the time left of `total`.
|
||||
fn eta(readings: &[(f64, u64)], total: u64) -> Option<f64> {
|
||||
let t0 = Instant::now();
|
||||
let mut p = Pace::default();
|
||||
for (s, d) in readings {
|
||||
p.observe(at(t0, *s), *d);
|
||||
}
|
||||
let done = readings.last().map_or(0, |r| r.1);
|
||||
p.eta(total.saturating_sub(done)).map(|d| d.as_secs_f64())
|
||||
}
|
||||
|
||||
/// A job at `fps` units per second, read every `step` seconds for `secs`.
|
||||
fn steady(fps: f64, step: f64, secs: f64) -> Vec<(f64, u64)> {
|
||||
(0..=(secs / step) as u64).map(|i| (i as f64 * step, (i as f64 * step * fps) as u64)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_job_gives_the_time_it_has_left() {
|
||||
// 50 units a second, 4 s in, of 1000: 800 units left = 16 s
|
||||
let e = eta(&steady(50.0, 0.15, 4.0), 1000).unwrap();
|
||||
assert!((e - 16.0).abs() < 0.5, "{e}");
|
||||
// and the estimate falls as the job goes on: 10 s in, 500 left
|
||||
let e = eta(&steady(50.0, 0.15, 10.0), 1000).unwrap();
|
||||
assert!((e - 10.0).abs() < 0.5, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_promised_before_there_is_progress_to_measure() {
|
||||
assert_eq!(eta(&[], 100), None);
|
||||
assert_eq!(eta(&[(0.0, 0)], 100), None, "one reading");
|
||||
assert_eq!(eta(&[(0.0, 0), (0.5, 20)], 100), None, "less than a second of readings");
|
||||
assert_eq!(eta(&[(0.0, 5), (2.0, 5)], 100), None, "nothing done in two seconds (a loudness pass, a seek)");
|
||||
// then, once frames flow
|
||||
assert!(eta(&[(0.0, 0), (2.0, 0), (3.0, 30)], 100).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_estimate_follows_a_change_of_speed_within_the_window() {
|
||||
// 100 units a second for 20 s, then 20 a second for 20 s (a banner comes in): the last
|
||||
// 15 s dominate, so with 10 000 units to do the estimate is near the slow speed's
|
||||
let mut r = steady(100.0, 0.15, 20.0);
|
||||
let (t, d) = *r.last().unwrap();
|
||||
r.extend((1..=133).map(|i| (t + i as f64 * 0.15, d + (i as f64 * 0.15 * 20.0) as u64)));
|
||||
let done = r.last().unwrap().1;
|
||||
let e = eta(&r, 10_000).unwrap();
|
||||
let slow = (10_000 - done) as f64 / 20.0;
|
||||
let fast = (10_000 - done) as f64 / 100.0;
|
||||
assert!((e - slow).abs() < (e - fast).abs(), "estimate {e}, slow {slow}, fast {fast}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_start_does_not_hold_the_estimate_back() {
|
||||
// 20 s with nothing done (a loudness pass), then 50 units a second for 3 s: the pace is 50 a
|
||||
// second, not what a 15 s window that is mostly idle would say
|
||||
let mut r: Vec<(f64, u64)> = (0..=133).map(|i| (i as f64 * 0.15, 0)).collect();
|
||||
let t = r.last().unwrap().0;
|
||||
r.extend((1..=20).map(|i| (t + i as f64 * 0.15, (i as f64 * 0.15 * 50.0) as u64)));
|
||||
let e = eta(&r, 1000).unwrap();
|
||||
let done = r.last().unwrap().1;
|
||||
assert!((e - (1000 - done) as f64 / 50.0).abs() < 1.0, "{e}");
|
||||
// the short flat spells between batches of a running job are not an idle start
|
||||
let batches: Vec<(f64, u64)> = (0..=40).map(|i| (i as f64 * 0.15, (i / 2) as u64 * 8)).collect();
|
||||
assert!(eta(&batches, 1000).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_makes_the_estimate_grow() {
|
||||
let mut r = steady(50.0, 0.15, 4.0);
|
||||
let before = eta(&r, 1000).unwrap();
|
||||
let (t, d) = *r.last().unwrap();
|
||||
r.extend((1..=40).map(|i| (t + i as f64 * 0.15, d)));
|
||||
let after = eta(&r, 1000).unwrap();
|
||||
assert!(after > before * 1.5, "{before} -> {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_that_goes_back_starts_over() {
|
||||
// a second pass, or a job reusing its counters: the old history must not count
|
||||
let mut r = steady(50.0, 0.15, 4.0);
|
||||
let (t, _) = *r.last().unwrap();
|
||||
r.extend((1..=20).map(|i| (t + i as f64 * 0.15, (i as f64 * 0.15 * 10.0) as u64)));
|
||||
let e = eta(&r, 100).unwrap();
|
||||
let done = r.last().unwrap().1;
|
||||
assert!((e - (100 - done) as f64 / 10.0).abs() < 1.0, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readings_that_arrive_together_count_once_and_the_history_stays_small() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = Pace::default();
|
||||
for _ in 0..1000 {
|
||||
p.observe(t0, 1);
|
||||
}
|
||||
assert_eq!(p.readings.len(), 1);
|
||||
// an hour of 150 ms readings keeps about a window of them
|
||||
for i in 0..24_000u64 {
|
||||
p.observe(at(t0, 1.0 + i as f64 * 0.15), 1 + i);
|
||||
}
|
||||
assert!(p.readings.len() <= (WINDOW.as_millis() / MIN_GAP.as_millis()) as usize + 3, "{}", p.readings.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_reports_its_time_left_until_it_is_done() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let t0 = Instant::now();
|
||||
let progress = crate::Progress::default();
|
||||
// no total yet, nothing to estimate
|
||||
assert_eq!(progress.eta_at(t0), None);
|
||||
progress.total.store(1000, Ordering::Relaxed);
|
||||
let mut last = None;
|
||||
for i in 0..=60u64 {
|
||||
progress.done.store(i * 5, Ordering::Relaxed); // 50 units a second, read every 0.1 s
|
||||
last = progress.eta_at(at(t0, i as f64 * 0.1));
|
||||
}
|
||||
// 6 s in, 300 done, 700 left at 50 a second
|
||||
let left = last.expect("a second of readings shows progress").as_secs_f64();
|
||||
assert!((left - 14.0).abs() < 0.5, "{left}");
|
||||
// done: nothing left to wait for
|
||||
progress.done.store(1000, Ordering::Relaxed);
|
||||
assert_eq!(progress.eta_at(at(t0, 7.0)), None);
|
||||
// a finished job (cancelled, failed) has none either, whatever it had left
|
||||
let other = crate::Progress::default();
|
||||
other.total.store(1000, Ordering::Relaxed);
|
||||
for i in 0..=30u64 {
|
||||
other.done.store(i * 5, Ordering::Relaxed);
|
||||
other.eta_at(at(t0, i as f64 * 0.1));
|
||||
}
|
||||
assert!(other.eta_at(at(t0, 3.1)).is_some());
|
||||
other.finished.store(true, Ordering::Relaxed);
|
||||
assert_eq!(other.eta_at(at(t0, 3.2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_numbers_give_a_capped_estimate_or_none_and_never_panic() {
|
||||
// almost no progress against a huge total: capped
|
||||
let e = eta(&[(0.0, 0), (10.0, 1)], u64::MAX).unwrap();
|
||||
assert!((e - MAX_ETA.as_secs_f64()).abs() < 1.0, "{e}");
|
||||
// huge counters
|
||||
assert!(eta(&[(0.0, u64::MAX - 100), (2.0, u64::MAX - 50)], u64::MAX).is_some());
|
||||
// a clock that does not move forward: no estimate, no division by zero
|
||||
assert_eq!(eta(&[(1.0, 0), (1.0, 50), (1.0, 100)], 1000), None);
|
||||
// nothing left
|
||||
let t0 = Instant::now();
|
||||
let mut p = Pace::default();
|
||||
p.observe(t0, 0);
|
||||
p.observe(at(t0, 2.0), 100);
|
||||
assert_eq!(p.eta(0), Some(Duration::ZERO));
|
||||
}
|
||||
}
|
||||
@@ -118,9 +118,13 @@ impl Pipeline {
|
||||
if lim.enabled {
|
||||
limit_f32(&mut out, lim.min_percent, lim.max_percent);
|
||||
}
|
||||
// the float image is not needed any more: its buffer serves the next frame's layers
|
||||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
return (Vec::new(), out);
|
||||
}
|
||||
let mut rgba = img.over_black_rgba8();
|
||||
// the float image is not needed any more: its buffer serves the next frame's layers
|
||||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
if lim.enabled {
|
||||
limit_rgba8(&mut rgba, lim.min_percent, lim.max_percent);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ fn mov(format: Format, profile: &str) -> ExportSettings {
|
||||
};
|
||||
match format {
|
||||
Format::ProRes => s.prores_profile = profile.into(),
|
||||
Format::Apv => s.apv_profile = profile.into(),
|
||||
_ => s.dnx_profile = profile.into(),
|
||||
}
|
||||
s
|
||||
@@ -124,6 +125,8 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
||||
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
|
||||
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
|
||||
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
|
||||
p("APV 422-10", bc, "QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::Apv, "422-10")),
|
||||
p("APV 422-12", bc, "QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::Apv, "422-12")),
|
||||
p("MXF OP1a DNxHR HQ", bc, "MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::Dnxhr, "hq")),
|
||||
p("MXF OP1a ProRes 422 HQ", bc, "MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode", mxf(Format::MxfOp1a, MxfVideoCodec::ProRes, "hq")),
|
||||
p("MXF OP1a H.264", bc, "MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM", mxf(Format::MxfOp1a, MxfVideoCodec::H264, "")),
|
||||
|
||||
@@ -61,6 +61,20 @@ impl H264Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder
|
||||
/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off
|
||||
/// by default: hardware encoders make different streams, and exports are otherwise byte-identical
|
||||
/// from run to run and machine to machine.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HardwareEncoding {
|
||||
#[default]
|
||||
Off,
|
||||
/// Use the hardware encoder when the system has one and it takes the settings; otherwise the
|
||||
/// software encoder.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Bitrate encoding of bitrate-driven codecs (H.264).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -392,8 +406,8 @@ impl ExportSettings {
|
||||
pub fn audio_codec(&self) -> AudioCodec {
|
||||
match (self.format, self.audio.codec) {
|
||||
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
|
||||
(Format::H264, _) if self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) if self.format == Format::H264 => AudioCodec::Aac,
|
||||
(f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac,
|
||||
(_, AudioCodec::Auto) => AudioCodec::Pcm,
|
||||
(_, c) => c,
|
||||
}
|
||||
@@ -411,7 +425,7 @@ impl ExportSettings {
|
||||
|
||||
/// File extension of the output.
|
||||
pub fn extension(&self) -> &'static str {
|
||||
if self.format == Format::H264 && self.multiplexer == Multiplexer::Mov { "mov" } else { self.format.extension() }
|
||||
if self.format.is_h26x() && self.multiplexer == Multiplexer::Mov { "mov" } else { self.format.extension() }
|
||||
}
|
||||
|
||||
/// Estimated output size in bytes for `duration` of a sequence (`seq_w`×`seq_h` at `seq_rate`).
|
||||
@@ -421,7 +435,7 @@ impl ExportSettings {
|
||||
let fps = r.rate.num as f64 / r.rate.den as f64;
|
||||
let px = r.width as f64 * r.height as f64;
|
||||
let video_bps = match self.video_format() {
|
||||
Format::H264 => r.target_kbps as f64 * 1000.0,
|
||||
Format::H264 | Format::Hevc => r.target_kbps as f64 * 1000.0,
|
||||
Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
|
||||
Format::DnxHr => {
|
||||
// nominal 1080p29.97 data rates of the DNxHR profiles (Mb/s)
|
||||
@@ -433,6 +447,15 @@ impl ExportSettings {
|
||||
};
|
||||
mbps * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97
|
||||
}
|
||||
Format::Apv => {
|
||||
let bpp = match crate::apv_profile(&self.apv_profile) {
|
||||
filmcraft_apv::Profile::P422_12 => 3.6,
|
||||
filmcraft_apv::Profile::P444_10 => 4.2,
|
||||
filmcraft_apv::Profile::P444_12 | filmcraft_apv::Profile::P4444_10 | filmcraft_apv::Profile::P4444_12 => 5.0,
|
||||
_ => 3.0,
|
||||
};
|
||||
px * fps * bpp
|
||||
}
|
||||
Format::Mjpeg => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0,
|
||||
Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps,
|
||||
Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps,
|
||||
@@ -485,6 +508,14 @@ impl ExportSettings {
|
||||
};
|
||||
v += &format!(", keyframe every {} frames", r.keyint);
|
||||
}
|
||||
Format::Hevc => {
|
||||
v += &format!(", HEVC Main (hardware encoder), {}", self.bitrate_mode.label());
|
||||
v += &match self.bitrate_mode {
|
||||
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
|
||||
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),
|
||||
};
|
||||
v += &format!(", keyframe every {} frames", r.keyint);
|
||||
}
|
||||
Format::ProRes => {
|
||||
use filmcraft_prores::Profile;
|
||||
v += match crate::prores_profile(&self.prores_profile) {
|
||||
@@ -495,6 +526,7 @@ impl ExportSettings {
|
||||
}
|
||||
}
|
||||
Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }),
|
||||
Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()),
|
||||
Format::Mjpeg => v += &format!(", quality {}", self.quality),
|
||||
_ => {}
|
||||
}
|
||||
@@ -514,9 +546,9 @@ impl ExportSettings {
|
||||
"No audio".to_string()
|
||||
};
|
||||
let container = match self.format {
|
||||
Format::H264 if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
||||
Format::H264 => "MP4",
|
||||
Format::ProRes | Format::DnxHr | Format::Mjpeg => "QuickTime",
|
||||
Format::H264 | Format::Hevc if self.multiplexer == Multiplexer::Mov => "QuickTime",
|
||||
Format::H264 | Format::Hevc => "MP4",
|
||||
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime",
|
||||
Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
|
||||
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",
|
||||
_ => "",
|
||||
@@ -527,6 +559,16 @@ impl ExportSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// A time left, rounded up to the second: `45 s`, `2:05`, `1:02:05`.
|
||||
pub fn format_eta(d: std::time::Duration) -> String {
|
||||
let s = d.as_secs().saturating_add(u64::from(d.subsec_nanos() > 0));
|
||||
match s {
|
||||
0..=59 => format!("{s} s"),
|
||||
60..=3599 => format!("{}:{:02}", s / 60, s % 60),
|
||||
_ => format!("{}:{:02}:{:02}", s / 3600, s % 3600 / 60, s % 60),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1.2 GB`, `350 MB`, `12 KB`.
|
||||
pub fn format_bytes(b: u64) -> String {
|
||||
let b = b as f64;
|
||||
|
||||
@@ -398,3 +398,84 @@ fn builtin_presets_are_valid_and_unique() {
|
||||
let back: ExportPreset = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.settings.adaptive_bitrate, Some(0.2));
|
||||
}
|
||||
|
||||
/// #108: the default 320 kbps is more than AAC can carry in stereo at 22.05 kHz (6 bits per sample
|
||||
/// and channel). The export failed with "bitrate out of range"; it is now capped to the limit.
|
||||
#[test]
|
||||
fn aac_bitrate_is_capped_for_low_sample_rates() {
|
||||
let mut s = ExportSettings::default();
|
||||
s.audio.bitrate_kbps = 320;
|
||||
for (rate, ch) in [(22_050, 2), (16_000, 2), (8_000, 6), (48_000, 2)] {
|
||||
let enc = aac_factory(Format::H264, rate, ch, &s).expect("AAC is always available");
|
||||
assert!(enc.is_ok(), "{rate} Hz × {ch}: {:?}", enc.err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_left_reads_like_a_clock() {
|
||||
use std::time::Duration;
|
||||
let f = |ms: u64| format_eta(Duration::from_millis(ms));
|
||||
assert_eq!(f(0), "0 s");
|
||||
assert_eq!(f(1), "1 s", "rounded up: never say 0 s while there is time left");
|
||||
assert_eq!(f(45_000), "45 s");
|
||||
assert_eq!(f(59_001), "1:00");
|
||||
assert_eq!(f(60_000), "1:00");
|
||||
assert_eq!(f(125_000), "2:05");
|
||||
assert_eq!(f(3_599_000), "59:59");
|
||||
assert_eq!(f(3_600_000), "1:00:00");
|
||||
assert_eq!(f(3_725_000), "1:02:05");
|
||||
assert_eq!(f(100 * 3600 * 1000), "100:00:00");
|
||||
assert!(!format_eta(Duration::MAX).is_empty(), "the largest duration cannot overflow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h265_is_a_format_that_needs_a_registered_encoder() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
// names, ids, files and serialized settings
|
||||
for name in ["hevc", "H.265", "h265", "HVC1", "x265"] {
|
||||
assert_eq!(Format::from_name(name), Some(Format::Hevc), "{name}");
|
||||
}
|
||||
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
|
||||
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
|
||||
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14);
|
||||
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
|
||||
assert_eq!(s.format, Format::Hevc);
|
||||
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");
|
||||
|
||||
// it is H.264's family: MP4 with AAC, or QuickTime; the same size estimate
|
||||
let h = ExportSettings { format: Format::Hevc, ..Default::default() };
|
||||
assert_eq!((h.audio_codec(), h.extension()), (AudioCodec::Aac, "mp4"));
|
||||
assert_eq!(ExportSettings { multiplexer: Multiplexer::Mov, ..h.clone() }.extension(), "mov");
|
||||
// 10 s of 20 Mbps video + 320 kbps AAC ≈ 25.4 MB, like H.264
|
||||
let b = h.estimate_bytes(1920, 1080, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
|
||||
assert!((b as f64 - 25.4e6).abs() < 0.2e6, "{b}");
|
||||
let summary = h.summary(1920, 1080, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
|
||||
assert_eq!(summary.format, "H.265 (HEVC) (MP4)");
|
||||
assert!(summary.video.contains("HEVC Main") && summary.video.contains("Target 20.00 Mbps"), "{}", summary.video);
|
||||
|
||||
// a hardware encoder has one pass
|
||||
let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..h.clone() }.validate().unwrap_err();
|
||||
assert!(e.to_string().contains("two-pass"), "{e}");
|
||||
assert!(ExportSettings { bitrate_mode: BitrateMode::Cbr, ..h.clone() }.validate().is_ok());
|
||||
|
||||
// formats with a built-in encoder are always available; this one only when a registered probe says so
|
||||
assert!(Format::ALL.iter().filter(|f| f.has_builtin_encoder()).all(|f| available(*f)));
|
||||
assert!(!Format::Hevc.has_builtin_encoder());
|
||||
static HERE: AtomicBool = AtomicBool::new(false);
|
||||
fn probe() -> bool {
|
||||
HERE.load(Ordering::SeqCst)
|
||||
}
|
||||
assert!(!available(Format::Hevc), "nothing registered an encoder for it");
|
||||
// and an export says so, honestly
|
||||
let (p, seq, m) = matte([0.0, 0.0, 1.0, 1.0], 160, 90, None);
|
||||
let sc = Scratch::new("h265-missing");
|
||||
let err = export(&p, seq, &ExportSettings { path: sc.path("x.mp4"), include_audio: false, ..h.clone() }, &m, &Progress::default()).unwrap_err().to_string();
|
||||
assert!(err.contains("H.265") && err.contains("encoder not available"), "{err}");
|
||||
register_format_probe(Format::Hevc, probe);
|
||||
register_format_probe(Format::Hevc, probe); // the same probe twice is harmless
|
||||
assert!(!available(Format::Hevc));
|
||||
HERE.store(true, Ordering::SeqCst);
|
||||
assert!(available(Format::Hevc));
|
||||
HERE.store(false, Ordering::SeqCst);
|
||||
assert!(!available(Format::Hevc));
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
pub mod atomic;
|
||||
pub mod autosave;
|
||||
|
||||
use filmcraft_project::Project;
|
||||
use filmcraft_project::{Project, ProjectView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -85,6 +85,9 @@ pub struct Loaded {
|
||||
pub schema_version: u32,
|
||||
/// The `generator` string of the writer, if recorded.
|
||||
pub generator: Option<String>,
|
||||
/// What was open when the file was saved (sequence tabs and how each was shown), when the
|
||||
/// file records it and it can be read.
|
||||
pub view: Option<ProjectView>,
|
||||
}
|
||||
|
||||
impl Loaded {
|
||||
@@ -105,6 +108,8 @@ struct EnvelopeOut<'a> {
|
||||
schema_version: u32,
|
||||
generator: String,
|
||||
project: &'a Project,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
view: Option<&'a ProjectView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -112,6 +117,17 @@ struct EnvelopeIn {
|
||||
#[serde(default)]
|
||||
generator: Option<String>,
|
||||
project: Project,
|
||||
/// Kept as JSON until the project itself has loaded: a view that cannot be read is dropped,
|
||||
/// it never stops a project from opening.
|
||||
#[serde(default)]
|
||||
view: Option<Value>,
|
||||
}
|
||||
|
||||
impl EnvelopeIn {
|
||||
fn loaded(self, schema_version: u32) -> Loaded {
|
||||
let view = self.view.and_then(|v| serde_json::from_value(v).ok());
|
||||
Loaded { project: self.project, schema_version, generator: self.generator, view }
|
||||
}
|
||||
}
|
||||
|
||||
/// Just enough of a document to know which schema it is.
|
||||
@@ -133,7 +149,14 @@ struct Probe {
|
||||
/// Serialize a project in the current schema. `pretty` = indented (human-diffable; used for the
|
||||
/// project file itself), compact otherwise (recovery snapshots).
|
||||
pub fn encode(project: &Project, pretty: bool) -> Vec<u8> {
|
||||
let env = EnvelopeOut { format: FORMAT_ID, schema_version: SCHEMA_VERSION, generator: generator(), project };
|
||||
encode_with_view(project, None, pretty)
|
||||
}
|
||||
|
||||
/// [`encode`], with what is open in the editor (`view`) stored beside the project. The view is
|
||||
/// outside the schema's promises: builds that do not know it ignore it, and it is dropped rather
|
||||
/// than migrated when it cannot be read.
|
||||
pub fn encode_with_view(project: &Project, view: Option<&ProjectView>, pretty: bool) -> Vec<u8> {
|
||||
let env = EnvelopeOut { format: FORMAT_ID, schema_version: SCHEMA_VERSION, generator: generator(), project, view };
|
||||
let r = if pretty { serde_json::to_vec_pretty(&env) } else { serde_json::to_vec(&env) };
|
||||
// Serializing plain data (string keys, finite numbers or null) cannot fail.
|
||||
r.unwrap_or_default()
|
||||
@@ -175,12 +198,12 @@ pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
if found == SCHEMA_VERSION {
|
||||
// Fast path: deserialize straight into the model.
|
||||
let env: EnvelopeIn = serde_json::from_slice(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
return Ok(Loaded { project: env.project, schema_version: found, generator: env.generator });
|
||||
return Ok(env.loaded(found));
|
||||
}
|
||||
let doc: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let doc = migrate_with(MIGRATIONS, doc, found)?;
|
||||
let env: EnvelopeIn = serde_json::from_value(doc).map_err(|e| FormatError::Corrupt(format!("after upgrading from schema v{found}: {e}")))?;
|
||||
Ok(Loaded { project: env.project, schema_version: found, generator: env.generator })
|
||||
Ok(env.loaded(found))
|
||||
}
|
||||
|
||||
fn check_supported(found: u32) -> Result<(), FormatError> {
|
||||
@@ -303,6 +326,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// What is open is stored beside the project. A file without it, or with one that cannot be
|
||||
/// read, opens all the same.
|
||||
#[test]
|
||||
fn the_view_is_kept_beside_the_project_and_never_stops_a_file_from_opening() {
|
||||
use filmcraft_project::{ItemId, SequenceSettings, SequenceView};
|
||||
let mut p = Project::new("Tabs");
|
||||
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None);
|
||||
let view = ProjectView {
|
||||
open_sequences: vec![seq],
|
||||
active_sequence: Some(seq),
|
||||
sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(),
|
||||
};
|
||||
for pretty in [true, false] {
|
||||
let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap();
|
||||
assert_eq!((l.view.as_ref(), &l.project), (Some(&view), &p));
|
||||
assert_eq!(decode(&encode(&p, pretty)).unwrap().view, None);
|
||||
}
|
||||
// the project is the same bytes with or without a view beside it
|
||||
let mut doc: Value = serde_json::from_slice(&encode_with_view(&p, Some(&view), false)).unwrap();
|
||||
assert_eq!(doc["project"], serde_json::from_slice::<Value>(&encode(&p, false)).unwrap()["project"]);
|
||||
// a view that is not one: dropped, the project loads
|
||||
for junk in [serde_json::json!(7), serde_json::json!({"open_sequences": {"a": 1}}), serde_json::json!({"sequences": [1]}), Value::Null] {
|
||||
doc["view"] = junk;
|
||||
let l = decode(&serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
assert_eq!((l.view, &l.project), (None, &p));
|
||||
}
|
||||
// unknown ids are not this crate's business (the editor checks them against the project)
|
||||
doc["view"] = serde_json::json!({"open_sequences": [424242]});
|
||||
assert_eq!(decode(&serde_json::to_vec(&doc).unwrap()).unwrap().view.unwrap().open_sequences, [ItemId(424242)]);
|
||||
}
|
||||
|
||||
/// A clip with unresolved "auto" points (NaN) is written with `null` coordinates. Such a file
|
||||
/// used to be refused as damaged; it opens, and the points are still "auto".
|
||||
#[test]
|
||||
|
||||
+312
-17
@@ -66,6 +66,38 @@ pub struct VideoFrame {
|
||||
pub pts: Tick,
|
||||
}
|
||||
|
||||
/// A rectangle of pixels: `x`, `y` is its top-left corner.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Region {
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.w == 0 || self.h == 0
|
||||
}
|
||||
/// This rectangle cut to a `w`×`h` picture (empty when it lies outside).
|
||||
pub fn clip(self, w: usize, h: usize) -> Region {
|
||||
let x = self.x.min(w);
|
||||
let y = self.y.min(h);
|
||||
Region { x, y, w: self.w.min(w - x), h: self.h.min(h - y) }
|
||||
}
|
||||
/// Whether this rectangle is the whole `w`×`h` picture.
|
||||
pub fn is_full(&self, w: usize, h: usize) -> bool {
|
||||
(self.x, self.y, self.w, self.h) == (0, 0, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
/// First and last index of the items of `row` for which `on` holds.
|
||||
fn row_extent<T>(row: &[T], on: impl Fn(&T) -> bool) -> Option<(usize, usize)> {
|
||||
let first = row.iter().position(&on)?;
|
||||
let last = row.iter().rposition(&on)?;
|
||||
Some((first, last))
|
||||
}
|
||||
|
||||
impl VideoFrame {
|
||||
pub fn rgba8(width: u32, height: u32, data: Vec<u8>) -> Self {
|
||||
debug_assert_eq!(data.len(), (width * height * 4) as usize);
|
||||
@@ -199,14 +231,55 @@ impl VideoFrame {
|
||||
/// transfer. Float frames are already linear and ignore it.
|
||||
pub fn to_linear_f32_decimated_with(&self, n: usize, decode: Option<&DecodeTable>) -> (usize, usize, Vec<f32>) {
|
||||
let n = n.max(1);
|
||||
let (ow, oh) = self.decimated_size(n);
|
||||
// every element is written below, so a recycled buffer needs no zero-fill
|
||||
let mut out = pool::take_f32_overwritten(ow * oh * 4);
|
||||
self.convert_region(n, decode, Region { x: 0, y: 0, w: ow, h: oh }, &mut out);
|
||||
(ow, oh, out)
|
||||
}
|
||||
|
||||
/// The size of the picture after `n`×`n` box decimation.
|
||||
pub fn decimated_size(&self, n: usize) -> (usize, usize) {
|
||||
let n = n.max(1);
|
||||
((self.width as usize / n).max(1), (self.height as usize / n).max(1))
|
||||
}
|
||||
|
||||
/// [`VideoFrame::to_linear_f32_decimated_with`] for one rectangle of the decimated picture
|
||||
/// only: the rectangle (clipped to the picture) and its premultiplied linear RGBA pixels, row
|
||||
/// by row. Each pixel is exactly what the full conversion gives at that position. The buffer
|
||||
/// may come from the pool ([`pool::recycle_f32`] hands it back).
|
||||
pub fn to_linear_f32_region(&self, n: usize, decode: Option<&DecodeTable>, region: Region) -> (Region, Vec<f32>) {
|
||||
let n = n.max(1);
|
||||
let (ow, oh) = self.decimated_size(n);
|
||||
let r = region.clip(ow, oh);
|
||||
let mut out = pool::take_f32_overwritten(r.w * r.h * 4);
|
||||
self.convert_region(n, decode, r, &mut out);
|
||||
(r, out)
|
||||
}
|
||||
|
||||
/// Convert the rectangle `r` (inside the decimated picture) into `out`, which must hold exactly
|
||||
/// `r.w * r.h * 4` floats; every one of them is written.
|
||||
fn convert_region(&self, n: usize, decode: Option<&DecodeTable>, r: Region, out: &mut [f32]) {
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let (ow, oh) = ((w / n).max(1), (h / n).max(1));
|
||||
let mut out = vec![0f32; ow * oh * 4];
|
||||
if r.w == 0 || r.h == 0 || out.len() != r.w * r.h * 4 {
|
||||
return;
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
if let PixelData::RgbaF32(d) = &self.data
|
||||
&& n == 1
|
||||
{
|
||||
out.copy_from_slice(d);
|
||||
return (ow, oh, out);
|
||||
for (ry, row) in out.chunks_exact_mut(r.w * 4).enumerate() {
|
||||
let start = ((r.y + ry) * w + r.x) * 4;
|
||||
match d.get(start..start + r.w * 4) {
|
||||
Some(src) => row.copy_from_slice(src),
|
||||
// a frame with less data than its size says: transparent, not what the buffer held before
|
||||
None => row.fill(0.0),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Encoded (0..1, quantised to 12 bits) → linear lookup for this frame's transfer.
|
||||
let info = self.color;
|
||||
@@ -218,8 +291,10 @@ impl VideoFrame {
|
||||
let inv = 1.0 / (n * n) as f32;
|
||||
match &self.data {
|
||||
PixelData::RgbaF32(d) => {
|
||||
out.par_chunks_mut(ow * 4).enumerate().for_each(|(oy, row)| {
|
||||
for ox in 0..ow {
|
||||
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
|
||||
let oy = r.y + ry;
|
||||
for rx in 0..r.w {
|
||||
let ox = r.x + rx;
|
||||
let mut acc = [0f32; 4];
|
||||
for dy in 0..n {
|
||||
let y = (oy * n + dy).min(h - 1);
|
||||
@@ -232,7 +307,7 @@ impl VideoFrame {
|
||||
}
|
||||
}
|
||||
for k in 0..4 {
|
||||
row[ox * 4 + k] = acc[k] * inv;
|
||||
row[rx * 4 + k] = acc[k] * inv;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -246,8 +321,10 @@ impl VideoFrame {
|
||||
}
|
||||
None => srgb_u8_to_linear_table(),
|
||||
};
|
||||
out.par_chunks_mut(ow * 4).enumerate().for_each(|(oy, row)| {
|
||||
for ox in 0..ow {
|
||||
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
|
||||
let oy = r.y + ry;
|
||||
for rx in 0..r.w {
|
||||
let ox = r.x + rx;
|
||||
let mut acc = [0f32; 4];
|
||||
for dy in 0..n {
|
||||
let y = (oy * n + dy).min(h - 1);
|
||||
@@ -262,7 +339,7 @@ impl VideoFrame {
|
||||
}
|
||||
}
|
||||
for k in 0..4 {
|
||||
row[ox * 4 + k] = acc[k] * inv;
|
||||
row[rx * 4 + k] = acc[k] * inv;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -276,8 +353,10 @@ impl VideoFrame {
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (cr_r, cb_b) = (2.0 * (1.0 - kr), 2.0 * (1.0 - kb));
|
||||
let (cr_g, cb_g) = (cr_r * kr / kg, cb_b * kb / kg);
|
||||
out.par_chunks_mut(ow * 4).enumerate().for_each(|(oy, row)| {
|
||||
for ox in 0..ow {
|
||||
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
|
||||
let oy = r.y + ry;
|
||||
for rx in 0..r.w {
|
||||
let ox = r.x + rx;
|
||||
let mut acc = [0f32; 4];
|
||||
for dy in 0..n {
|
||||
let y = (oy * n + dy).min(h - 1);
|
||||
@@ -296,7 +375,7 @@ impl VideoFrame {
|
||||
}
|
||||
}
|
||||
for k in 0..4 {
|
||||
row[ox * 4 + k] = acc[k] * inv;
|
||||
row[rx * 4 + k] = acc[k] * inv;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -306,8 +385,10 @@ impl VideoFrame {
|
||||
let cw = w.div_ceil(1 << sx);
|
||||
let bits = *bits;
|
||||
let amax = ((1u32 << bits) - 1) as f32;
|
||||
out.par_chunks_mut(ow * 4).enumerate().for_each(|(oy, row)| {
|
||||
for ox in 0..ow {
|
||||
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
|
||||
let oy = r.y + ry;
|
||||
for rx in 0..r.w {
|
||||
let ox = r.x + rx;
|
||||
let mut acc = [0f32; 4];
|
||||
for dy in 0..n {
|
||||
let y = (oy * n + dy).min(h - 1);
|
||||
@@ -327,13 +408,43 @@ impl VideoFrame {
|
||||
}
|
||||
}
|
||||
for k in 0..4 {
|
||||
row[ox * 4 + k] = acc[k] * inv;
|
||||
row[rx * 4 + k] = acc[k] * inv;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
(ow, oh, out)
|
||||
}
|
||||
|
||||
/// Where the picture is not transparent: the smallest rectangle of the `n`×`n`-decimated
|
||||
/// picture that holds every pixel whose alpha is not zero (empty for a picture that is
|
||||
/// transparent everywhere). `None` when the frame has no alpha plane to look at (it is opaque,
|
||||
/// or its alpha is not stored as integers), so nothing can be skipped. Pixels outside the
|
||||
/// rectangle have alpha exactly 0 and, in the premultiplied conversion, colour 0 as well.
|
||||
pub fn alpha_region(&self, n: usize) -> Option<Region> {
|
||||
let n = n.max(1);
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let px = w.checked_mul(h)?;
|
||||
// the first and last x of non-transparent pixels in source row `y`
|
||||
let extent: Box<dyn Fn(usize) -> Option<(usize, usize)> + Sync + '_> = match &self.data {
|
||||
PixelData::Yuv8 { alpha: Some(a), .. } if a.len() == px => Box::new(move |y| row_extent(&a[y * w..(y + 1) * w], |v| *v != 0)),
|
||||
PixelData::Yuv16 { alpha: Some(a), .. } if a.len() == px => Box::new(move |y| row_extent(&a[y * w..(y + 1) * w], |v| *v != 0)),
|
||||
PixelData::Rgba8(d) if d.len() == px.checked_mul(4)? => {
|
||||
Box::new(move |y| row_extent(d[y * w * 4..(y + 1) * w * 4].as_chunks::<4>().0, |p: &[u8; 4]| p[3] != 0))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
// (first row, last row, first column, last column) over the rows that have any
|
||||
let found = (0..h)
|
||||
.into_par_iter()
|
||||
.filter_map(|y| extent(y).map(|(a, b)| (y, y, a, b)))
|
||||
.reduce_with(|p, q| (p.0.min(q.0), p.1.max(q.1), p.2.min(q.2), p.3.max(q.3)));
|
||||
let (ow, oh) = self.decimated_size(n);
|
||||
let Some((y0, y1, x0, x1)) = found else { return Some(Region { x: 0, y: 0, w: 0, h: 0 }) };
|
||||
// a decimated pixel covers source pixels [o·n, o·n + n)
|
||||
let (rx0, ry0) = (x0 / n, y0 / n);
|
||||
let (rx1, ry1) = (x1 / n + 1, y1 / n + 1);
|
||||
Some(Region { x: rx0, y: ry0, w: rx1.saturating_sub(rx0), h: ry1.saturating_sub(ry0) }.clip(ow, oh))
|
||||
}
|
||||
|
||||
/// Convert to straight-alpha sRGB RGBA8 for display (fast paths for 8-bit sources).
|
||||
@@ -631,4 +742,188 @@ mod tests {
|
||||
assert_eq!(a.channels[1][0], 0.25);
|
||||
assert_eq!(a.interleaved()[..2], [0.5, 0.25]);
|
||||
}
|
||||
|
||||
/// Frames of every pixel format with partial and zero alpha, at a size that is not a multiple
|
||||
/// of the decimation factors.
|
||||
fn sample_frames() -> Vec<(&'static str, VideoFrame)> {
|
||||
sample_frames_sized(37, 23)
|
||||
}
|
||||
|
||||
fn sample_frames_sized(w: usize, h: usize) -> Vec<(&'static str, VideoFrame)> {
|
||||
let alpha = |x: usize, y: usize| -> u8 {
|
||||
if y < 9 || x < 6 {
|
||||
0
|
||||
} else if y == 9 || x == 6 {
|
||||
77
|
||||
} else {
|
||||
255
|
||||
}
|
||||
};
|
||||
let rgba8: Vec<u8> = (0..w * h).flat_map(|i| [(i * 7 % 251) as u8, (i * 13 % 241) as u8, (i * 29 % 239) as u8, alpha(i % w, i / w)]).collect();
|
||||
let f32s: Vec<f32> =
|
||||
(0..w * h).flat_map(|i| [(i % 17) as f32 / 17.0, (i % 5) as f32 / 5.0, (i % 11) as f32 / 11.0, alpha(i % w, i / w) as f32 / 255.0]).collect();
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let y8: Vec<u8> = (0..w * h).map(|i| (16 + i * 3 % 219) as u8).collect();
|
||||
let u8p: Vec<u8> = (0..cw * ch).map(|i| (16 + i * 5 % 224) as u8).collect();
|
||||
let v8p: Vec<u8> = (0..cw * ch).map(|i| (16 + i * 11 % 224) as u8).collect();
|
||||
let a8: Vec<u8> = (0..w * h).map(|i| alpha(i % w, i / w)).collect();
|
||||
let y16: Vec<u16> = (0..w * h).map(|i| (64 + i * 3 % 876) as u16).collect();
|
||||
let c16: Vec<u16> = (0..w * h).map(|i| (64 + i * 7 % 896) as u16).collect();
|
||||
let a16: Vec<u16> = (0..w * h).map(|i| (alpha(i % w, i / w) as u16) * 4).collect();
|
||||
let c422: Vec<u16> = (0..cw * h).map(|i| (64 + i * 7 % 896) as u16).collect();
|
||||
let mk = |data| VideoFrame { width: w as u32, height: h as u32, data, color: ColorInfo::REC709, par: (1, 1), pts: Tick::ZERO };
|
||||
vec![
|
||||
("rgba8", mk(PixelData::Rgba8(Arc::new(rgba8)))),
|
||||
("rgba f32", mk(PixelData::RgbaF32(Arc::new(f32s)))),
|
||||
(
|
||||
"yuv8 4:2:0 + alpha",
|
||||
mk(PixelData::Yuv8 {
|
||||
planes: [Arc::new(y8.clone()), Arc::new(u8p.clone()), Arc::new(v8p.clone())],
|
||||
chroma: Chroma::C420,
|
||||
alpha: Some(Arc::new(a8)),
|
||||
}),
|
||||
),
|
||||
("yuv8 4:2:0", mk(PixelData::Yuv8 { planes: [Arc::new(y8), Arc::new(u8p), Arc::new(v8p)], chroma: Chroma::C420, alpha: None })),
|
||||
(
|
||||
"yuv16 4:4:4 + alpha",
|
||||
mk(PixelData::Yuv16 {
|
||||
planes: [Arc::new(y16.clone()), Arc::new(c16.clone()), Arc::new(c16.clone())],
|
||||
chroma: Chroma::C444,
|
||||
bits: 10,
|
||||
alpha: Some(Arc::new(a16.clone())),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"yuv16 4:2:2 + alpha",
|
||||
mk(PixelData::Yuv16 {
|
||||
planes: [Arc::new(y16.clone()), Arc::new(c422.clone()), Arc::new(c422)],
|
||||
chroma: Chroma::C422,
|
||||
bits: 10,
|
||||
alpha: Some(Arc::new(a16)),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"yuv16 4:4:4",
|
||||
mk(PixelData::Yuv16 { planes: [Arc::new(y16), Arc::new(c16.clone()), Arc::new(c16)], chroma: Chroma::C444, bits: 12, alpha: None }),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_is_exactly_the_crop_of_the_full_conversion() {
|
||||
for (name, f) in sample_frames() {
|
||||
for n in [1usize, 2, 3] {
|
||||
let (ow, oh, full) = f.to_linear_f32_decimated_with(n, None);
|
||||
for want in [
|
||||
Region { x: 0, y: 0, w: ow, h: oh },
|
||||
Region { x: 0, y: 0, w: 1, h: 1 },
|
||||
Region { x: ow - 1, y: oh - 1, w: 1, h: 1 },
|
||||
Region { x: 3, y: 2, w: ow - 5, h: oh - 4 },
|
||||
Region { x: 0, y: oh / 2, w: ow, h: 1 },
|
||||
Region { x: ow / 2, y: 0, w: 1, h: oh },
|
||||
// sticks out of the picture: clipped
|
||||
Region { x: ow - 2, y: oh - 2, w: 10, h: 10 },
|
||||
] {
|
||||
let (r, px) = f.to_linear_f32_region(n, None, want);
|
||||
assert_eq!(px.len(), r.w * r.h * 4, "{name} n={n} {want:?}");
|
||||
assert_eq!(r, want.clip(ow, oh));
|
||||
for ry in 0..r.h {
|
||||
for rx in 0..r.w {
|
||||
for k in 0..4 {
|
||||
let (a, b) = (px[(ry * r.w + rx) * 4 + k], full[((r.y + ry) * ow + r.x + rx) * 4 + k]);
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "{name} n={n} {r:?} at ({rx},{ry}) channel {k}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// outside the picture altogether: nothing, and no panic
|
||||
let (r, px) = f.to_linear_f32_region(n, None, Region { x: ow + 5, y: 0, w: 4, h: 4 });
|
||||
assert!(r.is_empty() && px.is_empty(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversions write every float they hand out: into a buffer that held something else
|
||||
/// (here NaN, which the pool gives back as it was) the result is the one a fresh buffer gives.
|
||||
#[test]
|
||||
fn a_recycled_buffer_never_leaks_its_old_contents() {
|
||||
// big enough for the float images to be worth keeping (64 KiB), at sizes no other test of this
|
||||
// binary converts, so the poisoned buffer is the one the next conversion takes
|
||||
let poison = |len: usize| pool::recycle_f32(vec![f32::NAN; len]);
|
||||
let same = |what: &str, fresh: &[f32], reused: &[f32]| {
|
||||
assert_eq!(fresh.len(), reused.len(), "{what}");
|
||||
if let Some(i) = fresh.iter().zip(reused).position(|(a, b)| a.to_bits() != b.to_bits()) {
|
||||
panic!("{what}: element {i} is {} in a fresh buffer and {} in a recycled one", fresh[i], reused[i]);
|
||||
}
|
||||
};
|
||||
for (name, f) in sample_frames_sized(203, 151) {
|
||||
for n in [1usize, 2] {
|
||||
let (ow, oh) = f.decimated_size(n);
|
||||
let (_, _, fresh) = f.to_linear_f32_decimated(n);
|
||||
poison(ow * oh * 4);
|
||||
let (_, _, reused) = f.to_linear_f32_decimated(n);
|
||||
same(&format!("{name} n={n}, whole picture"), &fresh, &reused);
|
||||
|
||||
let want = Region { x: 1, y: 1, w: 70, h: 60 };
|
||||
let (r, fresh) = f.to_linear_f32_region(n, None, want);
|
||||
assert_eq!(r, want, "{name} n={n}");
|
||||
poison(r.w * r.h * 4);
|
||||
let (_, reused) = f.to_linear_f32_region(n, None, want);
|
||||
same(&format!("{name} n={n}, rectangle"), &fresh, &reused);
|
||||
}
|
||||
}
|
||||
// a frame with less data than its size says (no row of it fits): transparent, never what the buffer held
|
||||
let short = VideoFrame { data: PixelData::RgbaF32(Arc::new(vec![0.5; 100])), ..VideoFrame::rgba_f32(203, 151, vec![0.0; 203 * 151 * 4]) };
|
||||
poison(203 * 151 * 4);
|
||||
let (_, _, px) = short.to_linear_f32_decimated(1);
|
||||
assert!(px.iter().all(|v| *v == 0.0), "rows without data are transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_region_is_the_box_around_what_is_not_transparent() {
|
||||
for (name, f) in sample_frames() {
|
||||
for n in [1usize, 2, 4] {
|
||||
let got = f.alpha_region(n);
|
||||
let has_alpha = matches!(f.data, PixelData::Rgba8(_) | PixelData::Yuv8 { alpha: Some(_), .. } | PixelData::Yuv16 { alpha: Some(_), .. });
|
||||
assert_eq!(got.is_some(), has_alpha, "{name}");
|
||||
let Some(got) = got else { continue };
|
||||
// the same box measured on the converted picture: pixels whose alpha is not zero
|
||||
let (ow, oh, px) = f.to_linear_f32_decimated(n);
|
||||
let on: Vec<(usize, usize)> = (0..oh).flat_map(|y| (0..ow).map(move |x| (x, y))).filter(|(x, y)| px[(y * ow + x) * 4 + 3] != 0.0).collect();
|
||||
let want = match (on.iter().map(|p| p.0).min(), on.iter().map(|p| p.0).max(), on.iter().map(|p| p.1).min(), on.iter().map(|p| p.1).max()) {
|
||||
(Some(x0), Some(x1), Some(y0), Some(y1)) => Region { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 },
|
||||
_ => Region::default(),
|
||||
};
|
||||
assert_eq!(got, want, "{name} n={n}");
|
||||
// and everything outside it is exactly transparent black
|
||||
for y in 0..oh {
|
||||
for x in 0..ow {
|
||||
if !(x >= got.x && x < got.x + got.w && y >= got.y && y < got.y + got.h) {
|
||||
assert_eq!(&px[(y * ow + x) * 4..(y * ow + x) * 4 + 4], &[0.0; 4], "{name} n={n} at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// a picture that is transparent everywhere
|
||||
let clear = VideoFrame {
|
||||
data: PixelData::Yuv8 {
|
||||
planes: [Arc::new(vec![16; 64]), Arc::new(vec![128; 16]), Arc::new(vec![128; 16])],
|
||||
chroma: Chroma::C420,
|
||||
alpha: Some(Arc::new(vec![0; 64])),
|
||||
},
|
||||
..VideoFrame::rgba8(8, 8, vec![0; 256])
|
||||
};
|
||||
assert_eq!(clear.alpha_region(1), Some(Region::default()));
|
||||
// malformed alpha (too short): nothing can be assumed
|
||||
let short = VideoFrame {
|
||||
data: PixelData::Yuv8 {
|
||||
planes: [Arc::new(vec![16; 64]), Arc::new(vec![128; 16]), Arc::new(vec![128; 16])],
|
||||
chroma: Chroma::C420,
|
||||
alpha: Some(Arc::new(vec![255; 10])),
|
||||
},
|
||||
..VideoFrame::rgba8(8, 8, vec![0; 256])
|
||||
};
|
||||
assert_eq!(short.alpha_region(1), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
//! freed pages (measured: 2.5 GB retained next to 2.5 GB live after twelve clips). Decoders take
|
||||
//! their planes here instead ([`take_u8`], [`take_u16`]) and caches hand evicted frames back
|
||||
//! ([`recycle`]), so steady-state decoding allocates nothing.
|
||||
//!
|
||||
//! The compositor's working images (premultiplied linear `f32`, 33 MB at 1080p) get the same
|
||||
//! treatment ([`take_f32_overwritten`], [`recycle_f32`]): allocated and freed once per layer per
|
||||
//! frame they cost a zero-fill and fresh page faults every time.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -60,6 +64,13 @@ static U8: Mutex<Shelf<u8>> = Mutex::new(Shelf::new());
|
||||
static U16: Mutex<Shelf<u16>> = Mutex::new(Shelf::new());
|
||||
static REUSED: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Idle float images. Unlike the plane shelves a buffer keeps its length *and its old contents*:
|
||||
/// whoever overwrites every element gets it back without a zero-fill pass over tens of megabytes.
|
||||
static F32: Mutex<Vec<Vec<f32>>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Bytes of idle float images kept; a buffer that does not fit is freed.
|
||||
const F32_MAX_BYTES: usize = 320 << 20;
|
||||
|
||||
fn take<T>(shelf: &Mutex<Shelf<T>>, len: usize) -> Vec<T> {
|
||||
let found = shelf.lock().unwrap_or_else(PoisonError::into_inner).take(len);
|
||||
match found {
|
||||
@@ -88,6 +99,33 @@ pub fn take_u16(len: usize) -> Vec<u16> {
|
||||
take(&U16, len)
|
||||
}
|
||||
|
||||
/// A buffer of exactly `len` floats whose contents are unspecified (whatever it held last, or zeros
|
||||
/// when none was idle): the caller must write every element before it reads any. Handing it back
|
||||
/// ([`recycle_f32`]) is optional; a buffer that is dropped is simply freed.
|
||||
pub fn take_f32_overwritten(len: usize) -> Vec<f32> {
|
||||
if len.saturating_mul(4) >= MIN_BYTES {
|
||||
let mut idle = F32.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(k) = idle.iter().position(|b| b.len() == len) {
|
||||
REUSED.fetch_add(1, Ordering::Relaxed);
|
||||
return idle.swap_remove(k);
|
||||
}
|
||||
}
|
||||
vec![0.0; len]
|
||||
}
|
||||
|
||||
/// Keep a float image nobody needs any more for the next [`take_f32_overwritten`] of its length.
|
||||
pub fn recycle_f32(buf: Vec<f32>) {
|
||||
let bytes = buf.len().saturating_mul(4);
|
||||
if bytes < MIN_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut idle = F32.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let held: usize = idle.iter().map(|b| b.len().saturating_mul(4)).sum();
|
||||
if held.saturating_add(bytes) <= F32_MAX_BYTES {
|
||||
idle.push(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand back a frame nobody needs any more: its planes are kept for the next [`take_u8`] /
|
||||
/// [`take_u16`] unless something else still holds the frame or a plane.
|
||||
pub fn recycle(frame: Arc<VideoFrame>) {
|
||||
@@ -111,7 +149,8 @@ pub fn stats() -> PoolStats {
|
||||
let idle = |n: usize, m: usize| n.saturating_add(m);
|
||||
let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes;
|
||||
PoolStats { idle_bytes: idle(a, b), reused: REUSED.load(Ordering::Relaxed) }
|
||||
let c: usize = F32.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|b| b.len().saturating_mul(4)).sum();
|
||||
PoolStats { idle_bytes: idle(idle(a, b), c), reused: REUSED.load(Ordering::Relaxed) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -119,6 +158,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::Chroma;
|
||||
|
||||
/// The plane shelves may hold up to this much alongside the float shelf in a test binary.
|
||||
const U8_BYTES_UPPER_BOUND: usize = 2 * MAX_BYTES;
|
||||
|
||||
#[test]
|
||||
fn shelf_hands_back_a_fitting_buffer_and_stays_within_its_budget() {
|
||||
let mut s = Shelf::<u8>::new();
|
||||
@@ -139,6 +181,33 @@ mod tests {
|
||||
assert!(s.take(1 << 20).is_none(), "2 MiB wastes too much for 1 MiB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_images_come_back_whole_and_unchanged_within_their_budget() {
|
||||
// a length nothing else in this test binary asks for
|
||||
let len = 1_000_003usize;
|
||||
let mut b = take_f32_overwritten(len);
|
||||
assert_eq!(b.len(), len);
|
||||
assert!(b.iter().all(|v| *v == 0.0), "a fresh buffer is zeroed");
|
||||
b[17] = 4.5;
|
||||
let at = b.as_ptr() as usize;
|
||||
recycle_f32(b);
|
||||
// the same allocation, length and contents (the caller overwrites it)
|
||||
let again = take_f32_overwritten(len);
|
||||
assert_eq!((again.as_ptr() as usize, again.len(), again[17]), (at, len, 4.5));
|
||||
// another length does not match, and is not handed the idle one
|
||||
recycle_f32(again);
|
||||
let other = take_f32_overwritten(len + 1);
|
||||
assert_eq!(other.len(), len + 1);
|
||||
assert_ne!(other.as_ptr() as usize, at);
|
||||
// small buffers are not kept, and nothing exceeds the budget
|
||||
recycle_f32(vec![0.0; 100]);
|
||||
let budget_len = F32_MAX_BYTES / 4;
|
||||
recycle_f32(vec![0.0; budget_len + 1]);
|
||||
assert!(stats().idle_bytes <= F32_MAX_BYTES + (U8_BYTES_UPPER_BOUND), "idle {}", stats().idle_bytes);
|
||||
// leave nothing behind for other tests
|
||||
drop(take_f32_overwritten(len));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycled_frames_feed_the_next_take_unless_still_shared() {
|
||||
// sizes nothing else in this test binary asks for
|
||||
|
||||
@@ -14,7 +14,7 @@ struct U {
|
||||
p0: vec4<f32>, // opacity, kind (0 rgba8 srgb straight, 1 rgba16f premul linear, 2 yuv), taps, transfer (0 srgb, 1 linear, 2 pq, 3 hlg)
|
||||
p1: vec4<f32>, // y_off y_scale c_off c_scale (code units)
|
||||
p2: vec4<f32>, // kr kb code_scale footprint
|
||||
p3: vec4<f32>, // blend mode (index into filmcraft_render::Blend::ALL), unused ×3
|
||||
p3: vec4<f32>, // blend mode (index into filmcraft_render::Blend::ALL), alpha-plane scale (0: none), unused ×2
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: U;
|
||||
@@ -23,6 +23,8 @@ struct U {
|
||||
@group(0) @binding(3) var tex2: texture_2d<f32>;
|
||||
// The accumulator under the layer (blend modes other than Normal / Dissolve only).
|
||||
@group(0) @binding(4) var backdrop: texture_2d<f32>;
|
||||
// The alpha plane of a Y'CbCr source (ProRes 4444, ...); a dummy texture when `u.p3.y` is 0.
|
||||
@group(0) @binding(5) var tex3: texture_2d<f32>;
|
||||
|
||||
struct VOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@@ -103,7 +105,12 @@ fn sample(p: vec2<f32>) -> vec4<f32> {
|
||||
let R = y + 2.0 * (1.0 - kr) * r;
|
||||
let B = y + 2.0 * (1.0 - kb) * b;
|
||||
let G = (y - kr * R - kb * B) / kg;
|
||||
return vec4(to_linear(vec3(R, G, B)), 1.0);
|
||||
// straight alpha from the alpha plane (full resolution, like luma), premultiplied here
|
||||
var a = 1.0;
|
||||
if u.p3.y > 0.0 {
|
||||
a = clamp(load4(tex3, p).r * u.p3.y, 0.0, 1.0);
|
||||
}
|
||||
return vec4(to_linear(vec3(R, G, B)) * a, a);
|
||||
}
|
||||
|
||||
// The layer's premultiplied linear colour at this pixel, opacity applied.
|
||||
|
||||
+64
-14
@@ -50,9 +50,12 @@ pub const OUTPUT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
const ACCUM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
struct Uploaded {
|
||||
views: [wgpu::TextureView; 3],
|
||||
/// Y, Cb, Cr (or the RGBA texture) and the alpha plane (the dummy texture when there is none).
|
||||
views: [wgpu::TextureView; 4],
|
||||
kind: u32,
|
||||
code_scale: f32,
|
||||
/// Multiplier from the alpha texture's sample to alpha 0..1; 0 when the frame has no alpha plane.
|
||||
alpha_scale: f32,
|
||||
chroma: (u32, u32),
|
||||
last_used: u64,
|
||||
/// The uploaded pixel buffers, kept alive while cached: the cache key is the buffer address,
|
||||
@@ -193,7 +196,9 @@ fn codes_to_f16_bytes(v: &[u16], bits: u32) -> Vec<u8> {
|
||||
pub fn prepare_frame(f: &VideoFrame) -> Option<Prepared> {
|
||||
match &f.data {
|
||||
PixelData::RgbaF32(d) => Some(Prepared { planes: vec![f32_to_f16_bytes(d)] }),
|
||||
PixelData::Yuv16 { planes, bits, .. } => Some(Prepared { planes: planes.iter().map(|p| codes_to_f16_bytes(p, *bits)).collect() }),
|
||||
PixelData::Yuv16 { planes, bits, alpha, .. } => {
|
||||
Some(Prepared { planes: planes.iter().chain(alpha.as_ref()).map(|p| codes_to_f16_bytes(p, *bits)).collect() })
|
||||
}
|
||||
PixelData::Rgba8(_) | PixelData::Yuv8 { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -224,6 +229,7 @@ impl GpuCompositor {
|
||||
tex_entry(1),
|
||||
tex_entry(2),
|
||||
tex_entry(3),
|
||||
tex_entry(5),
|
||||
],
|
||||
});
|
||||
let blend_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -239,6 +245,7 @@ impl GpuCompositor {
|
||||
tex_entry(2),
|
||||
tex_entry(3),
|
||||
tex_entry(4),
|
||||
tex_entry(5),
|
||||
],
|
||||
});
|
||||
let final_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -405,7 +412,15 @@ impl GpuCompositor {
|
||||
let up = match &f.data {
|
||||
PixelData::Rgba8(d) => {
|
||||
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba8UnormSrgb, d, 4);
|
||||
Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() }
|
||||
Uploaded {
|
||||
views: [v, dummy(), dummy(), dummy()],
|
||||
kind: 0,
|
||||
code_scale: 1.0,
|
||||
alpha_scale: 0.0,
|
||||
chroma: (w, h),
|
||||
last_used: self.clock,
|
||||
_pixels: f.data.clone(),
|
||||
}
|
||||
}
|
||||
PixelData::RgbaF32(d) => {
|
||||
let owned;
|
||||
@@ -417,17 +432,35 @@ impl GpuCompositor {
|
||||
}
|
||||
};
|
||||
let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba16Float, half, 8);
|
||||
Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() }
|
||||
Uploaded {
|
||||
views: [v, dummy(), dummy(), dummy()],
|
||||
kind: 1,
|
||||
code_scale: 1.0,
|
||||
alpha_scale: 0.0,
|
||||
chroma: (w, h),
|
||||
last_used: self.clock,
|
||||
_pixels: f.data.clone(),
|
||||
}
|
||||
}
|
||||
PixelData::Yuv8 { planes, chroma, .. } => {
|
||||
PixelData::Yuv8 { planes, chroma, alpha } => {
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let y = self.plane_texture(w, h, wgpu::TextureFormat::R8Unorm, &planes[0], 1);
|
||||
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[1], 1);
|
||||
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[2], 1);
|
||||
Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() }
|
||||
let a = alpha.as_ref().map(|a| self.plane_texture(w, h, wgpu::TextureFormat::R8Unorm, a, 1));
|
||||
let alpha_scale = if a.is_some() { 1.0 } else { 0.0 };
|
||||
Uploaded {
|
||||
views: [y, u, v, a.unwrap_or_else(dummy)],
|
||||
kind: 2,
|
||||
code_scale: 255.0,
|
||||
alpha_scale,
|
||||
chroma: (cw, ch),
|
||||
last_used: self.clock,
|
||||
_pixels: f.data.clone(),
|
||||
}
|
||||
}
|
||||
PixelData::Yuv16 { planes, chroma, bits, .. } => {
|
||||
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let scale = (1u32 << bits) as f32;
|
||||
@@ -435,15 +468,26 @@ impl GpuCompositor {
|
||||
let p = match prep {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
owned = Prepared { planes: planes.iter().map(|p| codes_to_f16_bytes(p, *bits)).collect() };
|
||||
owned = Prepared { planes: planes.iter().chain(alpha.as_ref()).map(|p| codes_to_f16_bytes(p, *bits)).collect() };
|
||||
&owned
|
||||
}
|
||||
};
|
||||
let y = self.plane_texture(w, h, wgpu::TextureFormat::R16Float, &p.planes[0], 2);
|
||||
let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[1], 2);
|
||||
let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &p.planes[2], 2);
|
||||
// (full-resolution alpha plane: its codes span 0..2^bits - 1, hence the scale)
|
||||
let a = alpha.as_ref().and_then(|_| p.planes.get(3)).map(|b| self.plane_texture(w, h, wgpu::TextureFormat::R16Float, b, 2));
|
||||
let alpha_scale = if a.is_some() { scale / (scale - 1.0) } else { 0.0 };
|
||||
let _ = Chroma::C420;
|
||||
Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() }
|
||||
Uploaded {
|
||||
views: [y, u, v, a.unwrap_or_else(dummy)],
|
||||
kind: 2,
|
||||
code_scale: scale,
|
||||
alpha_scale,
|
||||
chroma: (cw, ch),
|
||||
last_used: self.clock,
|
||||
_pixels: f.data.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.uploads.insert(key, up);
|
||||
@@ -461,7 +505,7 @@ impl GpuCompositor {
|
||||
/// The source description of an uploaded frame.
|
||||
fn src_info(&self, f: &VideoFrame, key: (usize, u32, u32)) -> Option<SrcInfo> {
|
||||
let up = self.uploads.get(&key)?;
|
||||
Some(SrcInfo { kind: up.kind, code_scale: up.code_scale, chroma: up.chroma, size: (f.width, f.height), color: f.color })
|
||||
Some(SrcInfo { kind: up.kind, code_scale: up.code_scale, alpha: up.alpha_scale, chroma: up.chroma, size: (f.width, f.height), color: f.color })
|
||||
}
|
||||
|
||||
fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] {
|
||||
@@ -510,7 +554,7 @@ impl GpuCompositor {
|
||||
src.code_scale,
|
||||
footprint.max(1.0),
|
||||
blend.index() as f32,
|
||||
0.0,
|
||||
src.alpha,
|
||||
0.0,
|
||||
0.0,
|
||||
]
|
||||
@@ -601,11 +645,12 @@ impl GpuCompositor {
|
||||
let src = self.src_info(&l.frame, *k).unwrap_or(SrcInfo {
|
||||
kind: 1,
|
||||
code_scale: 1.0,
|
||||
alpha: 0.0,
|
||||
chroma: (1, 1),
|
||||
size: (l.frame.width, l.frame.height),
|
||||
color: l.frame.color,
|
||||
});
|
||||
let views = self.uploads.get(k).map_or_else(|| [self.dummy.clone(), self.dummy.clone(), self.dummy.clone()], |up| up.views.clone());
|
||||
let views = self.uploads.get(k).map_or_else(|| std::array::from_fn(|_| self.dummy.clone()), |up| up.views.clone());
|
||||
let job = match &l.fx {
|
||||
Some(lfx) => {
|
||||
// the source drawn onto the working image (box-decimated by n, as the CPU decodes)
|
||||
@@ -620,6 +665,7 @@ impl GpuCompositor {
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&views[0]) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&views[1]) },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&views[2]) },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&views[3]) },
|
||||
],
|
||||
});
|
||||
match self.fx.as_mut() {
|
||||
@@ -632,8 +678,8 @@ impl GpuCompositor {
|
||||
// what the layer draws: the effect result (linear premultiplied RGBA) or the frame
|
||||
let (u, tex) = match &job {
|
||||
Some(j) => {
|
||||
let s = SrcInfo { kind: 1, code_scale: 1.0, chroma: l.size(), size: l.size(), color: l.frame.color };
|
||||
(Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone()])
|
||||
let s = SrcInfo { kind: 1, code_scale: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color };
|
||||
(Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()])
|
||||
}
|
||||
None => {
|
||||
// (a layer with effects only gets here if its job failed, which the CPU
|
||||
@@ -656,6 +702,7 @@ impl GpuCompositor {
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&tex[0]) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&tex[1]) },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&tex[2]) },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&tex[3]) },
|
||||
];
|
||||
let (layout, region) = match (&backdrop_view, l.blend.reads_destination()) {
|
||||
(Some(b), true) => {
|
||||
@@ -762,6 +809,7 @@ impl GpuCompositor {
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&views[0]) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&views[1]) },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&views[2]) },
|
||||
wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&views[3]) },
|
||||
],
|
||||
});
|
||||
let stage = self.fx.as_mut()?;
|
||||
@@ -841,6 +889,8 @@ impl GpuCompositor {
|
||||
struct SrcInfo {
|
||||
kind: u32,
|
||||
code_scale: f32,
|
||||
/// Alpha texture sample → alpha (0: the frame has no alpha plane).
|
||||
alpha: f32,
|
||||
chroma: (u32, u32),
|
||||
size: (u32, u32),
|
||||
color: filmcraft_color::ColorInfo,
|
||||
|
||||
@@ -247,3 +247,76 @@ fn gpu_masked_mix_matches_cpu() {
|
||||
eprintln!("masked mix: max |cpu − gpu| = {worst:e}");
|
||||
assert!(worst < 2e-4, "{worst}");
|
||||
}
|
||||
|
||||
/// A 4:4:4 grey picture with an alpha plane that ramps from transparent (left) to opaque (right).
|
||||
fn yuv_alpha_frame(w: u32, h: u32, bits: u32) -> Arc<VideoFrame> {
|
||||
let n = (w * h) as usize;
|
||||
let max = (1u32 << bits) - 1;
|
||||
let ramp = |i: usize| ((i % w as usize) as u32 * max / (w - 1)) as u16;
|
||||
let data = if bits == 8 {
|
||||
PixelData::Yuv8 {
|
||||
planes: [Arc::new(vec![180u8; n]), Arc::new(vec![100u8; n]), Arc::new(vec![170u8; n])],
|
||||
chroma: Chroma::C444,
|
||||
alpha: Some(Arc::new((0..n).map(|i| ramp(i) as u8).collect())),
|
||||
}
|
||||
} else {
|
||||
let s = |v: u32| (v << (bits - 8)) as u16;
|
||||
PixelData::Yuv16 {
|
||||
planes: [Arc::new(vec![s(180); n]), Arc::new(vec![s(100); n]), Arc::new(vec![s(170); n])],
|
||||
chroma: Chroma::C444,
|
||||
bits,
|
||||
alpha: Some(Arc::new((0..n).map(ramp).collect())),
|
||||
}
|
||||
};
|
||||
Arc::new(VideoFrame { width: w, height: h, data, color: filmcraft_color::ColorInfo::REC709, par: (1, 1), pts: Default::default() })
|
||||
}
|
||||
|
||||
/// A Y'CbCr layer with an alpha plane (ProRes 4444) is drawn with its alpha: the GPU result matches
|
||||
/// the CPU compositor over an opaque background. (It used to draw such a layer opaque.)
|
||||
#[test]
|
||||
fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
|
||||
let Some((dev, q)) = device() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let (w, h) = (320usize, 180usize);
|
||||
let pixels = filmcraft_media::generators::render(
|
||||
&filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::OceanSunset),
|
||||
320,
|
||||
180,
|
||||
1.0,
|
||||
24,
|
||||
filmcraft_time::FrameRate::FPS_24,
|
||||
);
|
||||
let background = Arc::new(VideoFrame::rgba8(320, 180, pixels));
|
||||
for bits in [8, 10, 12] {
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let plan = FramePlan::Layers {
|
||||
width: w,
|
||||
height: h,
|
||||
layers: vec![
|
||||
PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None },
|
||||
PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None },
|
||||
],
|
||||
};
|
||||
let cpu = execute_cpu(&plan).over_black_rgba8();
|
||||
c.composite(&plan);
|
||||
let (_, _, gpu) = c.read_output().expect("readback");
|
||||
let mut diffs: Vec<u32> =
|
||||
cpu.chunks(4).zip(gpu.chunks(4)).map(|(a, b)| (0..3).map(|k| (a[k] as i32 - b[k] as i32).unsigned_abs()).max().unwrap_or(0)).collect();
|
||||
diffs.sort_unstable();
|
||||
let (p99, max) = (diffs[diffs.len() * 99 / 100], diffs[diffs.len() - 1]);
|
||||
assert!(p99 <= 3 && max <= 8, "{bits}-bit: p99 {p99}, max {max}");
|
||||
// the transparent left edge shows the background, the opaque right edge the layer
|
||||
let at = |img: &[u8], x: usize| (img[(90 * w + x) * 4], img[(90 * w + x) * 4 + 1], img[(90 * w + x) * 4 + 2]);
|
||||
let bg = execute_cpu(&FramePlan::Layers {
|
||||
width: w,
|
||||
height: h,
|
||||
layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }],
|
||||
})
|
||||
.over_black_rgba8();
|
||||
let (l, r, b0) = (at(&gpu, 1), at(&gpu, w - 2), at(&bg, 1));
|
||||
assert!((l.0 as i32 - b0.0 as i32).abs() <= 6 && (l.1 as i32 - b0.1 as i32).abs() <= 6, "{bits}-bit: left edge {l:?} vs background {b0:?}");
|
||||
assert!(r != at(&bg, w - 2), "{bits}-bit: right edge {r:?} should differ from the background");
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,16 @@ for p in enc.flush() { /* ... */ }
|
||||
## Verification
|
||||
|
||||
`cargo test --release -p filmcraft-h264enc` (ffmpeg/ffprobe used only as external oracles; tests skip when
|
||||
absent). Every stream is decoded with `ffmpeg -ec 0 -f rawvideo` and must report no errors, produce exactly the
|
||||
number of frames encoded, and be **bit-identical to the encoder's reconstruction** — this covers Baseline/Main/High,
|
||||
all presets, 0–3 B-frames, 1–4 slices, odd sizes, `testsrc2` and `mandelbrot` sources, and length-prefixed output
|
||||
re-wrapped with the avcC parameter sets. B-frame display order is checked with ffprobe.
|
||||
absent). Every stream is decoded with `ffmpeg -v warning -err_detect +crccheck+bitstream+buffer+explode -f rawvideo`
|
||||
and must print nothing (not even a `corrupt decoded frame` warning), produce exactly the number of frames encoded,
|
||||
and be **bit-identical to the encoder's reconstruction** — this covers Baseline/Main/High, all presets, 0–3
|
||||
B-frames, 1–4 slices and automatic slicing at 1080p, odd sizes, `testsrc2` and `mandelbrot` sources, and
|
||||
length-prefixed output re-wrapped with the avcC parameter sets. B-frame display order is checked with ffprobe. A
|
||||
negative control drops and truncates slices and checks that the oracle reports them.
|
||||
|
||||
The oracle leaves ffmpeg's error concealment on. With `-ec 0`, ffmpeg 7.1 and 8.1 print `corrupt decoded frame`
|
||||
for every picture that has more than one slice, libx264's multi-slice streams included, while the decoded pixels
|
||||
are unchanged (#74). The bit-exact comparison is what rules out concealment.
|
||||
|
||||
Results on the synthetic content (moving gradient, grain noise, text-like edges, sub-pixel panning):
|
||||
|
||||
|
||||
@@ -101,7 +101,11 @@ pub struct RateControl {
|
||||
model_done: f64,
|
||||
inflight: std::collections::VecDeque<f64>,
|
||||
last_q: f64,
|
||||
/// Virtual history for 1-pass ABR: (frame count, complexity of one virtual frame), fixed at the first frame.
|
||||
prior: Option<(f64, f64)>,
|
||||
/// Sum and count of the rate-model terms (`coef · cplx^QCOMP / type_factor`) of the coded P and B frames.
|
||||
inter_s: f64,
|
||||
inter_n: u64,
|
||||
}
|
||||
|
||||
impl RateControl {
|
||||
@@ -124,6 +128,8 @@ impl RateControl {
|
||||
inflight: std::collections::VecDeque::new(),
|
||||
last_q: 0.0,
|
||||
prior: None,
|
||||
inter_s: 0.0,
|
||||
inter_n: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,16 +179,16 @@ impl RateControl {
|
||||
let bpf = *kbps as f64 * 1000.0 / self.fps;
|
||||
// rate factor so that past frames at this rf would have hit the target
|
||||
let s_here = self.coef[ti] * cplx.powf(QCOMP) / type_factor(t);
|
||||
// Virtual history of one second of P frames (inter cost ~ 40% of the first frame's) so the first
|
||||
// I frame gets a realistic share of the budget instead of a single frame's worth.
|
||||
let (fps, coef1) = (self.fps, self.coef[1]);
|
||||
let (pn0, ps0) = *self.prior.get_or_insert_with(|| {
|
||||
let n = fps.max(1.0);
|
||||
let c0 = if t == SliceType::I { 0.4 * cplx } else { cplx };
|
||||
(n, n * coef1 * c0.powf(QCOMP))
|
||||
});
|
||||
// Virtual history of one second of inter frames so the first I frame gets a realistic share of the
|
||||
// budget instead of a single frame's worth. A virtual frame costs what the P and B frames coded so far
|
||||
// cost on average; before the first of them, a P frame of 40% of the first frame's complexity under
|
||||
// the current model. Re-estimated every frame: the initial guess of `coef` can be several times off,
|
||||
// and a history frozen at that guess starves the opening second of bits.
|
||||
let fps = self.fps;
|
||||
let (pn0, c0) = *self.prior.get_or_insert_with(|| (fps.max(1.0), if t == SliceType::I { 0.4 * cplx } else { cplx }));
|
||||
let per_frame = if self.inter_n > 0 { self.inter_s / self.inter_n as f64 } else { self.coef[1] * c0.powf(QCOMP) };
|
||||
let fade = (1.0 - self.frames as f64 / pn0).max(0.0);
|
||||
let (pn, ps) = (pn0 * fade, ps0 * fade);
|
||||
let (pn, ps) = (pn0 * fade, pn0 * fade * per_frame);
|
||||
let s = self.sum_s + s_here + ps;
|
||||
let wanted = bpf * (self.frames as f64 + 1.0 + pn);
|
||||
let mut rf = s / wanted;
|
||||
@@ -242,7 +248,12 @@ impl RateControl {
|
||||
self.coef[k] = self.coef[ti] * [1.2, 1.0, 0.85][k] / [1.2, 1.0, 0.85][ti];
|
||||
}
|
||||
}
|
||||
self.sum_s += self.coef[ti] * cplx.powf(QCOMP) / type_factor(t);
|
||||
let s_frame = self.coef[ti] * cplx.powf(QCOMP) / type_factor(t);
|
||||
self.sum_s += s_frame;
|
||||
if t != SliceType::I {
|
||||
self.inter_s += s_frame;
|
||||
self.inter_n += 1;
|
||||
}
|
||||
self.total_bits += bits as f64;
|
||||
if let Some(p) = self.inflight.pop_front() {
|
||||
self.model_done += p;
|
||||
|
||||
@@ -110,15 +110,36 @@ pub fn out_dir(name: &str) -> PathBuf {
|
||||
filmcraft_testkit::fixtures_dir(&format!("h264enc/{name}"))
|
||||
}
|
||||
|
||||
/// Decode an Annex-B file with ffmpeg; returns (stderr, raw yuv420p bytes).
|
||||
/// Strict decode of an Annex-B file with ffmpeg; returns (diagnostics, raw yuv420p bytes). Any diagnostic
|
||||
/// text is a failure for the caller.
|
||||
///
|
||||
/// - `-v warning`: ffmpeg reports a picture with a damaged, missing or concealed slice as
|
||||
/// `corrupt decoded frame`, which is a warning. At `-v error` a stream with a whole slice missing
|
||||
/// decodes silently (#74).
|
||||
/// - Error concealment stays at ffmpeg's default. With `-ec 0`, ffmpeg (7.1, 8.1) flags every picture
|
||||
/// that has more than one slice as corrupt, libx264's multi-slice streams included, so that mode
|
||||
/// can't tell a real defect from a clean multi-slice picture. Concealment can't hide a defect here:
|
||||
/// a concealed picture is still reported, and the caller compares every decoded pixel with the
|
||||
/// encoder's reconstruction.
|
||||
/// - `-err_detect`: also report the non-conformances ffmpeg tolerates by default.
|
||||
pub fn ffmpeg_decode(file: &Path) -> (String, Vec<u8>) {
|
||||
let out = Command::new(ffmpeg().unwrap())
|
||||
.args(["-v", "error", "-ec", "0", "-i"])
|
||||
.args(["-nostdin", "-hide_banner", "-v", "warning", "-err_detect", "+crccheck+bitstream+buffer+explode", "-i"])
|
||||
.arg(file)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
(String::from_utf8_lossy(&out.stderr).into_owned(), out.stdout)
|
||||
let mut diag = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
if !out.status.success() {
|
||||
diag.push_str(&format!("ffmpeg exited with {}", out.status));
|
||||
}
|
||||
(diag, out.stdout)
|
||||
}
|
||||
|
||||
/// Annex-B NAL units of `stream` as byte ranges (start code included).
|
||||
pub fn annexb_nals(stream: &[u8]) -> Vec<std::ops::Range<usize>> {
|
||||
let starts: Vec<usize> = (0..stream.len().saturating_sub(3)).filter(|&i| stream[i..i + 3] == [0, 0, 1]).collect();
|
||||
starts.iter().enumerate().map(|(k, &s)| s..starts.get(k + 1).copied().unwrap_or(stream.len())).collect()
|
||||
}
|
||||
|
||||
/// Raw frames produced by ffmpeg from a lavfi source.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Yuv, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth};
|
||||
use common::{Yuv, annexb_nals, ffmpeg, ffmpeg_decode, ffmpeg_source, ffprobe, out_dir, psnr, synth};
|
||||
use filmcraft_h264enc::*;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -101,6 +101,36 @@ fn bit_exact_profiles_presets() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The oracle must see a damaged multi-slice picture (#74): a missing slice used to decode silently
|
||||
/// at `-v error -ec 0`, and `-ec 0` at `-v warning` flags clean multi-slice pictures too.
|
||||
#[test]
|
||||
fn oracle_reports_damaged_slices() {
|
||||
if ffmpeg().is_none() {
|
||||
return;
|
||||
}
|
||||
let (w, h, n) = (176, 144, 6);
|
||||
let frames: Vec<Yuv> = (0..n).map(|t| synth(w, h, t)).collect();
|
||||
for (name, profile) in [("cavlc", Profile::Baseline), ("cabac", Profile::High)] {
|
||||
let run = encode(cfg(w, h, profile, Preset::Balanced, 0, RateControl::Qp(26), 3), &frames);
|
||||
// the undamaged 3-slice stream decodes without a single message
|
||||
verify(&format!("damage_{name}_clean"), &run, w, h, n);
|
||||
let slices: Vec<_> = annexb_nals(&run.stream).into_iter().filter(|r| matches!(run.stream[r.start + 3] & 0x1f, 1 | 5)).collect();
|
||||
assert_eq!(slices.len(), 3 * n, "{name}: three slices per picture");
|
||||
for (k, slice) in [(1usize, &slices[4]), (3, &slices[10])] {
|
||||
let mut dropped = run.stream.clone();
|
||||
dropped.drain(slice.clone());
|
||||
let mut truncated = run.stream.clone();
|
||||
truncated.drain(slice.start + slice.len() / 2..slice.end);
|
||||
for (damage, stream) in [("dropped", dropped), ("truncated", truncated)] {
|
||||
let path = out_dir("oracle").join(format!("damage_{name}_{damage}_{k}.h264"));
|
||||
std::fs::write(&path, &stream).unwrap();
|
||||
let (err, _) = ffmpeg_decode(&path);
|
||||
assert!(!err.trim().is_empty(), "{name}: the oracle missed a {damage} slice in picture {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_exact_lavfi_sources() {
|
||||
if ffmpeg().is_none() {
|
||||
@@ -157,6 +187,32 @@ fn abr_hits_target() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-pass VBR must not starve the opening second of a 60 fps export (#74): the rate model's initial guess
|
||||
/// is several times too pessimistic, and a virtual history frozen at that guess kept the pictures after the
|
||||
/// first one 6–9 dB below the rest until it faded out after one second.
|
||||
#[test]
|
||||
fn vbr_opening_second_is_not_starved() {
|
||||
if ffmpeg().is_none() {
|
||||
return;
|
||||
}
|
||||
let (w, h, n, fps) = (640, 360, 120, 60);
|
||||
let frames: Vec<Yuv> = (0..n).map(|t| synth(w, h, t)).collect();
|
||||
let mut c = cfg(w, h, Profile::High, Preset::Balanced, 2, RateControl::Vbr { target_kbps: 2000, max_kbps: 3000 }, 0);
|
||||
c.fps_num = fps;
|
||||
c.keyint = 2 * fps;
|
||||
let run = encode(c, &frames);
|
||||
let dec = verify("vbr_opening", &run, w, h, n);
|
||||
let fs = w * h * 3 / 2;
|
||||
let ps: Vec<f64> = frames.iter().enumerate().map(|(i, f)| psnr(&dec[i * fs..i * fs + w * h], &f.y)).collect();
|
||||
let mean = |r: std::ops::Range<usize>| ps[r.clone()].iter().sum::<f64>() / r.len() as f64;
|
||||
// 0.1–0.5 s against everything after the first second (the same GOP, so no new I frame in between).
|
||||
let (opening, settled) = (mean(6..30), mean(60..n));
|
||||
let kbps = run.stream.len() as f64 * 8.0 / (n as f64 / fps as f64) / 1000.0;
|
||||
eprintln!("opening {opening:.2} dB, after 1 s {settled:.2} dB, {kbps:.1} kbps");
|
||||
assert!(settled - opening <= 3.0, "opening second starved: {opening:.2} dB vs {settled:.2} dB after 1 s");
|
||||
assert!((kbps - 2000.0).abs() / 2000.0 <= 0.10, "{kbps:.1} kbps vs target 2000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pass_vbr_hits_target() {
|
||||
if ffmpeg().is_none() {
|
||||
|
||||
@@ -7,7 +7,9 @@ references are strings and audio essence is supplied by the caller (the engine).
|
||||
## Specifications
|
||||
|
||||
Clean-room: written from public specifications only. The AAF SDK, pyaaf2, the OMF Toolkit and
|
||||
other implementations were not consulted.
|
||||
other implementations were not consulted. Where a specification leaves a layout open (how a
|
||||
nested sequence is written, how several video tracks share a slot), what Premiere Pro does was
|
||||
read off files it exports, never from the application itself.
|
||||
|
||||
| Format | Document | Edition | Used for |
|
||||
|---|---|---|---|
|
||||
@@ -35,10 +37,32 @@ Embedded / consolidated audio (`essence`): `audio_needs` lists the media ranges
|
||||
references (per media item or per clip, with handles); the engine decodes or renders them and
|
||||
passes `AudioEssence` (embedded PCM or a written file) back in `MediaOptions`.
|
||||
|
||||
Not represented: speed changes and frame holds (exported at 100 % with a report entry), nested
|
||||
sequences, graphics and synthetic media (gaps), video effects other than transitions, clip
|
||||
Not represented: speed changes and frame holds (exported at 100 % with a report entry),
|
||||
graphics and synthetic media (gaps), video effects other than transitions, clip
|
||||
markers (written to the master mob, so they come back as media markers), OMF video and markers.
|
||||
|
||||
## Nested sequences
|
||||
|
||||
For the formats Premiere Pro exports (FCP7 XML, OTIO, EDL, AAF, OMF) a nested sequence is written
|
||||
the way Premiere Pro 26.5.2 writes it, as seen in the files it exports for a sequence with a nest.
|
||||
Premiere Pro does not write FCPXML.
|
||||
|
||||
| Format | Written as | On import into FilmCraft |
|
||||
|---|---|---|
|
||||
| FCP7 XML | a clip item holding the nested `<sequence>` (by id after the first use) | a sequence the clips use |
|
||||
| FCPXML | a `ref-clip` to a `media` resource holding the sequence | a sequence the clips use |
|
||||
| OTIO | a `Stack` inside the track | a sequence the clips use |
|
||||
| EDL | one event: reel `AX` in every reel mode, the nested sequence's name as the clip name, its own time as source timecode (the report says the nest's edit is not in the EDL) | an ordinary clip of that name |
|
||||
| AAF | a composition mob of its own (not tagged top-level); the nest's clips are source clips that point at it, at its first track of their kind. With embedded or consolidated audio the media inside the nest is prepared too (`NestNeeds::Inside`) | a sequence the clips use, read once however many clips use it |
|
||||
| OMF | the nested sequence's sound, mixed by the engine with what is on the clip (`NestNeeds::Render`), as one clip named after the sequence. Without that essence: a gap, and the report names the sequence | media in the document |
|
||||
|
||||
A sequence that claims to be inside itself (a damaged project or file) is a gap with a report
|
||||
entry in both directions; nothing recurses without a bound.
|
||||
|
||||
The AAF reader also accepts Premiere Pro's layout of a composition: all video tracks in ONE slot
|
||||
(a nested scope with a segment per track, lowest first), and "top-level" tagged on nested
|
||||
compositions too (the top-level ones are those no other composition uses).
|
||||
|
||||
## Graphics and adjustment layers
|
||||
|
||||
No format here has an equivalent of a FilmCraft graphic (title) clip or an adjustment layer, and
|
||||
@@ -62,7 +86,9 @@ OMF carries no video at all.
|
||||
gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio
|
||||
(sample data and source offsets); separate consolidated files with a video mixdown; OMF round
|
||||
trips with embedded audio, separate AIFF files and breakout; empty sequences; truncation and
|
||||
random corruption never panic.
|
||||
random corruption never panic. Nested sequences: AAF round trips as compositions (nests in
|
||||
nests, a nest used twice, another frame and sample rate, breakout to mono), OMF with the
|
||||
rendered sound, the audio needs of nests, a sequence inside itself.
|
||||
- `tests/uncarried_clips.rs`: every format's export report names each graphic clip and adjustment
|
||||
layer once, as a warning; a sequence without them reports nothing; what a re-import gives back
|
||||
matches the report; the FCP7 XML import names a clip item whose file is not defined.
|
||||
@@ -70,6 +96,7 @@ OMF carries no video at all.
|
||||
written (Header, Identification, Mobs, slots, components, descriptors), weak references
|
||||
resolving into the dictionary, source clips resolving to mobs and slots, the Edit Protocol
|
||||
transition rules and sequence length arithmetic; every stored form round-trips through the
|
||||
container.
|
||||
container; a file rewritten into Premiere Pro's layout (video tracks as layers of one slot,
|
||||
nested compositions tagged top-level) reads back as the same edit.
|
||||
- `src/omf/tests.rs`: the Bento label and TOC, the required properties of every OMF class
|
||||
written, reference resolution, embedded WAVE data, sequence lengths in samples.
|
||||
@@ -37,6 +37,7 @@ pub(crate) mod cls {
|
||||
pub const FILLER: Auid = class(0x09);
|
||||
pub const OPERATION_GROUP: Auid = class(0x0A);
|
||||
pub const NESTED_SCOPE: Auid = class(0x0B);
|
||||
pub const SCOPE_REFERENCE: Auid = class(0x0D);
|
||||
pub const SELECTOR: Auid = class(0x0E);
|
||||
pub const SEQUENCE: Auid = class(0x0F);
|
||||
pub const SOURCE_CLIP: Auid = class(0x11);
|
||||
|
||||
@@ -16,14 +16,20 @@
|
||||
//! `Amplitude` **ConstantValue** or **VaryingValue** (keyframes as control points),
|
||||
//! - sequence markers as **CommentMarker**s in an event slot;
|
||||
//!
|
||||
//! a **CompositionMob** without a usage code for every nested sequence, built the same way (a
|
||||
//! clip of the nested sequence is a **SourceClip** that points at that mob, at its first slot of
|
||||
//! the clip's kind, with its start in that slot's edit units; Premiere Pro writes nested sequences
|
||||
//! this way);
|
||||
//!
|
||||
//! and for every media item a **MasterMob**, a file **SourceMob** per essence (a CDCI or PCM
|
||||
//! descriptor with a network locator to the file, or `EssenceData` holding embedded PCM) and a
|
||||
//! tape **SourceMob** carrying the media's start timecode. Clip and media markers become comment
|
||||
//! markers on the master mob.
|
||||
//!
|
||||
//! Import reads the same structures (and the common variations: nested sequences, selectors,
|
||||
//! operation groups around clips, legacy data definitions, essence groups) into FilmCraft
|
||||
//! sequences, media items (linked by path; embedded audio is returned as WAV files for the
|
||||
//! Import reads the same structures (and the common variations: sequences inside sequences,
|
||||
//! selectors, operation groups around clips, legacy data definitions, essence groups, and Premiere
|
||||
//! Pro's one video slot holding a nested scope with a segment per video track) into FilmCraft
|
||||
//! sequences (a composition that others use becomes a nested sequence), media items (linked by path; embedded audio is returned as WAV files for the
|
||||
//! caller to write) and markers.
|
||||
|
||||
mod ids;
|
||||
@@ -53,7 +59,7 @@ pub struct AafOptions {
|
||||
pub fn export(project: &Project, sequence: ItemId, opts: &AafOptions) -> Result<(Vec<u8>, Report)> {
|
||||
let mut report = Report::default();
|
||||
let name = opts.name.clone().or_else(|| project.item(sequence).map(|i| i.name.clone())).unwrap_or_else(|| "Sequence".into());
|
||||
let doc = comp::from_project(project, sequence, &name, &opts.media, &mut report)?;
|
||||
let doc = comp::from_project(project, sequence, &name, &opts.media, comp::Nests::Compositions, &mut report)?;
|
||||
let version = if opts.small_sectors { filmcraft_cfb::Version::V3 } else { filmcraft_cfb::Version::V4 };
|
||||
let bytes = write::write(&doc, version, &mut report)?;
|
||||
Ok((bytes, report))
|
||||
|
||||
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user