Compare commits
91
Commits
No files matched your search
@@ -3,6 +3,44 @@
|
||||
These rules apply to every human and AI contributor. `CLAUDE.md` holds the working instructions; this
|
||||
file holds the rules that must never be broken. When the two disagree, this file wins.
|
||||
|
||||
## 0. Never crash
|
||||
|
||||
People trust FilmCraft with hours of work. A malformed file, a bad command or MCP parameter, a corrupt
|
||||
preset or project, an odd keystroke or a full disk must give an error the user (or agent) can act on,
|
||||
never a crash and never lost work. **This rule outranks feature work:** don't ship a feature by adding a
|
||||
panic path, and fix a crash before building on top of it. The cross-app standard is
|
||||
[`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
|
||||
1. **Fail with `Result<T, E>`.** Return the crate's error type and propagate with `?`; add context
|
||||
(`map_err`, an error variant) instead of discarding it. In the UI, report the error (status bar, error
|
||||
dialog) and carry on.
|
||||
2. **No panicking shortcuts outside tests:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`
|
||||
or `unimplemented!`. Use `?`, `ok_or(..)?`, `let Some(x) = .. else { return Err(..) }`, `if let`, or a
|
||||
fallback that is truly correct (never one that silently corrupts a project). An unfinished feature
|
||||
returns an "unsupported" error. The only exception is a call that is provably infallible from local
|
||||
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).
|
||||
4. **Every input-derived number is hostile.** Media files, project and interchange files, presets, fonts,
|
||||
CLI / MCP / control-channel parameters and UI state are untrusted. Index and slice with `get()` (or
|
||||
validate bounds once, up front, for a hot loop); slice strings only at char boundaries; use
|
||||
`checked_*` / `saturating_*` for lengths, offsets and counts; never divide by a value that can be zero
|
||||
(frame rates, timescales, sample rates, sizes); don't cast negative or NaN values to integers; don't
|
||||
call `clamp` with bounds that can cross; cap allocations sized by input.
|
||||
5. **Bound recursion and loops.** Projects can be cyclic (nested sequences) or deeply nested: walk them
|
||||
with seen-sets or depth limits.
|
||||
6. **Don't cascade.** Lock poisoning is not fatal: `lock().unwrap_or_else(PoisonError::into_inner)`.
|
||||
Every thread or job (export, previews, proxies, decoding workers…) runs under `catch_unwind` and
|
||||
reports a failure as an error: a dead worker leaves monitors blank or jobs "running" forever.
|
||||
7. **Last-resort guard.** The panic hook (`crates/ui-egui/src/crash.rs`) logs every panic, and the UI pass
|
||||
runs under `catch_unwind`, so an escaped panic becomes an error window and the session (and unsaved
|
||||
project) survives. It is a safety net, not a substitute for rules 1–6. Keep `panic = "unwind"`.
|
||||
8. **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the
|
||||
fix. Parsers and decoders get mutation-fuzz tests (truncation, bit flips, corrupt sizes) run under
|
||||
`catch_unwind`; new commands get hostile-parameter tests. Fuzzer findings become tests.
|
||||
9. **Enforced:** clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic,
|
||||
clippy::unimplemented, clippy::todo, clippy::unreachable)]` (moving to `[workspace.lints.clippy]` once
|
||||
every crate is clean). `clippy.toml` allows them in tests: a failing test should fail loudly.
|
||||
|
||||
## 1. Assets: no Adobe artwork, every asset licensed and attributed
|
||||
|
||||
This rule is absolute. Breaking it is the most serious mistake a contributor can make on this project.
|
||||
@@ -44,7 +82,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
and are listed in `ATTRIBUTION.md`.
|
||||
7. **When in doubt, leave it out** and draw or generate it yourself.
|
||||
8. **The one exception: first-party ArtCraft brand marks.** The ArtCraft name and logos in `docs/brand/`
|
||||
(supplied by the ArtCraft team) are trademarks, all rights reserved, used with permission. They still
|
||||
are trademarks of the ArtCraft Team, not open source, usable only unmodified and only in the context of
|
||||
FilmCraft under `docs/brand/LICENSE-brand.txt`; forks and modified versions must remove them. They still
|
||||
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).
|
||||
@@ -67,6 +106,12 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
|
||||
## 3. Engineering rules
|
||||
|
||||
### 3.1 Never crash
|
||||
|
||||
See [§0](#0-never-crash). It outranks every other engineering rule.
|
||||
|
||||
### 3.2 The rest
|
||||
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
|
||||
+15
-10
@@ -60,6 +60,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R
|
||||
| `crates/golden/goldens/hdr_tone_map.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `hdr_tone_map`) from procedurally generated media re-encoded as Rec. 2100 PQ | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/graphic_title.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_title`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/lumetri_hdr_pq.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_hdr_pq`) from procedurally generated media re-encoded as Rec. 2100 PQ | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/lumetri_presets_grid.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, `lumetri_presets_grid`): FilmCraft's own Lumetri presets on its procedural preview picture | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 |
|
||||
| `crates/golden/goldens/transition_cross_dissolve.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_cross_dissolve`) from procedurally generated media | MIT OR Apache-2.0 |
|
||||
@@ -101,23 +103,26 @@ is traced or derived from Adobe artwork.
|
||||
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
|
||||
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |
|
||||
| Lumetri "Look" presets (Teal & Orange, Warm Film, …) | `crates/render/src/effects.rs` (`apply_look`): procedural colour transforms, not LUT files |
|
||||
| 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 |
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
The ArtCraft name and logo are trademarks of the ArtCraft team, used in FilmCraft with permission. They are not
|
||||
openly licensed and are not covered by the project licence; forks must remove or replace them (AGENTS.md §1.8).
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
|
||||
are not covered by the project licence; they may be used only unmodified and only in the context of
|
||||
FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must
|
||||
remove them (AGENTS.md §1.8).
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft trademark, all rights reserved; used with permission (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark, light ink for dark backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft wordmark (symbol + "ARTCRAFT"), dark ink for light backgrounds) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft trademark, not open source; `docs/brand/LICENSE-brand.txt` (not MIT/Apache) |
|
||||
| `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) |
|
||||
|
||||
## Downloaded at runtime
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7; public summary: [`docs/agents.md`](docs/agents.md) §5–7). Don't stop to ask unless it lists the decision as the user's.
|
||||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** 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.
|
||||
**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.
|
||||
- **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` (planned; not created yet), optional, behind traits.
|
||||
- **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`.
|
||||
|
||||
+4
-1
@@ -5,7 +5,10 @@ Thanks for helping. Before you start:
|
||||
1. Read **[AGENTS.md](AGENTS.md)**. Its rules are absolute. In particular: no Adobe assets of any
|
||||
kind, every asset openly licensed with an attribution sidecar, no GPL/LGPL code, and ffmpeg only
|
||||
as an external test oracle.
|
||||
2. Follow **[docs/contributing.md](docs/contributing.md)**: setup per OS, building and running,
|
||||
2. **Never crash.** A crash loses someone's work, so this rule outranks feature work: no `unwrap()`,
|
||||
`expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` or `unsafe` in production code;
|
||||
return `Result` and treat every input as hostile. See [AGENTS.md](AGENTS.md) §0.
|
||||
3. Follow **[docs/contributing.md](docs/contributing.md)**: setup per OS, building and running,
|
||||
the quality gates (`cargo xtask ci`), commit conventions, and how to add commands, effects,
|
||||
transitions, codecs, panels and assets.
|
||||
|
||||
|
||||
Generated
+52
-44
@@ -1655,7 +1655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1676,7 +1676,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1686,7 +1686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1694,14 +1694,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1717,7 +1717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1728,14 +1728,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1746,9 +1746,13 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1764,7 +1768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1796,7 +1800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1804,7 +1808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1814,7 +1818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1826,7 +1830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -1839,6 +1843,7 @@ dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
@@ -1856,7 +1861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-audio-dsp",
|
||||
@@ -1868,6 +1873,7 @@ dependencies = [
|
||||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-render",
|
||||
@@ -1884,7 +1890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1896,7 +1902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -1906,7 +1912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1914,7 +1920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1931,7 +1937,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1948,7 +1954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1958,7 +1964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1968,7 +1974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1978,8 +1984,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
@@ -1994,7 +2001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2005,7 +2012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2013,7 +2020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2026,7 +2033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2035,7 +2042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2043,28 +2050,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2078,7 +2085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2088,7 +2095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2108,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2117,7 +2124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2135,14 +2142,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
@@ -2153,7 +2160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2161,7 +2168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2174,6 +2181,7 @@ dependencies = [
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-gpu",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
@@ -2192,7 +2200,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2201,7 +2209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7132,7 +7140,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -19,6 +19,7 @@ filmcraft-bitstream = { path = "crates/bitstream" }
|
||||
filmcraft-isobmff = { path = "crates/isobmff" }
|
||||
filmcraft-matroska = { path = "crates/matroska" }
|
||||
filmcraft-mxf = { path = "crates/mxf" }
|
||||
filmcraft-cfb = { path = "crates/cfb" }
|
||||
filmcraft-mpegts = { path = "crates/mpegts" }
|
||||
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
|
||||
filmcraft-ac3 = { path = "crates/ac3" }
|
||||
@@ -68,7 +69,7 @@ egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
|
||||
+151
-150
@@ -1,176 +1,177 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
1. Definitions.
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
1. Definitions.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 The FilmCraft contributors
|
||||
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
FilmCraft
|
||||
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
|
||||
Third-party material
|
||||
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
Inter 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.
|
||||
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
|
||||
@@ -20,7 +20,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license"><img alt="License: MIT or Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6"></a>
|
||||
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6"></a>
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6">
|
||||
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6"></a>
|
||||
@@ -197,7 +197,7 @@ MXF and hardware decode are next ([roadmap](ROADMAP.md)).
|
||||
<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.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, PNG sequences, animated GIF and WAV.
|
||||
- **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.
|
||||
|
||||
@@ -211,6 +211,8 @@ Move timelines between FilmCraft and every other editor:
|
||||
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
|
||||
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
|
||||
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
|
||||
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
|
||||
|
||||
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
|
||||
|
||||
@@ -328,18 +330,25 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
|
||||
<br>
|
||||
|
||||
## Credits and clean room
|
||||
## License and credits
|
||||
|
||||
FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
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).
|
||||
|
||||
**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).
|
||||
|
||||
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logo, which are trademarks of the ArtCraft team used with permission. ffmpeg is used only as an external test oracle.
|
||||
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
## License
|
||||
|
||||
FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. Bundled assets keep their own open licences, listed in [ATTRIBUTION.md](ATTRIBUTION.md). The ArtCraft name and logos in `docs/brand/` are trademarks of the ArtCraft team and are not covered by this licence.
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+6
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
|
||||
|
||||
**Last updated:** 2026-10-02 · **Overall parity:** ~86% (measured scorecard below) · **Code:** 34 crates, 1562 tests
|
||||
**Last updated:** 2026-10-02 · **Overall parity:** ~87% (measured scorecard below) · **Code:** 34 crates, 1605 tests
|
||||
|
||||
## Parity scorecard
|
||||
|
||||
@@ -20,10 +20,10 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
|
||||
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
|
||||
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
|
||||
| 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, image sequences, GIF, WAV; preset library + user presets, full Export-mode settings (loudness normalization, overlays, captions), export queue, Quick Export; no AAF/OMF, MXF export | ~80% |
|
||||
| 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`; 8K and AV1 not yet real-time on a loaded machine | ~62% |
|
||||
| **Weighted total** | | | **~86%** |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
|
||||
@@ -77,12 +77,15 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-04 (later):** interchange + MXF export (M11.4/M6.6): own compound-file container (`crates/cfb`), AAF Edit Protocol import/export (embedded/linked/consolidated audio, video mixdown, handles, breakout to mono), OMF 2.0 export (Bento), MXF OP1a/OP-Atom writer (DNxHR/ProRes/H.264/PCM) as export formats. 1605 tests.
|
||||
|
||||
- **2026-10-04:** MPEG-2 (M9.15): own MPEG-2/MPEG-1 video decoder (4:2:2, interlaced, field pictures; IEEE 1180 IDCT), MPEG TS/PS demux (AVCHD .mts, .m2ts, .ts, .mpg/.vob/.mod), own AC-3 decoder (ATSC A/52), MP2, LATM AAC, Blu-ray/DVD LPCM, MPEG-2 in MXF/MOV/MP4/MKV. 1562 tests.
|
||||
|
||||
- **2026-10-03 (evening):** Project panel and Media Browser (M12.7): Freeform view (stacks, arrangements), Icon view hover scrub with In/Out, list columns from all metadata, ten view presets, multiple Project panels; Media Browser with Favorites/drives/recent, back/forward, filters, thumbnails, Edit Columns, ingest, drag to timeline; all as `project.*` / `mediaBrowser.*` commands. 1513 tests.
|
||||
|
||||
- **2026-10-03 (later):** graphics & captions (M10.7): `.fcgt` graphics templates (export/install/apply/edit, 8 original built-ins; Adobe .mogrt refused), Essential Graphics Browse/Edit, rolls/crawls, responsive design pins and time, per-character styles, Upgrade Caption to Graphic / to Source Graphic, MCC/EBU STL/TTML/DFXP, Replace Fonts; schema v12. 1479 tests.
|
||||
- **2026-10-03:** 4K H.264 playback (M4.9): vectorised deblocking, branch-free CABAC bins and column-parallel inverse transforms cut H.264 decoding by 23 % (cycles per frame, bit-exact; 4K cold seeks −27 % CPU); opt-in draft decoding (Settings ▸ Playback) skips deblocking of non-reference pictures and uploads decimated planes while playing at 1/2–1/4, never for paused frames or exports; conformance runs 1, 3 and all threads. 4K plays 192/0 at load 100–140 (before: only at load ~90).
|
||||
- **2026-10-03:** 4K VP9 / HEVC / AV1 playback (M4.10): VP9 frame threading (references published per superblock row, loop filter overlapping the next frames) plus a vectorised loop filter, column-parallel transforms and 16-bit MC cut VP9 work by 25 % single-threaded and 44 % with all cores (4K decode 8 → 27–34 fps at load ~200); HEVC −28 % (vectorised SAO, MC without per-block zeroing, batched bypass bins), AV1 −20 % (fixed-width MC, recycled planes); opt-in draft decoding for all three; bit-exact at 1, 3 and all threads. 4K VP9 and HEVC now play 192/0 at load ~200 (before: 0–58 of 192 shown).
|
||||
|
||||
- **2026-10-03:** keyboard parity (M3.12: 143 new commands on Premiere's default keys, table validated against the registry), audio (M7.8: 5.1 tracks/submixes/Mix, 5.1 panner, BS.775 downmix, 5.1 WAV/MOV/AAC export, voice-over record with punch-in, Remix, Clip Mixer Latch/Touch/Write). 1432 tests.
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ fn insert_dotted(m: &mut Map<String, Value>, key: &str, v: Value) {
|
||||
if !e.is_object() {
|
||||
*e = Value::Object(Map::new());
|
||||
}
|
||||
insert_dotted(e.as_object_mut().expect("object"), tail, v);
|
||||
if let Value::Object(child) = e {
|
||||
insert_dotted(child, tail, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! Run `filmcraft-cli help` for the full reference (also in docs/agents.md).
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod args;
|
||||
mod probe;
|
||||
|
||||
@@ -27,7 +29,7 @@ SUBCOMMANDS
|
||||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
||||
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|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
a format (h264|prores|dnxhr|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
|
||||
@@ -136,7 +138,7 @@ fn format_for(path: &str) -> Option<&'static str> {
|
||||
Some(match ext.as_str() {
|
||||
"mp4" | "m4v" => "h264",
|
||||
"mov" => "prores",
|
||||
"mxf" => "dnxhr",
|
||||
"mxf" => "mxf-op1a",
|
||||
"png" => "png",
|
||||
"gif" => "gif",
|
||||
"wav" => "wav",
|
||||
@@ -339,7 +341,7 @@ async fn main() {
|
||||
if a.opt("--bridge").is_some() {
|
||||
usage("render is headless; with --bridge use the MCP `render_frame` tool or `exec` ui commands");
|
||||
}
|
||||
let Backend::Local(mut s) = Backend::open(&a) else { unreachable!() };
|
||||
let Backend::Local(mut s) = Backend::open(&a) else { usage("render is headless") };
|
||||
let secs: f64 = a.opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
|
||||
let scale: f32 = a.opt("--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let out = a.opt("--out").unwrap_or("frame.png");
|
||||
@@ -356,7 +358,7 @@ async fn main() {
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
|
||||
None => match Backend::open(&a) {
|
||||
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
|
||||
Backend::Bridge(_) => unreachable!(),
|
||||
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
|
||||
},
|
||||
};
|
||||
if let Err(e) = server.serve_stdio().await {
|
||||
@@ -366,3 +368,13 @@ async fn main() {
|
||||
other => usage(format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
assert_eq!(super::format_for("a.mov"), Some("prores"));
|
||||
assert_eq!(super::format_for("noext"), None);
|
||||
}
|
||||
}
|
||||
@@ -289,20 +289,25 @@ impl ByteReader for BlobReader {
|
||||
}
|
||||
return Err(filmcraft_media::pending::would_block());
|
||||
}
|
||||
FS.with(|fs| {
|
||||
let complete = FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
fs.clock += 1;
|
||||
let t = fs.clock;
|
||||
let mut pos = 0usize;
|
||||
for c in c0..=c1 {
|
||||
let (d, used) = fs.chunks.get_mut(&(self.id, c)).expect("checked above");
|
||||
// Present (checked above); evicted in between would be a bug, reported as an error.
|
||||
let Some((d, used)) = fs.chunks.get_mut(&(self.id, c)) else { return false };
|
||||
*used = t;
|
||||
let a = if c == c0 { (offset - c * CHUNK) as usize } else { 0 };
|
||||
let n = (d.len() - a).min(buf.len() - pos);
|
||||
buf[pos..pos + n].copy_from_slice(&d[a..a + n]);
|
||||
pos += n;
|
||||
}
|
||||
true
|
||||
});
|
||||
if !complete {
|
||||
return Err(filmcraft_media::pending::would_block());
|
||||
}
|
||||
// read-ahead for sequential access
|
||||
for c in c1 + 1..=(c1 + READ_AHEAD).min(last) {
|
||||
request(self.id, c);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//!
|
||||
//! Everything here is `wasm32`-only; on other targets the crate is empty.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod api;
|
||||
pub mod audio;
|
||||
@@ -135,7 +136,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
import::install_drop_handlers()?;
|
||||
|
||||
let demo = !query_flag("empty");
|
||||
let web_options = eframe::WebOptions::default();
|
||||
let mut web_options = eframe::WebOptions::default();
|
||||
// `?webgl`: WebGL2 only. The page reloads with it when starting on WebGPU fails (an adapter
|
||||
// whose device can't be created, a driver the browser rejects…); see `index.html`.
|
||||
if query_flag("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut web_options.wgpu_options.wgpu_setup
|
||||
{
|
||||
c.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
|
||||
@@ -193,14 +193,16 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
Err(_) => return Some(Ok(inner)),
|
||||
};
|
||||
let Some(track) = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty()) else { return Some(Ok(inner)) };
|
||||
let entry = &file.tracks[track].entries[0];
|
||||
let Some(entry) = file.tracks[track].entries.first() else { return Some(Ok(inner)) };
|
||||
let fourcc = entry.format.to_string();
|
||||
let Some((family, codec, description)) = codec_string(&entry.codec, &fourcc) else { return Some(Ok(inner)) };
|
||||
if !SUPPORTED.with(|s| s.borrow().contains(&family)) {
|
||||
return Some(Ok(inner));
|
||||
}
|
||||
let info = inner.info().clone();
|
||||
let (w, h) = info.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0));
|
||||
// the decoder wants the coded size; `info` reports the display size after the track's rotation
|
||||
let rotation = file.tracks[track].display_rotation().unwrap_or(0);
|
||||
let (w, h) = info.video.as_ref().map(|v| if rotation % 2 == 1 { (v.height, v.width) } else { (v.width, v.height) }).unwrap_or((0, 0));
|
||||
let id = NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
@@ -215,7 +217,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
file,
|
||||
track,
|
||||
id,
|
||||
config: Config { codec, description, width: w, height: h },
|
||||
config: Config { codec, description, width: w, height: h, rotation },
|
||||
state: Mutex::new(Cache::default()),
|
||||
})))
|
||||
}
|
||||
@@ -225,6 +227,8 @@ struct Config {
|
||||
description: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
|
||||
rotation: u8,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -455,10 +459,11 @@ impl WcSource {
|
||||
std::mem::take(&mut *s.out.borrow_mut())
|
||||
});
|
||||
for (pts, f) in out {
|
||||
let f = if self.config.rotation != 0 { f.rotated(self.config.rotation) } else { f };
|
||||
c.frames.insert(pts, Arc::new(f));
|
||||
}
|
||||
while c.frames.len() > CACHE_FRAMES {
|
||||
let far = c.frames.keys().copied().max_by_key(|p| (p - want).abs()).expect("non-empty");
|
||||
let Some(far) = c.frames.keys().copied().max_by_key(|p| (p - want).abs()) else { break };
|
||||
c.frames.remove(&far);
|
||||
}
|
||||
// the session's own frame came out: other requests may restart it now
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//
|
||||
// Steps: load (timing), demo project, play 2 s (frames shown/dropped), import the media through
|
||||
// `filmcraft.importUrl`, put it on a new sequence, export H.264 (download), screenshots of each
|
||||
// step. `--media` must be reachable from the page (copy it next to index.html). Prints a JSON
|
||||
// report; exits non-zero on failure.
|
||||
// step; then every mode and a tiny window. `--media` must be reachable from the page (copy it next
|
||||
// to index.html). Prints a JSON report; exits non-zero on failure, including any Rust panic or
|
||||
// uncaught exception in the console.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -148,6 +149,24 @@ try {
|
||||
await sleep(1000);
|
||||
report.steps.export.downloads = readdirSync(downloads).filter((f) => !f.endsWith(".crdownload")).map((f) => ({ f, bytes: statSync(join(downloads, f)).size }));
|
||||
report.steps.export.screenshot = await shot("04-exported");
|
||||
|
||||
// every top-level mode and a tiny window: these used to panic (temp_dir in Export mode, a dock
|
||||
// clamp below 40 points) and freeze the canvas
|
||||
for (const mode of ["import", "export", "edit"]) {
|
||||
await js(`filmcraft.request('ui.set', {mode: ${JSON.stringify(mode)}})`);
|
||||
await sleep(500);
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 120, height: 80, deviceScaleFactor: 1, mobile: false });
|
||||
await sleep(500);
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 2, mobile: false });
|
||||
await sleep(500);
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
report.steps.modes = { screenshot: await shot("05-modes") };
|
||||
// still alive? (a panicked app never answers)
|
||||
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
|
||||
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
|
||||
if (fatal.length) throw new Error("panic / uncaught exception:\n" + fatal.join("\n").slice(0, 4000));
|
||||
if (await js("window.filmcraftLoad.fatal || null")) throw new Error("fatal overlay shown");
|
||||
report.ok = true;
|
||||
} catch (e) {
|
||||
report.ok = false;
|
||||
|
||||
@@ -16,24 +16,78 @@
|
||||
}
|
||||
#loading img { width: 56px; height: 56px; }
|
||||
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
|
||||
#fatal {
|
||||
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
|
||||
background: rgba(20, 20, 22, 0.88); color: #e6e6e8; font: 14px/1.45 system-ui, sans-serif; z-index: 10;
|
||||
}
|
||||
#fatal.on { display: flex; }
|
||||
#fatal .box { max-width: min(78ch, calc(100vw - 32px)); padding: 20px 24px; background: #26272b; border-radius: 8px; }
|
||||
#fatal h1 { margin: 0 0 8px; font-size: 17px; color: #ff8a7a; }
|
||||
#fatal pre { max-height: 40vh; overflow: auto; white-space: pre-wrap; font-size: 12px; color: #c9c9cc; }
|
||||
#fatal button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #3b6fe0; color: #fff; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="filmcraft_canvas"></canvas>
|
||||
<div id="loading"><img src="favicon.png" alt=""><div id="status">Loading FilmCraft…</div></div>
|
||||
<script type="module">
|
||||
import init, { start } from "./filmcraft_web.js";
|
||||
const t0 = performance.now();
|
||||
<div id="fatal" role="alertdialog" aria-labelledby="fatal-title">
|
||||
<div class="box">
|
||||
<h1 id="fatal-title">FilmCraft stopped working</h1>
|
||||
<div>Unsaved changes are kept in the browser every few seconds and come back when you reload.</div>
|
||||
<pre id="fatal-msg"></pre>
|
||||
<button id="fatal-reload" type="button">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// A Rust panic (or an out-of-memory abort) inside a frame stops the app for good: eframe logs
|
||||
// "panicked at …" with console.error, then the wasm traps ("RuntimeError: unreachable") and
|
||||
// the canvas freezes or goes blank. Show what happened instead of a dead page.
|
||||
window.filmcraftLoad = {};
|
||||
let lastPanic = null;
|
||||
const consoleError = console.error.bind(console);
|
||||
console.error = (...a) => {
|
||||
const m = a.map(String).join(" ");
|
||||
if (/panicked at|memory allocation of/.test(m)) lastPanic = m;
|
||||
consoleError(...a);
|
||||
};
|
||||
window.filmcraftFatal = (what) => {
|
||||
if (window.filmcraftLoad.fatal) return;
|
||||
const msg = lastPanic || String(what && (what.stack || what.message) || what);
|
||||
window.filmcraftLoad.fatal = msg;
|
||||
const el = document.getElementById("fatal");
|
||||
document.getElementById("fatal-msg").textContent = msg.split("\n\nStack:")[0];
|
||||
el.classList.add("on");
|
||||
};
|
||||
document.getElementById("fatal-reload").onclick = () => location.reload();
|
||||
const isFatal = (e) => e instanceof WebAssembly.RuntimeError || /panicked at|unreachable|memory access out of bounds/.test(String(e && e.message || e));
|
||||
// only after start-up: a failed start is shown in #loading below
|
||||
addEventListener("error", (ev) => { if (window.filmcraftLoad.readyMs && isFatal(ev.error || ev.message)) window.filmcraftFatal(ev.error || ev.message); });
|
||||
addEventListener("unhandledrejection", (ev) => { if (window.filmcraftLoad.readyMs && isFatal(ev.reason)) window.filmcraftFatal(ev.reason); });
|
||||
</script>
|
||||
<script type="module">
|
||||
// `__FC_BUILD__` is replaced by `cargo xtask web` with a hash of the build: the glue and the
|
||||
// wasm are cached as immutable, so their URLs must change with every build.
|
||||
const build = "__FC_BUILD__";
|
||||
const v = build.startsWith("__") ? "" : "?v=" + build;
|
||||
const t0 = performance.now();
|
||||
const params = new URLSearchParams(location.search);
|
||||
try {
|
||||
await init();
|
||||
const { default: init, start } = await import("./filmcraft_web.js" + v);
|
||||
await init({ module_or_path: new URL("filmcraft_web_bg.wasm" + v, location.href) });
|
||||
window.filmcraftLoad.wasmMs = performance.now() - t0;
|
||||
await start("filmcraft_canvas");
|
||||
window.filmcraftLoad.readyMs = performance.now() - t0;
|
||||
} catch (e) {
|
||||
const s = document.getElementById("status");
|
||||
if (s) { s.className = "err"; s.textContent = "FilmCraft failed to start:\n" + (e && e.stack || e); }
|
||||
const text = String(e && e.stack || e);
|
||||
window.filmcraftLoad.error = String(e);
|
||||
// WebGPU was found but could not be started: try again on WebGL2 (once)
|
||||
if (!params.has("webgl") && !params.has("cpu") && /wgpu|webgpu|requestDevice|requestAdapter|GPUDevice|surface/i.test(text)) {
|
||||
params.set("webgl", "");
|
||||
location.replace(location.pathname + "?" + params.toString().replace(/=(&|$)/g, "$1") + location.hash);
|
||||
} else {
|
||||
const s = document.getElementById("status");
|
||||
if (s) { s.className = "err"; s.textContent = "FilmCraft failed to start:\n" + text; }
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
@@ -68,6 +70,9 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
@@ -85,7 +90,7 @@ fn main() -> eframe::Result {
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
let started = eframe::run_native(
|
||||
"FilmCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
@@ -178,7 +183,16 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
);
|
||||
// The window could not be created (no usable GPU / graphics driver, no display): say so
|
||||
// instead of exiting silently, and keep the reason in the crash log.
|
||||
if let Err(e) = &started {
|
||||
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
|
||||
eprintln!("filmcraft: {msg}");
|
||||
filmcraft_ui_egui::crash::record(&msg);
|
||||
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -31,6 +31,8 @@
|
||||
//! # Ok::<(), filmcraft_aac::Error>(())
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod config;
|
||||
mod decoder;
|
||||
mod encoder;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//!
|
||||
//! E-AC-3 (bsid 11-16) is recognised and refused.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod tables;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
@@ -359,8 +361,7 @@ fn bit_allocation(exp: &[u8; 256], p: &AllocParams, g: &Globals, bap: &mut [u8;
|
||||
let bndend = tables::MASKTAB[end - 1] as usize + 1;
|
||||
let mut excite = [0i32; 50];
|
||||
let (mut fastleak, mut slowleak) = p.leak.unwrap_or((0, 0));
|
||||
let begin;
|
||||
if bndstrt == 0 {
|
||||
let begin = if bndstrt == 0 {
|
||||
// the last band of the LFE channel (bndend 7) has no band above it
|
||||
let lfe_last = |bin: usize| !(bndend == 7 && bin == 6);
|
||||
let mut lowcomp = 0;
|
||||
@@ -391,10 +392,10 @@ fn bit_allocation(exp: &[u8; 256], p: &AllocParams, g: &Globals, bap: &mut [u8;
|
||||
slowleak = slowleak.max(bndpsd[bin] - sgain);
|
||||
excite[bin] = (fastleak - lowcomp).max(slowleak);
|
||||
}
|
||||
begin = 22;
|
||||
22
|
||||
} else {
|
||||
begin = bndstrt;
|
||||
}
|
||||
bndstrt
|
||||
};
|
||||
for bin in begin..bndend {
|
||||
fastleak -= fdecay;
|
||||
fastleak = fastleak.max(bndpsd[bin] - p.fgain);
|
||||
@@ -1030,7 +1031,7 @@ impl Decoder {
|
||||
/// Coded channel order (Table 5.8) → WAV / SMPTE order: fronts L R C, LFE, surrounds.
|
||||
fn wav_order(mut pcm: Vec<Vec<f32>>, acmod: usize, lfeon: bool) -> Vec<Vec<f32>> {
|
||||
let nf = NFCHANS[acmod];
|
||||
let lfe = lfeon.then(|| pcm.pop().expect("lfe"));
|
||||
let lfe = if lfeon { pcm.pop() } else { None };
|
||||
let mut fbw = pcm;
|
||||
// acmod 3, 5, 7 code L C R …: move C after R
|
||||
if acmod & 1 == 1 && acmod != 1 {
|
||||
|
||||
@@ -99,7 +99,7 @@ fn ffmpeg_encoded_streams_decode() {
|
||||
}
|
||||
}
|
||||
let o = Command::new(&ff).args(["-v", "error", "-i"]).arg(&f).args(["-f", "f32le", "-"]).output().unwrap();
|
||||
let want: Vec<f32> = o.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let want: Vec<f32> = o.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let ch = s.channels as usize;
|
||||
let n = want.len() / ch;
|
||||
assert_eq!(n, ours[0].len(), "{}: sample count", s.name);
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn matrix(from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
|
||||
let (ni, no) = (from.channels(), to.channels());
|
||||
let mut m = vec![vec![0.0f32; ni]; no];
|
||||
match (from, to) {
|
||||
(a, b) if a == b => {
|
||||
(Layout::Mono, Layout::Mono) | (Layout::Stereo, Layout::Stereo) | (Layout::Surround51, Layout::Surround51) => {
|
||||
for (i, row) in m.iter_mut().enumerate() {
|
||||
row[i] = 1.0;
|
||||
}
|
||||
@@ -159,7 +159,6 @@ pub fn matrix(from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
|
||||
m[1][LFE] = K;
|
||||
}
|
||||
}
|
||||
_ => unreachable!("all layout pairs are covered"),
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! denormals never appear (no FTZ/DAZ CPU flags needed, which keeps the crate `unsafe`-free and
|
||||
//! portable to wasm).
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod biquad;
|
||||
pub mod channels;
|
||||
pub mod design;
|
||||
@@ -169,12 +171,11 @@ impl ParamValues {
|
||||
pub(crate) fn get(&self, id: &str) -> Option<f32> {
|
||||
self.index_of(id).map(|i| self.values[i])
|
||||
}
|
||||
/// Value of a parameter that is known to exist (panics otherwise — programming error).
|
||||
/// Value of a parameter that is known to exist (0 otherwise — a programming error caught by
|
||||
/// debug builds).
|
||||
pub(crate) fn v(&self, id: &str) -> f32 {
|
||||
match self.get(id) {
|
||||
Some(v) => v,
|
||||
None => panic!("unknown parameter {id}"),
|
||||
}
|
||||
debug_assert!(self.get(id).is_some(), "unknown parameter {id}");
|
||||
self.get(id).unwrap_or(0.0)
|
||||
}
|
||||
pub(crate) fn on(&self, id: &str) -> bool {
|
||||
self.v(id) >= 0.5
|
||||
|
||||
@@ -42,7 +42,9 @@ impl BridgeClient {
|
||||
*guard = Some((BufReader::new(r), w));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let conn = guard.as_mut().expect("connected");
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
let res: Result<Value, AutomationError> = async {
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! (`filmcraft --control <port>`), so agents can also inspect, screenshot, click, drag, scroll
|
||||
//! and type in the live UI — every menu, panel, timeline gesture and keystroke.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod bridge;
|
||||
pub mod server;
|
||||
|
||||
|
||||
+32
-1
@@ -58,7 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
|
||||
with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and
|
||||
`16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit).
|
||||
|
||||
`tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
|
||||
`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
|
||||
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
|
||||
OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd
|
||||
sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame
|
||||
with libdav1d: **bit-exact** on all 6 fixtures.
|
||||
@@ -91,6 +92,36 @@ noisy; single-threaded figures use thread CPU time and are comparable.
|
||||
| `perf_1080p_gop_hq` (crf 18) | 19.5 fps | 19.4 fps | 2.4 fps | 3.6 fps |
|
||||
| `perf_1080p_gop_tiles` (4x2 tiles) | 44.4 fps | 44.7 fps | 2.7 fps | 16.3 fps |
|
||||
|
||||
### M4.10 (2026-10-03)
|
||||
|
||||
CPU cycles per frame counted by the kernel (`/usr/bin/time -l`, all threads summed; mean of two
|
||||
interleaved base / after rounds at load average 190-320) with
|
||||
`AV1_THREADS=n cargo run --release -p filmcraft-av1 --example av1dec -- in.ivf` on the
|
||||
`cargo xtask bench` decode clips (SVT-AV1 preset 10, CRF 35, keyint 48, testsrc2 + grain),
|
||||
bit-exact with libdav1d:
|
||||
|
||||
| stream | threads | Mcycles / frame before | after | after, draft mode |
|
||||
|---|---|---|---|---|
|
||||
| 1080p (120 frames) | 1 | 60.5 | **48.3** (−20 %) | 46.2 |
|
||||
| 1080p | 14 | 63.6 | **51.9** (−18 %) | 50.3 |
|
||||
| 2160p (72 frames) | 1 | 212.5 | **172.4** (−19 %) | 167.9 |
|
||||
| 2160p | 14 | 220.0 | **177.1** (−20 %) | 171.1 |
|
||||
|
||||
What changed: the sub-pixel filter passes run over fixed block widths (taps outer, columns
|
||||
inner); frame and tile-region planes come from a bounded pool of recycled buffers (zeroed on
|
||||
reuse) instead of a fresh `calloc` and `munmap` per frame (allocation, page faults and zeroing
|
||||
were ~10 % of the time); the palette mode-info arrays (40 bytes per 4x4 unit) are only
|
||||
allocated when screen content tools are allowed; CDF adaptation is branch-free (a branch-free
|
||||
symbol search was tried and was slower: most symbols have 2-4 values). Single-threaded 4K now
|
||||
splits into coefficient parsing ~20 %, inter prediction ~18 %, other block-level parsing
|
||||
~12 %, deblocking ~11 % (still one edge at a time), inverse transforms ~12 %, CDEF ~5 %.
|
||||
|
||||
Draft mode (`Decoder::set_draft(true)`, `AV1_DRAFT=1`; reduced-resolution playback only): shown
|
||||
frames whose `refresh_frame_flags` is 0 — no reference slot keeps them, so neither their samples,
|
||||
motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop restoration
|
||||
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
|
||||
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Decode an IVF AV1 file: `cargo run --release -p filmcraft-av1 --example av1dec -- in.ivf [out.yuv]`.
|
||||
//! Writes raw planar 16-bit little-endian output. `AV1_THREADS=n` sets the thread count,
|
||||
//! `AV1_LOOPS=k` decodes the file k times (benchmarking), `AV1_STATS=1` prints stage statistics.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("usage: av1dec in.ivf [out.yuv]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let data = std::fs::read(&args[1]).expect("read input");
|
||||
let mut out = args.get(2).map(|p| std::io::BufWriter::new(std::fs::File::create(p).expect("create output")));
|
||||
let threads = std::env::var("AV1_THREADS").ok().and_then(|s| s.parse().ok()).unwrap_or(0usize);
|
||||
let loops = std::env::var("AV1_LOOPS").ok().and_then(|s| s.parse().ok()).unwrap_or(1usize);
|
||||
let hl = u16::from_le_bytes([data[6], data[7]]) as usize;
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut n = 0;
|
||||
let mut dec = if threads > 0 { filmcraft_av1::Decoder::with_threads(threads) } else { filmcraft_av1::Decoder::new() };
|
||||
// AV1_DRAFT=1: draft mode (non-reference frames skip the in-loop filters).
|
||||
dec.set_draft(std::env::var_os("AV1_DRAFT").is_some());
|
||||
let mut write = |pics: Vec<filmcraft_av1::Picture>| {
|
||||
for pic in pics {
|
||||
n += 1;
|
||||
if let Some(o) = out.as_mut() {
|
||||
for pl in &pic.planes {
|
||||
let bytes: Vec<u8> = pl.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||
o.write_all(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for _ in 0..loops {
|
||||
let mut p = hl;
|
||||
let mut i = 0i64;
|
||||
while p + 12 <= data.len() {
|
||||
let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize;
|
||||
p += 12;
|
||||
let Some(tu) = data.get(p..p + sz) else { break };
|
||||
p += sz;
|
||||
match dec.decode_pts(tu, i) {
|
||||
Ok(pics) => write(pics),
|
||||
Err(e) => eprintln!("temporal unit {i}: {e}"),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
write(dec.flush());
|
||||
}
|
||||
eprintln!("{n} pictures in {:.3}s", t0.elapsed().as_secs_f64());
|
||||
if std::env::var_os("AV1_STATS").is_some() {
|
||||
eprintln!("{:#?}", dec.stats());
|
||||
}
|
||||
}
|
||||
@@ -237,12 +237,16 @@ fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(is
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let x: [i16; W] = b16[ci as usize..ci as usize + W].try_into().expect("centre row");
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let p: &[i16; W] = b16[start..start + W].try_into().expect("tap row");
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
// constrain(): .max / .min instead of clamp (whose bound check panics), and
|
||||
// the sign applied branch-free, so the loop vectorises.
|
||||
|
||||
@@ -72,10 +72,12 @@ impl<T: Clone> Slot<T> {
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
while g.is_none() {
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
g.as_ref().expect("slot value").clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +97,8 @@ struct FrameJob {
|
||||
picture: Option<Arc<Slot<Arc<Picture>>>>,
|
||||
pts: i64,
|
||||
apply_film_grain: bool,
|
||||
/// Draft mode: the frame refreshes no reference slot, so its in-loop filters are skipped.
|
||||
draft: bool,
|
||||
}
|
||||
|
||||
/// A shown picture in output order.
|
||||
@@ -193,6 +197,7 @@ pub struct Decoder {
|
||||
seen_frame_header: bool,
|
||||
/// Apply film grain synthesis to output frames (default true).
|
||||
pub apply_film_grain: bool,
|
||||
draft: bool,
|
||||
stats: Arc<Mutex<DecodeStats>>,
|
||||
threads: usize,
|
||||
pool: Pool,
|
||||
@@ -255,6 +260,7 @@ impl Decoder {
|
||||
pending: None,
|
||||
seen_frame_header: false,
|
||||
apply_film_grain: true,
|
||||
draft: false,
|
||||
stats,
|
||||
threads,
|
||||
pool,
|
||||
@@ -268,6 +274,14 @@ impl Decoder {
|
||||
}
|
||||
|
||||
/// Frame / tile counts and per-stage busy time of the frames finished so far.
|
||||
/// Draft mode for reduced-resolution playback (off by default): shown frames that refresh
|
||||
/// no reference slot (nothing can predict from them, read their motion vectors or CDFs)
|
||||
/// skip deblocking, CDEF and loop restoration and come out flagged [`Picture::draft`];
|
||||
/// every other picture is unchanged.
|
||||
pub fn set_draft(&mut self, on: bool) {
|
||||
self.draft = on;
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> DecodeStats {
|
||||
self.stats.lock().map(|s| *s).unwrap_or_default()
|
||||
}
|
||||
@@ -296,7 +310,7 @@ impl Decoder {
|
||||
if !o.is_ready() && self.out.len() <= max_queue {
|
||||
break;
|
||||
}
|
||||
let o = self.out.pop_front().expect("front");
|
||||
let Some(o) = self.out.pop_front() else { break };
|
||||
pics.push(o.take()?);
|
||||
}
|
||||
parsed?;
|
||||
@@ -346,7 +360,9 @@ impl Decoder {
|
||||
match last {
|
||||
Some((lt, lsid)) if lt == t => {
|
||||
if sid >= lsid {
|
||||
*sel.last_mut().expect("picture") = o;
|
||||
if let Some(l) = sel.last_mut() {
|
||||
*l = o;
|
||||
}
|
||||
last = Some((t, sid));
|
||||
}
|
||||
}
|
||||
@@ -503,6 +519,7 @@ impl Decoder {
|
||||
return Err(Error::Invalid("primary reference frame missing"));
|
||||
}
|
||||
let picture = fh.show_frame.then(Slot::new);
|
||||
let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0;
|
||||
self.pending = Some(Box::new(FrameJob {
|
||||
seq,
|
||||
fh,
|
||||
@@ -513,6 +530,7 @@ impl Decoder {
|
||||
picture,
|
||||
pts: self.pts,
|
||||
apply_film_grain: self.apply_film_grain,
|
||||
draft,
|
||||
}));
|
||||
Ok(None)
|
||||
}
|
||||
@@ -598,7 +616,7 @@ impl Decoder {
|
||||
self.in_flight.push_back(slot);
|
||||
// Bound the frames in flight (memory): wait for the oldest.
|
||||
while self.in_flight.len() > self.max_in_flight {
|
||||
let s = self.in_flight.pop_front().expect("in flight");
|
||||
let Some(s) = self.in_flight.pop_front() else { break };
|
||||
let _ = s.wait();
|
||||
}
|
||||
self.in_flight.retain(|s| !s.is_set());
|
||||
@@ -692,7 +710,8 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
|
||||
{
|
||||
cdfs = s;
|
||||
}
|
||||
crate::postfilter::apply(&mut fs, stats, pool);
|
||||
crate::postfilter::apply(&mut fs, stats, pool, job.draft);
|
||||
stats.draft_frames += job.draft as u64;
|
||||
let t = Timer::start();
|
||||
stats.frames += 1;
|
||||
if fh.seg.enabled && !fh.seg.update_map {
|
||||
@@ -714,8 +733,11 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
|
||||
saved_ref_frames: mf_refs,
|
||||
saved_mvs: mf_mvs,
|
||||
});
|
||||
let pic =
|
||||
fh.show_frame.then(|| output_picture(seq, &rf, fh.upscaled_width as usize, fh.frame_height as usize, &fh.film_grain, job.apply_film_grain, job.pts));
|
||||
let pic = fh.show_frame.then(|| {
|
||||
let mut p = output_picture(seq, &rf, fh.upscaled_width as usize, fh.frame_height as usize, &fh.film_grain, job.apply_film_grain, job.pts);
|
||||
p.draft = job.draft;
|
||||
p
|
||||
});
|
||||
stats.add(Stage::Output, t.secs());
|
||||
Ok((rf, pic))
|
||||
}
|
||||
@@ -800,6 +822,7 @@ fn output_picture(seq: &SequenceHeader, rf: &RefFrame, w: usize, h: usize, grain
|
||||
matrix_coefficients: rf.color.2,
|
||||
full_range: rf.color.3,
|
||||
pts,
|
||||
draft: false,
|
||||
};
|
||||
if apply_film_grain && seq.film_grain_params_present && grain.apply_grain {
|
||||
crate::grain::apply(&mut pic, grain, seq);
|
||||
|
||||
+57
-9
@@ -4,8 +4,32 @@
|
||||
//! or [`MiInfo`] may hold a region of the frame (origin `ox`, `oy`), addressed with frame
|
||||
//! coordinates throughout.
|
||||
|
||||
/// Recycled plane allocations: every frame allocates (and every evicted reference frees)
|
||||
/// megabytes of planes, and fresh large allocations cost page faults and kernel calls. Buffers
|
||||
/// are zeroed again on reuse, so contents never leak between frames.
|
||||
static PLANE_POOL: std::sync::Mutex<Vec<Vec<u16>>> = std::sync::Mutex::new(Vec::new());
|
||||
const POOL_MIN_LEN: usize = 1 << 16;
|
||||
/// Samples kept at most (96 M samples = 192 MB, a few 4K frames).
|
||||
const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
|
||||
/// A zeroed buffer of `len` samples (recycled memory for large ones).
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
v.clear();
|
||||
v.resize(len, 0);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
vec![0; len]
|
||||
}
|
||||
|
||||
/// One plane of samples (u16 at every bit depth), or a rectangular region of one.
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Default)]
|
||||
pub struct Plane {
|
||||
pub data: Vec<u16>,
|
||||
pub stride: usize,
|
||||
@@ -16,13 +40,34 @@ pub struct Plane {
|
||||
pub oy: usize,
|
||||
}
|
||||
|
||||
impl Clone for Plane {
|
||||
fn clone(&self) -> Plane {
|
||||
let mut data = zeroed(self.data.len());
|
||||
data.copy_from_slice(&self.data);
|
||||
Plane { data, stride: self.stride, rows: self.rows, ox: self.ox, oy: self.oy }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
pub fn new(width: usize, height: usize) -> Plane {
|
||||
Plane { data: vec![0; width * height], stride: width, rows: height, ox: 0, oy: 0 }
|
||||
Plane { data: zeroed(width * height), stride: width, rows: height, ox: 0, oy: 0 }
|
||||
}
|
||||
/// A `width` x `height` region with its top-left sample at frame position (ox, oy).
|
||||
pub fn region(ox: usize, oy: usize, width: usize, height: usize) -> Plane {
|
||||
Plane { data: vec![0; width * height], stride: width, rows: height, ox, oy }
|
||||
Plane { data: zeroed(width * height), stride: width, rows: height, ox, oy }
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn at(&self, x: usize, y: usize) -> u16 {
|
||||
@@ -173,13 +218,16 @@ pub struct MiInfo {
|
||||
}
|
||||
|
||||
impl MiInfo {
|
||||
pub fn new(cols: usize, rows: usize) -> MiInfo {
|
||||
MiInfo::region(cols, rows, 0, 0, cols, rows)
|
||||
/// `palette`: whether palette mode can occur (allow_screen_content_tools); without it the
|
||||
/// palette arrays (40 bytes per unit) stay empty.
|
||||
pub fn new(cols: usize, rows: usize, palette: bool) -> MiInfo {
|
||||
MiInfo::region(cols, rows, 0, 0, cols, rows, palette)
|
||||
}
|
||||
|
||||
/// Storage for the w x h mode info units at (ox, oy) of a cols x rows frame.
|
||||
pub fn region(cols: usize, rows: usize, ox: usize, oy: usize, w: usize, h: usize) -> MiInfo {
|
||||
pub fn region(cols: usize, rows: usize, ox: usize, oy: usize, w: usize, h: usize, palette: bool) -> MiInfo {
|
||||
let n = w * h;
|
||||
let np = if palette { n } else { 0 };
|
||||
MiInfo {
|
||||
cols,
|
||||
rows,
|
||||
@@ -198,8 +246,8 @@ impl MiInfo {
|
||||
inter_tx_size: vec![0; n],
|
||||
mi_size: vec![0; n],
|
||||
segment_id: vec![0; n],
|
||||
palette_size: [vec![0; n], vec![0; n]],
|
||||
palette_colors: [vec![[0; 8]; n], vec![[0; 8]; n]],
|
||||
palette_size: [vec![0; np], vec![0; np]],
|
||||
palette_colors: [vec![[0; 8]; np], vec![[0; 8]; np]],
|
||||
delta_lf: vec![[0; 4]; n],
|
||||
comp_group_idx: vec![0; n],
|
||||
compound_idx: vec![0; n],
|
||||
@@ -228,7 +276,7 @@ impl MiInfo {
|
||||
)*};
|
||||
}
|
||||
copy!(y_mode uv_mode ref_frame mv is_inter skip_mode skip tx_size inter_tx_size mi_size segment_id delta_lf comp_group_idx compound_idx interp_filter motion_mode tx_type written);
|
||||
for k in 0..2 {
|
||||
for k in (0..2).filter(|&k| !self.palette_size[k].is_empty()) {
|
||||
for y in 0..h {
|
||||
let d = dst.idx(self.oy + y, self.ox);
|
||||
dst.palette_size[k][d..d + w].copy_from_slice(&self.palette_size[k][y * w..(y + 1) * w]);
|
||||
|
||||
+87
-19
@@ -180,7 +180,11 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
|
||||
fn ref_plane(&self, ref_idx: i32, plane: usize) -> &Plane {
|
||||
if ref_idx < 0 { &self.t.cur.planes[plane] } else { &self.fs.refs[ref_idx as usize].as_ref().expect("reference frame").buf.planes[plane] }
|
||||
// A missing reference (rejected earlier for valid streams) predicts from the current frame.
|
||||
match usize::try_from(ref_idx).ok().and_then(|i| self.fs.refs.get(i)).and_then(|r| r.as_ref()) {
|
||||
Some(r) => &r.buf.planes[plane],
|
||||
None => &self.t.cur.planes[plane],
|
||||
}
|
||||
}
|
||||
|
||||
/// Block inter prediction process (7.11.3.4) into `pred` (stride PRED_STRIDE); `tmp` holds
|
||||
@@ -665,6 +669,49 @@ impl TileDecoder<'_, '_> {
|
||||
|
||||
const IDENTITY_TAPS: [i16; 8] = [0, 0, 0, 128, 0, 0, 0, 0];
|
||||
|
||||
/// Horizontal pass of one row for a block `W` wide: taps outer, columns inner, fixed width so
|
||||
/// the row stays in registers.
|
||||
#[inline(always)]
|
||||
fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
acc[j] += f * p[j] as i32;
|
||||
}
|
||||
}
|
||||
let Some(o) = out.first_chunk_mut::<W>() else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
o[j] = acc[j] >> sh;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical pass of one output row from 8 intermediate rows (`rows`, stride `W`).
|
||||
#[inline(always)]
|
||||
fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
acc[j] += f * p[j];
|
||||
}
|
||||
}
|
||||
let Some(o) = out.first_chunk_mut::<W>() else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
o[j] = acc[j] >> sh;
|
||||
}
|
||||
}
|
||||
|
||||
/// Block inter prediction without reference scaling: every column / row uses the same filter
|
||||
/// phase, so the filters run over whole rows (bit-identical to the per-sample process).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -711,17 +758,27 @@ fn mc_unscaled(
|
||||
*o = (v as i32) << (7 - r0);
|
||||
}
|
||||
} else {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&src[t..t + w]) {
|
||||
*a += f * v as i32;
|
||||
match w {
|
||||
2 => hpass::<2>(src, hf, out, half0, r0),
|
||||
4 => hpass::<4>(src, hf, out, half0, r0),
|
||||
8 => hpass::<8>(src, hf, out, half0, r0),
|
||||
16 => hpass::<16>(src, hf, out, half0, r0),
|
||||
32 => hpass::<32>(src, hf, out, half0, r0),
|
||||
64 => hpass::<64>(src, hf, out, half0, r0),
|
||||
_ => {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&src[t..t + w]) {
|
||||
*a += f * v as i32;
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half0) >> r0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half0) >> r0;
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in 0..h {
|
||||
@@ -731,17 +788,28 @@ fn mc_unscaled(
|
||||
*o = (128 * v + half1) >> r1;
|
||||
}
|
||||
} else {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&tmp[(r + t) * w..(r + t + 1) * w]) {
|
||||
*a += f * v;
|
||||
let rows = &tmp[r * w..(r + 8) * w];
|
||||
match w {
|
||||
2 => vpass::<2>(rows, vf, out, half1, r1),
|
||||
4 => vpass::<4>(rows, vf, out, half1, r1),
|
||||
8 => vpass::<8>(rows, vf, out, half1, r1),
|
||||
16 => vpass::<16>(rows, vf, out, half1, r1),
|
||||
32 => vpass::<32>(rows, vf, out, half1, r1),
|
||||
64 => vpass::<64>(rows, vf, out, half1, r1),
|
||||
_ => {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&tmp[(r + t) * w..(r + t + 1) * w]) {
|
||||
*a += f * v;
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half1) >> r1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half1) >> r1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
// Items used only by stages still being written (inter prediction, post filters).
|
||||
#![allow(dead_code)]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod bits;
|
||||
mod cdef;
|
||||
@@ -62,6 +63,9 @@ pub struct Picture {
|
||||
pub full_range: bool,
|
||||
/// Timestamp of the temporal unit that showed the picture ([`Decoder::decode_pts`]).
|
||||
pub pts: i64,
|
||||
/// Decoded in draft mode without deblocking / CDEF / loop restoration
|
||||
/// ([`Decoder::set_draft`]): approximate, for reduced-resolution playback only.
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
|
||||
@@ -6,14 +6,15 @@ use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
|
||||
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool) {
|
||||
/// `draft`: only super-resolution (which sets the output size) runs.
|
||||
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
|
||||
let mut t = Timer::start();
|
||||
let lvl = fs.fh.lf.level;
|
||||
if lvl[0] != 0 || lvl[1] != 0 {
|
||||
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
|
||||
loop_filter(fs);
|
||||
}
|
||||
stats.add(Stage::LoopFilter, t.lap());
|
||||
let cdef = crate::cdef::apply(fs);
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs) };
|
||||
stats.add(Stage::Cdef, t.lap());
|
||||
let (up_cur, up_cdef) = if fs.fh.use_superres {
|
||||
let c = crate::restoration::upscale(fs, &fs.cur);
|
||||
@@ -23,7 +24,11 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
|
||||
(std::mem::take(&mut fs.cur), cdef)
|
||||
};
|
||||
stats.add(Stage::Superres, t.lap());
|
||||
fs.cur = if fs.fh.lr.uses_lr { crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur)) } else { up_cdef.unwrap_or(up_cur) };
|
||||
fs.cur = if fs.fh.lr.uses_lr && !draft {
|
||||
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
|
||||
} else {
|
||||
up_cdef.unwrap_or(up_cur)
|
||||
};
|
||||
stats.add(Stage::Restoration, t.lap());
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ impl FrameState {
|
||||
prev_segment_ids: vec![0; mi_cols * mi_rows],
|
||||
},
|
||||
cur,
|
||||
mi: MiInfo::new(mi_cols, mi_rows),
|
||||
mi: MiInfo::new(mi_cols, mi_rows, fh.allow_screen_content_tools),
|
||||
cdef: vec![-1; cdef_cols * cdef_rows],
|
||||
lf_tx_size: std::array::from_fn(|_| Grid8::new(0, 0, mi_cols + 32, mi_rows + 32)),
|
||||
lr,
|
||||
@@ -260,7 +260,7 @@ impl FrameState {
|
||||
let x1 = if c1 >= mi_cols { usize::MAX } else { c1 * 4 };
|
||||
let y1 = if r1 >= mi_rows { usize::MAX } else { r1 * 4 };
|
||||
let cur = self.cur.region_like(c0 * 4, r0 * 4, x1, y1);
|
||||
let mi = MiInfo::region(mi_cols, mi_rows, c0, r0, c1 - c0, r1 - r0);
|
||||
let mi = MiInfo::region(mi_cols, mi_rows, c0, r0, c1 - c0, r1 - r0, !self.mi.palette_size[0].is_empty());
|
||||
let lf = std::array::from_fn(|p| {
|
||||
let (sx, sy) = if p == 0 { (0, 0) } else { (self.ssx, self.ssy) };
|
||||
let full = &self.lf_tx_size[p];
|
||||
|
||||
@@ -43,6 +43,8 @@ impl Stage {
|
||||
pub struct DecodeStats {
|
||||
/// Frames decoded (excluding show_existing_frame repeats).
|
||||
pub frames: u64,
|
||||
/// Frames decoded in draft mode without in-loop filters.
|
||||
pub draft_frames: u64,
|
||||
/// Tiles decoded.
|
||||
pub tiles: u64,
|
||||
/// Busy seconds per [`Stage`] (index with `stage as usize`).
|
||||
@@ -60,6 +62,7 @@ impl DecodeStats {
|
||||
|
||||
pub(crate) fn merge(&mut self, o: &DecodeStats) {
|
||||
self.frames += o.frames;
|
||||
self.draft_frames += o.draft_frames;
|
||||
self.tiles += o.tiles;
|
||||
for (a, b) in self.stage_secs.iter_mut().zip(o.stage_secs.iter()) {
|
||||
*a += b;
|
||||
|
||||
@@ -127,17 +127,12 @@ impl<'a> SymbolDecoder<'a> {
|
||||
pub(crate) fn update_cdf(cdf: &mut [u16], n: usize, symbol: usize) {
|
||||
let count = cdf[n];
|
||||
let rate = 3 + (count > 15) as u32 + (count > 31) as u32 + (31 - (n as u32).leading_zeros()).min(2);
|
||||
let mut tmp = 0u32;
|
||||
for i in 0..n - 1 {
|
||||
if i == symbol {
|
||||
tmp = 1 << 15;
|
||||
}
|
||||
let c = cdf[i] as u32;
|
||||
if tmp < c {
|
||||
cdf[i] = (c - ((c - tmp) >> rate)) as u16;
|
||||
} else {
|
||||
cdf[i] = (c + ((tmp - c) >> rate)) as u16;
|
||||
}
|
||||
// Entries below `symbol` move towards 0, the others towards 1 << 15 (the specification's
|
||||
// comparison with tmp, resolved per entry: c >= 0 and c <= 1 << 15), without branching on
|
||||
// the decoded symbol.
|
||||
for (i, e) in cdf[..n - 1].iter_mut().enumerate() {
|
||||
let c = *e as u32;
|
||||
*e = if i < symbol { c - (c >> rate) } else { c + (((1 << 15) - c) >> rate) } as u16;
|
||||
}
|
||||
cdf[n] += (count < 32) as u16;
|
||||
}
|
||||
@@ -474,10 +474,12 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
mi.tx_size[span.clone()].fill(b.tx_size as u8);
|
||||
mi.mi_size[span.clone()].fill(b.mi_size as u8);
|
||||
mi.segment_id[span.clone()].fill(b.segment_id as u8);
|
||||
mi.palette_size[0][span.clone()].fill(b.palette_size_y as u8);
|
||||
mi.palette_size[1][span.clone()].fill(b.palette_size_uv as u8);
|
||||
mi.palette_colors[0][span.clone()].fill(b.palette_colors_y);
|
||||
mi.palette_colors[1][span.clone()].fill(b.palette_colors_u);
|
||||
if !mi.palette_size[0].is_empty() {
|
||||
mi.palette_size[0][span.clone()].fill(b.palette_size_y as u8);
|
||||
mi.palette_size[1][span.clone()].fill(b.palette_size_uv as u8);
|
||||
mi.palette_colors[0][span.clone()].fill(b.palette_colors_y);
|
||||
mi.palette_colors[1][span.clone()].fill(b.palette_colors_u);
|
||||
}
|
||||
mi.delta_lf[span.clone()].fill(dlf);
|
||||
mi.motion_mode[span].fill(b.motion_mode);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn reference(ff: &Path, path: &Path, pix_fmt: &str) -> Vec<u16> {
|
||||
.expect("run ffmpeg");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
if pix_fmt.ends_with("le") {
|
||||
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
} else {
|
||||
out.stdout.iter().map(|&b| b as u16).collect()
|
||||
}
|
||||
@@ -89,7 +89,13 @@ pub fn decode_all(path: &Path) -> Result<Vec<Picture>, String> {
|
||||
|
||||
/// Decode every frame of an IVF file with a decoder using `threads` threads.
|
||||
pub fn decode_all_threads(path: &Path, threads: usize) -> Result<Vec<Picture>, String> {
|
||||
decode_all_opts(path, threads, false)
|
||||
}
|
||||
|
||||
/// [`decode_all_threads`] with draft mode on or off.
|
||||
pub fn decode_all_opts(path: &Path, threads: usize, draft: bool) -> Result<Vec<Picture>, String> {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
dec.set_draft(draft);
|
||||
let mut pics = Vec::new();
|
||||
for (i, f) in ivf_frames(path).iter().enumerate() {
|
||||
let p = dec.decode_pts(f, i as i64).map_err(|e| format!("frame {i}: {e}"))?;
|
||||
@@ -174,6 +180,7 @@ pub fn check_file(ff: &Path, name: &str, path: &Path) {
|
||||
assert!(a == b, "{name}: frame {i} differs with {threads} threads");
|
||||
}
|
||||
assert!(!pics.is_empty(), "{name}: no frames");
|
||||
assert!(pics.iter().all(|p| !p.draft), "{name}: picture flagged draft without draft mode");
|
||||
let raw = reference(ff, path, pix_fmt_for(&pics[0]));
|
||||
let per = picture_samples(&pics[0]);
|
||||
assert_eq!(raw.len(), per * pics.len(), "{name}: frame count/size ({} pictures)", pics.len());
|
||||
|
||||
@@ -40,6 +40,34 @@ fn inter_gops() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft mode: frames that refresh no reference slot skip the in-loop filters and are flagged;
|
||||
/// every other picture stays bit-exact, with 1 and several threads.
|
||||
#[test]
|
||||
fn draft_mode_changes_only_flagged_non_reference_frames() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let s = &specs()[0];
|
||||
let path = make(&ff, s);
|
||||
let first = decode_all_threads(&path, 1).expect("decode");
|
||||
let raw = reference(&ff, &path, pix_fmt_for(&first[0]));
|
||||
let per = picture_samples(&first[0]);
|
||||
for threads in [1, 4] {
|
||||
let pics = decode_all_opts(&path, threads, true).expect("decode");
|
||||
assert_eq!(pics.len(), first.len(), "threads {threads}");
|
||||
let (mut draft, mut changed) = (0, 0);
|
||||
for (i, p) in pics.iter().enumerate() {
|
||||
let (_, count) = compare(p, &raw[i * per..(i + 1) * per]);
|
||||
if p.draft {
|
||||
draft += 1;
|
||||
changed += (count > 0) as usize;
|
||||
} else {
|
||||
assert_eq!(count, 0, "threads {threads}: unflagged frame {i} differs");
|
||||
}
|
||||
}
|
||||
assert!(draft > 0, "threads {threads}: no non-reference frames");
|
||||
assert!(changed > 0, "threads {threads}: draft frames identical to the filtered ones");
|
||||
}
|
||||
}
|
||||
|
||||
/// Several tiles (decoded in parallel into private buffers, then merged).
|
||||
fn specs_tiles() -> Vec<Spec> {
|
||||
let p = |preset: &'static str, params: &'static str| -> Vec<&'static str> { vec!["-preset", preset, "-crf", "32", "-svtav1-params", params] };
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//! - **Burn-in:** [`burn`] lays a caption out with the track style and draws it with the
|
||||
//! `filmcraft-text` engine (Inter SemiBold, shaped and kerned).
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod burn;
|
||||
pub mod cea608;
|
||||
pub mod mcc;
|
||||
@@ -212,7 +214,7 @@ pub fn parse(bytes: &[u8], format: Format) -> Result<Document> {
|
||||
Format::Scc => scc::parse(&text),
|
||||
Format::Mcc => mcc::parse(&text),
|
||||
Format::Ttml | Format::Dfxp => ttml::parse(&text),
|
||||
Format::Stl => unreachable!("handled above"),
|
||||
Format::Stl => stl::parse(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +255,7 @@ pub fn decode_text(bytes: &[u8]) -> String {
|
||||
} else if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
|
||||
let le = bytes[0] == 0xff;
|
||||
let units: Vec<u16> =
|
||||
bytes[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
|
||||
bytes[2..].as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
match std::str::from_utf8(bytes) {
|
||||
|
||||
@@ -411,10 +411,10 @@ impl Dec708 {
|
||||
}
|
||||
fn put(&mut self, c: char) {
|
||||
let w = self.w();
|
||||
if w.rows.is_empty() {
|
||||
w.rows.push(String::new());
|
||||
match w.rows.last_mut() {
|
||||
Some(row) => row.push(c),
|
||||
None => w.rows.push(c.to_string()),
|
||||
}
|
||||
w.rows.last_mut().expect("row").push(c);
|
||||
}
|
||||
fn bitmap(&mut self, m: u8, f: impl Fn(&mut Win)) {
|
||||
for (i, w) in self.win.iter_mut().enumerate() {
|
||||
|
||||
@@ -251,7 +251,9 @@ pub fn parse(bytes: &[u8]) -> Result<Document> {
|
||||
if !sniff(bytes) {
|
||||
return Err(Error::NotFormat("EBU STL"));
|
||||
}
|
||||
let rate = rate_of(&bytes[3..11]).expect("sniffed");
|
||||
let Some(rate) = bytes.get(3..11).and_then(rate_of) else {
|
||||
return Err(Error::NotFormat("EBU STL"));
|
||||
};
|
||||
let gsi = &bytes[..GSI];
|
||||
// start of programme (HHMMSSFF, ASCII)
|
||||
let tcp = ascii_field(&gsi[256..264]);
|
||||
@@ -277,7 +279,7 @@ pub fn parse(bytes: &[u8]) -> Result<Document> {
|
||||
}
|
||||
}
|
||||
};
|
||||
for blk in bytes[GSI..].chunks_exact(TTI) {
|
||||
for blk in bytes[GSI..].as_chunks::<TTI>().0 {
|
||||
let sn = u16::from_le_bytes([blk[1], blk[2]]);
|
||||
let ebn = blk[3];
|
||||
let cf = blk[15];
|
||||
|
||||
@@ -103,7 +103,7 @@ fn inline(text: &str) -> String {
|
||||
rest = &after[end + 1..];
|
||||
continue;
|
||||
}
|
||||
let ch = rest.chars().next().expect("non-empty");
|
||||
let Some(ch) = rest.chars().next() else { break };
|
||||
if ch == '\n' {
|
||||
out.push_str("<br/>");
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "filmcraft-cfb"
|
||||
description = "Clean-room Compound File Binary (Microsoft structured storage, [MS-CFB]) reader and writer: the container of AAF files"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,52 @@
|
||||
# filmcraft-cfb
|
||||
|
||||
A clean-room reader and writer for the Compound File Binary format (Microsoft "structured
|
||||
storage"), the low-level container of AAF files. Layer L0: no dependencies beyond `std`, no
|
||||
`unsafe`, builds for `wasm32-unknown-unknown`. No third-party implementation (the AAF SDK's
|
||||
structured storage code, libgsf, olefile, POI…) was consulted.
|
||||
|
||||
## Specification
|
||||
|
||||
| Document | Edition | Used for |
|
||||
|---|---|---|
|
||||
| [MS-CFB] Compound File Binary File Format (Microsoft Open Specifications) | revision 10.0 (the format is unchanged since 1.0) | header, sector chains, FAT / DIFAT / mini FAT, directory entries, red-black sibling trees, name comparison, version 3 and 4 files |
|
||||
|
||||
## Reading
|
||||
|
||||
```rust
|
||||
let bytes = std::fs::read("sequence.aaf")?;
|
||||
let cf = filmcraft_cfb::CompoundFile::open(&bytes)?;
|
||||
let header = cf.find("Header-2")?; // a storage
|
||||
println!("{:02x?}", cf.entries[header].clsid); // its class id
|
||||
let props = cf.read_path("Header-2/properties")?; // a stream
|
||||
for &child in &cf.entries[header].children { /* sorted siblings */ }
|
||||
```
|
||||
|
||||
- Accepts version 3 (512-byte sectors) and version 4 (4096-byte sectors) files; other sector
|
||||
shifts between 7 and 16 are tolerated.
|
||||
- Every chain (DIFAT, FAT, mini FAT, directory, streams) is bounds-checked and cycle-checked; a
|
||||
directory tree that references an entry twice is rejected. Damaged or truncated files return
|
||||
`Error::Invalid`, never panic or loop.
|
||||
- Version 3 stream sizes ignore the high 32 bits (§2.6.3).
|
||||
|
||||
## Writing
|
||||
|
||||
```rust
|
||||
let mut w = filmcraft_cfb::Writer::new(filmcraft_cfb::Version::V4);
|
||||
w.set_clsid(filmcraft_cfb::Writer::ROOT, root_class);
|
||||
let h = w.storage(filmcraft_cfb::Writer::ROOT, "Header-2", header_class)?;
|
||||
w.stream(h, "properties", bytes)?;
|
||||
let file: Vec<u8> = w.finish();
|
||||
```
|
||||
|
||||
Layout: stream sectors (streams ≥ 4096 bytes), mini stream, mini FAT, directory, FAT, DIFAT. Each
|
||||
chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB] name order
|
||||
(length first, then upper-cased code units) coloured as valid red-black trees. Names are checked
|
||||
(1–31 UTF-16 code units, none of `/ \ : !`, unique per storage, case-insensitively).
|
||||
|
||||
## Tests
|
||||
|
||||
`src/tests.rs`: round trips for both versions with empty, mini, boundary (63/64/65, 4095/4096/4097
|
||||
bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
|
||||
a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
|
||||
29 bytes and 1500 random mutations never panic and only return complete streams.
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Clean-room Compound File Binary format reader and writer ("structured storage", the container
|
||||
//! of AAF files), written from the public Microsoft Open Specification **[MS-CFB]: Compound File
|
||||
//! Binary File Format** (protocol revision 10.0 and earlier editions; the on-disk format has not
|
||||
//! changed since 1.0).
|
||||
//!
|
||||
//! A compound file is a small FAT file system inside one file: fixed-size sectors (512 bytes in
|
||||
//! version 3, 4096 bytes in version 4) chained by a file allocation table, a directory of 128-byte
|
||||
//! entries arranged as one red-black tree of siblings per storage, and a "mini stream" with a mini
|
||||
//! FAT for streams smaller than 4096 bytes.
|
||||
//!
|
||||
//! - [`CompoundFile::open`] parses a file held in memory (no copies of large streams; every chain
|
||||
//! is bounds- and cycle-checked, so damaged files give errors, never panics or endless loops).
|
||||
//! - [`Writer`] builds a tree of storages and streams and lays it out in one pass
|
||||
//! ([`Writer::finish`]): stream sectors, mini stream, mini FAT, directory, FAT and DIFAT.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use read::{CompoundFile, Entry, EntryKind};
|
||||
pub use write::{NodeId, Version, Writer};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
/// The 8-byte header signature (§2.2).
|
||||
pub const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
/// Largest regular sector number (§2.1).
|
||||
pub const MAXREGSECT: u32 = 0xFFFF_FFFA;
|
||||
/// Sector used by the DIFAT.
|
||||
pub const DIFSECT: u32 = 0xFFFF_FFFC;
|
||||
/// Sector used by the FAT.
|
||||
pub const FATSECT: u32 = 0xFFFF_FFFD;
|
||||
/// End of a sector chain.
|
||||
pub const ENDOFCHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Unallocated sector.
|
||||
pub const FREESECT: u32 = 0xFFFF_FFFF;
|
||||
/// No sibling / child directory entry.
|
||||
pub const NOSTREAM: u32 = 0xFFFF_FFFF;
|
||||
/// Streams shorter than this live in the mini stream (§2.2 `Mini Stream Cutoff Size`).
|
||||
pub const MINI_STREAM_CUTOFF: u32 = 4096;
|
||||
/// Mini sector size (§2.2: mini sector shift 6).
|
||||
pub const MINI_SECTOR_SIZE: usize = 64;
|
||||
/// Longest directory entry name in UTF-16 code units, without the terminating NUL (§2.6.1).
|
||||
pub const MAX_NAME_LEN: usize = 31;
|
||||
|
||||
/// Errors from the reader and writer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not a compound file (bad signature or byte order mark).
|
||||
NotCfb,
|
||||
/// Structurally invalid or truncated.
|
||||
Invalid(String),
|
||||
/// No entry with that name / path.
|
||||
NotFound(String),
|
||||
/// A name the format cannot store (too long, illegal characters, duplicate).
|
||||
BadName(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::NotCfb => f.write_str("not a compound file"),
|
||||
Error::Invalid(s) => write!(f, "invalid compound file: {s}"),
|
||||
Error::NotFound(s) => write!(f, "no entry {s:?}"),
|
||||
Error::BadName(s) => write!(f, "invalid entry name {s:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Whether `head` starts with the compound file signature.
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
head.len() >= 8 && head[..8] == SIGNATURE
|
||||
}
|
||||
|
||||
fn upper(u: u16) -> u16 {
|
||||
if (b'a' as u16..=b'z' as u16).contains(&u) {
|
||||
return u - 32;
|
||||
}
|
||||
if u < 0x80 {
|
||||
return u;
|
||||
}
|
||||
match char::from_u32(u as u32) {
|
||||
Some(c) => {
|
||||
let mut up = c.to_uppercase();
|
||||
match (up.next(), up.next()) {
|
||||
(Some(x), None) if (x as u32) <= 0xFFFF => x as u32 as u16,
|
||||
_ => u,
|
||||
}
|
||||
}
|
||||
None => u,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sibling order of directory entries (§2.6.4): shorter names first, then a code-unit
|
||||
/// comparison of the upper-cased names.
|
||||
pub fn compare_names(a: &str, b: &str) -> Ordering {
|
||||
let ua: Vec<u16> = a.encode_utf16().collect();
|
||||
let ub: Vec<u16> = b.encode_utf16().collect();
|
||||
ua.len().cmp(&ub.len()).then_with(|| ua.iter().map(|&u| upper(u)).cmp(ub.iter().map(|&u| upper(u))))
|
||||
}
|
||||
|
||||
/// Check a directory entry name (§2.6.1): 1–31 UTF-16 code units, none of `/ \ : !`.
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
let n = name.encode_utf16().count();
|
||||
if n == 0 || n > MAX_NAME_LEN || name.contains(['/', '\\', ':', '!']) {
|
||||
return Err(Error::BadName(name.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Reading compound files ([MS-CFB] §2.2–2.6).
|
||||
|
||||
use crate::{ENDOFCHAIN, Error, FREESECT, MAXREGSECT, MINI_SECTOR_SIZE, NOSTREAM, Result, SIGNATURE, compare_names};
|
||||
|
||||
/// Directory entry object type (§2.6.1).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntryKind {
|
||||
Root,
|
||||
Storage,
|
||||
Stream,
|
||||
/// Unallocated (type 0) or an unknown type.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One directory entry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub kind: EntryKind,
|
||||
/// Class id of a storage (AAF: the class of the object stored in it).
|
||||
pub clsid: [u8; 16],
|
||||
pub state_bits: u32,
|
||||
/// FILETIME stamps (0 when not set).
|
||||
pub created: u64,
|
||||
pub modified: u64,
|
||||
/// First sector (regular or mini) of a stream; the mini stream's for the root.
|
||||
pub start: u32,
|
||||
pub size: u64,
|
||||
/// Child entries of a storage, in sibling-tree order (sorted by [`compare_names`]).
|
||||
pub children: Vec<usize>,
|
||||
left: u32,
|
||||
right: u32,
|
||||
child: u32,
|
||||
}
|
||||
|
||||
/// A parsed compound file over borrowed bytes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompoundFile<'a> {
|
||||
data: &'a [u8],
|
||||
/// Major version: 3 (512-byte sectors) or 4 (4096-byte sectors).
|
||||
pub version: u16,
|
||||
/// Header CLSID (normally zero).
|
||||
pub header_clsid: [u8; 16],
|
||||
sector_size: usize,
|
||||
mini_cutoff: u64,
|
||||
fat: Vec<u32>,
|
||||
minifat: Vec<u32>,
|
||||
mini_stream: Vec<u8>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> u16 {
|
||||
u16::from_le_bytes([b[at], b[at + 1]])
|
||||
}
|
||||
fn u32_at(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
|
||||
}
|
||||
fn u64_at(b: &[u8], at: usize) -> u64 {
|
||||
u64::from_le_bytes(b[at..at + 8].try_into().unwrap_or([0; 8]))
|
||||
}
|
||||
|
||||
fn invalid(s: impl Into<String>) -> Error {
|
||||
Error::Invalid(s.into())
|
||||
}
|
||||
|
||||
impl<'a> CompoundFile<'a> {
|
||||
/// Parse the header, FAT, mini FAT, directory and mini stream of `data`.
|
||||
pub fn open(data: &'a [u8]) -> Result<CompoundFile<'a>> {
|
||||
if data.len() < 512 || data[..8] != SIGNATURE || u16_at(data, 28) != 0xFFFE {
|
||||
return Err(Error::NotCfb);
|
||||
}
|
||||
let version = u16_at(data, 26);
|
||||
let shift = u16_at(data, 30);
|
||||
if !(7..=16).contains(&shift) {
|
||||
return Err(invalid(format!("sector shift {shift}")));
|
||||
}
|
||||
let mini_shift = u16_at(data, 32);
|
||||
if 1usize.checked_shl(mini_shift as u32) != Some(MINI_SECTOR_SIZE) {
|
||||
return Err(invalid(format!("mini sector shift {mini_shift}")));
|
||||
}
|
||||
let sector_size = 1usize << shift;
|
||||
let n_fat = u32_at(data, 44) as usize;
|
||||
let first_dir = u32_at(data, 48);
|
||||
let mini_cutoff = u32_at(data, 56) as u64;
|
||||
let first_minifat = u32_at(data, 60);
|
||||
let first_difat = u32_at(data, 68);
|
||||
let n_difat = u32_at(data, 72) as usize;
|
||||
let mut header_clsid = [0u8; 16];
|
||||
header_clsid.copy_from_slice(&data[8..24]);
|
||||
let max_sectors = data.len() / sector_size + 1;
|
||||
if n_fat > max_sectors {
|
||||
return Err(invalid(format!("{n_fat} FAT sectors in a {}-byte file", data.len())));
|
||||
}
|
||||
let mut cf = CompoundFile {
|
||||
data,
|
||||
version,
|
||||
header_clsid,
|
||||
sector_size,
|
||||
mini_cutoff,
|
||||
fat: Vec::new(),
|
||||
minifat: Vec::new(),
|
||||
mini_stream: Vec::new(),
|
||||
entries: Vec::new(),
|
||||
};
|
||||
// FAT sector numbers: 109 in the header, then the DIFAT chain.
|
||||
let mut fat_sectors: Vec<u32> = (0..109).map(|i| u32_at(data, 76 + i * 4)).filter(|&s| s <= MAXREGSECT).collect();
|
||||
let mut d = first_difat;
|
||||
let mut seen = 0;
|
||||
while d <= MAXREGSECT && fat_sectors.len() < n_fat {
|
||||
seen += 1;
|
||||
if seen > n_difat.max(1) + max_sectors {
|
||||
return Err(invalid("DIFAT chain loops"));
|
||||
}
|
||||
let s = cf.sector(d)?;
|
||||
let per = sector_size / 4 - 1;
|
||||
for i in 0..per {
|
||||
let v = u32_at(s, i * 4);
|
||||
if v <= MAXREGSECT {
|
||||
fat_sectors.push(v);
|
||||
}
|
||||
}
|
||||
d = u32_at(s, per * 4);
|
||||
}
|
||||
fat_sectors.truncate(n_fat);
|
||||
let mut fat = Vec::with_capacity(fat_sectors.len() * sector_size / 4);
|
||||
for s in fat_sectors {
|
||||
let b = cf.sector(s)?;
|
||||
fat.extend((0..sector_size / 4).map(|i| u32_at(b, i * 4)));
|
||||
}
|
||||
cf.fat = fat;
|
||||
// Directory.
|
||||
let dir = cf.read_chain(first_dir, None)?;
|
||||
let n_entries = dir.len() / 128;
|
||||
if n_entries == 0 {
|
||||
return Err(invalid("empty directory"));
|
||||
}
|
||||
for i in 0..n_entries {
|
||||
cf.entries.push(parse_entry(&dir[i * 128..(i + 1) * 128], version));
|
||||
}
|
||||
if cf.entries[0].kind != EntryKind::Root {
|
||||
return Err(invalid("first directory entry is not the root"));
|
||||
}
|
||||
// Mini FAT and mini stream.
|
||||
if first_minifat <= MAXREGSECT {
|
||||
let mf = cf.read_chain(first_minifat, None)?;
|
||||
cf.minifat = mf.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
|
||||
}
|
||||
let root = &cf.entries[0];
|
||||
if root.start <= MAXREGSECT && root.size > 0 {
|
||||
let size = root.size.min(data.len() as u64) as usize;
|
||||
cf.mini_stream = cf.read_chain(root.start, Some(size))?;
|
||||
}
|
||||
cf.build_tree()?;
|
||||
Ok(cf)
|
||||
}
|
||||
|
||||
/// Sector `n` (a whole sector; a truncated last sector is an error).
|
||||
fn sector(&self, n: u32) -> Result<&'a [u8]> {
|
||||
let start = (n as usize + 1).checked_mul(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
|
||||
let end = start.checked_add(self.sector_size).ok_or_else(|| invalid("sector number overflow"))?;
|
||||
if n > MAXREGSECT || end > self.data.len() {
|
||||
return Err(invalid(format!("sector {n} is outside the file")));
|
||||
}
|
||||
Ok(&self.data[start..end])
|
||||
}
|
||||
|
||||
/// Concatenate a regular sector chain; `limit` stops once that many bytes are read.
|
||||
fn read_chain(&self, start: u32, limit: Option<usize>) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut s = start;
|
||||
let mut steps = 0usize;
|
||||
while s != ENDOFCHAIN {
|
||||
if s > MAXREGSECT {
|
||||
return Err(invalid(format!("bad sector {s:#x} in a chain")));
|
||||
}
|
||||
steps += 1;
|
||||
if steps > self.fat.len().max(1) {
|
||||
return Err(invalid("sector chain loops"));
|
||||
}
|
||||
let b = self.sector(s)?;
|
||||
out.extend_from_slice(b);
|
||||
if limit.is_some_and(|l| out.len() >= l) {
|
||||
break;
|
||||
}
|
||||
s = *self.fat.get(s as usize).ok_or_else(|| invalid(format!("sector {s} has no FAT entry")))?;
|
||||
if s == FREESECT {
|
||||
return Err(invalid("chain runs into a free sector"));
|
||||
}
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
if out.len() < l {
|
||||
return Err(invalid("stream is truncated"));
|
||||
}
|
||||
out.truncate(l);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_mini_chain(&self, start: u32, size: usize) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(size);
|
||||
let mut s = start;
|
||||
let mut steps = 0usize;
|
||||
while out.len() < size {
|
||||
if s > MAXREGSECT {
|
||||
return Err(invalid("mini stream chain ends early"));
|
||||
}
|
||||
steps += 1;
|
||||
if steps > self.minifat.len().max(1) {
|
||||
return Err(invalid("mini sector chain loops"));
|
||||
}
|
||||
let at = s as usize * MINI_SECTOR_SIZE;
|
||||
let b = self.mini_stream.get(at..at + MINI_SECTOR_SIZE).ok_or_else(|| invalid("mini sector outside the mini stream"))?;
|
||||
out.extend_from_slice(b);
|
||||
s = *self.minifat.get(s as usize).ok_or_else(|| invalid("mini sector has no mini FAT entry"))?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_tree(&mut self) -> Result<()> {
|
||||
let n = self.entries.len();
|
||||
let mut owner = vec![false; n];
|
||||
owner[0] = true;
|
||||
for parent in 0..n {
|
||||
if !matches!(self.entries[parent].kind, EntryKind::Root | EntryKind::Storage) {
|
||||
continue;
|
||||
}
|
||||
let mut kids = Vec::new();
|
||||
// in-order walk of the sibling tree, iterative, cycle-safe
|
||||
let mut stack: Vec<u32> = Vec::new();
|
||||
let mut cur = self.entries[parent].child;
|
||||
let mut steps = 0;
|
||||
while cur != NOSTREAM || !stack.is_empty() {
|
||||
while cur != NOSTREAM {
|
||||
let i = cur as usize;
|
||||
if i >= n || owner[i] {
|
||||
return Err(invalid(format!("directory entry {cur} is referenced twice or does not exist")));
|
||||
}
|
||||
owner[i] = true;
|
||||
stack.push(cur);
|
||||
cur = self.entries[i].left;
|
||||
steps += 1;
|
||||
if steps > n {
|
||||
return Err(invalid("directory tree loops"));
|
||||
}
|
||||
}
|
||||
if let Some(top) = stack.pop() {
|
||||
kids.push(top as usize);
|
||||
cur = self.entries[top as usize].right;
|
||||
}
|
||||
}
|
||||
self.entries[parent].children = kids;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Entry {
|
||||
&self.entries[0]
|
||||
}
|
||||
|
||||
pub fn entry(&self, id: usize) -> Option<&Entry> {
|
||||
self.entries.get(id)
|
||||
}
|
||||
|
||||
/// Child of storage `parent` named `name` (case-insensitive as in §2.6.4).
|
||||
pub fn child(&self, parent: usize, name: &str) -> Option<usize> {
|
||||
self.entries.get(parent)?.children.iter().copied().find(|&c| compare_names(&self.entries[c].name, name).is_eq())
|
||||
}
|
||||
|
||||
/// Entry at a `/`-separated path below the root (`""` is the root).
|
||||
pub fn find(&self, path: &str) -> Result<usize> {
|
||||
let mut cur = 0;
|
||||
for part in path.split('/').filter(|p| !p.is_empty()) {
|
||||
cur = self.child(cur, part).ok_or_else(|| Error::NotFound(path.to_string()))?;
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Contents of stream `id`.
|
||||
pub fn read(&self, id: usize) -> Result<Vec<u8>> {
|
||||
let e = self.entries.get(id).ok_or_else(|| Error::NotFound(format!("entry {id}")))?;
|
||||
if e.kind != EntryKind::Stream {
|
||||
return Err(invalid(format!("{:?} is not a stream", e.name)));
|
||||
}
|
||||
let size = usize::try_from(e.size).map_err(|_| invalid("stream too large"))?;
|
||||
if size == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if size as u64 > self.data.len() as u64 {
|
||||
return Err(invalid(format!("stream {:?} is larger than the file", e.name)));
|
||||
}
|
||||
if e.size < self.mini_cutoff { self.read_mini_chain(e.start, size) } else { self.read_chain(e.start, Some(size)) }
|
||||
}
|
||||
|
||||
/// Contents of the stream at `path`.
|
||||
pub fn read_path(&self, path: &str) -> Result<Vec<u8>> {
|
||||
self.read(self.find(path)?)
|
||||
}
|
||||
|
||||
/// Sector size in bytes (512 or 4096).
|
||||
pub fn sector_size(&self) -> usize {
|
||||
self.sector_size
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_entry(b: &[u8], version: u16) -> Entry {
|
||||
let name_len = (u16_at(b, 64) as usize).min(64);
|
||||
let units: Vec<u16> = (0..name_len / 2).map(|i| u16_at(b, i * 2)).take_while(|&u| u != 0).collect();
|
||||
let kind = match b[66] {
|
||||
5 => EntryKind::Root,
|
||||
1 => EntryKind::Storage,
|
||||
2 => EntryKind::Stream,
|
||||
_ => EntryKind::Unknown,
|
||||
};
|
||||
let mut clsid = [0u8; 16];
|
||||
clsid.copy_from_slice(&b[80..96]);
|
||||
let size = u64_at(b, 120);
|
||||
Entry {
|
||||
name: String::from_utf16_lossy(&units),
|
||||
kind,
|
||||
clsid,
|
||||
state_bits: u32_at(b, 96),
|
||||
created: u64_at(b, 100),
|
||||
modified: u64_at(b, 108),
|
||||
start: u32_at(b, 116),
|
||||
// version 3 files may leave garbage in the high 32 bits (§2.6.3)
|
||||
size: if version == 3 { size & 0xFFFF_FFFF } else { size },
|
||||
children: Vec::new(),
|
||||
left: u32_at(b, 68),
|
||||
right: u32_at(b, 72),
|
||||
child: u32_at(b, 76),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use crate::*;
|
||||
|
||||
fn pattern(n: usize, seed: u8) -> Vec<u8> {
|
||||
(0..n).map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8).collect()
|
||||
}
|
||||
|
||||
/// A tree with mini and regular streams, nested storages and many siblings.
|
||||
fn sample(version: Version, siblings: usize) -> (Vec<u8>, Vec<(String, Vec<u8>)>) {
|
||||
let mut w = Writer::new(version);
|
||||
w.set_clsid(Writer::ROOT, [7; 16]);
|
||||
let mut expect = Vec::new();
|
||||
let a = w.storage(Writer::ROOT, "Header-2", [1; 16]).unwrap();
|
||||
let b = w.storage(a, "Content-3b03", [2; 16]).unwrap();
|
||||
for (i, size) in [0usize, 1, 63, 64, 65, 4095, 4096, 4097, 20_000].iter().enumerate() {
|
||||
let name = format!("s{i}");
|
||||
let data = pattern(*size, i as u8);
|
||||
w.stream(b, &name, data.clone()).unwrap();
|
||||
expect.push((format!("Header-2/Content-3b03/{name}"), data));
|
||||
}
|
||||
for i in 0..siblings {
|
||||
let name = format!("Mobs-1901{{{i:x}}}");
|
||||
let s = w.storage(Writer::ROOT, &name, [3; 16]).unwrap();
|
||||
let data = pattern(10 + i * 7, 99);
|
||||
w.stream(s, "properties", data.clone()).unwrap();
|
||||
expect.push((format!("{name}/properties"), data));
|
||||
}
|
||||
(w.finish(), expect)
|
||||
}
|
||||
|
||||
fn check(bytes: &[u8], expect: &[(String, Vec<u8>)]) {
|
||||
let cf = CompoundFile::open(bytes).unwrap();
|
||||
assert_eq!(cf.root().clsid, [7; 16]);
|
||||
for (path, data) in expect {
|
||||
assert_eq!(&cf.read_path(path).unwrap(), data, "{path}");
|
||||
}
|
||||
let h = cf.find("Header-2").unwrap();
|
||||
assert_eq!(cf.entries[h].clsid, [1; 16]);
|
||||
assert_eq!(cf.entries[h].kind, EntryKind::Storage);
|
||||
// children come back sorted in CFB order
|
||||
for e in &cf.entries {
|
||||
for w in e.children.windows(2) {
|
||||
assert!(compare_names(&cf.entries[w[0]].name, &cf.entries[w[1]].name).is_lt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every path from a node to a nil leaf has the same number of black nodes, and no red node has
|
||||
/// a red child.
|
||||
fn check_red_black(bytes: &[u8]) {
|
||||
let cf = CompoundFile::open(bytes).unwrap();
|
||||
let raw = |i: u32| -> (u32, u32, u8) {
|
||||
let ss = cf.sector_size();
|
||||
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
||||
// the writer stores the directory contiguously
|
||||
let at = (dir_start as usize + 1) * ss + i as usize * 128;
|
||||
let e = &bytes[at..at + 128];
|
||||
(u32::from_le_bytes(e[68..72].try_into().unwrap()), u32::from_le_bytes(e[72..76].try_into().unwrap()), e[67])
|
||||
};
|
||||
fn black_height(raw: &dyn Fn(u32) -> (u32, u32, u8), n: u32, parent_red: bool) -> usize {
|
||||
if n == NOSTREAM {
|
||||
return 1;
|
||||
}
|
||||
let (l, r, c) = raw(n);
|
||||
let red = c == 0;
|
||||
assert!(!(red && parent_red), "red node with a red parent");
|
||||
let hl = black_height(raw, l, red);
|
||||
let hr = black_height(raw, r, red);
|
||||
assert_eq!(hl, hr, "unbalanced black height");
|
||||
hl + usize::from(!red)
|
||||
}
|
||||
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
||||
let ss = cf.sector_size();
|
||||
for i in 0..cf.entries.len() {
|
||||
let at = (dir_start as usize + 1) * ss + i * 128;
|
||||
let child = u32::from_le_bytes(bytes[at + 76..at + 80].try_into().unwrap());
|
||||
if child != NOSTREAM {
|
||||
let (_, _, c) = raw(child);
|
||||
assert_eq!(c, 1, "tree roots are black");
|
||||
black_height(&raw, child, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_v3_and_v4() {
|
||||
for v in [Version::V3, Version::V4] {
|
||||
for n in [0, 1, 2, 3, 7, 8, 40] {
|
||||
let (bytes, expect) = sample(v, n);
|
||||
assert_eq!(bytes.len() % v_sector(v), 0);
|
||||
check(&bytes, &expect);
|
||||
check_red_black(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn v_sector(v: Version) -> usize {
|
||||
if v == Version::V3 { 512 } else { 4096 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_fields() {
|
||||
let (b, _) = sample(Version::V4, 3);
|
||||
assert!(sniff(&b));
|
||||
assert_eq!(&b[24..26], &0x3Eu16.to_le_bytes());
|
||||
assert_eq!(&b[26..28], &4u16.to_le_bytes());
|
||||
assert_eq!(&b[30..32], &12u16.to_le_bytes());
|
||||
let (b, _) = sample(Version::V3, 3);
|
||||
assert_eq!(&b[26..28], &3u16.to_le_bytes());
|
||||
assert_eq!(&b[30..32], &9u16.to_le_bytes());
|
||||
assert_eq!(&b[40..44], &0u32.to_le_bytes(), "version 3 leaves the directory sector count 0");
|
||||
assert_eq!(CompoundFile::open(&b).unwrap().version, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_file_needs_difat() {
|
||||
// > 109 FAT sectors with 512-byte sectors: 109 * 128 sectors * 512 bytes ≈ 7.1 MB
|
||||
let mut w = Writer::new(Version::V3);
|
||||
let big = pattern(7_500_000, 5);
|
||||
w.stream(Writer::ROOT, "big", big.clone()).unwrap();
|
||||
w.stream(Writer::ROOT, "small", vec![1, 2, 3]).unwrap();
|
||||
let b = w.finish();
|
||||
let n_difat = u32::from_le_bytes(b[72..76].try_into().unwrap());
|
||||
assert!(n_difat >= 1);
|
||||
let cf = CompoundFile::open(&b).unwrap();
|
||||
assert_eq!(cf.read_path("big").unwrap(), big);
|
||||
assert_eq!(cf.read_path("small").unwrap(), vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert!(compare_names("b", "aa").is_lt(), "shorter first");
|
||||
assert!(compare_names("abc", "ABD").is_lt());
|
||||
assert!(compare_names("Abc", "aBC").is_eq());
|
||||
assert!(validate_name("a/b").is_err());
|
||||
assert!(validate_name(&"x".repeat(32)).is_err());
|
||||
assert!(validate_name(&"x".repeat(31)).is_ok());
|
||||
let mut w = Writer::default();
|
||||
w.stream(Writer::ROOT, "Name", vec![]).unwrap();
|
||||
assert!(matches!(w.stream(Writer::ROOT, "NAME", vec![]), Err(Error::BadName(_))));
|
||||
let s = w.stream(Writer::ROOT, "x", vec![]).unwrap();
|
||||
assert!(w.storage(s, "y", [0; 16]).is_err(), "streams have no children");
|
||||
let cf_bytes = w.finish();
|
||||
let cf = CompoundFile::open(&cf_bytes).unwrap();
|
||||
assert!(cf.find("name").is_ok(), "lookups are case-insensitive");
|
||||
assert!(matches!(cf.find("nope"), Err(Error::NotFound(_))));
|
||||
assert!(cf.read(0).is_err(), "the root is not a stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_names() {
|
||||
let mut w = Writer::default();
|
||||
w.stream(Writer::ROOT, "Ünïcødé ストリーム", vec![9; 10]).unwrap();
|
||||
let b = w.finish();
|
||||
let cf = CompoundFile::open(&b).unwrap();
|
||||
assert_eq!(cf.read_path("üNÏCØDÉ ストリーム").unwrap(), vec![9; 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_cfb() {
|
||||
assert_eq!(CompoundFile::open(b"hello").unwrap_err(), Error::NotCfb);
|
||||
assert_eq!(CompoundFile::open(&[0u8; 600]).unwrap_err(), Error::NotCfb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_never_panics() {
|
||||
let (b, expect) = sample(Version::V3, 20);
|
||||
let mut ok = 0;
|
||||
for cut in (0..b.len()).step_by(29) {
|
||||
if let Ok(cf) = CompoundFile::open(&b[..cut]) {
|
||||
ok += 1;
|
||||
for (path, data) in &expect {
|
||||
if let Ok(d) = cf.read_path(path) {
|
||||
assert_eq!(&d, data, "a stream that reads back is complete");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ok;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_never_panics_or_hangs() {
|
||||
let (b, _) = sample(Version::V3, 12);
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
for _ in 0..1500 {
|
||||
let mut g = b.clone();
|
||||
for _ in 0..6 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
// bias towards the header, FAT and directory (the end of the file)
|
||||
let at = if seed & 1 == 0 { (seed >> 8) as usize % 512 } else { g.len() - 1 - (seed >> 8) as usize % 2048.min(g.len()) };
|
||||
g[at] = (seed >> 40) as u8;
|
||||
}
|
||||
if let Ok(cf) = CompoundFile::open(&g) {
|
||||
for i in 0..cf.entries.len() {
|
||||
let _ = cf.read(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Writing compound files ([MS-CFB] §2.2–2.6).
|
||||
//!
|
||||
//! Layout written by [`Writer::finish`] (sector numbers ascending): the sectors of every stream of
|
||||
//! 4096 bytes or more, the mini stream, the mini FAT, the directory, the FAT, then DIFAT sectors
|
||||
//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
|
||||
//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
|
||||
|
||||
use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
|
||||
|
||||
/// File format version.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Version {
|
||||
/// Version 3: 512-byte sectors.
|
||||
V3,
|
||||
/// Version 4: 4096-byte sectors (AAF's "4K" files).
|
||||
#[default]
|
||||
V4,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
fn sector_size(self) -> usize {
|
||||
match self {
|
||||
Version::V3 => 512,
|
||||
Version::V4 => 4096,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A storage or stream added to a [`Writer`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct NodeId(pub usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Root,
|
||||
Storage,
|
||||
Stream,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Node {
|
||||
name: String,
|
||||
kind: Kind,
|
||||
clsid: [u8; 16],
|
||||
data: Vec<u8>,
|
||||
children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Builds a compound file in memory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Writer {
|
||||
version: Version,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
impl Default for Writer {
|
||||
fn default() -> Self {
|
||||
Writer::new(Version::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// The root storage.
|
||||
pub const ROOT: NodeId = NodeId(0);
|
||||
|
||||
pub fn new(version: Version) -> Self {
|
||||
Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
|
||||
}
|
||||
|
||||
fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec<u8>) -> Result<NodeId> {
|
||||
validate_name(name)?;
|
||||
let p = self.nodes.get(parent.0).ok_or_else(|| crate::Error::NotFound(format!("node {}", parent.0)))?;
|
||||
if p.kind == Kind::Stream {
|
||||
return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
|
||||
}
|
||||
if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
|
||||
return Err(crate::Error::BadName(format!("duplicate name {name}")));
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
|
||||
self.nodes[parent.0].children.push(id);
|
||||
Ok(NodeId(id))
|
||||
}
|
||||
|
||||
/// Add a storage below `parent`.
|
||||
pub fn storage(&mut self, parent: NodeId, name: &str, clsid: [u8; 16]) -> Result<NodeId> {
|
||||
self.add(parent, name, Kind::Storage, clsid, Vec::new())
|
||||
}
|
||||
|
||||
/// Add a stream below `parent`.
|
||||
pub fn stream(&mut self, parent: NodeId, name: &str, data: Vec<u8>) -> Result<NodeId> {
|
||||
self.add(parent, name, Kind::Stream, [0; 16], data)
|
||||
}
|
||||
|
||||
/// Set the class id of a storage (or the root).
|
||||
pub fn set_clsid(&mut self, node: NodeId, clsid: [u8; 16]) {
|
||||
if let Some(n) = self.nodes.get_mut(node.0) {
|
||||
n.clsid = clsid;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay the file out and return its bytes.
|
||||
pub fn finish(self) -> Vec<u8> {
|
||||
let ss = self.version.sector_size();
|
||||
let per_sector = ss / 4;
|
||||
// Directory order: depth-first pre-order from the root.
|
||||
let mut order = Vec::with_capacity(self.nodes.len());
|
||||
let mut stack = vec![0usize];
|
||||
while let Some(n) = stack.pop() {
|
||||
order.push(n);
|
||||
for &c in self.nodes[n].children.iter().rev() {
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
let mut dir_index = vec![0u32; self.nodes.len()];
|
||||
for (i, &n) in order.iter().enumerate() {
|
||||
dir_index[n] = i as u32;
|
||||
}
|
||||
|
||||
// Stream placement.
|
||||
let mut start = vec![ENDOFCHAIN; self.nodes.len()];
|
||||
let mut next_sector = 0u32;
|
||||
let mut big_chains: Vec<(u32, u32)> = Vec::new(); // (first, count)
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
|
||||
let count = node.data.len().div_ceil(ss) as u32;
|
||||
start[n] = next_sector;
|
||||
big_chains.push((next_sector, count));
|
||||
next_sector += count;
|
||||
}
|
||||
}
|
||||
let mut next_mini = 0u32;
|
||||
let mut mini_chains: Vec<(u32, u32)> = Vec::new();
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
|
||||
let count = node.data.len().div_ceil(MINI_SECTOR_SIZE) as u32;
|
||||
start[n] = next_mini;
|
||||
mini_chains.push((next_mini, count));
|
||||
next_mini += count;
|
||||
}
|
||||
}
|
||||
let mini_bytes = next_mini as usize * MINI_SECTOR_SIZE;
|
||||
let mini_stream_sectors = mini_bytes.div_ceil(ss) as u32;
|
||||
let mini_stream_start = if mini_stream_sectors > 0 { next_sector } else { ENDOFCHAIN };
|
||||
next_sector += mini_stream_sectors;
|
||||
let minifat_sectors = (next_mini as usize * 4).div_ceil(ss) as u32;
|
||||
let minifat_start = if minifat_sectors > 0 { next_sector } else { ENDOFCHAIN };
|
||||
next_sector += minifat_sectors;
|
||||
let dir_sectors = (order.len() * 128).div_ceil(ss) as u32;
|
||||
let dir_start = next_sector;
|
||||
next_sector += dir_sectors;
|
||||
let base = next_sector as usize;
|
||||
let (mut fat_n, mut difat_n) = (1usize, 0usize);
|
||||
loop {
|
||||
let total = base + fat_n + difat_n;
|
||||
let need_fat = total.div_ceil(per_sector);
|
||||
let need_difat = if need_fat > 109 { (need_fat - 109).div_ceil(per_sector - 1) } else { 0 };
|
||||
if need_fat == fat_n && need_difat == difat_n {
|
||||
break;
|
||||
}
|
||||
fat_n = need_fat.max(fat_n);
|
||||
difat_n = need_difat.max(difat_n);
|
||||
}
|
||||
let fat_start = next_sector;
|
||||
let difat_start = fat_start + fat_n as u32;
|
||||
let total = base + fat_n + difat_n;
|
||||
|
||||
// FAT.
|
||||
let mut fat = vec![FREESECT; fat_n * per_sector];
|
||||
let chain = |first: u32, count: u32, fat: &mut [u32]| {
|
||||
for i in 0..count {
|
||||
fat[(first + i) as usize] = if i + 1 == count { ENDOFCHAIN } else { first + i + 1 };
|
||||
}
|
||||
};
|
||||
for &(f, c) in &big_chains {
|
||||
chain(f, c, &mut fat);
|
||||
}
|
||||
if mini_stream_sectors > 0 {
|
||||
chain(mini_stream_start, mini_stream_sectors, &mut fat);
|
||||
}
|
||||
if minifat_sectors > 0 {
|
||||
chain(minifat_start, minifat_sectors, &mut fat);
|
||||
}
|
||||
chain(dir_start, dir_sectors, &mut fat);
|
||||
for i in 0..fat_n {
|
||||
fat[fat_start as usize + i] = FATSECT;
|
||||
}
|
||||
for i in 0..difat_n {
|
||||
fat[difat_start as usize + i] = DIFSECT;
|
||||
}
|
||||
debug_assert!(total <= fat.len());
|
||||
|
||||
// Mini FAT.
|
||||
let mut minifat = vec![FREESECT; minifat_sectors as usize * per_sector];
|
||||
for &(f, c) in &mini_chains {
|
||||
for i in 0..c {
|
||||
minifat[(f + i) as usize] = if i + 1 == c { ENDOFCHAIN } else { f + i + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// Directory.
|
||||
let mut tree = vec![(NOSTREAM, NOSTREAM, NOSTREAM, 1u8); self.nodes.len()]; // left, right, child, colour
|
||||
for (p, node) in self.nodes.iter().enumerate() {
|
||||
if node.kind == Kind::Stream || node.children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut kids = node.children.clone();
|
||||
kids.sort_by(|&a, &b| compare_names(&self.nodes[a].name, &self.nodes[b].name));
|
||||
let height = usize::BITS - (kids.len() + 1).leading_zeros() - 1; // floor(log2(n + 1))
|
||||
let perfect = (1usize << height) - 1 == kids.len();
|
||||
let root = build_tree(&kids, 0, perfect, height as usize, &mut tree, &dir_index);
|
||||
tree[p].2 = root;
|
||||
}
|
||||
let mut dir = Vec::with_capacity(dir_sectors as usize * ss);
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
let mut e = [0u8; 128];
|
||||
let units: Vec<u16> = node.name.encode_utf16().collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
e[i * 2..i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
e[64..66].copy_from_slice(&(((units.len() + 1) * 2) as u16).to_le_bytes());
|
||||
e[66] = match node.kind {
|
||||
Kind::Root => 5,
|
||||
Kind::Storage => 1,
|
||||
Kind::Stream => 2,
|
||||
};
|
||||
let (l, r, c, colour) = tree[n];
|
||||
e[67] = if node.kind == Kind::Root { 1 } else { colour };
|
||||
e[68..72].copy_from_slice(&l.to_le_bytes());
|
||||
e[72..76].copy_from_slice(&r.to_le_bytes());
|
||||
e[76..80].copy_from_slice(&c.to_le_bytes());
|
||||
e[80..96].copy_from_slice(&node.clsid);
|
||||
let (st, size) = match node.kind {
|
||||
Kind::Root => (mini_stream_start, mini_bytes as u64),
|
||||
Kind::Storage => (0, 0),
|
||||
Kind::Stream => (if node.data.is_empty() { ENDOFCHAIN } else { start[n] }, node.data.len() as u64),
|
||||
};
|
||||
e[116..120].copy_from_slice(&st.to_le_bytes());
|
||||
e[120..128].copy_from_slice(&size.to_le_bytes());
|
||||
dir.extend_from_slice(&e);
|
||||
}
|
||||
while dir.len() < dir_sectors as usize * ss {
|
||||
let mut e = [0u8; 128];
|
||||
e[68..80].copy_from_slice(&[0xFF; 12]);
|
||||
dir.extend_from_slice(&e);
|
||||
}
|
||||
|
||||
// Header.
|
||||
let mut out = Vec::with_capacity((total + 1) * ss);
|
||||
out.extend_from_slice(&SIGNATURE);
|
||||
out.extend_from_slice(&[0; 16]);
|
||||
out.extend_from_slice(&0x003Eu16.to_le_bytes());
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 3u16 } else { 4u16 }).to_le_bytes());
|
||||
out.extend_from_slice(&0xFFFEu16.to_le_bytes());
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 9u16 } else { 12u16 }).to_le_bytes());
|
||||
out.extend_from_slice(&6u16.to_le_bytes());
|
||||
out.extend_from_slice(&[0; 6]);
|
||||
out.extend_from_slice(&(if self.version == Version::V3 { 0 } else { dir_sectors }).to_le_bytes());
|
||||
out.extend_from_slice(&(fat_n as u32).to_le_bytes());
|
||||
out.extend_from_slice(&dir_start.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&MINI_STREAM_CUTOFF.to_le_bytes());
|
||||
out.extend_from_slice(&minifat_start.to_le_bytes());
|
||||
out.extend_from_slice(&minifat_sectors.to_le_bytes());
|
||||
out.extend_from_slice(&(if difat_n > 0 { difat_start } else { ENDOFCHAIN }).to_le_bytes());
|
||||
out.extend_from_slice(&(difat_n as u32).to_le_bytes());
|
||||
for i in 0..109 {
|
||||
let v = if i < fat_n { fat_start + i as u32 } else { FREESECT };
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.resize(ss, 0);
|
||||
|
||||
// Sectors.
|
||||
let pad = |out: &mut Vec<u8>| {
|
||||
let r = out.len() % ss;
|
||||
if r != 0 {
|
||||
out.resize(out.len() + ss - r, 0);
|
||||
}
|
||||
};
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && node.data.len() as u64 >= MINI_STREAM_CUTOFF as u64 {
|
||||
out.extend_from_slice(&node.data);
|
||||
pad(&mut out);
|
||||
}
|
||||
}
|
||||
for &n in &order {
|
||||
let node = &self.nodes[n];
|
||||
if node.kind == Kind::Stream && !node.data.is_empty() && (node.data.len() as u64) < MINI_STREAM_CUTOFF as u64 {
|
||||
out.extend_from_slice(&node.data);
|
||||
let r = node.data.len() % MINI_SECTOR_SIZE;
|
||||
if r != 0 {
|
||||
out.resize(out.len() + MINI_SECTOR_SIZE - r, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
pad(&mut out);
|
||||
for v in &minifat {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&dir);
|
||||
for v in &fat {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let mut remaining: Vec<u32> = (109..fat_n).map(|i| fat_start + i as u32).collect();
|
||||
for i in 0..difat_n {
|
||||
let mut s = Vec::with_capacity(ss);
|
||||
let take = remaining.len().min(per_sector - 1);
|
||||
for v in remaining.drain(..take) {
|
||||
s.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
while s.len() < ss - 4 {
|
||||
s.extend_from_slice(&FREESECT.to_le_bytes());
|
||||
}
|
||||
let next = if i + 1 < difat_n { difat_start + i as u32 + 1 } else { ENDOFCHAIN };
|
||||
s.extend_from_slice(&next.to_le_bytes());
|
||||
out.extend_from_slice(&s);
|
||||
}
|
||||
debug_assert_eq!(out.len(), (total + 1) * ss);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Balanced BST of `kids[..]` (sorted). Nodes on the bottom level of an imperfect tree are red,
|
||||
/// so every root-to-leaf path has the same number of black nodes.
|
||||
fn build_tree(kids: &[usize], depth: usize, perfect: bool, height: usize, tree: &mut [(u32, u32, u32, u8)], dir_index: &[u32]) -> u32 {
|
||||
if kids.is_empty() {
|
||||
return NOSTREAM;
|
||||
}
|
||||
let mid = kids.len() / 2;
|
||||
let n = kids[mid];
|
||||
let left = build_tree(&kids[..mid], depth + 1, perfect, height, tree, dir_index);
|
||||
let right = build_tree(&kids[mid + 1..], depth + 1, perfect, height, tree, dir_index);
|
||||
tree[n].0 = left;
|
||||
tree[n].1 = right;
|
||||
tree[n].3 = if !perfect && depth == height { 0 } else { 1 };
|
||||
dir_index[n]
|
||||
}
|
||||
@@ -36,7 +36,7 @@ log = { workspace = true }
|
||||
web-time = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
# Bootstrap audio decoding (MPL-2.0, used unmodified); replaced by own decoders over time.
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "mp1", "mp2", "mp3", "flac", "alac", "pcm", "wav", "ogg", "vorbis", "isomp4"] }
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "mp1", "mp2", "mp3", "flac", "alac", "pcm", "wav", "aiff", "ogg", "vorbis", "isomp4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
+30
-14
@@ -172,6 +172,8 @@ pub struct GopCache {
|
||||
state: Mutex<State>,
|
||||
/// Colour signalled by the container, which wins over the bitstream's.
|
||||
explicit_color: Option<ColorInfo>,
|
||||
/// Clockwise quarter turns from the container's display matrix, applied to every frame.
|
||||
rotation: u8,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
@@ -200,21 +202,31 @@ impl GopCache {
|
||||
bytes: 0,
|
||||
}),
|
||||
explicit_color,
|
||||
rotation: 0,
|
||||
budget: 384 << 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour signalled by the container wins over the bitstream's (YUV frames only).
|
||||
fn apply_color(&self, f: &mut VideoFrame) {
|
||||
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
|
||||
/// rotation, e.g. portrait phone video stored landscape).
|
||||
pub fn with_rotation(mut self, quarter_turns: u8) -> Self {
|
||||
self.rotation = quarter_turns % 4;
|
||||
self
|
||||
}
|
||||
|
||||
/// Colour signalled by the container wins over the bitstream's (YUV frames only); the
|
||||
/// container's display rotation is applied.
|
||||
fn finish(&self, mut f: VideoFrame) -> VideoFrame {
|
||||
if let Some(c) = self.explicit_color
|
||||
&& !matches!(f.data, filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_))
|
||||
{
|
||||
f.color = c;
|
||||
}
|
||||
if self.rotation != 0 { f.rotated(self.rotation) } else { f }
|
||||
}
|
||||
|
||||
fn store(&self, st: &mut State, pts: i64, mut f: VideoFrame, draft: bool) {
|
||||
self.apply_color(&mut f);
|
||||
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
let f = self.finish(f);
|
||||
if draft {
|
||||
st.drafts.insert(pts);
|
||||
DRAFT.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -228,8 +240,7 @@ impl GopCache {
|
||||
}
|
||||
// evict frames far from the most recent (keep a window around the working position)
|
||||
while st.bytes > budget && st.frames.len() > 2 {
|
||||
let first = *st.frames.keys().next().expect("non-empty");
|
||||
let last = *st.frames.keys().next_back().expect("non-empty");
|
||||
let (Some(&first), Some(&last)) = (st.frames.keys().next(), st.frames.keys().next_back()) else { break };
|
||||
let victim = if pts - first > last - pts { first } else { last };
|
||||
if let Some(v) = st.frames.remove(&victim) {
|
||||
st.drafts.remove(&victim);
|
||||
@@ -311,7 +322,7 @@ impl GopCache {
|
||||
// every sample): step back to a sample the decoder can start from.
|
||||
while key > 0 {
|
||||
let data = s.read(key)?;
|
||||
if st.decoder.as_ref().expect("decoder").is_random_access(&data) != Some(false) {
|
||||
if st.decoder.as_ref().and_then(|d| d.is_random_access(&data)) != Some(false) {
|
||||
break;
|
||||
}
|
||||
key = s.sync_before(key - 1);
|
||||
@@ -325,7 +336,10 @@ impl GopCache {
|
||||
}
|
||||
let limit = (i.max(st.next) + 64).min(n);
|
||||
let late = late_before.unwrap_or(i64::MIN).min(want_pts);
|
||||
st.decoder.as_mut().expect("decoder").set_draft(draft);
|
||||
let Some(decoder) = st.decoder.as_mut() else {
|
||||
return Err(CodecError::Decode("no video decoder".into()));
|
||||
};
|
||||
decoder.set_draft(draft);
|
||||
while st.next < limit {
|
||||
if filmcraft_media::cancel::cancelled() {
|
||||
// The decoder state stays consistent (`next`, `out_max`): a later request continues.
|
||||
@@ -333,12 +347,15 @@ impl GopCache {
|
||||
}
|
||||
let k = st.next;
|
||||
let data = s.read(k)?;
|
||||
if k != i && s.pts(k) < late && st.decoder.as_ref().expect("decoder").is_disposable(&data) {
|
||||
if k != i && s.pts(k) < late && st.decoder.as_ref().is_some_and(|d| d.is_disposable(&data)) {
|
||||
st.next += 1;
|
||||
SKIPPED.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let out = timed(|| st.decoder.as_mut().expect("decoder").decode(&data, s.pts(k)))?;
|
||||
let Some(decoder) = st.decoder.as_mut() else {
|
||||
return Err(CodecError::Decode("no video decoder".into()));
|
||||
};
|
||||
let out = timed(|| decoder.decode(&data, s.pts(k)))?;
|
||||
st.next += 1;
|
||||
DECODED.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_output(&mut st, out);
|
||||
@@ -347,7 +364,7 @@ impl GopCache {
|
||||
}
|
||||
}
|
||||
if st.cached(want_pts, draft).is_none() {
|
||||
let out = timed(|| st.decoder.as_mut().expect("decoder").flush());
|
||||
let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default();
|
||||
self.store_output(&mut st, out);
|
||||
st.next = usize::MAX;
|
||||
}
|
||||
@@ -361,14 +378,13 @@ impl GopCache {
|
||||
let mut d = s.make_decoder()?;
|
||||
let mut next = usize::MAX;
|
||||
let out = Self::decode_to(s, d.as_mut(), &mut next, i, want_pts, n)?;
|
||||
let mut f = out
|
||||
let f = out
|
||||
.into_iter()
|
||||
.filter(|p| p.pts <= want_pts)
|
||||
.max_by_key(|p| p.pts)
|
||||
.map(|p| p.frame)
|
||||
.ok_or_else(|| CodecError::Decode("frame not produced".into()))?;
|
||||
self.apply_color(&mut f);
|
||||
Ok(Arc::new(f))
|
||||
Ok(Arc::new(self.finish(f)))
|
||||
}
|
||||
|
||||
/// Decode with `d` (positioned at `next`) until the picture with `want_pts` (sample `i`) comes out.
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
|
||||
//! - [`openers`]: the openers to register with the engine's media pool.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod audio;
|
||||
pub mod gop;
|
||||
pub mod mkv;
|
||||
|
||||
@@ -154,6 +154,20 @@ fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoI
|
||||
})
|
||||
}
|
||||
|
||||
/// HDR static metadata from the Matroska `Colour` element.
|
||||
fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMetadata> {
|
||||
if c.mastering.is_none() && c.max_cll.is_none() && c.max_fall.is_none() {
|
||||
return None;
|
||||
}
|
||||
let nz = |v: f64| (v > 0.0).then_some(v as f32);
|
||||
Some(filmcraft_color::HdrMetadata {
|
||||
mastering_max_nits: c.mastering.as_ref().and_then(|m| nz(m.luminance_max)),
|
||||
mastering_min_nits: c.mastering.as_ref().map(|m| m.luminance_min as f32),
|
||||
max_cll: c.max_cll.and_then(|v| nz(v as f64)),
|
||||
max_fall: c.max_fall.and_then(|v| nz(v as f64)),
|
||||
})
|
||||
}
|
||||
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32) -> (ColorInfo, bool) {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
let Some(col) = &v.colour else { return (c, false) };
|
||||
@@ -220,6 +234,8 @@ impl MkvSource {
|
||||
}
|
||||
let mut explicit_color = None;
|
||||
let mut ventry = None;
|
||||
// display rotation from the track's Projection (portrait phone video is stored landscape)
|
||||
let rotation = vtrack.and_then(|i| file.tracks[i].video.as_ref()).and_then(|v| v.display_rotation()).unwrap_or(0);
|
||||
let video = vtrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let v = t.video.clone().unwrap_or_default();
|
||||
@@ -241,16 +257,18 @@ impl MkvSource {
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
VideoStreamInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
frame_rate: rate,
|
||||
par: v.pixel_aspect(),
|
||||
par,
|
||||
codec: codec_label(&t.codec),
|
||||
pixel_format: String::new(),
|
||||
color,
|
||||
has_alpha: v.alpha_mode != 0,
|
||||
bitrate,
|
||||
hdr: v.colour.as_ref().and_then(hdr_metadata),
|
||||
}
|
||||
});
|
||||
let audio = atrack.map(|i| {
|
||||
@@ -317,7 +335,7 @@ impl MkvSource {
|
||||
vtrack,
|
||||
atrack,
|
||||
ventry,
|
||||
video: GopCache::new(explicit_color),
|
||||
video: GopCache::new(explicit_color).with_rotation(rotation),
|
||||
audio: Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }),
|
||||
audio_starts,
|
||||
audio_preroll,
|
||||
@@ -351,9 +369,9 @@ impl MkvSource {
|
||||
if let Some(p) = st.packets.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let ti = self.atrack.expect("audio");
|
||||
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let track = &self.file.tracks[ti];
|
||||
let ainfo = self.info.audio.as_ref().expect("audio info");
|
||||
let ainfo = self.info.audio.as_ref().ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let data = self.read(ti, i)?;
|
||||
let decoded = match &track.codec {
|
||||
Codec::Pcm { float, big_endian, bits } => {
|
||||
@@ -374,7 +392,7 @@ impl MkvSource {
|
||||
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
|
||||
// one packet, or `SeekPreRoll` worth for Opus).
|
||||
if st.last_decoded.is_none_or(|l| l + 1 != i) {
|
||||
let d = st.decoder.as_mut().expect("decoder");
|
||||
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
d.reset();
|
||||
let from = if self.audio_preroll > 0 {
|
||||
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
|
||||
@@ -387,7 +405,7 @@ impl MkvSource {
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = st.decoder.as_mut().expect("decoder").decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
st.last_decoded = Some(i);
|
||||
r.unwrap_or_default()
|
||||
}
|
||||
|
||||
+147
-7
@@ -46,6 +46,20 @@ pub fn sniff(b: &[u8]) -> bool {
|
||||
b.len() >= 12 && matches!(&b[4..8], b"ftyp" | b"moov" | b"mdat" | b"wide" | b"free" | b"skip")
|
||||
}
|
||||
|
||||
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
|
||||
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
|
||||
if md.is_none() && cll.is_none() {
|
||||
return None;
|
||||
}
|
||||
let nz = |v: f32| (v > 0.0).then_some(v);
|
||||
Some(filmcraft_color::HdrMetadata {
|
||||
mastering_max_nits: md.and_then(|m| nz(m.max_nits() as f32)),
|
||||
mastering_min_nits: md.map(|m| m.min_luminance as f32 / 10_000.0),
|
||||
max_cll: cll.and_then(|c| nz(c.0 as f32)),
|
||||
max_fall: cll.and_then(|c| nz(c.1 as f32)),
|
||||
})
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -93,13 +107,18 @@ impl Mp4Source {
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let bytes = crate::Src(reader);
|
||||
let file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty());
|
||||
let atrack = file.tracks.iter().position(|t| t.kind == TrackKind::Audio && !t.samples.is_empty());
|
||||
// A track needs samples, a sample description and a timescale to be playable (damaged
|
||||
// files can lack them: indexing `entries[0]` or dividing by the timescale used to panic).
|
||||
let playable = |t: &&filmcraft_isobmff::Track, kind| t.kind == kind && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0;
|
||||
let vtrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Video));
|
||||
let atrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Audio));
|
||||
if vtrack.is_none() && atrack.is_none() {
|
||||
return Err(CodecError::Unsupported("no playable tracks".into()));
|
||||
}
|
||||
let mut color = ColorInfo::REC709;
|
||||
let mut explicit_color = None;
|
||||
// display rotation from the track matrix (portrait phone video is stored landscape)
|
||||
let rotation = vtrack.and_then(|i| file.tracks[i].display_rotation()).unwrap_or(0);
|
||||
let video = vtrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let entry = &t.entries[0];
|
||||
@@ -110,6 +129,8 @@ impl Mp4Source {
|
||||
durs.sort_unstable();
|
||||
let d = durs.get(durs.len() / 2).copied().unwrap_or(1).max(1);
|
||||
let rate = FrameRate::from_f64(t.timescale as f64 / d as f64);
|
||||
// A rate that rounds to zero (one sample per huge duration) can't be divided by.
|
||||
let rate = if rate.num > 0 && rate.den > 0 { rate } else { FrameRate::default() };
|
||||
color = color_from(entry, w, h);
|
||||
if entry
|
||||
.video
|
||||
@@ -129,7 +150,12 @@ impl Mp4Source {
|
||||
color,
|
||||
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x"),
|
||||
bitrate,
|
||||
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
|
||||
};
|
||||
if rotation % 2 == 1 {
|
||||
(info.width, info.height) = (info.height, info.width);
|
||||
info.par = (info.par.1, info.par.0);
|
||||
}
|
||||
if matches!(entry.codec, CodecConfig::Dnx { .. }) {
|
||||
// the sample entry doesn't say which VC-3 compression ID it is: read the first frame header
|
||||
let s0 = &t.samples[0];
|
||||
@@ -220,7 +246,7 @@ impl Mp4Source {
|
||||
file,
|
||||
vtrack,
|
||||
atrack,
|
||||
video: GopCache::new(explicit_color),
|
||||
video: GopCache::new(explicit_color).with_rotation(rotation),
|
||||
audio: Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }),
|
||||
audio_starts,
|
||||
audio_offset,
|
||||
@@ -243,9 +269,9 @@ impl Mp4Source {
|
||||
if let Some(p) = st.packets.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let ti = self.atrack.expect("audio");
|
||||
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let track = &self.file.tracks[ti];
|
||||
let entry = &track.entries[0];
|
||||
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
|
||||
let data = self.read(ti, i)?;
|
||||
let decoded = match &entry.codec {
|
||||
CodecConfig::Pcm(p) => decode_pcm(&data, p),
|
||||
@@ -256,7 +282,7 @@ impl Mp4Source {
|
||||
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
|
||||
// one packet, or `OPUS_PRE_ROLL` worth for Opus).
|
||||
if st.last_decoded.is_none_or(|l| l + 1 != i) {
|
||||
let d = st.decoder.as_mut().expect("decoder");
|
||||
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
d.reset();
|
||||
let from = if self.audio_preroll > 0 {
|
||||
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
|
||||
@@ -269,7 +295,7 @@ impl Mp4Source {
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = st.decoder.as_mut().expect("decoder").decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
st.last_decoded = Some(i);
|
||||
match r {
|
||||
Ok(v) => v,
|
||||
@@ -462,3 +488,117 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
|
||||
}
|
||||
Some(Mp4Source::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_media::FrameRequest;
|
||||
|
||||
/// A one-frame 64×32 ProRes MOV (bright left half, dark right half) whose `tkhd` matrix is
|
||||
/// replaced by `matrix` — built here, no media files.
|
||||
fn rotated_mov(matrix: [i32; 9]) -> Arc<[u8]> {
|
||||
let (w, h) = (64u32, 32u32);
|
||||
let mut fr = filmcraft_prores::Frame::new(w, h, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
|
||||
for (i, y) in fr.y.iter_mut().enumerate() {
|
||||
*y = if (i as u32 % w) < w / 2 { 800 } else { 100 };
|
||||
}
|
||||
let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, 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(*b"apch"), 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");
|
||||
let mut b = mux.finish().expect("finish").into_inner();
|
||||
// tkhd (ISO/IEC 14496-12 §8.3.2): version/flags, then 20 (v0) or 32 (v1) bytes of
|
||||
// times/id/duration, 8 reserved, layer, alternate_group, volume, reserved, matrix
|
||||
let k = b.windows(4).position(|x| x == b"tkhd").expect("tkhd") + 4;
|
||||
let m = k + 4 + if b[k] == 1 { 32 } else { 20 } + 16;
|
||||
for (j, v) in matrix.iter().enumerate() {
|
||||
b[m + j * 4..m + j * 4 + 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
b[k..k + 4].copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Damaged MOVs found by mutation fuzzing panicked on import: a track with timescale 0
|
||||
/// ("attempt to divide by zero" in the duration) and one whose sample description list is
|
||||
/// empty ("index out of bounds" on `entries[0]`). Both must open as an error, or as media
|
||||
/// that answers requests without panicking.
|
||||
#[test]
|
||||
fn damaged_track_headers_never_panic() {
|
||||
let identity = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000];
|
||||
// mdhd v0: version/flags, creation, modification, then timescale
|
||||
let mut zero_timescale = rotated_mov(identity).to_vec();
|
||||
patch_u32(&mut zero_timescale, b"mdhd", 12, 0);
|
||||
// stsd: version/flags, then entry_count
|
||||
let mut no_entries = rotated_mov(identity).to_vec();
|
||||
patch_u32(&mut no_entries, b"stsd", 4, 0);
|
||||
for (name, b) in [("ts0.mov", zero_timescale), ("stsd0.mov", no_entries)] {
|
||||
if let Ok(s) = Mp4Source::open(name, b.into()) {
|
||||
let _ = s.video_frame(FrameRequest::full(Tick::ZERO));
|
||||
let _ = s.audio(0, 1024, 48_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean luma of the top and bottom halves of the decoded frame, and its size.
|
||||
fn halves(src: &Mp4Source) -> (u32, u32, u8, u8) {
|
||||
let f = src.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame");
|
||||
let l = f.luma8();
|
||||
let half = l.len() / 2;
|
||||
let mean = |s: &[u8]| (s.iter().map(|&v| v as u32).sum::<u32>() / s.len() as u32) as u8;
|
||||
(f.width, f.height, mean(&l[..half]), mean(&l[half..]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_matrix_rotates_frames_and_reported_size() {
|
||||
const ONE: i32 = 0x10000;
|
||||
const W: i32 = 0x4000_0000;
|
||||
// identity: landscape 64x32, top and bottom alike
|
||||
let s = Mp4Source::open("id.mov", rotated_mov([ONE, 0, 0, 0, ONE, 0, 0, 0, W])).expect("open");
|
||||
let v = s.info().video.as_ref().expect("video");
|
||||
assert_eq!((v.width, v.height), (64, 32));
|
||||
let (w, h, top, bottom) = halves(&s);
|
||||
assert_eq!((w, h), (64, 32));
|
||||
assert!(top.abs_diff(bottom) < 4);
|
||||
|
||||
// 90° clockwise (what an iPhone writes for portrait): the left half ends up on top
|
||||
let s = Mp4Source::open("cw.mov", rotated_mov([0, ONE, 0, -ONE, 0, 0, 32 * ONE, 0, W])).expect("open");
|
||||
let v = s.info().video.as_ref().expect("video");
|
||||
assert_eq!((v.width, v.height), (32, 64));
|
||||
let (w, h, top, bottom) = halves(&s);
|
||||
assert_eq!((w, h), (32, 64));
|
||||
assert!(top > bottom + 100, "top {top} bottom {bottom}");
|
||||
|
||||
// 270° clockwise: the left half ends up at the bottom
|
||||
let s = Mp4Source::open("ccw.mov", rotated_mov([0, -ONE, 0, ONE, 0, 0, 0, 64 * ONE, W])).expect("open");
|
||||
let (w, h, top, bottom) = halves(&s);
|
||||
assert_eq!((w, h), (32, 64));
|
||||
assert!(bottom > top + 100, "top {top} bottom {bottom}");
|
||||
|
||||
// 180°: still landscape
|
||||
let s = Mp4Source::open("180.mov", rotated_mov([-ONE, 0, 0, 0, -ONE, 0, 64 * ONE, 32 * ONE, W])).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_or_scaled_matrices_are_not_mistaken_for_rotations() {
|
||||
const ONE: i32 = 0x10000;
|
||||
const W: i32 = 0x4000_0000;
|
||||
// horizontal mirror: not a pure rotation, so the frame is left as stored
|
||||
let s = Mp4Source::open("mirror.mov", rotated_mov([-ONE, 0, 0, 0, ONE, 0, 64 * ONE, 0, W])).expect("open");
|
||||
let (w, h, top, bottom) = halves(&s);
|
||||
assert_eq!((w, h), (64, 32));
|
||||
assert!(top.abs_diff(bottom) < 4);
|
||||
// a 2× scaled identity is still upright (only the signs decide the rotation)
|
||||
let s = Mp4Source::open("scaled.mov", rotated_mov([2 * ONE, 0, 0, 0, 2 * ONE, 0, 0, 0, W])).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)));
|
||||
// a scaled quarter turn is still a quarter turn
|
||||
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)));
|
||||
}
|
||||
}
|
||||
+43
-15
@@ -123,6 +123,19 @@ fn audio_supported(c: &Codec) -> bool {
|
||||
matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3)
|
||||
}
|
||||
|
||||
/// The coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding). Never
|
||||
/// zero: damaged timestamps spread over a huge span give a rate that rounds to 0 fps, which the
|
||||
/// duration computation divided by.
|
||||
fn stream_rate(coded: Option<FrameRate>, from_pts: Option<f64>) -> FrameRate {
|
||||
let rate = match (coded, from_pts) {
|
||||
(Some(r), Some(p)) if (r.as_f64() - p).abs() / p > 0.01 => FrameRate::from_f64(p),
|
||||
(Some(r), _) => r,
|
||||
(None, Some(p)) => FrameRate::from_f64(p),
|
||||
(None, None) => FrameRate::FPS_25,
|
||||
};
|
||||
if rate.num > 0 && rate.den > 0 { rate } else { FrameRate::FPS_25 }
|
||||
}
|
||||
|
||||
/// Display-order keys of samples (decode order): the PTS when every unit has one, else (MPEG
|
||||
/// video) GOP start + temporal_reference, else the decode order.
|
||||
fn display_keys(units: &[&Unit], mpeg: bool) -> Vec<i64> {
|
||||
@@ -242,8 +255,9 @@ impl MpegSource {
|
||||
src.file = Some(file);
|
||||
src.video = Some(track);
|
||||
src.describe_video(rate_from_pts)?;
|
||||
let file = src.file.take().expect("file");
|
||||
src.setup_audio(file, astream, origin);
|
||||
if let Some(file) = src.file.take() {
|
||||
src.setup_audio(file, astream, origin);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
src.unsupported_video = Some(format!("{} video in {} (FilmCraft has no decoder for it)", st.codec.name(), file.format.name()));
|
||||
@@ -257,6 +271,7 @@ impl MpegSource {
|
||||
color: filmcraft_color::ColorInfo::REC709,
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
});
|
||||
src.setup_audio(file, astream, None);
|
||||
}
|
||||
@@ -368,14 +383,14 @@ impl MpegSource {
|
||||
if let Some((data, aus)) = &self.es {
|
||||
return Ok(data[aus[u].clone()].to_vec());
|
||||
}
|
||||
let file = self.file.as_ref().expect("file");
|
||||
let file = self.file.as_ref().ok_or_else(|| CodecError::Container("no stream file".into()))?;
|
||||
file.read_unit(&self.bytes, v.stream, u).map_err(|e| CodecError::Container(e.to_string()))
|
||||
}
|
||||
|
||||
/// Fill in the video stream info from the first picture.
|
||||
fn describe_video(&mut self, rate_from_pts: Option<f64>) -> crate::Result<()> {
|
||||
let mut seq_header = None;
|
||||
let v = self.video.as_ref().expect("video");
|
||||
let v = self.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video stream".into()))?;
|
||||
let first = self.read_unit(v, 0)?;
|
||||
let n = v.order.len();
|
||||
let (width, height, par, codec, pixel_format, mut color);
|
||||
@@ -431,12 +446,7 @@ impl MpegSource {
|
||||
}
|
||||
}
|
||||
// the coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding)
|
||||
let rate = match (rate, rate_from_pts) {
|
||||
(Some(r), Some(p)) if (r.as_f64() - p).abs() / p > 0.01 => FrameRate::from_f64(p),
|
||||
(Some(r), _) => r,
|
||||
(None, Some(p)) => FrameRate::from_f64(p),
|
||||
(None, None) => FrameRate::FPS_25,
|
||||
};
|
||||
let rate = stream_rate(rate, rate_from_pts);
|
||||
let duration = rate.tick_of(n as i64);
|
||||
let total_bytes: u64 = match (&self.es, &self.file) {
|
||||
(Some((_, aus)), _) => v.units.iter().map(|&u| aus[u].len() as u64).sum(),
|
||||
@@ -444,8 +454,10 @@ impl MpegSource {
|
||||
_ => 0,
|
||||
};
|
||||
let secs = duration.seconds();
|
||||
if let Some(h) = seq_header {
|
||||
self.video.as_mut().expect("video").header = h;
|
||||
if let Some(h) = seq_header
|
||||
&& let Some(v) = self.video.as_mut()
|
||||
{
|
||||
v.header = h;
|
||||
}
|
||||
self.info.duration = duration;
|
||||
self.info.video = Some(VideoStreamInfo {
|
||||
@@ -458,6 +470,7 @@ impl MpegSource {
|
||||
color,
|
||||
has_alpha: false,
|
||||
bitrate: (secs > 0.0).then(|| (total_bytes as f64 * 8.0 / secs) as u64),
|
||||
hdr: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -523,7 +536,7 @@ impl MpegSource {
|
||||
return self.audio_unsupported(&codec_name, "DVD LPCM: no format header".into());
|
||||
}
|
||||
},
|
||||
_ => unreachable!("audio_supported"),
|
||||
_ => return self.audio_unsupported(&codec_name, format!("{codec_name} audio")),
|
||||
};
|
||||
if codec_is_ac3_unsupported(codec) {
|
||||
return self.audio_unsupported(&codec_name, format!("{codec_name} audio (FilmCraft has no AC-3 decoder yet)"));
|
||||
@@ -606,12 +619,12 @@ impl MpegSource {
|
||||
if let Some(p) = st.cache.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let file = self.file.as_ref().expect("file");
|
||||
let file = self.file.as_ref().ok_or_else(|| CodecError::Container("no stream file".into()))?;
|
||||
let read = |k: usize| file.read_unit(&self.bytes, a.stream, k).map_err(|e| CodecError::Container(e.to_string()));
|
||||
if st.dec.is_none() {
|
||||
st.dec = Some(AudioDecoder::new(a.codec, a.rate, a.channels, a.latm.clone())?);
|
||||
}
|
||||
let dec = st.dec.as_mut().expect("decoder");
|
||||
let dec = st.dec.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
if st.last.is_none_or(|l| l + 1 != i) {
|
||||
dec.reset();
|
||||
if i > 0
|
||||
@@ -838,6 +851,21 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mutated transport stream whose PTS span gave ~0 fps panicked on open with "attempt to
|
||||
/// divide by zero" (`FrameRate::tick_of`).
|
||||
#[test]
|
||||
fn damaged_timestamps_never_give_a_zero_rate() {
|
||||
for (coded, p) in
|
||||
[(None, Some(1e-9)), (Some(FrameRate::FPS_25), Some(1e-9)), (Some(FrameRate::new(0, 0)), None), (None, Some(0.0)), (None, Some(f64::NAN))]
|
||||
{
|
||||
let r = stream_rate(coded, p);
|
||||
assert!(r.num > 0 && r.den > 0, "{coded:?} {p:?} -> {r:?}");
|
||||
let _ = r.tick_of(1000);
|
||||
}
|
||||
assert_eq!(stream_rate(Some(FrameRate::FPS_25), Some(25.0)), FrameRate::FPS_25);
|
||||
assert_eq!(stream_rate(None, Some(50.0)), FrameRate::from_f64(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_drops_leading_pictures_and_orders_by_key() {
|
||||
// decode order I(2) B(0) B(1) P(5) B(3) B(4): the leading B pictures are not presented
|
||||
|
||||
@@ -320,6 +320,7 @@ impl MxfSource {
|
||||
color,
|
||||
has_alpha: p.alpha_depth > 0,
|
||||
bitrate: (secs > 0.0).then(|| (bytes_total as f64 * 8.0 / (t.samples.len().max(1) as f64 / rate.as_f64().max(1e-9))) as u64),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
// Audio: all PCM sound tracks of the first track's rate, channels in track order
|
||||
@@ -332,7 +333,7 @@ impl MxfSource {
|
||||
let mut channels = 0;
|
||||
for &ai in &atracks {
|
||||
let t = &file.tracks[ai];
|
||||
let si = t.sound.as_ref().expect("sound info");
|
||||
let Some(si) = t.sound.as_ref() else { continue };
|
||||
if si.sample_rate != rate {
|
||||
continue;
|
||||
}
|
||||
@@ -423,7 +424,7 @@ impl VideoSamples for MxfVideo<'_> {
|
||||
return Err(CodecError::Unsupported(why.clone()));
|
||||
}
|
||||
let t = &self.src.file.tracks[self.track];
|
||||
let v = self.src.info.video.as_ref().expect("video info");
|
||||
let v = self.src.info.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video track".into()))?;
|
||||
let (w, h) = (v.width.min(u16::MAX as u32) as u16, v.height.min(u16::MAX as u32) as u16);
|
||||
match t.codec {
|
||||
Codec::Avc { .. } => Ok(Box::new(crate::video::H264Decoder::annexb())),
|
||||
|
||||
@@ -133,7 +133,7 @@ impl OggSource {
|
||||
}
|
||||
// Non-sequential access: reset and decode the pre-roll (RFC 7845 §4.6; see OPUS_PRE_ROLL).
|
||||
if st.last_decoded.is_none_or(|l| l + 1 != i) {
|
||||
let d = st.decoder.as_mut().expect("decoder");
|
||||
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
d.reset();
|
||||
let from = timing.starts.partition_point(|&x| x <= timing.starts[i] - OPUS_PRE_ROLL as i64).saturating_sub(1);
|
||||
for j in from..i {
|
||||
@@ -143,7 +143,7 @@ impl OggSource {
|
||||
}
|
||||
}
|
||||
let data = read(i)?;
|
||||
let r = st.decoder.as_mut().expect("decoder").decode(&data, 0);
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, 0);
|
||||
st.last_decoded = Some(i);
|
||||
let p = Arc::new(r.unwrap_or_default());
|
||||
st.packets.insert(i, p.clone());
|
||||
|
||||
@@ -88,6 +88,33 @@ fn mp3_file_decodes() {
|
||||
assert!(a.peaks()[0] > 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_file_decodes() {
|
||||
// 0.1 s of 48 kHz 16-bit stereo PCM: left a ramp, right silent (AIFF, big-endian samples)
|
||||
let frames = 4800u32;
|
||||
let mut b = b"FORM".to_vec();
|
||||
b.extend((4 + 26 + 16 + frames * 4).to_be_bytes());
|
||||
b.extend(b"AIFFCOMM");
|
||||
b.extend(18u32.to_be_bytes());
|
||||
b.extend(2u16.to_be_bytes());
|
||||
b.extend(frames.to_be_bytes());
|
||||
b.extend(16u16.to_be_bytes());
|
||||
b.extend([0x40, 0x0E, 0xBB, 0x80, 0, 0, 0, 0, 0, 0]); // 48000 as an 80-bit extended float
|
||||
b.extend(b"SSND");
|
||||
b.extend((8 + frames * 4).to_be_bytes());
|
||||
b.extend([0; 8]);
|
||||
for i in 0..frames {
|
||||
b.extend(((i as i16) * 4).to_be_bytes());
|
||||
b.extend(0i16.to_be_bytes());
|
||||
}
|
||||
let src = crate::open_bytes("tone.aif", b.into()).unwrap();
|
||||
assert!((src.info().duration.seconds() - 0.1).abs() < 1e-3, "{}", src.info().duration.seconds());
|
||||
let a = src.audio(0, 4800, 48_000).unwrap();
|
||||
assert_eq!(a.channels.len(), 2);
|
||||
assert!((a.channels[0][1000] - 4000.0 / 32768.0).abs() < 1e-4, "{}", a.channels[0][1000]);
|
||||
assert_eq!(a.channels[1][1000], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_backwards_and_forwards_mjpeg() {
|
||||
let Some(b) = fixture("counter_mjpeg.mov", &["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=3", "-c:v", "mjpeg"]) else { return };
|
||||
@@ -445,7 +472,7 @@ fn opus_reference(name: &str) -> Option<Vec<f32>> {
|
||||
if !st.success() {
|
||||
return None;
|
||||
}
|
||||
Some(std::fs::read(out).ok()?.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect())
|
||||
Some(std::fs::read(out).ok()?.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect())
|
||||
}
|
||||
|
||||
fn snr_db(reference: &[f32], test: &[f32]) -> f64 {
|
||||
|
||||
@@ -311,7 +311,7 @@ impl HevcDecoder {
|
||||
}
|
||||
let par = if p.sar.0 > 0 && p.sar.1 > 0 { (p.sar.0 as u32, p.sar.1 as u32) } else { (1, 1) };
|
||||
let frame = VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO };
|
||||
DecodedFrame { pts: p.pts, frame, draft: false }
|
||||
DecodedFrame { pts: p.pts, frame, draft: p.draft }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,9 @@ impl VideoDecoder for HevcDecoder {
|
||||
fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
hevc_disposable(sample, self.length_size, self.highest_tid)
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.dec.set_draft(on);
|
||||
}
|
||||
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
// Annex B samples carry their parameter sets: an IDR / CRA / BLA access unit is a
|
||||
// starting point.
|
||||
@@ -383,7 +386,8 @@ impl Vp9Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(&self, p: filmcraft_vp9::Picture) -> DecodedFrame {
|
||||
/// `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_frame::{Chroma, PixelData};
|
||||
use filmcraft_vp9::Plane;
|
||||
@@ -410,7 +414,7 @@ impl Vp9Decoder {
|
||||
if p.color.full_range {
|
||||
color.range = Range::Full;
|
||||
}
|
||||
let pts = p.pts;
|
||||
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.
|
||||
let shift = p.bit_depth - 8;
|
||||
@@ -420,7 +424,7 @@ impl Vp9Decoder {
|
||||
}
|
||||
let mut frame = VideoFrame::rgba8(p.width, p.height, rgba);
|
||||
frame.color = filmcraft_color::ColorInfo { range: Range::Full, transfer: filmcraft_color::Transfer::Srgb, ..color };
|
||||
return DecodedFrame { pts, frame, draft: false };
|
||||
return Some(DecodedFrame { pts, frame, draft });
|
||||
}
|
||||
// 4:4:0 has no frame format of its own: chroma rows are repeated to 4:4:4.
|
||||
let (chroma, rows_440) = match (p.subsampling_x, p.subsampling_y) {
|
||||
@@ -452,22 +456,22 @@ impl Vp9Decoder {
|
||||
bits: p.bit_depth,
|
||||
alpha: None,
|
||||
},
|
||||
_ => unreachable!("VP9 planes share one sample type"),
|
||||
_ => return None,
|
||||
};
|
||||
// render_size (the intended display size) is not applied: the container's display
|
||||
// dimensions / pixel aspect describe the presentation.
|
||||
let par = (1, 1);
|
||||
DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }, draft: false }
|
||||
Some(DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }, draft })
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for Vp9Decoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
Ok(pics.into_iter().map(|p| self.convert(p)).collect())
|
||||
Ok(pics.into_iter().filter_map(|p| self.convert(p)).collect())
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
self.dec.flush().into_iter().map(|p| self.convert(p)).collect()
|
||||
self.dec.flush().into_iter().filter_map(|p| self.convert(p)).collect()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.dec.reset();
|
||||
@@ -475,6 +479,9 @@ impl VideoDecoder for Vp9Decoder {
|
||||
fn name(&self) -> &str {
|
||||
"FilmCraft VP9"
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.dec.set_draft(on);
|
||||
}
|
||||
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
Some(filmcraft_vp9::is_keyframe(sample))
|
||||
}
|
||||
@@ -502,6 +509,7 @@ impl Av1Decoder {
|
||||
|
||||
fn convert(p: filmcraft_av1::Picture, pts: i64) -> DecodedFrame {
|
||||
use std::sync::Arc;
|
||||
let draft = p.draft;
|
||||
let w = p.width as usize;
|
||||
let h = p.height as usize;
|
||||
let mut color = filmcraft_color::ColorInfo::REC709;
|
||||
@@ -541,7 +549,7 @@ impl Av1Decoder {
|
||||
} else {
|
||||
filmcraft_frame::PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, bits: p.bit_depth as u32, alpha: None }
|
||||
};
|
||||
DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }, draft: false }
|
||||
DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }, draft }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +588,9 @@ impl VideoDecoder for Av1Decoder {
|
||||
fn name(&self) -> &str {
|
||||
"FilmCraft AV1"
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.dec.set_draft(on);
|
||||
}
|
||||
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
Some(filmcraft_av1::is_key_frame_unit(sample))
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn ffmpeg_audio_f32(ff: &Path, file: &Path, extra: &[&str]) -> Vec<f32> {
|
||||
let mut args = vec!["-i", file.to_str().unwrap()];
|
||||
args.extend_from_slice(extra);
|
||||
args.extend_from_slice(&["-map", "0:a:0", "-f", "f32le", "-"]);
|
||||
ffmpeg_out(ff, &args).chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()
|
||||
ffmpeg_out(ff, &args).as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect()
|
||||
}
|
||||
|
||||
/// Our frame's three planes widened to u16 (and their sizes).
|
||||
@@ -63,7 +63,7 @@ pub fn planes(f: &VideoFrame) -> Vec<Vec<u16>> {
|
||||
/// Split one raw frame of `ffmpeg_frames` into planes (`bps` bytes per sample, chroma `cw`×`ch`).
|
||||
pub fn raw_planes(raw: &[u8], w: usize, h: usize, cw: usize, ch: usize, bps: usize) -> Vec<Vec<u16>> {
|
||||
let get = |b: &[u8]| -> Vec<u16> {
|
||||
if bps == 1 { b.iter().map(|&v| v as u16).collect() } else { b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }
|
||||
if bps == 1 { b.iter().map(|&v| v as u16).collect() } else { b.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() }
|
||||
};
|
||||
let y = w * h * bps;
|
||||
let c = cw * ch * bps;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Draft decoding of VP9, HEVC and AV1 through the media stack (as `h264_draft.rs` for H.264):
|
||||
//! off by default, approximate frames only reach draft requests, and decoding for an export after
|
||||
//! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_media::FrameRequest;
|
||||
use filmcraft_media::cancel::with_draft;
|
||||
|
||||
/// Play `name` without and with draft decoding, then "export" it: every non-draft request must
|
||||
/// be bit-exact with ffmpeg, and draft playback must have produced some approximate frames.
|
||||
fn check(name: &str, args: &[&str], w: usize, h: usize) {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let Some(file) = fixture(&ff, name, args) else { return };
|
||||
let raw = ffmpeg_frames(&ff, &file, "yuv420p");
|
||||
let fsize = w * h * 3 / 2;
|
||||
let n = raw.len() / fsize;
|
||||
assert!(n >= 16, "{name}: {n} frames");
|
||||
let reference = |i: usize| raw_planes(&raw[i * fsize..(i + 1) * fsize], w, h, w / 2, h / 2, 1);
|
||||
|
||||
// Default: nothing is a draft, every frame exact.
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&file)).expect("open");
|
||||
let rate = src.info().frame_rate();
|
||||
let g0 = filmcraft_codecs::gop_stats();
|
||||
for i in 0..n {
|
||||
let f = src.video_frame(FrameRequest { time: rate.tick_of(i as i64), scale: 0.5 }).expect("frame");
|
||||
assert_eq!(max_diff(&planes(&f), &reference(i)), 0, "{name}: frame {i} without draft mode");
|
||||
}
|
||||
assert_eq!(filmcraft_codecs::gop_stats().draft, g0.draft, "{name}: draft decoding is off by default");
|
||||
|
||||
// Draft playback at 1/2 resolution on a fresh source: non-reference frames are drafts.
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&file)).expect("open");
|
||||
let g1 = filmcraft_codecs::gop_stats();
|
||||
let mut approximate = 0;
|
||||
for i in 0..n {
|
||||
let f = with_draft(true, || src.video_frame(FrameRequest { time: rate.tick_of(i as i64), scale: 0.5 })).expect("frame");
|
||||
approximate += (max_diff(&planes(&f), &reference(i)) > 0) as usize;
|
||||
}
|
||||
let drafts = filmcraft_codecs::gop_stats().draft - g1.draft;
|
||||
assert!(drafts > 0 && approximate > 0 && approximate as u64 <= drafts, "{name}: {drafts} draft frames, {approximate} differ");
|
||||
|
||||
// Then an export from the same source (no draft hint): every frame is exact again.
|
||||
for i in 0..n {
|
||||
let f = src.video_frame(FrameRequest::full(rate.tick_of(i as i64))).expect("frame");
|
||||
assert_eq!(max_diff(&planes(&f), &reference(i)), 0, "{name}: export frame {i} after draft playback");
|
||||
}
|
||||
}
|
||||
|
||||
/// One test (the GOP cache statistics are process-wide, so the codecs run one after another).
|
||||
#[test]
|
||||
fn draft_playback_never_reaches_exports() {
|
||||
vp9();
|
||||
hevc();
|
||||
av1();
|
||||
}
|
||||
|
||||
fn vp9() {
|
||||
// Two temporal layers: every other frame refreshes no reference slot. (25 fps: WebM stores
|
||||
// millisecond timestamps.)
|
||||
check(
|
||||
"draft_vp9.webm",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=320x240:r=25:d=1,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-deadline",
|
||||
"realtime",
|
||||
"-speed",
|
||||
"8",
|
||||
"-b:v",
|
||||
"600k",
|
||||
"-ts-parameters",
|
||||
"ts_number_layers=2:ts_target_bitrate=300,600:ts_rate_decimator=2,1:ts_periodicity=2:ts_layer_id=0,1:ts_layering_mode=2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
320,
|
||||
240,
|
||||
);
|
||||
}
|
||||
|
||||
fn hevc() {
|
||||
// B-pyramid: non-reference B pictures (sub-layer non-reference NAL types).
|
||||
check(
|
||||
"draft_hevc.mp4",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=320x240:r=25:d=1,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libx265",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-x265-params",
|
||||
"bframes=4:b-pyramid=1:keyint=24:log-level=error",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-tag:v",
|
||||
"hvc1",
|
||||
],
|
||||
320,
|
||||
240,
|
||||
);
|
||||
}
|
||||
|
||||
fn av1() {
|
||||
// SVT-AV1 hierarchical GOP: the top layer refreshes no reference slot.
|
||||
check(
|
||||
"draft_av1.mp4",
|
||||
&["-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=1,noise=alls=10:allf=t", "-c:v", "libsvtav1", "-preset", "8", "-crf", "35", "-pix_fmt", "yuv420p"],
|
||||
320,
|
||||
240,
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ fn decode_both(ff: &Path, file: &Path, decoder: &[&str]) -> (OggSource, Vec<f32>
|
||||
let ch = a.channels as usize;
|
||||
let mut args: Vec<&str> = decoder.to_vec();
|
||||
args.extend_from_slice(&["-i", file.to_str().unwrap(), "-map", "0:a:0", "-f", "f32le", "-"]);
|
||||
let want: Vec<f32> = ffmpeg_out(ff, &args).chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let want: Vec<f32> = ffmpeg_out(ff, &args).as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let n = want.len() / ch;
|
||||
let frames = src.info().duration.to_units_floor(a.sample_rate as i64) as usize;
|
||||
assert_eq!(frames, n, "{}: our length = ffmpeg's ({} samples)", file.display(), n);
|
||||
|
||||
+23
-1
@@ -41,7 +41,8 @@ for limited- and full-range files.
|
||||
|
||||
The encoders are the exact inverses given in the same documents. DJI **D-Log M** is not
|
||||
implemented: DJI has not published its formula (only conversion LUTs), and we do not derive curves
|
||||
from third-party LUTs.
|
||||
from third-party LUTs. (Checked again in October 2026: DJI's published white papers cover D-Log /
|
||||
D-Gamut only; D-Log M remains unsupported, so D-Log M footage should be interpreted with a LUT.)
|
||||
|
||||
Gamut primaries (all D65 white, x/y):
|
||||
|
||||
@@ -84,6 +85,27 @@ HLG, and for log media the curve's own peak capped at 4000 cd/m² (so the knee s
|
||||
grey). Target peak: 203 cd/m² = SDR white. HDR reference white then lands at ≈ 0.79 linear (≈ 90 %
|
||||
on the sRGB curve).
|
||||
|
||||
## Grading signal (`grade`)
|
||||
|
||||
Lumetri's controls work on a 0…1 signal. `GradeSpace` picks it from the working space:
|
||||
|
||||
| Working space | Signal of working-linear `c` (1.0 = 203 cd/m² in HDR) | 1.0 means |
|
||||
|---|---|---|
|
||||
| Rec. 709 | sRGB curve, clamped back to 0…1 | SDR white |
|
||||
| Rec. 2100 PQ | `PQ⁻¹(c·203/10000) / PQ⁻¹(W/10000)` (SMPTE ST 2084) | HDR White `W` cd/m² |
|
||||
| Rec. 2100 HLG | `F = c·203/W`, `Y = BT.2020 luma(F)`, `E = F·Y^((1−γ)/γ)`, signal = HLG OETF(E), γ = 1.2 + 0.42·log10(W/1000) (BT.2100 inverse OOTF, note 5f) | display peak `W` |
|
||||
|
||||
`W` is Lumetri's HDR White (Basic Correction) or HDR Range (Curves), default 1000 cd/m². HDR
|
||||
values above `W` are signals above 1 and are kept (PQ to 10 000 cd/m², HLG to signal 1.5). Checked
|
||||
values: PQ 100 / 203 / 1000 cd/m² = 0.508 / 0.581 / 0.752 of the plain PQ signal (BT.2408); HLG on
|
||||
a 1000 cd/m² display: 203 cd/m² = 0.75, 26 cd/m² (18 % grey) ≈ 0.38 (BT.2408).
|
||||
|
||||
## HDR static metadata
|
||||
|
||||
`HdrMetadata` (mastering display max / min luminance, MaxCLL, MaxFALL; 0 = unknown) gives the
|
||||
tone-mapping source peak of PQ media: MaxCLL when known (the brightest pixel of the content),
|
||||
else the mastering peak, clamped to 203…10 000 cd/m²; without metadata 1000 cd/m² is assumed.
|
||||
|
||||
## Gamut mapping
|
||||
|
||||
* **Input** (camera gamuts wider than the working gamut): per-channel distance compression towards
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! The grading signal colour correctors (Lumetri) work on.
|
||||
//!
|
||||
//! Grading controls (whites / blacks, highlights / shadows, contrast, curves, wheels, keys) are
|
||||
//! defined on a perceptual signal from 0 (black) to 1 (white), not on linear light. In an SDR
|
||||
//! working space that signal is the sRGB-encoded display value, and 1 is SDR white.
|
||||
//!
|
||||
//! In an HDR working space (Rec. 2100 PQ or HLG) the signal follows the working space's own
|
||||
//! transfer function and is **normalised to HDR White** (cd/m², Lumetri ▸ Basic Correction ▸ HDR
|
||||
//! White; the curves have their own HDR Range): signal 1 is HDR White, so the sliders and curves
|
||||
//! span 0 … HDR White nits the way they span black … white in SDR, and highlights above HDR
|
||||
//! White (speculars) are values above 1 that pass through instead of clipping:
|
||||
//!
|
||||
//! - **PQ** (SMPTE ST 2084): `signal = PQ⁻¹(nits / 10000) / PQ⁻¹(white / 10000)`.
|
||||
//! - **HLG** (ITU-R BT.2100): the inverse OOTF of a display with peak `white` (system gamma
|
||||
//! γ = 1.2 + 0.42·log10(white / 1000), BT.2100 note 5f), then the HLG OETF; so 203 cd/m²
|
||||
//! (BT.2408 HDR reference white) is signal 0.75 with HDR White = 1000 cd/m².
|
||||
//!
|
||||
//! Working units are linear light with 1.0 = 203 cd/m² ([`crate::REFERENCE_WHITE_NITS`]) in HDR
|
||||
//! working spaces, as in [`crate::transform`].
|
||||
|
||||
use crate::spaces::{Gamut, WorkingSpace};
|
||||
use crate::{REFERENCE_WHITE_NITS, hlg_inverse_oetf, hlg_oetf, linear_to_srgb, pq_eotf, pq_inverse_eotf, srgb_to_linear};
|
||||
|
||||
/// Default HDR White / curve HDR Range (cd/m²): the nominal HLG display peak and the usual PQ
|
||||
/// mastering peak.
|
||||
pub const DEFAULT_HDR_WHITE_NITS: f32 = 1000.0;
|
||||
|
||||
/// The signal a grading operation works on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum GradeSpace {
|
||||
/// sRGB-encoded display values, clamped to 0…1 on the way back (the classic SDR behaviour).
|
||||
Sdr,
|
||||
/// PQ signal normalised to `white` cd/m².
|
||||
Pq { white: f32, norm: f32 },
|
||||
/// HLG signal of a display with peak `white` cd/m² (`gamma` = its system gamma).
|
||||
Hlg { white: f32, gamma: f32 },
|
||||
}
|
||||
|
||||
impl GradeSpace {
|
||||
/// The grading space of a working space, with HDR White `white_nits` (ignored for SDR).
|
||||
pub fn new(working: WorkingSpace, white_nits: f32) -> Self {
|
||||
let white = white_nits.clamp(100.0, 10_000.0);
|
||||
match working {
|
||||
WorkingSpace::Rec709 => GradeSpace::Sdr,
|
||||
WorkingSpace::Rec2100Pq => GradeSpace::Pq { white, norm: pq_inverse_eotf(white / 10_000.0) },
|
||||
WorkingSpace::Rec2100Hlg => GradeSpace::Hlg { white, gamma: 1.2 + 0.42 * (white / 1000.0).log10() },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_hdr(&self) -> bool {
|
||||
!matches!(self, GradeSpace::Sdr)
|
||||
}
|
||||
|
||||
/// HDR White in cd/m² (None for SDR).
|
||||
pub fn white_nits(&self) -> Option<f32> {
|
||||
match *self {
|
||||
GradeSpace::Sdr => None,
|
||||
GradeSpace::Pq { white, .. } | GradeSpace::Hlg { white, .. } => Some(white),
|
||||
}
|
||||
}
|
||||
|
||||
/// Working linear → grading signal (1 = white; HDR values above HDR White are > 1).
|
||||
#[inline]
|
||||
pub fn encode(&self, c: [f32; 3]) -> [f32; 3] {
|
||||
match *self {
|
||||
GradeSpace::Sdr => c.map(|v| linear_to_srgb(v.max(0.0))),
|
||||
GradeSpace::Pq { norm, .. } => c.map(|v| pq_inverse_eotf((v.max(0.0) * REFERENCE_WHITE_NITS as f32 / 10_000.0).min(1.0)) / norm),
|
||||
GradeSpace::Hlg { white, gamma } => {
|
||||
// display light normalised to the peak, inverse OOTF (scene light), OETF
|
||||
let fd = c.map(|v| v.max(0.0) * REFERENCE_WHITE_NITS as f32 / white);
|
||||
let l = luma2020();
|
||||
let yd = l[0] * fd[0] + l[1] * fd[1] + l[2] * fd[2];
|
||||
if yd <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let k = yd.powf((1.0 - gamma) / gamma);
|
||||
fd.map(|v| hlg_oetf(v * k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grading signal → working linear. SDR clamps to 0…1 (white); HDR keeps values above HDR
|
||||
/// White up to 10 000 cd/m² (PQ) or the HLG signal's range.
|
||||
#[inline]
|
||||
pub fn decode(&self, v: [f32; 3]) -> [f32; 3] {
|
||||
match *self {
|
||||
GradeSpace::Sdr => v.map(|q| srgb_to_linear(q.clamp(0.0, 1.0))),
|
||||
GradeSpace::Pq { norm, .. } => v.map(|q| pq_eotf((q * norm).clamp(0.0, 1.0)) * 10_000.0 / REFERENCE_WHITE_NITS as f32),
|
||||
GradeSpace::Hlg { white, gamma } => {
|
||||
// graded highlights may go past signal 1.0 (the display peak); keep them up to
|
||||
// 1.5 (≈ 28× the peak) instead of clipping
|
||||
let es = v.map(|q| hlg_inverse_oetf(q.clamp(0.0, 1.5)));
|
||||
let l = luma2020();
|
||||
let ys = l[0] * es[0] + l[1] * es[1] + l[2] * es[2];
|
||||
if ys <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let k = ys.powf(gamma - 1.0);
|
||||
es.map(|e| e * k * white / REFERENCE_WHITE_NITS as f32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The signal of a grey of `nits` cd/m² (HDR) — what a nit-scaled slider position means.
|
||||
pub fn signal_of_nits(&self, nits: f32) -> f32 {
|
||||
let w = nits / REFERENCE_WHITE_NITS as f32;
|
||||
self.encode([w; 3])[1]
|
||||
}
|
||||
|
||||
/// The cd/m² of a grey of signal `s` (HDR).
|
||||
pub fn nits_of_signal(&self, s: f32) -> f32 {
|
||||
self.decode([s; 3])[1] * REFERENCE_WHITE_NITS as f32
|
||||
}
|
||||
}
|
||||
|
||||
fn luma2020() -> [f32; 3] {
|
||||
let l = Gamut::Bt2020.luma();
|
||||
[l[0] as f32, l[1] as f32, l[2] as f32]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grey(nits: f32) -> [f32; 3] {
|
||||
[nits / REFERENCE_WHITE_NITS as f32; 3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdr_is_the_srgb_signal() {
|
||||
let g = GradeSpace::new(WorkingSpace::Rec709, 1000.0);
|
||||
assert!(!g.is_hdr());
|
||||
let e = g.encode([0.18, 0.5, 1.0]);
|
||||
assert!((e[0] - linear_to_srgb(0.18)).abs() < 1e-6 && (e[2] - 1.0).abs() < 1e-6);
|
||||
// values above white clip on the way back (SDR)
|
||||
assert_eq!(g.decode([1.4, 1.4, 1.4]), [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pq_known_nit_values() {
|
||||
// HDR White 10 000: the plain PQ signal; 203 cd/m² ≈ 58 % (BT.2408), 1000 cd/m² ≈ 75.2 %
|
||||
let g = GradeSpace::new(WorkingSpace::Rec2100Pq, 10_000.0);
|
||||
assert!((g.signal_of_nits(203.0) - 0.5807).abs() < 1e-3);
|
||||
assert!((g.signal_of_nits(1000.0) - 0.7518).abs() < 1e-3);
|
||||
assert!((g.signal_of_nits(100.0) - 0.5081).abs() < 1e-3);
|
||||
// HDR White 1000: 1000 cd/m² is signal 1, reference white 0.772
|
||||
let g = GradeSpace::new(WorkingSpace::Rec2100Pq, 1000.0);
|
||||
assert!((g.signal_of_nits(1000.0) - 1.0).abs() < 1e-4);
|
||||
assert!((g.signal_of_nits(203.0) - 0.5807 / 0.7518).abs() < 1e-3);
|
||||
// above HDR White: > 1, not clipped, round trip to 4000 cd/m²
|
||||
let s = g.signal_of_nits(4000.0);
|
||||
assert!(s > 1.1);
|
||||
assert!((g.nits_of_signal(s) - 4000.0).abs() < 4.0);
|
||||
for n in [0.05f32, 1.0, 26.0, 100.0, 203.0, 600.0, 1000.0, 2500.0, 10_000.0] {
|
||||
let back = g.decode(g.encode(grey(n)))[0] * REFERENCE_WHITE_NITS as f32;
|
||||
assert!((back - n).abs() <= n * 2e-3 + 1e-3, "{n} → {back}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hlg_known_nit_values() {
|
||||
// a 1000 cd/m² HLG display: reference white 203 cd/m² = 75 % (BT.2408), the peak = 100 %
|
||||
let g = GradeSpace::new(WorkingSpace::Rec2100Hlg, 1000.0);
|
||||
assert!((g.signal_of_nits(203.0) - 0.75).abs() < 2e-3, "{}", g.signal_of_nits(203.0));
|
||||
assert!((g.signal_of_nits(1000.0) - 1.0).abs() < 1e-3);
|
||||
// 18 % grey card (≈ 26 cd/m² on that display, BT.2408 table) ≈ 38 %
|
||||
assert!((g.signal_of_nits(26.0) - 0.38).abs() < 0.01, "{}", g.signal_of_nits(26.0));
|
||||
// colour round trip (the inverse OOTF depends on luminance)
|
||||
let c = [2.0f32, 0.5, 0.25];
|
||||
let back = g.decode(g.encode(c));
|
||||
assert!(c.iter().zip(back).all(|(a, b)| (a - b).abs() < 1e-3 * a.max(1.0)), "{back:?}");
|
||||
// a brighter display has a higher system gamma (BT.2100: 1.2 + 0.42 log10(Lw / 1000))
|
||||
let GradeSpace::Hlg { gamma, .. } = GradeSpace::new(WorkingSpace::Rec2100Hlg, 2000.0) else { panic!() };
|
||||
assert!((gamma - 1.2 - 0.42 * 2f32.log10()).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,18 @@
|
||||
//! The compositor works in **linear-light, premultiplied RGBA f32** in the sequence working space
|
||||
//! (Rec.709 primaries by default). Decoded frames carry [`ColorInfo`] so conversions are explicit.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub mod grade;
|
||||
pub mod log;
|
||||
pub mod lut;
|
||||
pub mod spaces;
|
||||
pub mod transform;
|
||||
|
||||
pub use grade::GradeSpace;
|
||||
pub use log::LogCurve;
|
||||
pub use lut::{Lut, Lut1d, Lut3d, LutFormat};
|
||||
pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace};
|
||||
@@ -101,6 +105,33 @@ pub struct ColorInfo {
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
/// HDR static metadata of a stream: SMPTE ST 2086 mastering display luminance and the CTA-861.3
|
||||
/// content light levels (MaxCLL / MaxFALL), in cd/m². Read from `mdcv` / `clli` (MP4 / MOV) and
|
||||
/// Matroska `MasteringMetadata` / `MaxCLL` / `MaxFALL`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HdrMetadata {
|
||||
pub mastering_max_nits: Option<f32>,
|
||||
pub mastering_min_nits: Option<f32>,
|
||||
pub max_cll: Option<f32>,
|
||||
pub max_fall: Option<f32>,
|
||||
}
|
||||
|
||||
impl HdrMetadata {
|
||||
/// The content's peak for tone mapping: MaxCLL when signalled (the brightest pixel actually
|
||||
/// in the content), else the mastering display's peak; `None` when neither is (0 means
|
||||
/// unknown in both). Never below HDR reference white.
|
||||
pub fn peak_nits(&self) -> Option<f32> {
|
||||
let known = |v: Option<f32>| v.filter(|n| *n > 0.0);
|
||||
let p = match (known(self.max_cll), known(self.mastering_max_nits)) {
|
||||
(Some(c), Some(m)) => c.min(m),
|
||||
(Some(c), None) => c,
|
||||
(None, m) => m?,
|
||||
};
|
||||
Some(p.clamp(transform::REFERENCE_WHITE_NITS as f32, 10_000.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
pub const SRGB_FULL: ColorInfo = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Srgb, primaries: Primaries::Bt709, range: Range::Full };
|
||||
pub const REC709: ColorInfo = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
|
||||
@@ -38,7 +38,7 @@ let f = decode_frame_with(&sample, &DecodeOptions { threads: false, ..Default::d
|
||||
let hdr: FrameHeader = probe(&sample)?; // cid, raster, depth, chroma…
|
||||
let name = cid_name(hdr.cid); // "DNxHR HQX", "DNxHD 1080i 8-bit (CID 1242)"
|
||||
|
||||
let mut enc = Encoder::new(Profile::Hq, 1920, 1080); // or Encoder::with_config(EncoderConfig { .. })
|
||||
let mut enc = Encoder::new(Profile::Hq, 1920, 1080)?; // or Encoder::with_config(EncoderConfig { .. })
|
||||
let sample: Vec<u8> = enc.encode(&frame)?; // exactly enc.frame_size() bytes (CBR)
|
||||
```
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ pub(crate) fn idct8x8(block: &mut [f32; 64]) {
|
||||
let mut nz = [false; 8];
|
||||
let mut dc_only = true;
|
||||
// Horizontal pass: t[r][x] = Σ_u F[r][u] · B[u][x]
|
||||
for r in 0..8 {
|
||||
let c: &[f32; 8] = block[r * 8..r * 8 + 8].try_into().unwrap();
|
||||
for (r, c) in block.as_chunks::<8>().0.iter().enumerate() {
|
||||
if c[1..].iter().all(|&v| v == 0.0) {
|
||||
if c[0] != 0.0 {
|
||||
t[r] = [c[0] * BASIS[0][0]; 8];
|
||||
|
||||
@@ -61,8 +61,9 @@ struct Prepared {
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Encoder {
|
||||
Encoder::with_config(EncoderConfig::new(profile, width, height)).expect("valid default configuration")
|
||||
/// An encoder with the default configuration; fails on rasters the profile can't code.
|
||||
pub fn new(profile: Profile, width: u32, height: u32) -> Result<Encoder> {
|
||||
Encoder::with_config(EncoderConfig::new(profile, width, height))
|
||||
}
|
||||
|
||||
pub fn with_config(cfg: EncoderConfig) -> Result<Encoder> {
|
||||
@@ -79,7 +80,7 @@ impl Encoder {
|
||||
if !ok_depth {
|
||||
return Err(Error::Input(format!("{} does not support {}-bit", cfg.profile.name(), cfg.bit_depth)));
|
||||
}
|
||||
let info = cid_info(cfg.profile.cid()).expect("RI CID");
|
||||
let info = cid_info(cfg.profile.cid()).ok_or_else(|| Error::Input(format!("no compression table for {}", cfg.profile.name())))?;
|
||||
let frame_size = cfg.profile.frame_size(cfg.width, cfg.height);
|
||||
let header_size = header::header_size_for(cfg.height);
|
||||
Ok(Encoder { cfg, info, frame_size, header_size, last_q: 8 })
|
||||
|
||||
@@ -127,7 +127,7 @@ fn probe_weights() {
|
||||
let px: Vec<f64> = if depth == 8 {
|
||||
out.stdout.iter().map(|&b| b as f64).collect()
|
||||
} else {
|
||||
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
|
||||
};
|
||||
let (off, stride) = if blk == 0 { (0, w as usize) } else { ((w * h) as usize, w as usize / 2) };
|
||||
let mid = (1 << (depth - 1)) as f64;
|
||||
@@ -224,7 +224,7 @@ fn probe_reconstruction() {
|
||||
let px: Vec<f64> = if depth == 8 {
|
||||
out.stdout.iter().map(|&b| b as f64).collect()
|
||||
} else {
|
||||
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]]) as f64).collect()
|
||||
};
|
||||
let mid = (1 << (depth - 1)) as f64;
|
||||
let mut sum = 0f64;
|
||||
|
||||
@@ -152,7 +152,7 @@ pub fn parse(d: &[u8]) -> Result<FrameHeader> {
|
||||
2 => ColorVolume::Bt2020Cl,
|
||||
_ => ColorVolume::OutOfBand,
|
||||
};
|
||||
let timecode = (d[0x30] & 0x80 != 0).then(|| d[0x31..0x39].try_into().unwrap());
|
||||
let timecode = if d[0x30] & 0x80 != 0 { d.get(0x31..).and_then(|t| t.first_chunk::<8>()).copied() } else { None };
|
||||
let mb_rows = be16(d, 0x16C);
|
||||
if width == 0 || lines == 0 || width > 16384 || lines > 16384 {
|
||||
return Err(Error::Invalid("raster size"));
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//!
|
||||
//! See the crate README for accuracy, performance and limitations.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod bits;
|
||||
mod dct;
|
||||
mod decode;
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn reference(ff: &Path, mov: &Path, pix_fmt: &str) -> Vec<u16> {
|
||||
.expect("run ffmpeg");
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
if pix_fmt.ends_with("le") {
|
||||
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
} else {
|
||||
out.stdout.iter().map(|&b| b as u16).collect()
|
||||
}
|
||||
@@ -247,7 +247,7 @@ pub fn ffmpeg_decode(ff: &Path, mov: &Path, pix_fmt: &str) -> Vec<u16> {
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(out.status.success() && err.trim().is_empty(), "ffmpeg failed: {err}");
|
||||
if pix_fmt.ends_with("le") {
|
||||
out.stdout.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
out.stdout.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
} else {
|
||||
out.stdout.iter().map(|&b| b as u16).collect()
|
||||
}
|
||||
@@ -270,8 +270,11 @@ pub fn source_frames(ff: &Path, name: &str, lavfi: &str, frames: u32, w: u32, h:
|
||||
std::fs::rename(&tmp, &path).unwrap();
|
||||
}
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let s: Vec<u16> =
|
||||
if depth == 8 { bytes.iter().map(|&b| b as u16).collect() } else { bytes.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
|
||||
let s: Vec<u16> = if depth == 8 {
|
||||
bytes.iter().map(|&b| b as u16).collect()
|
||||
} else {
|
||||
bytes.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect()
|
||||
};
|
||||
let proto = Frame::new(w, h, chroma, depth, false);
|
||||
let per = frame_samples(&proto);
|
||||
assert_eq!(s.len(), per * frames as usize);
|
||||
|
||||
@@ -68,17 +68,20 @@ fn roundtrip_all_profiles() {
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
let mut enc = Encoder::new(Profile::Hq, 64, 64);
|
||||
let mut enc = Encoder::new(Profile::Hq, 64, 64).unwrap();
|
||||
assert!(enc.encode(&Frame::new(32, 64, ChromaFormat::Yuv422, 8, false)).is_err());
|
||||
assert!(enc.encode(&Frame::new(64, 64, ChromaFormat::Yuv444, 8, false)).is_err());
|
||||
assert!(Encoder::with_config(EncoderConfig { bit_depth: 10, ..EncoderConfig::new(Profile::Hq, 64, 64) }).is_err());
|
||||
assert!(decode_frame(&[0u8; 100]).is_err());
|
||||
assert!(decode_frame(&[0u8; 1000]).is_err());
|
||||
// An unsupported raster is an error, not a panic.
|
||||
assert!(Encoder::new(Profile::Hq, 0, 0).is_err());
|
||||
assert!(Encoder::new(Profile::Hq, 20_000, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_streams_never_panic() {
|
||||
let mut enc = Encoder::new(Profile::Hq, 256, 128);
|
||||
let mut enc = Encoder::new(Profile::Hq, 256, 128).unwrap();
|
||||
let good = enc.encode(&picture(256, 128, ChromaFormat::Yuv422, 8, 1)).unwrap();
|
||||
let mut seed = 0x1234_5678u32;
|
||||
let mut rnd = || {
|
||||
|
||||
@@ -105,8 +105,9 @@ pub fn merge_captions(seq: &mut Sequence, ids: &[ClipId]) -> Result<ClipId> {
|
||||
if idx.windows(2).any(|w| w[1] != w[0] + 1) {
|
||||
return Err(EditError::Other("captions to merge must be next to each other".into()));
|
||||
}
|
||||
let first = idx[0];
|
||||
let last = *idx.last().expect("non-empty");
|
||||
let (Some(&first), Some(&last)) = (idx.first(), idx.last()) else {
|
||||
return Err(EditError::Nothing);
|
||||
};
|
||||
let end = tr.captions[last].end();
|
||||
let text: Vec<String> = idx.iter().map(|&i| tr.captions[i].text.trim().to_string()).filter(|t| !t.is_empty()).collect();
|
||||
let keep = tr.captions[first].id;
|
||||
@@ -205,7 +206,7 @@ pub fn move_captions(seq: &mut Sequence, ids: &[ClipId], delta: Tick) -> Result<
|
||||
return Err(EditError::Nothing);
|
||||
}
|
||||
for tid in tracks {
|
||||
let tr = seq.caption_track_mut(tid).expect("checked");
|
||||
let Some(tr) = seq.caption_track_mut(tid) else { continue };
|
||||
for c in tr.captions.iter_mut().filter(|c| ids.contains(&c.id)) {
|
||||
c.start += d;
|
||||
}
|
||||
@@ -319,7 +320,7 @@ mod tests {
|
||||
|
||||
fn ctx(next: &mut u64) -> EditCtx<'_> {
|
||||
static NONE: fn(filmcraft_project::ItemId) -> Option<Tick> = |_| None;
|
||||
EditCtx { next_id: next, media_duration: &NONE, min_duration: Tick(10) }
|
||||
EditCtx { next_id: next, media_duration: &NONE, media_start: &|_| Tick::ZERO, min_duration: Tick(10) }
|
||||
}
|
||||
|
||||
fn spans(seq: &Sequence, tid: TrackId) -> Vec<(i64, i64, String)> {
|
||||
|
||||
+58
-5
@@ -15,6 +15,8 @@
|
||||
//!
|
||||
//! Keyframes are stored in media time, so trims and splits never need to move them.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod captions;
|
||||
pub mod multicam;
|
||||
pub mod through;
|
||||
@@ -50,8 +52,12 @@ pub type Result<T> = std::result::Result<T, EditError>;
|
||||
/// Supplies fresh ids and media durations to edits.
|
||||
pub struct EditCtx<'a> {
|
||||
pub next_id: &'a mut u64,
|
||||
/// Media duration of a project item (None = unlimited, e.g. stills / adjustment layers).
|
||||
/// Media duration of a project item (None = unlimited, e.g. stills / adjustment layers): the
|
||||
/// latest media time its clips may show.
|
||||
pub media_duration: &'a dyn Fn(ItemId) -> Option<Tick>,
|
||||
/// The earliest media time clips of a project item may show (a subclip that restricts trims
|
||||
/// starts at its In point; everything else at 0).
|
||||
pub media_start: &'a dyn Fn(ItemId) -> Tick,
|
||||
/// Minimum item duration (one sequence frame).
|
||||
pub min_duration: Tick,
|
||||
}
|
||||
@@ -177,6 +183,34 @@ pub fn remove_orphan_transitions(track: &mut Track) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Where a transition sits: the incoming clip's start, or the outgoing clip's end for a fade to nothing.
|
||||
fn transition_anchor(track: &Track, tr: &Transition) -> Option<Tick> {
|
||||
match (tr.from, tr.to) {
|
||||
(_, Some(to)) => track.item(to).map(|i| i.start),
|
||||
(Some(from), None) => track.item(from).map(|i| i.end()),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep transitions on their edit points after an edit moved cuts (ripple trim, roll, slide, ripple
|
||||
/// speed change): each transition shifts by as much as its anchor clip edge moved.
|
||||
fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) {
|
||||
for track in after.all_tracks_mut() {
|
||||
let Some(old) = before.track(track.id) else { continue };
|
||||
let shifts: Vec<Tick> = track
|
||||
.transitions
|
||||
.iter()
|
||||
.map(|tr| match (transition_anchor(old, tr), transition_anchor(track, tr)) {
|
||||
(Some(a), Some(b)) => b - a,
|
||||
_ => Tick::ZERO,
|
||||
})
|
||||
.collect();
|
||||
for (tr, d) in track.transitions.iter_mut().zip(shifts) {
|
||||
tr.start += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn place(track: &mut Track, item: TrackItem) {
|
||||
let idx = track.items.partition_point(|i| i.start <= item.start);
|
||||
track.items.insert(idx, item);
|
||||
@@ -412,6 +446,14 @@ fn media_len(ctx: &EditCtx, item: &TrackItem) -> Option<Tick> {
|
||||
(ctx.media_duration)(item.item)
|
||||
}
|
||||
|
||||
/// Media available before a clip's source In (its head handle, media ticks).
|
||||
fn head(ctx: &EditCtx, item: &TrackItem) -> Tick {
|
||||
if item.frame_hold.is_some() {
|
||||
return item.source_in;
|
||||
}
|
||||
(item.source_in - (ctx.media_start)(item.item)).max(Tick::ZERO)
|
||||
}
|
||||
|
||||
fn src_of(dur: Tick, speed: f64) -> Tick {
|
||||
Tick((dur.0 as f64 * speed.abs()).round() as i64)
|
||||
}
|
||||
@@ -435,7 +477,7 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
|
||||
match edge {
|
||||
Edge::In => {
|
||||
// extending left (d<0) needs media before source_in; shortening needs min duration
|
||||
let max_ext = Tick((it.source_in.0 as f64 / speed).floor() as i64);
|
||||
let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64);
|
||||
let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext };
|
||||
let hi = it.duration - ctx.min_duration;
|
||||
d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi);
|
||||
@@ -508,6 +550,7 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
tr.sort();
|
||||
}
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
}
|
||||
work.check().map_err(EditError::Other)?;
|
||||
*seq = work;
|
||||
@@ -585,6 +628,7 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
}
|
||||
tr.sort();
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
work.check().map_err(EditError::Other)?;
|
||||
*seq = work;
|
||||
Ok(d)
|
||||
@@ -603,11 +647,12 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
if let Some(m) = media_len(ctx, l) {
|
||||
d = d.min(Tick(((m - l.source_out()).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
}
|
||||
d = d.max(-Tick((r.source_in.0 as f64 / r.speed.abs()).floor() as i64));
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
if d == Tick::ZERO {
|
||||
return Ok(d);
|
||||
}
|
||||
let (ls, rs) = (l.speed.abs(), r.speed.abs());
|
||||
let before = seq.clone();
|
||||
{
|
||||
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
|
||||
l.duration += d;
|
||||
@@ -619,6 +664,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
r.source_in += src_of(d, rs);
|
||||
}
|
||||
let _ = ls;
|
||||
transitions_follow_cuts(&before, seq);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
@@ -627,7 +673,8 @@ pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditC
|
||||
let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let used = src_of(it.duration, it.speed);
|
||||
let max_in = media_len(ctx, it).map(|m| m - used).unwrap_or(Tick::MAX);
|
||||
let new_in = (it.source_in + delta_media).clamp(Tick::ZERO, max_in.max(Tick::ZERO));
|
||||
let lo = if it.frame_hold.is_some() { Tick::ZERO } else { (ctx.media_start)(it.item) };
|
||||
let new_in = (it.source_in + delta_media).clamp(lo, max_in.max(lo));
|
||||
let d = new_in - it.source_in;
|
||||
let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
it.source_in = new_in;
|
||||
@@ -653,13 +700,14 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
}
|
||||
if let Some(n) = &next {
|
||||
d = d.min(n.duration - ctx.min_duration);
|
||||
d = d.max(-Tick((n.source_in.0 as f64 / n.speed.abs()).floor() as i64));
|
||||
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
} else {
|
||||
d = d.min(neighbours(tr, clip).1 - it.end());
|
||||
}
|
||||
if d == Tick::ZERO {
|
||||
return Ok(d);
|
||||
}
|
||||
let before = seq.clone();
|
||||
let t = seq.track_mut(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
if let Some(p) = prev {
|
||||
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
|
||||
@@ -673,6 +721,7 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
}
|
||||
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
me.start += d;
|
||||
transitions_follow_cuts(&before, seq);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
@@ -715,6 +764,7 @@ pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ri
|
||||
new_dur = new_dur.min(next_start - it.start);
|
||||
}
|
||||
let delta = new_dur - it.duration;
|
||||
let before = ripple.then(|| seq.clone());
|
||||
{
|
||||
let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
it.speed = speed;
|
||||
@@ -733,6 +783,9 @@ pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ri
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(before) = before {
|
||||
transitions_follow_cuts(&before, seq);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Fx {
|
||||
Self { seq, next: 10_000 }
|
||||
}
|
||||
fn ctx<'a>(next: &'a mut u64) -> EditCtx<'a> {
|
||||
EditCtx { next_id: next, media_duration: &media, min_duration: f(1) }
|
||||
EditCtx { next_id: next, media_duration: &media, media_start: &|_| Tick::ZERO, min_duration: f(1) }
|
||||
}
|
||||
fn v(&self, i: usize) -> TrackId {
|
||||
self.seq.video_tracks[i].id
|
||||
@@ -210,6 +210,53 @@ fn roll_slip_slide() {
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_stay_on_their_cuts() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
let c = fx.put(v1, 20, 10, 10);
|
||||
let cross = |id, start, from, to| Transition {
|
||||
id: TransitionId(id),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(start),
|
||||
duration: f(4),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
let t = fx.seq.track_mut(v1).unwrap();
|
||||
t.transitions = vec![cross(1, 8, Some(a), Some(b)), cross(2, 18, Some(b), Some(c)), cross(3, 26, Some(c), None)];
|
||||
// every transition is centred on its cut (or ends with the clip it fades out)
|
||||
let check = |fx: &Fx, what: &str| {
|
||||
let t = fx.seq.track(v1).unwrap();
|
||||
for tr in &t.transitions {
|
||||
let cut = match tr.to {
|
||||
Some(to) => t.item(to).unwrap().start,
|
||||
None => t.item(tr.from.unwrap()).unwrap().end() - f(2),
|
||||
};
|
||||
assert_eq!(tr.start + f(2), cut, "{what}: transition {:?} left its cut", tr.id);
|
||||
}
|
||||
assert_eq!(t.transitions.len(), 3, "{what}");
|
||||
};
|
||||
let mut n = fx.next;
|
||||
trim(&mut fx.seq, a, Edge::Out, TrimMode::Ripple, f(4), &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "ripple trim out");
|
||||
trim(&mut fx.seq, b, Edge::In, TrimMode::Ripple, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "ripple trim in");
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::Out, -f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "ripple trim group");
|
||||
roll(&mut fx.seq, b, c, f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "roll");
|
||||
slide(&mut fx.seq, b, -f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "slide");
|
||||
set_speed(&mut fx.seq, b, 0.5, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
check(&fx, "ripple speed change");
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_stretch_and_speed() {
|
||||
let mut fx = Fx::new();
|
||||
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn ctx(next: &mut u64) -> EditCtx<'_> {
|
||||
EditCtx { next_id: next, media_duration: &|_| None, min_duration: Tick(1) }
|
||||
EditCtx { next_id: next, media_duration: &|_| None, media_start: &|_| Tick::ZERO, min_duration: Tick(1) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -344,7 +344,9 @@ pub fn caption_blocks(words: &[SeqWord], rules: &CaptionRules, rate: FrameRate)
|
||||
continue;
|
||||
}
|
||||
if fits_line {
|
||||
*lines.last_mut().expect("non-empty") += 1 + len;
|
||||
if let Some(l) = lines.last_mut() {
|
||||
*l += 1 + len;
|
||||
}
|
||||
} else {
|
||||
lines.push(len);
|
||||
}
|
||||
@@ -362,7 +364,7 @@ pub fn caption_blocks(words: &[SeqWord], rules: &CaptionRules, rate: FrameRate)
|
||||
for g in groups {
|
||||
let mut text_lines: Vec<String> = vec![String::new()];
|
||||
for w in &words[g.clone()] {
|
||||
let l = text_lines.last_mut().expect("non-empty");
|
||||
let Some(l) = text_lines.last_mut() else { break };
|
||||
if l.is_empty() {
|
||||
l.push_str(&w.text);
|
||||
} else if l.chars().count() + 1 + w.text.chars().count() <= max_chars {
|
||||
|
||||
@@ -170,7 +170,7 @@ fn extract_text_closes_the_gap() {
|
||||
let w = sequence_words(&q, &transcripts());
|
||||
let r = word_range(&w, 0, 3, R).unwrap();
|
||||
let mut next = 1000;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, media_start: &|_| Tick::ZERO, min_duration: R.frame_duration() };
|
||||
let tracks: Vec<TrackId> = q.all_tracks().map(|t| t.id).collect();
|
||||
crate::extract(&mut q, &tracks, r, &mut ctx);
|
||||
q.check().unwrap();
|
||||
@@ -192,7 +192,7 @@ fn pauses_are_found_and_removed_in_one_pass() {
|
||||
let p2 = find_pauses(&w, s(0.4), s(0.1), R);
|
||||
assert_eq!(p2.len(), 3);
|
||||
let mut next = 1000;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, media_start: &|_| Tick::ZERO, min_duration: R.frame_duration() };
|
||||
let removed = ripple_delete_ranges(&mut q, p2.clone(), &mut ctx);
|
||||
q.check().unwrap();
|
||||
assert_eq!(removed, p2.iter().map(|r| r.duration).fold(Tick::ZERO, |a, b| a + b));
|
||||
@@ -219,7 +219,7 @@ fn fillers_are_found_with_phrases_and_removed() {
|
||||
// "Um," 3.70–4.00 → nearest frames 3.68–4.00; "uh" 8.20–8.50 → 8.20–8.48
|
||||
assert_eq!(ranges.iter().map(|r| (r.start, r.end())).collect::<Vec<_>>(), vec![(s(3.68), s(4.00)), (s(8.20), s(8.48))]);
|
||||
let mut next = 1000;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, min_duration: R.frame_duration() };
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &media_dur, media_start: &|_| Tick::ZERO, min_duration: R.frame_duration() };
|
||||
ripple_delete_ranges(&mut q, ranges, &mut ctx);
|
||||
q.check().unwrap();
|
||||
let w2 = sequence_words(&q, &transcripts());
|
||||
|
||||
@@ -30,6 +30,7 @@ filmcraft-scopes = { workspace = true }
|
||||
filmcraft-text = { workspace = true }
|
||||
filmcraft-audio-dsp = { workspace = true }
|
||||
filmcraft-export = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-format = { workspace = true }
|
||||
filmcraft-interchange = { workspace = true }
|
||||
filmcraft-captions = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! File ▸ Export ▸ AAF… / OMF… and File ▸ Import of AAF / OMF documents.
|
||||
//!
|
||||
//! The interchange crate writes the documents; this module prepares their audio essence the way
|
||||
//! Premiere's AAF and OMF export dialogs offer it: embedded in the document or written as separate
|
||||
//! WAV / AIFF files next to it, at a chosen sample rate and bit depth, trimmed to the used ranges
|
||||
//! plus handles (or whole), with clip effects rendered in, broken out to mono; and for AAF an
|
||||
//! optional video mixdown rendered to one file.
|
||||
//!
|
||||
//! `file.exportAaf {path, sequence?, mixdownVideo?, breakoutToMono?, audio?: "embedded"|"separate"|
|
||||
//! "linked", audioFormat?: "wav"|"aiff"|"mxf" (OP-Atom, AAF only), sampleRate?, bitDepth?, trimAudio?, handles? (frames),
|
||||
//! renderAudioEffects?, smallSectors?}`
|
||||
//!
|
||||
//! `file.exportOmf {path, sequence?, audio?: "embedded"|"separate", audioFormat?, sampleRate?,
|
||||
//! bitDepth?, trimAudio? (default on), handles?, renderAudioEffects?, breakoutToMono?}`
|
||||
//!
|
||||
//! `file.importAaf {path}` (also `file.import` of `.aaf` / `.omf` files).
|
||||
|
||||
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_time::{Tick, TimeRange};
|
||||
|
||||
use crate::commands::{bad, bool_p, str_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Where exported audio goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AudioMode {
|
||||
/// Inside the document.
|
||||
Embedded,
|
||||
/// Separate WAV / AIFF files next to the document.
|
||||
Separate,
|
||||
/// The original media files (AAF only; untrimmed, unrendered).
|
||||
Linked,
|
||||
}
|
||||
|
||||
/// The audio settings of the AAF / OMF export dialogs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPlan {
|
||||
pub mode: AudioMode,
|
||||
pub aiff: bool,
|
||||
/// Separate files as OP-Atom MXF (AAF only; Avid-style consolidated media).
|
||||
pub mxf: bool,
|
||||
pub sample_rate: u32,
|
||||
pub bits: u16,
|
||||
pub trim: bool,
|
||||
pub handles: Tick,
|
||||
pub render_effects: bool,
|
||||
pub breakout: bool,
|
||||
}
|
||||
|
||||
fn plan_from(s: &Session, p: &Value, seq: ItemId, cmd: &str, omf: bool) -> Result<AudioPlan> {
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let mode = match str_p(p, "audio").unwrap_or("embedded") {
|
||||
"embedded" | "embed" | "encapsulate" => AudioMode::Embedded,
|
||||
"separate" | "separateAudio" => AudioMode::Separate,
|
||||
"linked" | "link" if !omf => AudioMode::Linked,
|
||||
other => return Err(bad(cmd, format!("unknown audio mode {other:?} (embedded, separate{})", if omf { "" } else { " or linked" }))),
|
||||
};
|
||||
let (aiff, mxf) = match str_p(p, "audioFormat").unwrap_or("wav") {
|
||||
"wav" | "bwf" | "broadcastWave" => (false, false),
|
||||
"aiff" | "aif" => (true, false),
|
||||
"mxf" | "opatom" if !omf => (false, true),
|
||||
other => return Err(bad(cmd, format!("unknown audio format {other:?} (wav, aiff{})", if omf { "" } else { " or mxf" }))),
|
||||
};
|
||||
let sample_rate = u64_p(p, "sampleRate").map(|r| r as u32).unwrap_or(q.settings.sample_rate.max(1));
|
||||
if !(8_000..=192_000).contains(&sample_rate) {
|
||||
return Err(bad(cmd, "sampleRate must be 8000–192000"));
|
||||
}
|
||||
let bits = u64_p(p, "bitDepth").unwrap_or(16) as u16;
|
||||
if bits != 16 && bits != 24 {
|
||||
return Err(bad(cmd, "bitDepth must be 16 or 24"));
|
||||
}
|
||||
let frames = u64_p(p, "handles").unwrap_or(if omf { 30 } else { 0 }) as i64;
|
||||
Ok(AudioPlan {
|
||||
mode,
|
||||
aiff,
|
||||
mxf,
|
||||
sample_rate,
|
||||
bits,
|
||||
trim: bool_p(p, "trimAudio").unwrap_or(omf),
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
fn quantize(planar: &[&Vec<f32>], bits: u16) -> Vec<u8> {
|
||||
let n = planar.first().map_or(0, |c| c.len());
|
||||
let bps = if bits >= 24 { 3 } else { 2 };
|
||||
let mut out = Vec::with_capacity(n * planar.len() * bps);
|
||||
for i in 0..n {
|
||||
for c in planar {
|
||||
let x = c[i].clamp(-1.0, 1.0);
|
||||
if bps == 3 {
|
||||
out.extend_from_slice(&((x * 8_388_607.0).round() as i32).to_le_bytes()[..3]);
|
||||
} else {
|
||||
out.extend_from_slice(&((x * 32_767.0).round() as i16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dequantize(pcm: &[u8], channels: usize, bits: u16) -> Vec<Vec<f32>> {
|
||||
let bps = bits.div_ceil(8) as usize;
|
||||
let ch = channels.max(1);
|
||||
let n = pcm.len() / (bps * ch).max(1);
|
||||
let mut out = vec![Vec::with_capacity(n); ch];
|
||||
for i in 0..n {
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
let s = &pcm[(i * ch + c) * bps..(i * ch + c + 1) * bps];
|
||||
let v = match bps {
|
||||
1 => (s[0] as f32 - 128.0) / 128.0,
|
||||
2 => i16::from_le_bytes([s[0], s[1]]) as f32 / 32_768.0,
|
||||
3 => (i32::from_le_bytes([0, s[0], s[1], s[2]]) >> 8) as f32 / 8_388_608.0,
|
||||
_ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32 / 2_147_483_648.0,
|
||||
};
|
||||
o.push(v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode a media range at `sample_rate` (planar).
|
||||
fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32>>> {
|
||||
let src = s
|
||||
.media
|
||||
.full_res_source(&s.project, need.item, &*s.services)
|
||||
.ok_or_else(|| EngineError::Other(format!("media {} is offline", need.path.as_deref().unwrap_or("?"))))?;
|
||||
let start = need.start.to_units_floor(sample_rate as i64);
|
||||
let frames = (need.end.to_units_floor(sample_rate as i64) - start).max(0) as usize;
|
||||
let mut out: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); need.channels.max(1) as usize];
|
||||
let chunk = sample_rate as usize * 10;
|
||||
let mut done = 0usize;
|
||||
while done < frames {
|
||||
let n = chunk.min(frames - done);
|
||||
let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
match b.channels.get(c).or(b.channels.first()) {
|
||||
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
|
||||
None => o.extend(std::iter::repeat_n(0.0, n)),
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render one clip (with its effects, gain and volume) over a media range, through the export
|
||||
/// 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 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);
|
||||
let dur = need.end - need.start;
|
||||
let mut c = c;
|
||||
c.start = Tick::ZERO;
|
||||
c.source_in = need.start;
|
||||
c.duration = dur;
|
||||
c.speed = 1.0;
|
||||
c.reverse = false;
|
||||
c.link = None;
|
||||
let channels = match track.channels {
|
||||
filmcraft_project::AudioChannels::Mono => 1,
|
||||
filmcraft_project::AudioChannels::Surround51 => 6,
|
||||
_ => 2,
|
||||
};
|
||||
if let Some(t) = p.sequence_mut(rs).and_then(|r| r.audio_tracks.first_mut()) {
|
||||
t.channels = track.channels;
|
||||
t.items = vec![c];
|
||||
}
|
||||
let buf: Arc<Mutex<Vec<u8>>> = Arc::default();
|
||||
let sink_buf = buf.clone();
|
||||
let mut settings = filmcraft_export::ExportSettings {
|
||||
format: filmcraft_export::Format::Wav,
|
||||
path: "render.wav".into(),
|
||||
range: Some(TimeRange::new(Tick::ZERO, dur)),
|
||||
part_of_batch: true,
|
||||
sink: Some(filmcraft_export::OutputSink(Arc::new(move |_: &str, d: Vec<u8>| {
|
||||
*sink_buf.lock().unwrap_or_else(|e| e.into_inner()) = d;
|
||||
Ok(())
|
||||
}))),
|
||||
..Default::default()
|
||||
};
|
||||
settings.audio.sample_rate = Some(sample_rate);
|
||||
settings.audio.channels = channels;
|
||||
settings.audio.bits = bits;
|
||||
let project = Arc::new(p);
|
||||
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
|
||||
filmcraft_export::export(&project, rs, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let wav = std::mem::take(&mut *buf.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let (pcm, ch, _, b) = filmcraft_interchange::wav::parse_wav(&wav).ok_or_else(|| EngineError::Other("audio render produced no WAVE data".into()))?;
|
||||
Ok(dequantize(&pcm, ch as usize, b))
|
||||
}
|
||||
|
||||
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();
|
||||
if n.is_empty() { "audio".into() } else { n }
|
||||
}
|
||||
|
||||
fn ensure_dir(s: &Session, dir: &str) {
|
||||
if !s.services.export_in_memory() && !dir.is_empty() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the audio essence of an export; returns the essence and the files written.
|
||||
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
|
||||
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 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)? };
|
||||
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();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
need.path
|
||||
.as_deref()
|
||||
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
|
||||
.as_deref()
|
||||
.unwrap_or("audio"),
|
||||
),
|
||||
};
|
||||
for (ch, chans) in groups {
|
||||
let pcm = quantize(&chans, plan.bits);
|
||||
let n = chans.len() as u32;
|
||||
let data = match plan.mode {
|
||||
AudioMode::Embedded => EssenceData::Embedded(pcm),
|
||||
AudioMode::Separate | AudioMode::Linked => {
|
||||
let ext = if plan.mxf {
|
||||
"mxf"
|
||||
} else if plan.aiff {
|
||||
"aif"
|
||||
} else {
|
||||
"wav"
|
||||
};
|
||||
let stem = match ch {
|
||||
Some(c) => format!("{base}_A{}", c + 1),
|
||||
None => base.clone(),
|
||||
};
|
||||
let mut name = format!("{stem}.{ext}");
|
||||
let mut k = 1;
|
||||
while !used.insert(name.to_ascii_lowercase()) {
|
||||
k += 1;
|
||||
name = format!("{stem} ({k}).{ext}");
|
||||
}
|
||||
let path = if media_dir.is_empty() { name.clone() } else { format!("{media_dir}/{name}") };
|
||||
let bytes = if plan.mxf {
|
||||
let bps = plan.bits.div_ceil(8) as usize;
|
||||
let samples: Vec<i32> = pcm
|
||||
.chunks_exact(bps)
|
||||
.map(|b| if bps == 3 { i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8 } else { i16::from_le_bytes([b[0], b[1]]) as i32 })
|
||||
.collect();
|
||||
let opts = filmcraft_mxf::OpAtomPcm {
|
||||
sample_rate: plan.sample_rate,
|
||||
bits: plan.bits as u32,
|
||||
channels: n,
|
||||
edit_rate: None,
|
||||
ids: filmcraft_mxf::PackageIds::from_seed(&path, &name),
|
||||
timecode: None,
|
||||
};
|
||||
filmcraft_mxf::write_opatom_pcm(&opts, &samples).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
} else if plan.aiff {
|
||||
filmcraft_interchange::wav::aiff_file(&pcm, n as u16, plan.sample_rate, plan.bits)
|
||||
} else {
|
||||
filmcraft_interchange::wav::wav_file(&pcm, n as u16, plan.sample_rate, plan.bits)
|
||||
};
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
files.push(path.clone());
|
||||
EssenceData::File { path }
|
||||
}
|
||||
};
|
||||
essence.push(AudioEssence {
|
||||
key: need.key,
|
||||
channel: ch,
|
||||
start: need.start,
|
||||
frames,
|
||||
sample_rate: plan.sample_rate,
|
||||
bits: plan.bits,
|
||||
channels: n,
|
||||
data,
|
||||
effects_rendered: plan.render_effects,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok((essence, files))
|
||||
}
|
||||
|
||||
/// Render the video of `seq` to one file for an AAF video mixdown.
|
||||
fn mixdown(s: &Session, seq: ItemId, path: &str) -> Result<MixdownVideo> {
|
||||
let q = s.project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let dur = q.duration();
|
||||
if dur <= Tick::ZERO {
|
||||
return Err(EngineError::Other("the sequence is empty".into()));
|
||||
}
|
||||
let format = if path.to_ascii_lowercase().ends_with(".mxf") {
|
||||
filmcraft_export::Format::from_name("mxf-opatom").unwrap_or(filmcraft_export::Format::DnxHr)
|
||||
} else {
|
||||
filmcraft_export::Format::DnxHr
|
||||
};
|
||||
let mut settings = filmcraft_export::ExportSettings {
|
||||
format,
|
||||
path: path.to_string(),
|
||||
range: Some(TimeRange::new(Tick::ZERO, dur)),
|
||||
include_audio: false,
|
||||
part_of_batch: true,
|
||||
dnx_profile: "hq".into(),
|
||||
..Default::default()
|
||||
};
|
||||
if s.services.export_in_memory() {
|
||||
let services = s.services.clone();
|
||||
settings.sink = Some(filmcraft_export::OutputSink(Arc::new(move |p: &str, d: Vec<u8>| services.write_file(p, &d))));
|
||||
}
|
||||
let project = s.project.clone();
|
||||
let provider = s.media.full_res_provider(project.clone(), s.services.clone());
|
||||
filmcraft_export::export(&project, seq, &settings, &provider, &Default::default()).map_err(|e| EngineError::Other(format!("video mixdown: {e}")))?;
|
||||
Ok(MixdownVideo { path: path.to_string(), start: Tick::ZERO, duration: dur, width: q.settings.width, height: q.settings.height })
|
||||
}
|
||||
|
||||
fn report_json(r: &filmcraft_interchange::Report) -> Vec<String> {
|
||||
r.entries.iter().map(|e| if e.count > 1 { format!("{} (×{})", e.message, e.count) } else { e.message.clone() }).collect()
|
||||
}
|
||||
|
||||
fn split(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let dir = p.parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let stem = p.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Export".into());
|
||||
(dir, stem)
|
||||
}
|
||||
|
||||
fn join(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }
|
||||
}
|
||||
|
||||
/// `file.exportAaf`
|
||||
pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportAaf";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, false)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Media"));
|
||||
let mut files = Vec::new();
|
||||
if plan.mode != AudioMode::Embedded || bool_p(p, "mixdownVideo").unwrap_or(false) {
|
||||
ensure_dir(s, &media_dir);
|
||||
}
|
||||
let (essence, written) = prepare_audio(s, seq, &plan, &media_dir)?;
|
||||
files.extend(written);
|
||||
let mixdown_video = if bool_p(p, "mixdownVideo").unwrap_or(false) {
|
||||
let ext = if str_p(p, "mixdownFormat") == Some("mxf") { "mxf" } else { "mov" };
|
||||
let m = mixdown(s, seq, &join(&media_dir, &format!("{} Video Mixdown.{ext}", safe_name(&stem))))?;
|
||||
files.push(m.path.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let opts = filmcraft_interchange::aaf::AafOptions {
|
||||
name: s.project.item(seq).map(|i| i.name.clone()),
|
||||
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: false, essence, mixdown_video },
|
||||
small_sectors: bool_p(p, "smallSectors").unwrap_or(false),
|
||||
};
|
||||
let (bytes, report) = filmcraft_interchange::aaf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
|
||||
}
|
||||
|
||||
/// `file.exportOmf`
|
||||
pub fn export_omf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportOmf";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, true)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Audio Files"));
|
||||
if plan.mode == AudioMode::Separate {
|
||||
ensure_dir(s, &media_dir);
|
||||
}
|
||||
let (essence, files) = prepare_audio(s, seq, &plan, &media_dir)?;
|
||||
let opts = filmcraft_interchange::omf::OmfOptions {
|
||||
name: str_p(p, "title").map(str::to_string).or_else(|| s.project.item(seq).map(|i| i.name.clone())),
|
||||
media: MediaOptions { breakout_to_mono: plan.breakout, audio_only: true, essence, mixdown_video: None },
|
||||
};
|
||||
let (bytes, report) = filmcraft_interchange::omf::export(&s.project, seq, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if bytes.len() as u64 > i32::MAX as u64 {
|
||||
return Err(EngineError::Other("the OMF file would exceed 2 GB; use separate audio files or trim the audio".into()));
|
||||
}
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "mediaFiles": files, "report": report_json(&report)}))
|
||||
}
|
||||
|
||||
/// `file.importAaf` (and OMF): import a document as sequences.
|
||||
pub fn import_document(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let format = crate::interchange::detect(&path, &bytes).filter(|f| matches!(f, filmcraft_interchange::Format::Aaf | filmcraft_interchange::Format::Omf));
|
||||
let format = format.ok_or_else(|| bad(cmd, format!("{path} is not an AAF or OMF file")))?;
|
||||
crate::interchange::import(s, &path, &bytes, format)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! File ▸ Export ▸ AAF / OMF and their import, end to end with real media written by our encoders.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_project::{ItemId, ItemKind, MediaRef, Param, ParamValue, TrackKind};
|
||||
use filmcraft_time::FrameRate;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::{make_movie, session_with, tmp_dir};
|
||||
|
||||
/// Two 2-second ProRes + PCM movies cut back to back on V1/A1 (24 fps).
|
||||
fn setup(name: &str) -> (Session, Vec<ItemId>, ItemId, std::path::PathBuf) {
|
||||
let dir = tmp_dir(name);
|
||||
let a = dir.join("a.mov");
|
||||
let b = dir.join("b.mov");
|
||||
make_movie(&a, filmcraft_media::DemoScene::Aurora, 64, 36, 48);
|
||||
make_movie(&b, filmcraft_media::DemoScene::Plasma, 64, 36, 48);
|
||||
let (mut s, items, seq) = session_with(&[&a, &b]);
|
||||
// trim both clips so handles exist: A plays media 12..36, B 6..30
|
||||
let rate = FrameRate::FPS_24;
|
||||
let mut p = (*s.project).clone();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
for t in q.all_tracks_mut() {
|
||||
for (i, c) in t.items.iter_mut().enumerate() {
|
||||
c.start = rate.tick_of(i as i64 * 24);
|
||||
c.source_in = rate.tick_of(if i == 0 { 12 } else { 6 });
|
||||
c.duration = rate.tick_of(24);
|
||||
}
|
||||
}
|
||||
s.project = Arc::new(p);
|
||||
(s, items, seq, dir)
|
||||
}
|
||||
|
||||
fn audio_of(s: &Session, item: ItemId, start: i64, n: usize) -> Vec<Vec<f32>> {
|
||||
let src = s.media.full_res_source(&s.project, item, &*s.services).expect("online");
|
||||
src.audio(start, n, 48_000).unwrap().channels
|
||||
}
|
||||
|
||||
fn imported_seq(r: &serde_json::Value) -> ItemId {
|
||||
let docs = r["documents"].as_array().cloned().unwrap_or_default();
|
||||
let seq = r["sequences"][0].as_u64().or_else(|| docs.first().and_then(|d| d["sequences"][0].as_u64())).unwrap_or_else(|| panic!("no sequence in {r}"));
|
||||
ItemId(seq)
|
||||
}
|
||||
|
||||
fn path_of(s: &Session, item: ItemId) -> String {
|
||||
match &s.project.item(item).unwrap().kind {
|
||||
ItemKind::Media(m) => match &m.media {
|
||||
MediaRef::File { path } => path.clone(),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
|
||||
let (mut s, items, seq, dir) = setup("aaf-embed");
|
||||
let path = dir.join("cut.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["mediaFiles"].as_array().unwrap().is_empty(), "{r}");
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap().clone();
|
||||
let orig = s.project.sequence(seq).unwrap().clone();
|
||||
for kind in [TrackKind::Video, TrackKind::Audio] {
|
||||
let a: Vec<_> = q.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
let b: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
assert_eq!(a, b, "{kind:?}");
|
||||
}
|
||||
// video links to the original movies, audio to WAV files extracted next to the AAF
|
||||
assert_eq!(path_of(&s, q.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
for (c, oc) in q.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}");
|
||||
assert!(!s.project.item(c.item).unwrap().as_media().unwrap().offline);
|
||||
// trimmed: the essence starts 6 frames (handles) before the clip
|
||||
assert_eq!(c.source_in, FrameRate::FPS_24.tick_of(6));
|
||||
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 ch in 0..2 {
|
||||
for i in 0..4800 {
|
||||
assert!((got[ch][i] - want[ch][i]).abs() <= 1.0 / 32_768.0 + 1e-6, "ch {ch} sample {i}: {} vs {}", got[ch][i], want[ch][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_separate_aiff_breakout_and_linked() {
|
||||
let (mut s, items, seq, dir) = setup("aaf-separate");
|
||||
let path = dir.join("sep.aaf").to_string_lossy().into_owned();
|
||||
let r = s
|
||||
.execute(
|
||||
"file.exportAaf",
|
||||
json!({"path": path, "audio": "separate", "audioFormat": "aiff", "bitDepth": 24, "breakoutToMono": true, "trimAudio": true, "handles": 0}),
|
||||
)
|
||||
.unwrap();
|
||||
let files: Vec<String> = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 4, "two media × two channels: {r}");
|
||||
for f in &files {
|
||||
let b = std::fs::read(f).unwrap();
|
||||
let (pcm, ch, sr, bits) = filmcraft_interchange::wav::parse_aiff(&b).unwrap();
|
||||
assert_eq!((ch, sr, bits), (1, 48_000, 24));
|
||||
assert_eq!(pcm.len(), 24 * 2000 * 3, "one second of mono 24-bit audio (no handles)");
|
||||
}
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let mono: Vec<_> = q.audio_tracks.iter().filter(|t| !t.items.is_empty()).collect();
|
||||
assert_eq!(mono.len(), 2);
|
||||
assert!(mono.iter().all(|t| t.channels == filmcraft_project::AudioChannels::Mono));
|
||||
|
||||
// Avid-style consolidated OP-Atom MXF audio: our MXF reader opens every file
|
||||
let path = dir.join("mxf.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "separate", "audioFormat": "mxf", "breakoutToMono": true, "trimAudio": true})).unwrap();
|
||||
let files: Vec<String> = r["mediaFiles"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 4);
|
||||
for f in &files {
|
||||
let b = std::fs::read(f).unwrap();
|
||||
let m = filmcraft_mxf::open(&b).unwrap();
|
||||
assert_eq!(m.operational_pattern.name(), "OP-Atom");
|
||||
let t = m.track_of_kind(filmcraft_mxf::TrackKind::Sound).unwrap();
|
||||
assert_eq!(m.tracks[t].stored_sample_frames(), 48_000);
|
||||
}
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let a = &s.project.sequence(n).unwrap().audio_tracks[0].items[0];
|
||||
assert!(path_of(&s, a.item).ends_with(".mxf"));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline, "the MXF audio links");
|
||||
|
||||
// linked: no media is written, the document points at the original movies
|
||||
let path = dir.join("linked.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "audio": "linked", "sequence": seq.0})).unwrap();
|
||||
assert!(r["mediaFiles"].as_array().unwrap().is_empty());
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let a = &q.audio_tracks[0].items[0];
|
||||
assert_eq!(path_of(&s, a.item), path_of(&s, items[0]));
|
||||
assert_eq!(a.source_in, FrameRate::FPS_24.tick_of(12));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_video_mixdown() {
|
||||
let (mut s, _items, seq, dir) = setup("aaf-mixdown");
|
||||
let path = dir.join("mix.aaf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportAaf", json!({"path": path, "mixdownVideo": true, "audio": "linked", "sequence": seq.0})).unwrap();
|
||||
let files = r["mediaFiles"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let mov = files[0].as_str().unwrap();
|
||||
assert!(mov.ends_with("Video Mixdown.mov") && std::path::Path::new(mov).exists());
|
||||
let r = s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), 1);
|
||||
let v = &q.video_tracks[0].items[0];
|
||||
assert_eq!(v.duration, FrameRate::FPS_24.tick_of(48));
|
||||
assert_eq!(path_of(&s, v.item), mov);
|
||||
let m = s.project.item(v.item).unwrap().as_media().unwrap();
|
||||
assert!(!m.offline);
|
||||
assert_eq!(m.info.video.as_ref().map(|v| (v.width, v.height)), Some((64, 36)));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omf_renders_clip_effects_into_embedded_audio() {
|
||||
let (mut s, _items, seq, dir) = setup("omf-render");
|
||||
// −6.0206 dB on the first audio clip: half amplitude
|
||||
let mut p = (*s.project).clone();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
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);
|
||||
let orig = s.project.sequence(seq).unwrap().clone();
|
||||
let path = dir.join("mix.omf").to_string_lossy().into_owned();
|
||||
let r = s.execute("file.exportOmf", json!({"path": path, "renderAudioEffects": true, "handles": 0, "sequence": seq.0})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 48_000 * 2 * 2, "audio is encapsulated: {r}");
|
||||
let r = s.execute("file.importAaf", json!({"path": path})).unwrap();
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap().clone();
|
||||
assert!(q.video_tracks.iter().all(|t| t.items.is_empty()));
|
||||
let a = &q.audio_tracks[0].items;
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(
|
||||
a.iter().map(|c| (c.start, c.duration)).collect::<Vec<_>>(),
|
||||
orig.audio_tracks[0].items.iter().map(|c| (c.start, c.duration)).collect::<Vec<_>>()
|
||||
);
|
||||
// no gain left on the clips: it is in the samples
|
||||
assert!(a.iter().all(|c| c.effect("volume").and_then(|e| e.param("level")).is_none_or(|p| p.value.as_f64() == Some(0.0))));
|
||||
let oc = &orig.audio_tracks[0].items[0];
|
||||
let got = audio_of(&s, a[0].item, a[0].source_in.to_units_floor(48_000), 2400);
|
||||
let want = audio_of(&s, oc.item, oc.source_in.to_units_floor(48_000), 2400);
|
||||
let peak = want[0].iter().fold(0f32, |m, x| m.max(x.abs()));
|
||||
assert!(peak > 0.01, "the demo scene has audio");
|
||||
for i in 0..2400 {
|
||||
assert!((got[0][i] - want[0][i] * 0.5).abs() < 2e-3, "sample {i}: {} vs {}", got[0][i], want[0][i] * 0.5);
|
||||
}
|
||||
// the second clip is unchanged
|
||||
let oc2 = &orig.audio_tracks[0].items[1];
|
||||
let got = audio_of(&s, a[1].item, a[1].source_in.to_units_floor(48_000), 2400);
|
||||
let want = audio_of(&s, oc2.item, oc2.source_in.to_units_floor(48_000), 2400);
|
||||
for i in 0..2400 {
|
||||
assert!((got[0][i] - want[0][i]).abs() < 2e-3);
|
||||
}
|
||||
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", &[][..])] {
|
||||
let c = crate::commands::find(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert_eq!(c.menu, menu);
|
||||
}
|
||||
let (mut s, _, seq, dir) = setup("aaf-params");
|
||||
let path = dir.join("x.aaf").to_string_lossy().into_owned();
|
||||
assert!(s.execute("file.exportAaf", json!({"sequence": seq.0})).is_err(), "path is required");
|
||||
assert!(s.execute("file.exportAaf", json!({"path": path, "bitDepth": 20})).is_err());
|
||||
assert!(s.execute("file.exportOmf", json!({"path": path, "audio": "linked"})).is_err(), "OMF has no linked mode");
|
||||
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -424,6 +424,84 @@ fn media_root(p: &Project, id: ItemId) -> Option<(ItemId, &MediaClip, Option<Tim
|
||||
}
|
||||
}
|
||||
|
||||
/// What the Source monitor shows for an item: the media-time span of its time ruler, its frame
|
||||
/// rate, In / Out marks and markers.
|
||||
///
|
||||
/// A subclip that restricts trims shows only its range (the playhead can't leave it); one that
|
||||
/// doesn't shows its parent's whole media with the subclip's range as In / Out. Either way a
|
||||
/// subclip shows the markers of its parent media that fall inside its range (inherited: they
|
||||
/// belong to the master clip, so adding one there shows on every subclip that covers it).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SourceView {
|
||||
pub item: ItemId,
|
||||
/// The media item whose frames are shown (the parent of a subclip).
|
||||
pub media: ItemId,
|
||||
pub start: Tick,
|
||||
pub end: Tick,
|
||||
pub rate: FrameRate,
|
||||
pub mark_in: Option<Tick>,
|
||||
pub mark_out: Option<Tick>,
|
||||
pub markers: Vec<filmcraft_project::Marker>,
|
||||
/// For a subclip: its range and whether trims are restricted to it.
|
||||
pub subclip: Option<(TimeRange, bool)>,
|
||||
}
|
||||
|
||||
impl SourceView {
|
||||
pub fn to_json(&self, playhead: Tick) -> Value {
|
||||
json!({
|
||||
"item": self.item.0, "media": self.media.0, "start": self.start.0, "end": self.end.0, "fps": self.rate.as_f64(),
|
||||
"playhead": playhead.0, "markIn": self.mark_in.map(|t| t.0), "markOut": self.mark_out.map(|t| t.0),
|
||||
"markers": self.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "name": m.name, "duration": m.duration.0})).collect::<Vec<_>>(),
|
||||
"subclip": self.subclip.map(|(r, restrict)| json!({"start": r.start.0, "end": r.end().0, "restrictTrims": restrict})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The Source monitor view of `item` (see [`SourceView`]).
|
||||
pub fn source_view(s: &Session, item: ItemId) -> Option<SourceView> {
|
||||
let pi = s.project.item(item)?;
|
||||
let still = |m: &MediaClip| matches!(m.info.kind, filmcraft_media::MediaKind::Still) || m.info.duration.0 <= 0;
|
||||
Some(match &pi.kind {
|
||||
ItemKind::Media(m) => {
|
||||
let rate = m.frame_rate();
|
||||
let end = if still(m) { s.prefs.timeline.still_duration(rate) } else { m.duration() };
|
||||
SourceView { item, media: item, start: Tick::ZERO, end, rate, mark_in: m.mark_in, mark_out: m.mark_out, markers: m.markers.clone(), subclip: None }
|
||||
}
|
||||
ItemKind::Sequence(q) => SourceView {
|
||||
item,
|
||||
media: item,
|
||||
start: Tick::ZERO,
|
||||
end: q.duration(),
|
||||
rate: q.settings.frame_rate,
|
||||
mark_in: q.mark_in,
|
||||
mark_out: q.mark_out,
|
||||
markers: q.markers.clone(),
|
||||
subclip: None,
|
||||
},
|
||||
ItemKind::Subclip { range, restrict_trims, .. } => {
|
||||
let (root, m, _) = media_root(&s.project, item)?;
|
||||
let rate = m.frame_rate();
|
||||
let fd = rate.frame_duration();
|
||||
let markers = m.markers.iter().filter(|k| k.start >= range.start && k.start < range.end()).cloned().collect();
|
||||
let full = if still(m) { range.end() } else { m.duration().max(range.end()) };
|
||||
let (start, end, mi, mo) =
|
||||
if *restrict_trims { (range.start, range.end(), None, None) } else { (Tick::ZERO, full, Some(range.start), Some(range.end() - fd)) };
|
||||
SourceView { item, media: root, start, end, rate, mark_in: mi, mark_out: mo, markers, subclip: Some((*range, *restrict_trims)) }
|
||||
}
|
||||
_ => SourceView {
|
||||
item,
|
||||
media: item,
|
||||
start: Tick::ZERO,
|
||||
end: pi.duration(),
|
||||
rate: pi.frame_rate(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: Vec::new(),
|
||||
subclip: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Frame rate of an item, resolving subclips to their media.
|
||||
fn item_rate(p: &Project, id: ItemId) -> FrameRate {
|
||||
match media_root(p, id) {
|
||||
@@ -679,6 +757,7 @@ fn offline_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
color: Default::default(),
|
||||
has_alpha: false,
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: has_a.then(|| filmcraft_media::AudioStreamInfo {
|
||||
sample_rate: u64_p(p, "sampleRate").unwrap_or(48_000) as u32,
|
||||
@@ -1018,7 +1097,7 @@ fn subclip_plan(s: &Session, p: &Value) -> Result<(ItemId, TimeRange, String, La
|
||||
} else {
|
||||
let item = item_p(p, "item").or_else(|| subclip_source(s)).ok_or_else(|| bad("clip.makeSubclip", "open a clip in the Source monitor"))?;
|
||||
let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.makeSubclip", "only media clips can be subclipped"))?;
|
||||
let pi = s.project.item(item).expect("resolved");
|
||||
let pi = s.project.item(item).ok_or_else(|| EngineError::Other("no such item".into()))?;
|
||||
let rate = m.frame_rate();
|
||||
let still = s.prefs.timeline.still_duration(rate);
|
||||
let is_still = matches!(m.info.kind, filmcraft_media::MediaKind::Still);
|
||||
@@ -1103,10 +1182,13 @@ fn edit_subclip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Edit Subclip", |pr, _| {
|
||||
let it = pr.item_mut(item).ok_or_else(|| bad("clip.editSubclip", "no such item"))?;
|
||||
if convert {
|
||||
// a master clip of the whole media, marked with the subclip's range; the parent's
|
||||
// markers inside that range come along (they were the subclip's inherited markers)
|
||||
let mut m = parent_media.ok_or_else(|| bad("clip.editSubclip", "the subclip's media is gone"))?;
|
||||
m.mark_in = None;
|
||||
m.mark_out = None;
|
||||
m.markers.clear();
|
||||
let fd = m.frame_rate().frame_duration();
|
||||
m.mark_in = Some(a);
|
||||
m.mark_out = Some((b - fd).max(a));
|
||||
m.markers.retain(|k| k.start >= a && k.start < b);
|
||||
it.kind = ItemKind::Media(m);
|
||||
} else {
|
||||
it.kind = ItemKind::Subclip { parent, range: TimeRange::from_bounds(a, b), restrict_trims: restrict };
|
||||
@@ -1164,7 +1246,7 @@ fn audio_channels(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = Vec::new();
|
||||
let mut maps = Vec::new();
|
||||
for i in &items {
|
||||
let m = s.project.item(*i).and_then(|it| it.as_media()).expect("filtered");
|
||||
let Some(m) = s.project.item(*i).and_then(|it| it.as_media()) else { continue };
|
||||
let n = m.info.audio.as_ref().map_or(2, |a| a.channels) as u16;
|
||||
let fmt = format.unwrap_or_else(|| m.interpret.audio_channels.as_ref().map_or(AudioChannels::Stereo, |a| a.format));
|
||||
let map = match &custom {
|
||||
@@ -1521,7 +1603,7 @@ fn extract_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.ok_or_else(|| EngineError::Other("save the project first: extracted audio is written next to it".into()))?,
|
||||
};
|
||||
let pi = s.project.item(item).expect("resolved");
|
||||
let pi = s.project.item(item).ok_or_else(|| EngineError::Other("no such item".into()))?;
|
||||
let name = pi.name.clone();
|
||||
let stem = std::path::Path::new(&name).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
|
||||
let src = s.media.full_res_source(&s.project, root, &*s.services).ok_or_else(|| EngineError::Other(format!("{name}: media is offline")))?;
|
||||
|
||||
@@ -614,3 +614,78 @@ fn audio_source_channels_pick_what_a_clip_plays() {
|
||||
it.source_channels = vec![5];
|
||||
assert_eq!(filmcraft_render::audio::source_pair(&it, 2), (0, 0), "missing channels fall back to the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subclip_source_monitor_trims_and_markers() {
|
||||
let mut s = demo();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
let r24 = filmcraft_time::FrameRate::FPS_23_976;
|
||||
let f = |n: i64| r24.tick_of(n);
|
||||
// clip markers on the master clip at frames 10, 30, 60 and 90
|
||||
let mut p = (*s.project).clone();
|
||||
if let Some(ItemKind::Media(m)) = p.item_mut(ocean).map(|i| &mut i.kind) {
|
||||
for (k, n) in [10, 30, 60, 90].into_iter().enumerate() {
|
||||
m.markers.push(filmcraft_project::Marker {
|
||||
id: filmcraft_project::MarkerId(9000 + k as u64),
|
||||
start: f(n),
|
||||
duration: Tick::ZERO,
|
||||
name: format!("m{n}"),
|
||||
comment: String::new(),
|
||||
kind: filmcraft_project::MarkerKind::Comment,
|
||||
color: Label::Green,
|
||||
});
|
||||
}
|
||||
}
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.execute("project.setMarks", json!({"item": ocean.0, "in": f(24).0, "out": f(71).0})).unwrap();
|
||||
s.execute("project.select", json!({"items": [ocean.0]})).unwrap();
|
||||
s.state.source_item = None;
|
||||
let sub = ItemId(s.execute("clip.makeSubclip", json!({})).unwrap()["item"].as_u64().unwrap());
|
||||
// the Source monitor shows the subclip's range, opens at its In and keeps the playhead inside
|
||||
s.execute("source.open", json!({"item": sub.0})).unwrap();
|
||||
let v = s.execute("source.inspect", json!({})).unwrap();
|
||||
assert_eq!((v["start"].as_i64(), v["end"].as_i64(), v["playhead"].as_i64()), (Some(f(24).0), Some(f(72).0), Some(f(24).0)));
|
||||
assert_eq!(v["media"], ocean.0);
|
||||
// inherited markers: the master clip's markers inside the range
|
||||
let names: Vec<&str> = v["markers"].as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["m30", "m60"]);
|
||||
assert_eq!(s.execute("source.setPlayhead", json!({"frame": 0})).unwrap()["time"], f(24).0);
|
||||
assert_eq!(s.execute("source.setPlayhead", json!({"frame": 500})).unwrap()["time"], f(71).0);
|
||||
// edited in on V3 at 100 s: In 24, 48 frames
|
||||
let r = s.execute("timeline.place", json!({"item": sub.0, "track": "V3", "seconds": 100.0})).unwrap();
|
||||
let c = r["clips"][0].as_u64().unwrap();
|
||||
let start = clip(&s, c).start;
|
||||
assert_eq!((clip(&s, c).source_in, clip(&s, c).duration), (f(24), f(48)));
|
||||
// restricted trims: no handles beyond the subclip on either side
|
||||
let trim = |s: &mut Session, edge: &str, d: i64| {
|
||||
s.execute("timeline.trim", json!({"clip": c, "edge": edge, "deltaFrames": d})).unwrap();
|
||||
let it = clip(s, c);
|
||||
(r24.frame_at(it.start - start), r24.frame_at(it.source_in), r24.frame_at(it.duration))
|
||||
};
|
||||
assert_eq!(trim(&mut s, "in", -10), (0, 24, 48), "head stops at the subclip's In");
|
||||
assert_eq!(trim(&mut s, "out", 10), (0, 24, 48), "tail stops at the subclip's Out");
|
||||
assert_eq!(trim(&mut s, "in", 6), (6, 30, 42));
|
||||
assert_eq!(trim(&mut s, "in", -10), (0, 24, 48), "only back to the subclip's In");
|
||||
// slip stays inside the subclip too
|
||||
trim(&mut s, "out", -8);
|
||||
s.execute("timeline.slip", json!({"clip": c, "deltaFrames": -20})).unwrap();
|
||||
assert_eq!(r24.frame_at(clip(&s, c).source_in), 24);
|
||||
s.execute("timeline.slip", json!({"clip": c, "deltaFrames": 20})).unwrap();
|
||||
assert_eq!(r24.frame_at(clip(&s, c).source_in), 32, "In + 40 frames used = the subclip's Out");
|
||||
// without the restriction the master clip's media is the limit (and the monitor shows it all,
|
||||
// with the subclip's range as In / Out)
|
||||
s.execute("project.select", json!({"items": [sub.0]})).unwrap();
|
||||
s.execute("clip.editSubclip", json!({"restrictTrims": false})).unwrap();
|
||||
s.execute("timeline.slip", json!({"clip": c, "deltaFrames": -20})).unwrap();
|
||||
assert_eq!(r24.frame_at(clip(&s, c).source_in), 12);
|
||||
let v = s.execute("source.inspect", json!({})).unwrap();
|
||||
assert_eq!((v["start"].as_i64(), v["markIn"].as_i64(), v["markOut"].as_i64()), (Some(0), Some(f(24).0), Some(f(71).0)));
|
||||
assert_eq!(v["subclip"]["restrictTrims"], false);
|
||||
// Convert to Master Clip: the whole media, marked with the range, keeping the inherited markers
|
||||
s.execute("clip.editSubclip", json!({"convertToMaster": true})).unwrap();
|
||||
let m = s.project.item(sub).unwrap().as_media().unwrap().clone();
|
||||
assert_eq!((m.mark_in, m.mark_out), (Some(f(24)), Some(f(71))));
|
||||
assert_eq!(m.markers.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(), ["m30", "m60"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.item(sub).unwrap().kind, ItemKind::Subclip { restrict_trims: false, .. }));
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
//!
|
||||
//! Lumetri commands address a clip by `clip` (default: the first selected video clip with
|
||||
//! Lumetri Color, else the first selected video clip — Lumetri is added if missing).
|
||||
//!
|
||||
//! Lumetri Presets (Effects panel): `lumetri.presets` lists them, `lumetri.applyPreset` adds a
|
||||
//! Lumetri Color configured as the preset to clips (one undo step), `lumetri.presetThumbnails`
|
||||
//! renders a folder's thumbnail grid (our Lumetri on the procedural preview picture) to a PNG.
|
||||
|
||||
use filmcraft_color::{ColorSpace, Lut, LutFormat, WorkingSpace};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, ParamValue, ProjectLut, TrackKind};
|
||||
@@ -41,6 +45,14 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
interpret,
|
||||
),
|
||||
query("color.spaces", "List Colour Spaces", "{}", spaces),
|
||||
query("lumetri.presets", "List Lumetri Presets", r#"{"folder":str?}"#, list_presets),
|
||||
spec("lumetri.applyPreset", "Apply Lumetri Preset", &[], r#"{"name":str,"clips":[id]?}"#, has_seq, apply_preset),
|
||||
query(
|
||||
"lumetri.presetThumbnails",
|
||||
"Lumetri Preset Thumbnails",
|
||||
r#"{"folder":str?,"names":[str]?,"width":n=160,"columns":n=4,"path":str?}"#,
|
||||
preset_thumbnails,
|
||||
),
|
||||
query("media.colorInfo", "Media Colour Info", r#"{"item":id}"#, color_info),
|
||||
spec("lut.import", "Import LUT…", &[], r#"{"path":str,"name":str?}"#, always, import),
|
||||
query("lut.list", "List LUTs", "{}", list),
|
||||
@@ -323,9 +335,74 @@ fn color_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"effective": effective.map(|c| c.id()),
|
||||
"effectiveLabel": effective.map(|c| c.label()),
|
||||
"hdr": effective.is_some_and(|c| c.is_hdr()),
|
||||
// mastering display / content light level and the peak tone mapping uses
|
||||
"hdrMetadata": m.info.video.as_ref().and_then(|v| v.hdr),
|
||||
"toneMapPeakNits": m.info.video.as_ref().and_then(|v| v.hdr.and_then(|h| h.peak_nits())),
|
||||
}))
|
||||
}
|
||||
|
||||
fn presets_of(p: &Value) -> Vec<filmcraft_render::lumetri_presets::LumetriPreset> {
|
||||
let all = filmcraft_render::lumetri_presets::presets();
|
||||
let names: Option<Vec<String>> = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_lowercase)).collect());
|
||||
let folder = str_p(p, "folder");
|
||||
all.into_iter()
|
||||
.filter(|x| folder.is_none_or(|f| x.folder.eq_ignore_ascii_case(f)))
|
||||
.filter(|x| names.as_ref().is_none_or(|n| n.contains(&x.name.to_lowercase())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list_presets(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v: Vec<Value> = presets_of(p).iter().map(|x| json!({"folder": x.folder, "name": x.name, "description": x.description})).collect();
|
||||
Ok(json!({"folders": filmcraft_render::lumetri_presets::FOLDERS, "presets": v}))
|
||||
}
|
||||
|
||||
/// Lumetri Presets ▸ apply: a new Lumetri Color configured as the preset on each video clip
|
||||
/// (standard effects go before the intrinsic ones, like `effects.apply`).
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "lumetri.applyPreset";
|
||||
let name = str_p(p, "name").ok_or_else(|| bad(cmd, "need `name`"))?;
|
||||
let preset = filmcraft_render::lumetri_presets::find(name).ok_or_else(|| bad(cmd, format!("no Lumetri preset `{name}`")))?;
|
||||
let clips = crate::commands::clips_p(s, p);
|
||||
if clips.is_empty() {
|
||||
return Err(bad(cmd, "select clips first"));
|
||||
}
|
||||
let inst = preset.instance();
|
||||
let n = s.edit_sequence(&format!("Apply {}", preset.name), |q, _, _| {
|
||||
let mut n = 0;
|
||||
for t in q.video_tracks.iter_mut() {
|
||||
for it in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
|
||||
let pos = it.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(it.effects.len());
|
||||
it.effects.insert(pos, inst.clone());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Err(bad(cmd, "no video clips among the clips"));
|
||||
}
|
||||
Ok(json!({"preset": preset.name, "clips": n}))
|
||||
}
|
||||
|
||||
fn preset_thumbnails(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "lumetri.presetThumbnails";
|
||||
let presets = presets_of(p);
|
||||
if presets.is_empty() {
|
||||
return Err(bad(cmd, "no presets match"));
|
||||
}
|
||||
let w = p.get("width").and_then(Value::as_u64).unwrap_or(160).clamp(16, 1920) as usize;
|
||||
let h = (w * 9 / 16).max(9);
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(4).clamp(1, 32) as usize;
|
||||
let img = filmcraft_render::lumetri_presets::grid(&presets, cols, w, h);
|
||||
let mut out = json!({"presets": presets.iter().map(|x| x.name).collect::<Vec<_>>(), "width": img.w, "height": img.h, "cell": [w, h], "columns": cols});
|
||||
if let Some(path) = str_p(p, "path") {
|
||||
let png = filmcraft_export::encode_png(img.over_black_rgba8(), img.w as u32, img.h as u32).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
std::fs::write(path, png).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Lumetri ▸ Color Wheels & Match ▸ Apply Match (see `filmcraft_render::color_match`): match the
|
||||
/// clip at the playhead to the sequence frame at the reference time. Sets the three wheels, their
|
||||
/// lightness and Basic saturation in one undo step.
|
||||
|
||||
@@ -33,7 +33,7 @@ fn lumetri_text(s: &Session, clip: ClipId, p: &str) -> String {
|
||||
fn mean(img: &filmcraft_render::Image) -> [f32; 3] {
|
||||
assert!(img.px.iter().any(|v| *v > 0.01), "blank render");
|
||||
let mut m = [0f64; 3];
|
||||
for p in img.px.chunks_exact(4) {
|
||||
for p in img.px.as_chunks::<4>().0 {
|
||||
for k in 0..3 {
|
||||
m[k] += p[k] as f64;
|
||||
}
|
||||
@@ -194,3 +194,63 @@ fn interpret_footage_and_sequence_colour_settings() {
|
||||
assert_eq!(spaces["colorSpaces"].as_array().unwrap().len(), filmcraft_color::ColorSpace::ALL.len());
|
||||
assert!(s.execute("sequence.colorSettings", json!({"workingSpace": "xyz"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_presets_list_apply_and_thumbnails() {
|
||||
let mut s = demo();
|
||||
let clip = pick_clip(&mut s);
|
||||
let r = s.execute("lumetri.presets", json!({})).unwrap();
|
||||
assert_eq!(r["folders"], json!(["Cinematic", "Film Emulation", "Monochrome", "Technical"]));
|
||||
let mono = s.execute("lumetri.presets", json!({"folder": "monochrome"})).unwrap();
|
||||
assert!(mono["presets"].as_array().unwrap().iter().all(|p| p["folder"] == "Monochrome"));
|
||||
// apply: one undo step adding a configured Lumetri Color before the intrinsic effects
|
||||
let before = s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len();
|
||||
let r = s.execute("lumetri.applyPreset", json!({"name": "Neutral Mono"})).unwrap();
|
||||
assert_eq!(r["clips"], 1);
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
assert_eq!(it.effects.len(), before + 1);
|
||||
let k = it.effects.iter().position(|e| e.effect == "lumetri").unwrap();
|
||||
assert!(it.effects[k + 1..].iter().all(|e| e.def().is_some_and(|d| d.intrinsic)) || k + 1 == it.effects.len());
|
||||
assert_eq!(it.effects[k].param("saturation").unwrap().value, ParamValue::Float(0.0));
|
||||
let img = s.render_program(0.25).unwrap();
|
||||
let grey = img.px.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.5).all(|p| (p[0] - p[1]).abs() < 0.01 && (p[1] - p[2]).abs() < 0.01);
|
||||
assert!(grey, "the program is black and white");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.len(), before);
|
||||
assert!(s.execute("lumetri.applyPreset", json!({"name": "No Such Look"})).is_err());
|
||||
// thumbnails of a folder, written as a PNG
|
||||
let path = tmp("lumetri-presets.png");
|
||||
let r = s.execute("lumetri.presetThumbnails", json!({"folder": "Technical", "width": 64, "columns": 3, "path": path})).unwrap();
|
||||
let n = r["presets"].as_array().unwrap().len();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(3 * 68 + 4), Some((n.div_ceil(3) * 40 + 4) as u64)));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lumetri_and_mastering_metadata_through_commands() {
|
||||
let mut s = demo();
|
||||
let clip = pick_clip(&mut s);
|
||||
s.execute("sequence.colorSettings", json!({"workingSpace": "rec2100-pq"})).unwrap();
|
||||
s.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})).unwrap();
|
||||
let idx = s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().position(|e| e.effect == "lumetri").unwrap();
|
||||
// +1 stop doubles the light of the (SDR) clip in the PQ working space: no clipping at
|
||||
// 203 cd/m², the HDR working output keeps the values above reference white
|
||||
let base = s.render_program_working(0.25).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "exposure", "value": 1.0})).unwrap();
|
||||
let up = s.render_program_working(0.25).unwrap();
|
||||
let (m0, m1) = (mean(&base), mean(&up));
|
||||
for k in 0..3 {
|
||||
assert!((m1[k] / m0[k] - 2.0).abs() < 0.08, "channel {k}: {m0:?} → {m1:?}");
|
||||
}
|
||||
assert!(up.px.as_chunks::<4>().0.iter().any(|p| p[1] > 1.2), "values above reference white survive");
|
||||
// HDR White / HDR Specular / HDR Range are ordinary Lumetri parameters
|
||||
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_white", "value": 2000.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "hdr_specular", "value": -50.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip.0, "effect": idx, "param": "curves_hdr_range", "value": 4000.0})).unwrap();
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
assert_eq!(it.effects[idx].param("hdr_white").unwrap().value, ParamValue::Float(2000.0));
|
||||
// media.colorInfo reports the HDR metadata (none for the demo's generated media)
|
||||
let item = it.item;
|
||||
let r = s.execute("media.colorInfo", json!({"item": item.0})).unwrap();
|
||||
assert!(r["hdrMetadata"].is_null() && r["toneMapPeakNits"].is_null(), "{r}");
|
||||
}
|
||||
@@ -121,6 +121,15 @@ pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
||||
pub(crate) fn f64_p(p: &Value, k: &str) -> Option<f64> {
|
||||
p.get(k).and_then(Value::as_f64)
|
||||
}
|
||||
/// `fps` as a sequence frame rate; zero or negative rates are refused (all frame maths divides by them).
|
||||
fn fps_p(p: &Value, cmd: &str) -> Result<Option<FrameRate>> {
|
||||
let Some(fps) = f64_p(p, "fps") else { return Ok(None) };
|
||||
let r = FrameRate::from_f64(fps);
|
||||
if r.num <= 0 || r.frame_duration() <= Tick::ZERO {
|
||||
return Err(bad(cmd, format!("fps must be a positive frame rate, got {fps}")));
|
||||
}
|
||||
Ok(Some(r))
|
||||
}
|
||||
pub(crate) fn bool_p(p: &Value, k: &str) -> Option<bool> {
|
||||
p.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
@@ -353,7 +362,7 @@ pub(crate) fn place_item(
|
||||
let frame = (seq.settings.width, seq.settings.height);
|
||||
let mut placements = Vec::new();
|
||||
let link = if has_v && has_a { Some(p.alloc_id()) } else { None };
|
||||
if has_v {
|
||||
if let Some(vdest) = vdest.filter(|_| has_v) {
|
||||
let mut v = p.make_track_item(item, TrackKind::Video, at, range, rate).ok_or_else(|| bad(label, "bad item"))?;
|
||||
v.link = link;
|
||||
for e in &mut v.effects {
|
||||
@@ -362,10 +371,9 @@ pub(crate) fn place_item(
|
||||
if is_media && src_size != frame {
|
||||
crate::settings::apply_media_scaling(&mut v, &scaling, frame, src_size);
|
||||
}
|
||||
placements.push((vdest.expect("checked"), v));
|
||||
placements.push((vdest, v));
|
||||
}
|
||||
if has_a {
|
||||
let adest = adest.expect("checked");
|
||||
if let Some(adest) = adest.filter(|_| has_a) {
|
||||
let (arange, aat) = audio_split.unwrap_or((range, at));
|
||||
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
|
||||
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
|
||||
@@ -387,12 +395,14 @@ pub(crate) fn place_item(
|
||||
placements.push((*tid, b));
|
||||
}
|
||||
}
|
||||
let snapshot = p.clone();
|
||||
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 min = rate.frame_duration();
|
||||
let mut next = p.next_id;
|
||||
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, min_duration: min };
|
||||
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
|
||||
let span = placements.iter().map(|x| x.1.start).min().zip(placements.iter().map(|x| x.1.end()).max());
|
||||
let ids = if insert { edit::insert(seq, placements, &mut ctx)? } else { edit::overwrite(seq, placements, &mut ctx)? };
|
||||
if insert
|
||||
@@ -506,8 +516,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
if let Some(h) = u64_p(p, "height") {
|
||||
st.height = h as u32;
|
||||
}
|
||||
if let Some(fps) = f64_p(p, "fps") {
|
||||
st.frame_rate = FrameRate::from_f64(fps);
|
||||
if let Some(fps) = fps_p(p, "file.newSequence")? {
|
||||
st.frame_rate = fps;
|
||||
}
|
||||
if let Some(sr) = u64_p(p, "sampleRate") {
|
||||
st.sample_rate = sr as u32;
|
||||
@@ -664,27 +674,27 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(id) => ids.push(id.0),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
},
|
||||
Ok(b) if crate::captions::detect(&path, &b).is_some() => {
|
||||
let fmt = crate::captions::detect(&path, &b).expect("detected");
|
||||
match crate::captions::import(s, &path, &b, fmt, None) {
|
||||
Ok(r) => reports.push(r),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(b) if crate::interchange::detect(&path, &b).is_some() => {
|
||||
let fmt = crate::interchange::detect(&path, &b).expect("detected");
|
||||
match crate::interchange::import(s, &path, &b, fmt) {
|
||||
Ok(r) => {
|
||||
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
|
||||
reports.push(r);
|
||||
Ok(b) => {
|
||||
if let Some(fmt) = crate::captions::detect(&path, &b) {
|
||||
match crate::captions::import(s, &path, &b, fmt, None) {
|
||||
Ok(r) => reports.push(r),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
} else if let Some(fmt) = crate::interchange::detect(&path, &b) {
|
||||
match crate::interchange::import(s, &path, &b, fmt) {
|
||||
Ok(r) => {
|
||||
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
|
||||
reports.push(r);
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
} else {
|
||||
match import_bytes(s, &path, b.into(), bin) {
|
||||
Ok(id) => ids.push(id.0),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(b) => match import_bytes(s, &path, b.into(), bin) {
|
||||
Ok(id) => ids.push(id.0),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
},
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -750,6 +760,25 @@ fn build() -> Vec<CommandSpec> {
|
||||
q["format"] = json!("otio");
|
||||
crate::interchange::export(s, &q)
|
||||
}),
|
||||
cmd!(
|
||||
"file.exportAaf",
|
||||
"AAF…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"sequence":id?,"mixdownVideo":bool?,"mixdownFormat":"mov|mxf"?,"breakoutToMono":bool?,"audio":"embedded|separate|linked"?,"audioFormat":"wav|aiff|mxf"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"smallSectors":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| crate::aaf_omf::export_aaf(s, p)
|
||||
),
|
||||
cmd!(
|
||||
"file.exportOmf",
|
||||
"OMF…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"sequence":id?,"title":str?,"audio":"embedded|separate"?,"audioFormat":"wav|aiff"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"breakoutToMono":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| crate::aaf_omf::export_omf(s, p)
|
||||
),
|
||||
cmd!("file.importAaf", "Import AAF…", [], None, r#"{"path":str}"#, always, |s, p| crate::aaf_omf::import_document(s, p, "file.importAaf")),
|
||||
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| {
|
||||
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;
|
||||
write_project(s, &path, true)
|
||||
@@ -860,7 +889,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|prores|dnxhr|mjpeg|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"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
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}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
@@ -1240,6 +1269,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let fps = fps_p(p, "sequence.settings")?;
|
||||
let mix = match str_p(p, "mix") {
|
||||
Some(m) => Some(
|
||||
crate::mixer::channels_from(m).ok_or_else(|| bad("sequence.settings", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?,
|
||||
@@ -1249,7 +1279,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
let p = p.clone();
|
||||
s.edit("Sequence Settings", |pr, _| {
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
pr.item_mut(id).expect("seq").name = n.to_string();
|
||||
pr.item_mut(id).ok_or(EngineError::NoSequence)?.name = n.to_string();
|
||||
}
|
||||
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
|
||||
if let Some(w) = u64_p(&p, "width") {
|
||||
@@ -1258,8 +1288,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
if let Some(h) = u64_p(&p, "height") {
|
||||
q.settings.height = h as u32;
|
||||
}
|
||||
if let Some(f) = f64_p(&p, "fps") {
|
||||
q.settings.frame_rate = FrameRate::from_f64(f);
|
||||
if let Some(f) = fps {
|
||||
q.settings.frame_rate = f;
|
||||
}
|
||||
if let Some(sr) = u64_p(&p, "sampleRate") {
|
||||
q.settings.sample_rate = sr as u32;
|
||||
@@ -1495,13 +1525,15 @@ fn build() -> Vec<CommandSpec> {
|
||||
.ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?;
|
||||
s.project.item(id).ok_or_else(|| bad("source.open", "no such item"))?;
|
||||
s.state.source_item = Some(id);
|
||||
s.state.source_playhead = Tick::ZERO;
|
||||
// a subclip opens at its In point
|
||||
s.state.source_playhead = crate::clip_ops::source_view(s, id).map(|v| v.start).unwrap_or_default();
|
||||
s.events.push(crate::Event::OpenSource(id));
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("source.setPlayhead", "Set Source Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64}"#, always, |s, p| {
|
||||
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?;
|
||||
let rate = s.project.item(item).map(|i| i.frame_rate()).unwrap_or_default();
|
||||
let view = crate::clip_ops::source_view(s, item);
|
||||
let rate = view.as_ref().map(|v| v.rate).unwrap_or_default();
|
||||
let t = p
|
||||
.get("time")
|
||||
.and_then(Value::as_i64)
|
||||
@@ -1509,10 +1541,16 @@ fn build() -> Vec<CommandSpec> {
|
||||
.or_else(|| p.get("frame").and_then(Value::as_i64).map(|f| rate.tick_of(f)))
|
||||
.or_else(|| f64_p(p, "seconds").map(Tick::from_seconds_f64))
|
||||
.unwrap_or_default();
|
||||
let dur = s.project.item(item).map(|i| i.duration()).unwrap_or(Tick::MAX);
|
||||
s.state.source_playhead = rate.snap(t.clamp(Tick::ZERO, (dur - rate.frame_duration()).max(Tick::ZERO)));
|
||||
// the Source monitor's span: a subclip that restricts trims keeps the playhead inside it
|
||||
let (lo, hi) = view.map(|v| (v.start, v.end)).unwrap_or((Tick::ZERO, Tick::MAX));
|
||||
s.state.source_playhead = rate.snap(t.clamp(lo, (hi - rate.frame_duration()).max(lo))).max(lo);
|
||||
Ok(json!({"time": s.state.source_playhead.0}))
|
||||
}),
|
||||
query!("source.inspect", "Inspect Source Monitor", "{}", |s, _| {
|
||||
let Some(item) = s.state.source_item else { return Ok(json!({"item": null})) };
|
||||
let v = crate::clip_ops::source_view(s, item).ok_or_else(|| bad("source.inspect", "the source item is gone"))?;
|
||||
Ok(v.to_json(s.state.source_playhead))
|
||||
}),
|
||||
cmd!("source.insert", "Insert", ["Clip"], Some(","), "{}", has_source, |s, _| edit_at_source(s, true)),
|
||||
cmd!("source.overwrite", "Overwrite", ["Clip"], Some("."), "{}", has_source, |s, _| edit_at_source(s, false)),
|
||||
// ================= Timeline gestures =================
|
||||
@@ -1754,7 +1792,21 @@ fn build() -> Vec<CommandSpec> {
|
||||
.map(Tick)
|
||||
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
|
||||
.unwrap_or_default();
|
||||
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide(q, c, d, ctx)?))?;
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slide", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slide them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
let x = edit::slide(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slide(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
|
||||
@@ -2401,7 +2453,7 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Nest", |pr, st| {
|
||||
let nid = pr.new_sequence(&name, q.settings.clone(), q.video_tracks.len(), q.audio_tracks.len(), None);
|
||||
{
|
||||
let nq = pr.sequence_mut(nid).expect("new");
|
||||
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
|
||||
for (k, ti, it) in &items {
|
||||
let mut it = it.clone();
|
||||
it.start -= start;
|
||||
@@ -2412,11 +2464,15 @@ fn nest(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let rate = q.settings.frame_rate;
|
||||
let mut v = pr.make_track_item(nid, TrackKind::Video, start, TimeRange::new(Tick::ZERO, end - start), rate).expect("item");
|
||||
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()))?;
|
||||
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).expect("item");
|
||||
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 seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
edit::delete_items(seq, &sel);
|
||||
|
||||
+20
-11
@@ -44,6 +44,15 @@ pub fn add_generator(p: &mut Project, pool: &MediaPool, src: GeneratorSource, na
|
||||
|
||||
/// Build the demo project. Returns (project, main sequence).
|
||||
pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
build_demo(pool).unwrap_or_else(|| {
|
||||
// Only reachable if the built-in items could not be placed: an empty demo, not a crash.
|
||||
let mut p = Project::new("FilmCraft Demo");
|
||||
let seq = p.new_sequence("Main Edit", SequenceSettings::default(), 3, 3, None);
|
||||
(p, seq)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_demo(pool: &MediaPool) -> Option<(Project, ItemId)> {
|
||||
let mut p = Project::new("FilmCraft Demo");
|
||||
let footage = p.add_bin("Footage", None);
|
||||
let audio_bin = p.add_bin("Audio", None);
|
||||
@@ -108,8 +117,8 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
for (ci, sin, dur) in cuts {
|
||||
let src_range = TimeRange::new(r.snap(Tick::from_seconds_f64(sin)), r.snap(Tick::from_seconds_f64(dur)));
|
||||
let link = p.alloc_id();
|
||||
let mut v = p.make_track_item(clips[ci], TrackKind::Video, t, src_range, r).expect("item");
|
||||
let mut a = p.make_track_item(clips[ci], TrackKind::Audio, t, src_range, r).expect("item");
|
||||
let mut v = p.make_track_item(clips[ci], TrackKind::Video, t, src_range, r)?;
|
||||
let mut a = p.make_track_item(clips[ci], TrackKind::Audio, t, src_range, r)?;
|
||||
v.link = Some(link);
|
||||
a.link = Some(link);
|
||||
for e in &mut v.effects {
|
||||
@@ -118,14 +127,14 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
a.effect_mut("volume").map(|e| e.params.get_mut("level").map(|l| l.value = ParamValue::Float(-8.0)));
|
||||
let dur_t = v.duration;
|
||||
placed.push((v.id, a.id, t, dur_t));
|
||||
let s = p.sequence_mut(seq).expect("seq");
|
||||
let s = p.sequence_mut(seq)?;
|
||||
s.video_tracks[0].items.push(v);
|
||||
s.audio_tracks[0].items.push(a);
|
||||
t += dur_t;
|
||||
}
|
||||
// A lower-third-ish overlay on V2: the plasma clip scaled down in the corner with a drop shadow.
|
||||
let ov_range = TimeRange::new(Tick::ZERO, r.snap(Tick::from_seconds_f64(4.0)));
|
||||
let mut ov = p.make_track_item(clips[4], TrackKind::Video, r.snap(Tick::from_seconds_f64(6.0)), ov_range, r).expect("item");
|
||||
let mut ov = p.make_track_item(clips[4], TrackKind::Video, r.snap(Tick::from_seconds_f64(6.0)), ov_range, r)?;
|
||||
for e in &mut ov.effects {
|
||||
resolve_auto_points(e, (1920, 1080), (1920, 1080));
|
||||
}
|
||||
@@ -152,18 +161,18 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
lum.params.get_mut("temperature").map(|v| v.value = ParamValue::Float(18.0));
|
||||
lum.params.get_mut("contrast").map(|v| v.value = ParamValue::Float(22.0));
|
||||
lum.params.get_mut("vignette_amount").map(|v| v.value = ParamValue::Float(-1.4));
|
||||
let s = p.sequence_mut(seq).expect("seq");
|
||||
let s = p.sequence_mut(seq)?;
|
||||
if let Some(it) = s.video_tracks[0].items.get_mut(3) {
|
||||
it.effects.insert(0, lum);
|
||||
}
|
||||
}
|
||||
let s = p.sequence_mut(seq).expect("seq");
|
||||
let s = p.sequence_mut(seq)?;
|
||||
s.video_tracks[1].items.push(ov);
|
||||
// Transitions: cross dissolve between shots 1-2, dip to black 3-4, push 5-6, fade in at start.
|
||||
let tr_len = r.tick_of(24);
|
||||
let mk = |id: u64, eff: &str, from: Option<filmcraft_project::ClipId>, to: Option<filmcraft_project::ClipId>, start: Tick| Transition {
|
||||
id: TransitionId(id),
|
||||
effect: find_effect(eff).expect("fx").instance(),
|
||||
effect: crate::presets::effect_instance(eff),
|
||||
start,
|
||||
duration: tr_len,
|
||||
from,
|
||||
@@ -181,7 +190,7 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
s.video_tracks[0].transitions = trs;
|
||||
s.audio_tracks[0].transitions.push(Transition {
|
||||
id: TransitionId(900_010),
|
||||
effect: find_effect("constant_power").expect("fx").instance(),
|
||||
effect: crate::presets::effect_instance("constant_power"),
|
||||
start: cut(1) - tr_len.mul_ratio(1, 2),
|
||||
duration: tr_len,
|
||||
from: Some(placed[0].1),
|
||||
@@ -207,13 +216,13 @@ pub fn demo_project(pool: &MediaPool) -> (Project, ItemId) {
|
||||
});
|
||||
}
|
||||
// Music on A2 (under the whole edit), ducked -14 dB.
|
||||
let mut m = p.make_track_item(music, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, end), r).expect("music");
|
||||
let mut m = p.make_track_item(music, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, end), r)?;
|
||||
m.effect_mut("volume").map(|e| e.params.get_mut("level").map(|l| l.value = ParamValue::Float(-14.0)));
|
||||
m.label = Label::Caribbean;
|
||||
let s = p.sequence_mut(seq).expect("seq");
|
||||
let s = p.sequence_mut(seq)?;
|
||||
s.audio_tracks[1].items.push(m);
|
||||
for tr in s.all_tracks_mut() {
|
||||
tr.sort();
|
||||
}
|
||||
(p, seq)
|
||||
Some((p, seq))
|
||||
}
|
||||
@@ -124,6 +124,18 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
||||
assert_eq!((v["width"].as_u64().unwrap(), v["height"].as_u64().unwrap()), (640, 360));
|
||||
continue;
|
||||
}
|
||||
"mxf-opatom" => {
|
||||
// Avid style: picture only in Demo.mxf, one mono PCM file per channel
|
||||
assert_eq!(fmt_name, "mxf", "{name}");
|
||||
assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}");
|
||||
assert_eq!(stream(&j, "video").unwrap()["codec_name"], "dnxhd", "{name}");
|
||||
for k in 1..=2 {
|
||||
let a = probe(&format!("{}_A{k}.mxf", out.trim_end_matches(".mxf"))).unwrap();
|
||||
let a = stream(&a, "audio").unwrap();
|
||||
assert_eq!((a["codec_name"].as_str(), a["channels"].as_u64()), (Some("pcm_s24le"), Some(1)), "{name} A{k}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let v = stream(&j, "video").unwrap_or_else(|| panic!("{name}: no video"));
|
||||
@@ -167,6 +179,16 @@ 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}");
|
||||
}
|
||||
"mxf-op1a" => {
|
||||
assert_eq!(fmt_name, "mxf", "{name}");
|
||||
let codec = match settings["mxfVideoCodec"].as_str().unwrap() {
|
||||
"proRes" => "prores",
|
||||
"h264" => "h264",
|
||||
_ => "dnxhd",
|
||||
};
|
||||
assert_eq!(v["codec_name"], codec, "{name}");
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
other => panic!("{name}: unexpected format {other}"),
|
||||
}
|
||||
}
|
||||
@@ -222,7 +244,7 @@ fn user_presets_persist_with_favourites_import_and_export() {
|
||||
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(favs, 2);
|
||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(hits, 4);
|
||||
assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
// persisted: a fresh session with the same data directory sees both
|
||||
let mut s2 = demo();
|
||||
s2.export_presets.set_dir(&dir.0);
|
||||
@@ -389,6 +411,9 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
|
||||
assert_eq!(frames(&mut s, json!({"preset": "PNG Sequence"})), io, "In/Out is the default");
|
||||
assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area");
|
||||
assert_eq!(frames(&mut s, json!({"preset": "Animated GIF 640×360", "range": "custom", "startSeconds": 0, "endSeconds": 2})), 30, "15 fps");
|
||||
let bare = frames(&mut s, json!({"preset": "Animated GIF 640×360", "startSeconds": 0, "endSeconds": 2}));
|
||||
assert_eq!(bare, 30, "start/end times without `range` are a custom range, not the In/Out or whole sequence");
|
||||
assert!(s.execute("export.resolve", json!({"startSeconds": 1})).is_err(), "a start without an end is an error");
|
||||
let res = s.execute("export.resolve", json!({"preset": "YouTube 2160p 4K Ultra HD"})).unwrap();
|
||||
assert_eq!(res["output"]["width"], 3840);
|
||||
assert!(res["summary"]["video"].as_str().unwrap().contains("3840x2160"));
|
||||
@@ -405,3 +430,18 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
|
||||
// a bad preset name is a clean error
|
||||
assert!(s.execute("file.exportMedia", json!({"preset": "Nope", "path": out})).is_err());
|
||||
}
|
||||
|
||||
/// Export mode asks for a default folder as soon as it opens. With no saved project and no
|
||||
/// HOME (the web build) this used to reach `std::env::temp_dir()`, which panics on wasm32 and
|
||||
/// killed the web app whenever the Export tab was clicked.
|
||||
#[test]
|
||||
fn default_export_dir_never_needs_a_real_filesystem() {
|
||||
let s = Session::default();
|
||||
let d = crate::export_tools::default_export_dir(&s);
|
||||
assert!(!d.as_os_str().is_empty());
|
||||
// the shared temp-dir helper is what every former `std::env::temp_dir()` call goes through
|
||||
assert!(!crate::temp_dir().as_os_str().is_empty());
|
||||
// a saved project exports next to itself
|
||||
let s = Session { path: Some("/projects/show/edit.fcproj".into()), ..Default::default() };
|
||||
assert_eq!(crate::export_tools::default_export_dir(&s), std::path::PathBuf::from("/projects/show"));
|
||||
}
|
||||
@@ -209,6 +209,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, "mxfVideoCodec") {
|
||||
settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?;
|
||||
}
|
||||
if let Some(v) = bool_p(p, "sdr") {
|
||||
settings.sdr = v;
|
||||
}
|
||||
@@ -241,12 +244,14 @@ pub fn sequence_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
|
||||
}
|
||||
|
||||
/// Export ▸ Range: `entire` | `inOut` | `workArea` | `custom` (`start…` / `end…` times).
|
||||
/// None = the default (In/Out when set, else the whole sequence).
|
||||
/// None = the default (In/Out when set, else the whole sequence); bare `start…` / `end…` times
|
||||
/// without a `range` mean `custom`.
|
||||
pub fn range_param(s: &Session, project: &Project, seq: ItemId, v: Option<&Value>, p: &Value, cmd: &str) -> Result<Option<TimeRange>> {
|
||||
let q = project.sequence(seq).ok_or(EngineError::NoSequence)?;
|
||||
let fd = q.settings.frame_rate.frame_duration();
|
||||
let whole = TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd));
|
||||
let mode = match v {
|
||||
None | Some(Value::Null) if time_p(s, p, "start").is_some() || time_p(s, p, "end").is_some() => "custom".to_string(),
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(m)) => m.to_ascii_lowercase().replace(['-', '_', ' ', '/'], ""),
|
||||
Some(o @ Value::Object(_)) => return range_param(s, project, seq, o.get("mode").or(Some(&json!("custom"))), o, cmd),
|
||||
@@ -278,11 +283,15 @@ pub fn default_export_dir(s: &Session) -> PathBuf {
|
||||
if let Some(d) = s.path.as_deref().and_then(|p| Path::new(p).parent()).filter(|d| !d.as_os_str().is_empty()) {
|
||||
return d.to_path_buf();
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
// the web build's virtual file table: written files are offered as downloads
|
||||
return PathBuf::from("/exports");
|
||||
}
|
||||
if let Ok(h) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
|
||||
let movies = Path::new(&h).join("Movies");
|
||||
return if movies.is_dir() { movies } else { PathBuf::from(h) };
|
||||
}
|
||||
std::env::temp_dir()
|
||||
crate::temp_dir()
|
||||
}
|
||||
|
||||
fn file_safe(n: &str) -> String {
|
||||
@@ -378,6 +387,7 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
|
||||
}
|
||||
*res.lock().unwrap_or_else(|x| x.into_inner()) = Some(r);
|
||||
};
|
||||
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
|
||||
s.jobs.push(job);
|
||||
if wait || cfg!(target_arch = "wasm32") {
|
||||
run();
|
||||
@@ -387,6 +397,24 @@ pub fn spawn_export(s: &mut Session, project: Arc<Project>, seq: ItemId, mut set
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Last-resort guard for a background job body (export, previews, proxies, …): a panic marks the
|
||||
/// job failed with an error instead of killing its thread and leaving the job "running" forever.
|
||||
/// The panic hook has already logged where it happened.
|
||||
pub(crate) fn guard_job(
|
||||
progress: Arc<filmcraft_export::Progress>,
|
||||
result: Arc<std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>>,
|
||||
run: impl FnOnce() + Send + 'static,
|
||||
) -> impl FnOnce() + Send + 'static {
|
||||
move || {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).is_err() {
|
||||
let msg = "internal error (see the crash log); the job was stopped".to_string();
|
||||
*progress.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(msg.clone());
|
||||
progress.finished.store(true, Ordering::Relaxed);
|
||||
*result.lock().unwrap_or_else(|x| x.into_inner()) = Some(Err(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(path: &str) -> String {
|
||||
Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
@@ -669,9 +697,9 @@ fn queue_cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cancel_item(s, i);
|
||||
}
|
||||
}
|
||||
if bool_p(p, "wait").unwrap_or(false) {
|
||||
// wait for a cancelled encode to stop
|
||||
let t0 = std::time::Instant::now();
|
||||
// wait for a cancelled encode to stop (there is no encode thread, clock or sleep on wasm32)
|
||||
if bool_p(p, "wait").unwrap_or(false) && !cfg!(target_arch = "wasm32") {
|
||||
let t0 = web_time::Instant::now();
|
||||
while s.export_queue.items.iter().any(|i| i.status == QueueStatus::Encoding) && t0.elapsed() < std::time::Duration::from_secs(60) {
|
||||
let running = s.export_queue.running;
|
||||
s.export_queue.running = false;
|
||||
@@ -1001,3 +1029,20 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
spec("export.quick", "Quick Export", r#"{"preset":str?,"path":str?,"wait":bool?, …settings params}"#, has_seq, quick, true),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod guard_tests {
|
||||
use super::*;
|
||||
|
||||
/// A panicking job body must leave the job finished with an error, not "running" forever.
|
||||
#[test]
|
||||
fn panicking_job_is_reported_as_failed() {
|
||||
let progress: Arc<filmcraft_export::Progress> = Arc::default();
|
||||
let result: Arc<std::sync::Mutex<Option<std::result::Result<filmcraft_export::Report, String>>>> = Arc::default();
|
||||
let run = guard_job(progress.clone(), result.clone(), || panic!("job bug"));
|
||||
std::thread::spawn(run).join().expect("the guard catches the panic");
|
||||
assert!(progress.finished.load(Ordering::Relaxed));
|
||||
assert!(progress.error.lock().unwrap().is_some());
|
||||
assert!(matches!(&*result.lock().unwrap(), Some(Err(_))));
|
||||
}
|
||||
}
|
||||
@@ -157,15 +157,15 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec<u8>) {
|
||||
let mut p = Project::new("thumb");
|
||||
let rate = filmcraft_time::FrameRate::FPS_30;
|
||||
let g = p.add_item("G", Label::Rose, ItemKind::Graphic { width: t.canvas[0], height: t.canvas[1], rate }, None);
|
||||
let mut it = p
|
||||
.make_track_item(
|
||||
g,
|
||||
filmcraft_project::TrackKind::Video,
|
||||
Tick::ZERO,
|
||||
filmcraft_time::TimeRange::new(Tick::ZERO, t.duration.max(rate.frame_duration())),
|
||||
rate,
|
||||
)
|
||||
.expect("item");
|
||||
let Some(mut it) = p.make_track_item(
|
||||
g,
|
||||
filmcraft_project::TrackKind::Video,
|
||||
Tick::ZERO,
|
||||
filmcraft_time::TimeRange::new(Tick::ZERO, t.duration.max(rate.frame_duration())),
|
||||
rate,
|
||||
) else {
|
||||
return (w, h, vec![0; w as usize * h as usize * 4]);
|
||||
};
|
||||
it.effects.extend(t.layers.iter().cloned());
|
||||
it.graphic = Some(Box::new(meta));
|
||||
// a moment into the clip, so intro animations have settled a little
|
||||
|
||||
@@ -551,11 +551,11 @@ macro_rules! arrange_cmd {
|
||||
fn new_shape_cmd(s: &mut Session, p: &Value, shape: &str) -> Result<Value> {
|
||||
let mut q = p.clone();
|
||||
q["shape"] = json!(shape);
|
||||
(find_spec("graphics.newShape").run)(s, &q)
|
||||
(find_spec("graphics.newShape")?.run)(s, &q)
|
||||
}
|
||||
|
||||
fn find_spec(id: &str) -> CommandSpec {
|
||||
commands().into_iter().find(|c| c.id == id).expect("graphics command")
|
||||
fn find_spec(id: &str) -> Result<CommandSpec> {
|
||||
commands().into_iter().find(|c| c.id == id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))
|
||||
}
|
||||
|
||||
/// Graphics menu commands (New Layer shapes, Align/Distribute/Arrange/Select submenus, resets).
|
||||
@@ -576,7 +576,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
p["vertical"] = json!(true);
|
||||
(find_spec("graphics.newText").run)(s, &p)
|
||||
(find_spec("graphics.newText")?.run)(s, &p)
|
||||
},
|
||||
),
|
||||
spec(
|
||||
@@ -731,7 +731,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
let rate = q.settings.frame_rate;
|
||||
let want = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
|
||||
let tr = q.video_tracks.iter_mut().find(|tr| tr.items.iter().any(|i| i.id == clip)).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let i = tr.items.iter().position(|i| i.id == clip).expect("found");
|
||||
let i = tr.items.iter().position(|i| i.id == clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let start = tr.items[i].start;
|
||||
let limit = tr.items.get(i + 1).map_or(want, |n| n.start - start);
|
||||
let d = want.min(limit).max(rate.frame_duration());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO) ↔ the session's project.
|
||||
//! Interchange documents (CMX 3600 EDL, FCP7 XML, FCPXML, OTIO, AAF, OMF) ↔ the session's project.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
|
||||
/// The interchange format of a file, if it is one (by content, with the extension as a hint).
|
||||
pub fn detect(path: &str, bytes: &[u8]) -> Option<Format> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio")) {
|
||||
if !matches!(ext.as_deref(), Some("edl" | "xml" | "fcpxml" | "otio" | "aaf" | "omf" | "omfi")) {
|
||||
return None;
|
||||
}
|
||||
filmcraft_interchange::detect(bytes, ext.as_deref())
|
||||
@@ -29,7 +29,21 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
||||
name: p.file_stem().map(|n| n.to_string_lossy().to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let (fragment, report) = filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (fragment, report) = match format {
|
||||
// AAF / OMF may embed audio: write it next to the document (the media items point there)
|
||||
Format::Aaf | Format::Omf => {
|
||||
let r = if format == Format::Aaf { filmcraft_interchange::aaf::import(bytes, &opts) } else { filmcraft_interchange::omf::import(bytes, &opts) };
|
||||
let (fragment, extracted, report) = r.map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
for m in extracted {
|
||||
if let Some(dir) = std::path::Path::new(&m.path).parent().filter(|_| !s.services.export_in_memory()) {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
s.services.write_file(&m.path, &m.wav).map_err(|e| EngineError::Other(format!("{}: {e}", m.path)))?;
|
||||
}
|
||||
(fragment, report)
|
||||
}
|
||||
_ => filmcraft_interchange::import_with(bytes, format, &opts).map_err(|e| EngineError::Other(format!("{path}: {e}")))?,
|
||||
};
|
||||
let before: std::collections::HashSet<ItemId> = s.project.items.keys().copied().collect();
|
||||
let name = opts.name.clone().unwrap_or_else(|| format.name().to_string());
|
||||
let seqs = s.edit(&format!("Import {name}"), |proj, _| Ok(filmcraft_interchange::merge_into(proj, fragment, None)))?;
|
||||
@@ -100,6 +114,8 @@ pub fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"edl" => Format::Edl,
|
||||
"fcpxml" => Format::Fcpxml,
|
||||
"otio" => Format::Otio,
|
||||
"aaf" => Format::Aaf,
|
||||
"omf" => Format::Omf,
|
||||
_ => Format::Fcp7Xml,
|
||||
};
|
||||
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| EngineError::Other("need `path`".into()))?.to_string();
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots (cheap
|
||||
//! thanks to structural sharing of untouched items).
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod aaf_omf;
|
||||
pub mod autosave;
|
||||
pub mod captions;
|
||||
pub mod clip_ops;
|
||||
@@ -62,6 +65,12 @@ pub use filmcraft_render as render;
|
||||
pub use filmcraft_time as time;
|
||||
pub use media_pool::MediaPool;
|
||||
|
||||
/// The OS temp folder; `/tmp` on wasm32, where `std::env::temp_dir()` panics ("no filesystem on
|
||||
/// this platform"). Use this instead of `std::env::temp_dir()` in any code the web build runs.
|
||||
pub fn temp_dir() -> std::path::PathBuf {
|
||||
if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/tmp") } else { std::env::temp_dir() }
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
@@ -246,6 +255,10 @@ pub struct EditorState {
|
||||
/// audio clips too.
|
||||
#[serde(default)]
|
||||
pub multicam_audio_follows_video: bool,
|
||||
/// Multi-Camera view settings: grid layout and page, selection order, preview monitor,
|
||||
/// playback quality, transmit.
|
||||
#[serde(default)]
|
||||
pub multicam_view: multicam::MulticamView,
|
||||
/// Audio Clip Mixer automation mode per audio track (track id → mode; missing = Read):
|
||||
/// Latch / Touch / Write record the clip Volume and Panner moves as clip keyframes.
|
||||
#[serde(default)]
|
||||
@@ -588,7 +601,11 @@ impl Session {
|
||||
return Err(e);
|
||||
}
|
||||
self.exec_depth += 1;
|
||||
let r = (spec.run)(self, ¶ms);
|
||||
// Last-resort guard: a panic in a command (a bug that escaped the never-crash rules)
|
||||
// becomes an error for the caller (UI, CLI, control channel, MCP) instead of taking the
|
||||
// session and its unsaved project down. The panic hook has logged where it happened.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("internal error in `{id}` (see the crash log); the command did not complete"))));
|
||||
self.exec_depth -= 1;
|
||||
// source graphics: an edited instance updates the shared layers and the other instances
|
||||
if r.is_ok() && spec.journal && id.starts_with("graphics.") && !self.project.source_graphics.is_empty() {
|
||||
@@ -670,13 +687,15 @@ impl Session {
|
||||
let seq_id = self.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let media = self.media.clone();
|
||||
self.edit(label, move |p, st| {
|
||||
let project_snapshot = p.clone();
|
||||
let project_snapshot = std::sync::Arc::new(p.clone());
|
||||
let snap = project_snapshot.clone();
|
||||
let durations = move |id: ItemId| -> Option<Tick> { media_duration(&project_snapshot, &media, id) };
|
||||
let starts = move |id: ItemId| media_start(&snap, id);
|
||||
let min = p.sequence(seq_id).map(|s| s.settings.frame_rate.frame_duration()).unwrap_or(Tick(1));
|
||||
let mut next = p.next_id;
|
||||
let r = {
|
||||
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, min_duration: min };
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
|
||||
f(seq, &mut ctx, st)?
|
||||
};
|
||||
p.next_id = next;
|
||||
@@ -821,6 +840,15 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The earliest media time clips of an item may show: a subclip that restricts trims starts at its
|
||||
/// In point, everything else at 0.
|
||||
pub fn media_start(p: &Project, id: ItemId) -> Tick {
|
||||
match p.item(id).map(|i| &i.kind) {
|
||||
Some(filmcraft_project::ItemKind::Subclip { range, restrict_trims: true, .. }) => range.start,
|
||||
_ => Tick::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -837,6 +865,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option<Tick
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod aaf_omf_tests;
|
||||
#[cfg(test)]
|
||||
mod audio_effects_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -618,7 +618,14 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = match &err {
|
||||
// stopping keeps what was tracked
|
||||
Some(e) if e != "stopped" => Err(e.clone()),
|
||||
_ => Ok(filmcraft_export::Report { path: String::new(), frames: done, seconds: secs, bytes: 0, render_fps: done as f64 / secs.max(1e-6) }),
|
||||
_ => Ok(filmcraft_export::Report {
|
||||
path: String::new(),
|
||||
frames: done,
|
||||
seconds: secs,
|
||||
bytes: 0,
|
||||
render_fps: done as f64 / secs.max(1e-6),
|
||||
extra_files: Vec::new(),
|
||||
}),
|
||||
};
|
||||
*lock(&prog.status) = match &err {
|
||||
Some(e) => format!("{e} ({done} frame(s) tracked)"),
|
||||
@@ -627,6 +634,7 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
prog.finished.store(true, Ordering::Relaxed);
|
||||
*lock(&res) = Some(r);
|
||||
};
|
||||
let run = crate::export_tools::guard_job(job.progress.clone(), job.result.clone(), run);
|
||||
s.jobs.push(job);
|
||||
s.mask_jobs.push(PendingTrack { job: id, target: MaskSel { clip, effect: ei, mask: mi }, seq: seq_id, keys, applied: 0 });
|
||||
let wait = crate::commands::bool_p(p, "wait").unwrap_or(false);
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn psnr(a: &[u8], b: &[u8]) -> f64 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
let mut se = 0.0;
|
||||
let mut n = 0.0;
|
||||
for (x, y) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
|
||||
for (x, y) in a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0) {
|
||||
for c in 0..3 {
|
||||
let d = x[c] as f64 - y[c] as f64;
|
||||
se += d * d;
|
||||
|
||||
+210
-14
@@ -14,6 +14,13 @@
|
||||
//! | `multicam.audioFollowsVideo` | | switching video switches the linked audio clips too |
|
||||
//! | `multicam.editCameras` | | rename / show / hide cameras, change the audio mode |
|
||||
//! | `multicam.inspect` | | the multi-camera clip at the playhead, its cameras and the recorder state |
|
||||
//! | `multicam.gridLayout` | Program ▸ Multi-Camera ▸ Layout | angle grid: automatic, 2×2, 3×3 or 4×4 (paged when the angles don't fit) |
|
||||
//! | `multicam.page` / `multicam.nextPage` / `multicam.prevPage` | page arrows | page of the angle grid (keys 1–9 pick cameras on the shown page) |
|
||||
//! | `multicam.selectionTopDown` | Multi-Camera Selection Top Down | stacked multi-camera clips: switch the topmost instead of the lowest |
|
||||
//! | `multicam.showPreviewMonitor` | Show Multi-Camera Preview Monitor | the program next to the grid (off: the grid fills the monitor) |
|
||||
//! | `multicam.autoAdjustQuality` | Auto-Adjust Multi-Camera Playback Quality | lower-resolution grid decode while playing |
|
||||
//! | `multicam.transmitView` | Transmit Multi-Camera View | send the grid to the transmit device (no transmit output yet: stored only) |
|
||||
//! | `multicam.grid` | | the grid page at the playhead: layout, pages, cells (camera, angle, name, active) |
|
||||
//!
|
||||
//! Sync methods are described in [`crate::sync`].
|
||||
|
||||
@@ -28,6 +35,7 @@ use filmcraft_project::{
|
||||
TrackId, TrackItem, TrackKind, resolve_auto_points,
|
||||
};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_selection, has_seq, item_p, str_p, time_p, track_p, u64_p, with_links};
|
||||
@@ -52,7 +60,7 @@ macro_rules! camera_keys {
|
||||
shortcut: Some(concat!($n)),
|
||||
params: r#"{"videoOnly":bool?}"#,
|
||||
enabled: has_seq,
|
||||
run: |s, p| cut(s, &with_camera(p, $n)),
|
||||
run: |s, p| cut(s, &on_page(with_camera(p, $n))),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
@@ -62,7 +70,7 @@ macro_rules! camera_keys {
|
||||
shortcut: Some(concat!("Ctrl+", $n)),
|
||||
params: r#"{"videoOnly":bool?}"#,
|
||||
enabled: has_seq,
|
||||
run: |s, p| cut_to_camera(s, &with_camera(p, $n)),
|
||||
run: |s, p| cut_to_camera(s, &on_page(with_camera(p, $n))),
|
||||
journal: true,
|
||||
},
|
||||
)*]
|
||||
@@ -75,6 +83,51 @@ fn with_camera(p: &Value, n: u64) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// Keys 1–9 pick the camera on the grid page shown.
|
||||
fn on_page(mut v: Value) -> Value {
|
||||
v["pageRelative"] = json!(true);
|
||||
v
|
||||
}
|
||||
|
||||
/// Multi-Camera view settings (Program monitor ▸ wrench menu); editor state, not project data.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MulticamView {
|
||||
/// Grid side (2 = 2×2, 3 = 3×3, 4 = 4×4); None = automatic.
|
||||
pub layout: Option<usize>,
|
||||
/// Grid page (0-based).
|
||||
pub page: usize,
|
||||
/// Multi-Camera Selection Top Down: of stacked multi-camera clips, the topmost one is switched.
|
||||
pub top_down: bool,
|
||||
/// Show Multi-Camera Preview Monitor: the program next to the grid.
|
||||
pub show_preview: bool,
|
||||
/// Auto-Adjust Multi-Camera Playback Quality: lower-resolution grid decode while playing.
|
||||
pub auto_quality: bool,
|
||||
/// Transmit Multi-Camera View: send the grid (not the program) to the transmit device.
|
||||
pub transmit: bool,
|
||||
}
|
||||
|
||||
impl Default for MulticamView {
|
||||
fn default() -> Self {
|
||||
MulticamView { layout: None, page: 0, top_down: false, show_preview: true, auto_quality: false, transmit: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl MulticamView {
|
||||
pub fn layout_name(&self) -> &'static str {
|
||||
match self.layout {
|
||||
Some(2) => "2x2",
|
||||
Some(3) => "3x3",
|
||||
Some(4) => "4x4",
|
||||
_ => "auto",
|
||||
}
|
||||
}
|
||||
/// The grid page for `shown` angles.
|
||||
pub fn page_layout(&self, shown: usize) -> filmcraft_render::multicam::PageLayout {
|
||||
filmcraft_render::multicam::page_layout(shown, self.layout, self.page)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<CommandSpec> {
|
||||
let mut v = base_commands();
|
||||
v.extend(camera_keys!(1, 2, 3, 4, 5, 6, 7, 8, 9));
|
||||
@@ -135,6 +188,40 @@ fn base_commands() -> Vec<CommandSpec> {
|
||||
true,
|
||||
),
|
||||
spec("multicam.cutToCamera", "Cut to Camera", &[], r#"{"camera":1..16|"angle":0-based,"time":ticks?,"videoOnly":bool?}"#, has_seq, cut_to_camera, true),
|
||||
spec("multicam.gridLayout", "Multi-Camera Layout", &[], r#"{"layout":"auto|2x2|3x3|4x4"}"#, always, grid_layout, true),
|
||||
spec("multicam.page", "Multi-Camera Page", &[], r#"{"page":0-based|"next"|"prev"}"#, always, set_page, true),
|
||||
spec("multicam.nextPage", "Next Multi-Camera Page", &[], "{}", always, |s, _| set_page(s, &json!({"page": "next"})), true),
|
||||
spec("multicam.prevPage", "Previous Multi-Camera Page", &[], "{}", always, |s, _| set_page(s, &json!({"page": "prev"})), true),
|
||||
spec("multicam.selectionTopDown", "Multi-Camera Selection Top Down", &[], r#"{"enabled":bool?}"#, always, |s, p| view_flag(s, p, Flag::TopDown), true),
|
||||
spec(
|
||||
"multicam.showPreviewMonitor",
|
||||
"Show Multi-Camera Preview Monitor",
|
||||
&[],
|
||||
r#"{"enabled":bool?}"#,
|
||||
always,
|
||||
|s, p| view_flag(s, p, Flag::Preview),
|
||||
true,
|
||||
),
|
||||
spec(
|
||||
"multicam.autoAdjustQuality",
|
||||
"Auto-Adjust Multi-Camera Playback Quality",
|
||||
&[],
|
||||
r#"{"enabled":bool?}"#,
|
||||
always,
|
||||
|s, p| view_flag(s, p, Flag::AutoQuality),
|
||||
true,
|
||||
),
|
||||
spec("multicam.transmitView", "Transmit Multi-Camera View", &[], r#"{"enabled":bool?}"#, always, |s, p| view_flag(s, p, Flag::Transmit), true),
|
||||
CommandSpec {
|
||||
id: "multicam.grid",
|
||||
label: "Multi-Camera Grid",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"time":ticks?,"playing":bool?,"cellPixels":f32?,"playbackScale":f32?}"#,
|
||||
enabled: always,
|
||||
run: grid_info,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "multicam.inspect",
|
||||
label: "Inspect Multi-Camera",
|
||||
@@ -189,14 +276,16 @@ fn offset_p(p: &Value, rate: FrameRate) -> Tick {
|
||||
|
||||
/// Run `f` on sequence `seq_id` of `p` with an edit context (ids, media durations), then check it.
|
||||
fn seq_edit<R>(p: &mut Project, seq_id: ItemId, media: &Arc<MediaPool>, f: impl FnOnce(&mut Sequence, &mut EditCtx) -> Result<R>) -> Result<R> {
|
||||
let snapshot = p.clone();
|
||||
let snapshot = Arc::new(p.clone());
|
||||
let snap = snapshot.clone();
|
||||
let media = media.clone();
|
||||
let durations = move |id: ItemId| crate::media_duration(&snapshot, &media, id);
|
||||
let starts = move |id: ItemId| crate::media_start(&snap, id);
|
||||
let min = p.sequence(seq_id).map(|s| s.settings.frame_rate.frame_duration()).unwrap_or(Tick(1));
|
||||
let mut next = p.next_id;
|
||||
let r = {
|
||||
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, min_duration: min };
|
||||
let mut ctx = EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
|
||||
f(seq, &mut ctx)?
|
||||
};
|
||||
p.next_id = next;
|
||||
@@ -465,7 +554,7 @@ fn merge_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.link = Some(link);
|
||||
a_items.push(a);
|
||||
}
|
||||
let q = pr.sequence_mut(sid).expect("new sequence");
|
||||
let q = pr.sequence_mut(sid).ok_or(EngineError::NoSequence)?;
|
||||
for (t, it) in q.video_tracks.iter_mut().zip(v_items) {
|
||||
t.items.push(it);
|
||||
}
|
||||
@@ -527,7 +616,7 @@ fn create_multicam(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut vi, mut ai) = (0usize, 0usize);
|
||||
let mut placed: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||||
let (vtracks, atracks): (Vec<TrackId>, Vec<TrackId>) = {
|
||||
let q = pr.sequence(sid).expect("new sequence");
|
||||
let q = pr.sequence(sid).ok_or(EngineError::NoSequence)?;
|
||||
(q.video_tracks.iter().map(|t| t.id).collect(), q.audio_tracks.iter().map(|t| t.id).collect())
|
||||
};
|
||||
for (k, c) in clips.iter().enumerate() {
|
||||
@@ -551,7 +640,7 @@ fn create_multicam(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cameras.push(cam);
|
||||
}
|
||||
{
|
||||
let q = pr.sequence_mut(sid).expect("new sequence");
|
||||
let q = pr.sequence_mut(sid).ok_or(EngineError::NoSequence)?;
|
||||
for (kind, idx, it) in placed {
|
||||
q.tracks_mut(kind)[idx].items.push(it);
|
||||
}
|
||||
@@ -590,12 +679,16 @@ fn create_multicam(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The topmost enabled multi-camera video clip at `t` (targeted tracks first) in the active
|
||||
/// sequence: (track, clip).
|
||||
///
|
||||
/// Targeted tracks are searched first, then every video track; from the lowest track up, or from
|
||||
/// the topmost down with Multi-Camera Selection Top Down.
|
||||
pub fn multicam_clip_at(s: &Session, t: Tick) -> Option<(TrackId, TrackItem)> {
|
||||
let q = s.active_sequence()?;
|
||||
let tg = s.targeting().targeted;
|
||||
let at = |tr: &Track| tr.item_at(t).filter(|i| i.multicam.is_some_and(|m| m.enabled) && s.project.sequence(i.item).is_some()).cloned();
|
||||
let targeted = q.video_tracks.iter().rev().filter(|tr| tg.contains(&tr.id)).find_map(|tr| at(tr).map(|i| (tr.id, i)));
|
||||
targeted.or_else(|| q.video_tracks.iter().rev().find_map(|tr| at(tr).map(|i| (tr.id, i))))
|
||||
let order: Vec<&Track> = if s.state.multicam_view.top_down { q.video_tracks.iter().rev().collect() } else { q.video_tracks.iter().collect() };
|
||||
let targeted = order.iter().filter(|tr| tg.contains(&tr.id)).find_map(|tr| at(tr).map(|i| (tr.id, i)));
|
||||
targeted.or_else(|| order.iter().find_map(|tr| at(tr).map(|i| (tr.id, i))))
|
||||
}
|
||||
|
||||
/// Resolve `camera` (1-based, shown order) or `angle` (0-based) for the source of `clip`.
|
||||
@@ -608,8 +701,11 @@ fn angle_p(s: &Session, p: &Value, source: ItemId, cmd: &str) -> Result<u32> {
|
||||
}
|
||||
return Err(bad(cmd, format!("angle {a} out of range (0–{})", cams.cameras.len().saturating_sub(1))));
|
||||
}
|
||||
let n = u64_p(p, "camera").ok_or_else(|| bad(cmd, "need `camera` (1-based) or `angle`"))? as usize;
|
||||
let mut n = u64_p(p, "camera").ok_or_else(|| bad(cmd, "need `camera` (1-based) or `angle`"))? as usize;
|
||||
let shown = cams.shown_angles();
|
||||
if bool_p(p, "pageRelative").unwrap_or(false) {
|
||||
n += s.state.multicam_view.page_layout(shown.len()).first;
|
||||
}
|
||||
shown.get(n.wrapping_sub(1)).map(|a| *a as u32).ok_or_else(|| bad(cmd, format!("there is no camera {n} ({} shown)", shown.len())))
|
||||
}
|
||||
|
||||
@@ -796,10 +892,14 @@ fn record_start(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn apply_pass(s: &mut Session, end: Tick) -> Result<usize> {
|
||||
let Some(seq_id) = s.mcrec.seq else { return Ok(0) };
|
||||
let merging = s.history.merge_key.as_deref() == Some(s.mcrec.key.as_str()) && s.history.undo.last().is_some_and(|u| u.0 == RECORD_LABEL);
|
||||
if !merging || s.mcrec.base.is_none() {
|
||||
s.mcrec.base = Some(s.project.clone());
|
||||
}
|
||||
let base = s.mcrec.base.clone().expect("set above");
|
||||
let base = match s.mcrec.base.clone() {
|
||||
Some(b) if merging => b,
|
||||
_ => {
|
||||
let b = s.project.clone();
|
||||
s.mcrec.base = Some(b.clone());
|
||||
b
|
||||
}
|
||||
};
|
||||
let (tracks, cuts, key) = (s.mcrec.tracks.clone(), s.mcrec.cuts.clone(), s.mcrec.key.clone());
|
||||
let Some(source) = s.mcrec.source else { return Ok(0) };
|
||||
let media = s.media.clone();
|
||||
@@ -959,9 +1059,105 @@ pub fn inspect_at(s: &Session, t: Tick) -> Value {
|
||||
"cameras": cams.cameras.iter().enumerate().map(|(i, c)| json!({"angle": i, "name": c.name, "enabled": c.enabled, "video": c.video_track.is_some()})).collect::<Vec<_>>(),
|
||||
"recorder": rec,
|
||||
"audioFollowsVideo": s.state.multicam_audio_follows_video,
|
||||
"view": s.state.multicam_view,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Multi-Camera view settings
|
||||
|
||||
fn grid_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "multicam.gridLayout";
|
||||
let l = str_p(p, "layout").ok_or_else(|| bad(cmd, "need `layout` (auto, 2x2, 3x3, 4x4)"))?;
|
||||
s.state.multicam_view.layout = match l.to_ascii_lowercase().replace(['×', ' '], "x").as_str() {
|
||||
"auto" | "automatic" => None,
|
||||
"2x2" | "2" => Some(2),
|
||||
"3x3" | "3" => Some(3),
|
||||
"4x4" | "4" => Some(4),
|
||||
_ => return Err(bad(cmd, format!("unknown layout `{l}` (auto, 2x2, 3x3, 4x4)"))),
|
||||
};
|
||||
s.state.multicam_view.page = 0;
|
||||
Ok(json!({"layout": s.state.multicam_view.layout_name()}))
|
||||
}
|
||||
|
||||
/// Shown angles of the multi-camera clip at the playhead (0 without one).
|
||||
fn shown_count(s: &Session) -> usize {
|
||||
multicam_clip_at(s, s.playhead()).and_then(|(_, c)| s.project.sequence(c.item).map(|q| q.cameras().shown_angles().len())).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn set_page(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = shown_count(s);
|
||||
let l = s.state.multicam_view.page_layout(n);
|
||||
let page = match p.get("page") {
|
||||
Some(Value::String(d)) if d == "next" => (l.page + 1).min(l.pages - 1),
|
||||
Some(Value::String(d)) if d == "prev" || d == "previous" => l.page.saturating_sub(1),
|
||||
Some(v) => v.as_u64().ok_or_else(|| bad("multicam.page", "`page` is a 0-based number, \"next\" or \"prev\""))? as usize,
|
||||
None => return Err(bad("multicam.page", "need `page`")),
|
||||
};
|
||||
let l = filmcraft_render::multicam::page_layout(n, s.state.multicam_view.layout, page);
|
||||
s.state.multicam_view.page = l.page;
|
||||
Ok(json!({"page": l.page, "pages": l.pages}))
|
||||
}
|
||||
|
||||
enum Flag {
|
||||
TopDown,
|
||||
Preview,
|
||||
AutoQuality,
|
||||
Transmit,
|
||||
}
|
||||
|
||||
fn view_flag(s: &mut Session, p: &Value, f: Flag) -> Result<Value> {
|
||||
let v = &mut s.state.multicam_view;
|
||||
let slot = match f {
|
||||
Flag::TopDown => &mut v.top_down,
|
||||
Flag::Preview => &mut v.show_preview,
|
||||
Flag::AutoQuality => &mut v.auto_quality,
|
||||
Flag::Transmit => &mut v.transmit,
|
||||
};
|
||||
*slot = bool_p(p, "enabled").unwrap_or(!*slot);
|
||||
let on = *slot;
|
||||
let mut out = json!({"enabled": on});
|
||||
if matches!(f, Flag::Transmit) {
|
||||
// no transmit (video output) devices yet: the setting is kept for when there are
|
||||
out["device"] = Value::Null;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The grid page at the playhead (or `time`): layout, pages and cells. With `cellPixels` (on-screen
|
||||
/// cell width in pixels), `playing` and `playbackScale` it also returns the decode scale the
|
||||
/// view uses (Auto-Adjust Multi-Camera Playback Quality).
|
||||
fn grid_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let v = s.state.multicam_view.clone();
|
||||
let Some((_, clip)) = multicam_clip_at(s, t) else {
|
||||
return Ok(json!({"clip": null, "view": v}));
|
||||
};
|
||||
let q = s.project.sequence(clip.item).ok_or(EngineError::NoSequence)?;
|
||||
let cams = q.cameras();
|
||||
let shown = cams.shown_angles();
|
||||
let l = v.page_layout(shown.len());
|
||||
let active = clip.multicam.map(|m| m.angle as usize);
|
||||
let cells: Vec<Value> = shown
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(l.first)
|
||||
.take(l.count)
|
||||
.map(|(k, &a)| {
|
||||
json!({"cell": k - l.first, "camera": k + 1, "angle": a, "name": cams.cameras.get(a).map(|c| c.name.clone()), "active": active == Some(a)})
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"clip": clip.id.0, "source": clip.item.0, "layout": v.layout_name(), "cols": l.cols, "rows": l.rows,
|
||||
"page": l.page, "pages": l.pages, "angles": shown.len(), "cells": cells, "view": v,
|
||||
});
|
||||
if let Some(px) = p.get("cellPixels").and_then(Value::as_f64) {
|
||||
let playing = bool_p(p, "playing").unwrap_or(false);
|
||||
let pb = p.get("playbackScale").and_then(Value::as_f64).unwrap_or(1.0) as f32;
|
||||
out["cellScale"] = json!(filmcraft_render::multicam::grid_cell_scale(q.settings.width, px as f32, pb, playing, v.auto_quality, l.per_page));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
Ok(inspect_at(s, t))
|
||||
|
||||
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user