Compare commits
166
Commits
No files matched your search
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/filmcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.filmcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.filmcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml"
|
||||
|
||||
@@ -112,6 +112,9 @@ jobs:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -221,8 +224,9 @@ jobs:
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs).
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION" \
|
||||
|| cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then
|
||||
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked
|
||||
fi
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'FilmCraft'
|
||||
foreach ($exe in 'filmcraft.exe', 'filmcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'filmcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "filmcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok filmcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'filmcraft.exe')) { throw 'uninstall left filmcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -3,6 +3,49 @@
|
||||
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). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox; later encoding and other OSes) and does nothing else. It
|
||||
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
|
||||
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
|
||||
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
|
||||
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 +87,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,9 +111,21 @@ 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.
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
|
||||
as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## See also
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/contributing.md](docs/contributing.md): setup, gates, commits, how to add things
|
||||
|
||||
+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,9 +9,10 @@ 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.
|
||||
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
|
||||
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
|
||||
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
|
||||
|
||||
+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
+123
-44
@@ -1655,13 +1655,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
"eframe",
|
||||
"egui",
|
||||
"filmcraft-engine",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -1676,7 +1677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1686,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1694,14 +1695,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1717,7 +1718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1728,14 +1729,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1746,9 +1747,13 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.2.1"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1757,6 +1762,7 @@ dependencies = [
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-ogg",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-time",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1764,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1796,7 +1802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1804,7 +1810,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1814,7 +1820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1826,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -1839,6 +1845,7 @@ dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
@@ -1856,7 +1863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-audio-dsp",
|
||||
@@ -1868,6 +1875,7 @@ dependencies = [
|
||||
"filmcraft-h264enc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
"filmcraft-prores",
|
||||
"filmcraft-render",
|
||||
@@ -1884,7 +1892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
@@ -1896,7 +1904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -1906,7 +1914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1914,7 +1922,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1931,7 +1939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1948,7 +1956,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1958,7 +1966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1968,7 +1976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1978,8 +1986,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
@@ -1994,7 +2003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2005,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2013,7 +2022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2026,7 +2035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2035,7 +2044,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2043,28 +2052,46 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-media",
|
||||
"objc2-core-video",
|
||||
"objc2-video-toolbox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2078,7 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2088,7 +2115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2108,7 +2135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2117,7 +2144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2135,14 +2162,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
@@ -2153,7 +2180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2161,7 +2188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2174,7 +2201,9 @@ dependencies = [
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-gpu",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-media",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-scopes",
|
||||
@@ -2192,7 +2221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2201,7 +2230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3771,7 +3800,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
@@ -3812,6 +3843,37 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-media"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-video",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3984,6 +4046,23 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-video-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05bf9a3c14831a7d9641b0d81d87dd913ee238a012b2fde27db5a84b56f5df3e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-media",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -7132,7 +7211,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.2.1"
|
||||
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" }
|
||||
@@ -50,6 +51,7 @@ filmcraft-format = { path = "crates/format" }
|
||||
filmcraft-engine = { path = "crates/engine" }
|
||||
filmcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
filmcraft-automation = { path = "crates/automation" }
|
||||
filmcraft-platform = { path = "crates/platform" }
|
||||
filmcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# foundation
|
||||
@@ -68,7 +70,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>
|
||||
@@ -184,7 +184,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
|
||||
MXF and hardware decode are next ([roadmap](ROADMAP.md)).
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), with Linux and Windows to follow ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -264,11 +266,26 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
|
||||
| [docs/control-protocol.md](docs/control-protocol.md) | Control-channel and MCP reference |
|
||||
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
|
||||
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
|
||||
| [ROADMAP.md](ROADMAP.md) | Milestones and estimates |
|
||||
| [ROADMAP.md](ROADMAP.md) | Honest assessment, what's missing, milestones and estimates |
|
||||
|
||||
## Status
|
||||
|
||||
FilmCraft is young and moving fast: roughly 45% of the way to Premiere Pro parity, with editing, trimming, colour, keyframes, titles, captions, mixing, codecs and export working today. [ROADMAP.md](ROADMAP.md) tracks every milestone with estimates.
|
||||
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
|
||||
|
||||
We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
|
||||
- **Feature checklist: ~87%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft.
|
||||
- **Ready for real work: ~50–60%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS; Windows and Linux have no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys, export and encoding still run on the CPU ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only delivery-codec export; no HEVC or AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
- **AI features.** Few so far; speech to text is optional and off by default.
|
||||
|
||||
Bug reports with real footage are the most useful thing you can send us: [open an issue](https://github.com/storytold/filmcraft/issues) or tell us in [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -328,18 +345,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">
|
||||
|
||||
+101
-8
@@ -1,10 +1,88 @@
|
||||
# FilmCraft Roadmap
|
||||
|
||||
Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
|
||||
Progress toward 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-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests
|
||||
|
||||
## Parity scorecard
|
||||
Two numbers, because they answer different questions. The **feature checklist** counts whether
|
||||
Premiere's menu items, effects, panels and formats exist in FilmCraft. **Ready for real work** is
|
||||
our honest estimate of how close FilmCraft is to replacing Premiere for an editor on real
|
||||
projects: depth, correctness, robustness, speed and the pro ecosystem. The gap between the two
|
||||
is the work that matters most now. Agents: read [Where we are lacking](#where-we-are-lacking)
|
||||
before choosing work.
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
### How the checklist number is made, and what it doesn't tell you
|
||||
|
||||
- **Partly measured.** The menu figure comes from diffing a dump of Premiere 26.5's menus against
|
||||
`filmcraft-cli commands`. The effects, transitions and audio-effects counts are checked by unit
|
||||
tests against Premiere's Effects panel tree (`premiere_26_video_effects_catalogue`,
|
||||
`premiere_audio_set_is_complete_and_foldered`, `premiere_26_transition_tree`).
|
||||
- **Partly agent-reported.** The other areas' percentages are written by the agents doing the work.
|
||||
No automated parity check runs in CI (PhotoCraft has `cargo xtask parity`; we don't yet), so
|
||||
nothing stops a number drifting upward.
|
||||
- **Presence, not quality.** An item counts once it exists. An effect described as an
|
||||
"approximation" counts the same as an exact one. Nothing compares our rendering with
|
||||
Premiere's.
|
||||
- **Weights understate what users hit first.** Performance carries 5%. Plugins and AI features
|
||||
aren't in the scorecard at all.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Checklist says | Honest estimate | Evidence |
|
||||
|---|---|---|---|
|
||||
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS only (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software). The GPU does compositing, blend modes (#32) and 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. Lumetri, keys, masks, the remaining effects, export and encoding are CPU. Linux and Windows have no hardware path. 8K and AV1 aren't real-time. The effect list shows Premiere's "GPU accelerated" badge, but only 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
| **Platforms** | not tracked | **macOS solid; Windows and Linux thin** | Development and testing happen on macOS. Windows and Linux are built for releases but barely exercised at runtime. The web build is a compile target; the browser app shell isn't finished. |
|
||||
| **UI fidelity** | ~85% | **~80%** | Agents compare with Premiere reference screenshots; this hasn't been checked independently. |
|
||||
|
||||
### Where we are lacking
|
||||
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API) and Windows (Media Foundation);
|
||||
- zero-copy decoded frames into wgpu;
|
||||
- the remaining effects on the GPU: Lumetri, keys, Vignette, Video Limiter, masks (31 common effects are done);
|
||||
- GPU export, then hardware encode (H.264 / HEVC).
|
||||
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
|
||||
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
|
||||
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
|
||||
3. **Real-world media corpus.** Phone, camera and screen recordings, variable frame rate, damaged
|
||||
files, long GOPs, odd containers. Each gets an import → edit → export round-trip test with an
|
||||
ffprobe / ffmpeg oracle.
|
||||
4. **Workflow acceptance tests.** Scripted MCP sessions that do real jobs end to end: assemble and
|
||||
trim a scene, colour it, mix and loudness-normalise it, caption it, deliver it, round-trip
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC and AV1 export: hardware encoders first, and `rav1e` (BSD-2) for AV1.
|
||||
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
|
||||
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
|
||||
8. **AI features** with openly licensed local models: transcription on by default, text-based
|
||||
editing, speech enhancement, reframing.
|
||||
9. **The web app shell**: file access, WebCodecs, audio.
|
||||
|
||||
### Where we're going
|
||||
|
||||
**Next:** close items 1–5. Hardware acceleration fixes the gap users notice first (CPU-bound 4K playback and export). The
|
||||
parity tool, media corpus and workflow tests make the progress number trustworthy. Windows and
|
||||
Linux CI makes "runs everywhere" true.
|
||||
|
||||
**After that:** delivery codecs, plugin hosting (pending the owner's decision) and AI features,
|
||||
measured with the same tools.
|
||||
|
||||
**Goal:** "ready for real work" at 85%+, measured rather than estimated, on all three desktop
|
||||
platforms.
|
||||
|
||||
## Feature checklist scorecard
|
||||
|
||||
Measured against Premiere Pro 26.5 on this machine. Menu items: the native menu bar dump, minus
|
||||
Adobe-cloud-only items (Team Projects, Productions, Firefly, Stock, Dynamic Link, account/help pages),
|
||||
@@ -20,13 +98,16 @@ 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%** |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Windows/Linux (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
|
||||
This estimate is for the **feature checklist**. Closing the honest-assessment gaps (above) is the
|
||||
"Robust on real-world material" row, plus plugins and AI features, which it doesn't include.
|
||||
|
||||
Measured throughput in the last work block (2026-10-01 night → 10-02): five Opus 5.5 agents in
|
||||
parallel for ~4.5 wall-clock hours (~18 agent-hours including integration) moved parity by ~4 points
|
||||
(≈4–5 agent-hours per point), on a machine that was heavily overloaded (load 150–300 on 14 cores) and
|
||||
@@ -62,7 +143,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
|
||||
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 |
|
||||
| M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 |
|
||||
| M5 | Effects, keyframes, GPU | 🟡 | ~60 CPU effects, 30 transitions, keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Full ~150-effect catalogue, WGSL parity for all effects and masks in the live GPU path, Warp Stabilizer, Morph Cut | 20–30 |
|
||||
| M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 31 common effects on the GPU (#32, GPU2), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Effects on the GPU (in progress, #30), GPU masks in the live path, GPU export, exact Warp Stabilizer (3-D) and Morph Cut | 20–30 |
|
||||
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |
|
||||
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
@@ -73,16 +154,24 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Running now
|
||||
|
||||
- Nothing; next: remaining Premiere menu items (Settings categories, Scene Edit Detection, Normalize Mix Track, OMF/AAF, Find), AV1 single-thread re-measure on an idle machine
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 31 common effects on the GPU (GPU2). Next: VA-API (Linux) and Media Foundation (Windows) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
|
||||
|
||||
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -113,3 +202,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
- **2026-09-30 (midday):** HEVC decoder, Matroska/WebM demuxer and audio DSP merged; HEVC import wired; asset rules (AGENTS.md, ATTRIBUTION.md, `cargo xtask assets`); README with hero screenshot and the Craft family.
|
||||
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
|
||||
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
@@ -21,6 +21,7 @@ whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
|
||||
filmcraft-engine = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-codecs = { workspace = true }
|
||||
filmcraft-platform = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-mpegts = { workspace = true }
|
||||
filmcraft-ogg = { workspace = true }
|
||||
|
||||
@@ -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",
|
||||
@@ -153,6 +155,8 @@ async fn main() {
|
||||
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own, as in the desktop app.
|
||||
let _ = filmcraft_platform::register();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
@@ -339,7 +343,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 +360,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 +370,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>
|
||||
|
||||
@@ -18,6 +18,7 @@ whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"]
|
||||
[dependencies]
|
||||
filmcraft-engine = { workspace = true }
|
||||
filmcraft-ui-egui = { workspace = true }
|
||||
filmcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The desktop app's command line (`filmcraft --help`).
|
||||
|
||||
/// `filmcraft --help`.
|
||||
pub const USAGE: &str = "\
|
||||
Usage: filmcraft [options] [project.fcproj | media files...]
|
||||
|
||||
Options:
|
||||
--control <port> start the localhost JSON-lines control server
|
||||
(also: FILMCRAFT_CONTROL_PORT)
|
||||
--demo open the demo project
|
||||
--empty start with an empty project
|
||||
--recover recover the newest unsaved changes without asking
|
||||
--no-recover start without asking about unsaved changes
|
||||
--data-dir <dir> folder for auto-save, crash recovery and logs
|
||||
(also: FILMCRAFT_DATA_DIR)
|
||||
--version print the version and exit
|
||||
-h, --help print this help and exit
|
||||
";
|
||||
|
||||
/// What the command line asks for.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Cli {
|
||||
Run(Launch),
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
|
||||
/// Options of a normal start.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Launch {
|
||||
pub control_port: Option<u16>,
|
||||
/// A project and / or media files to open.
|
||||
pub files: Vec<String>,
|
||||
pub demo: bool,
|
||||
/// `--demo` / `--empty` given: skip Settings ▸ General ▸ At Startup.
|
||||
pub startup_flag: bool,
|
||||
pub recover: Option<bool>,
|
||||
pub data_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name. `env_control_port` is the value of
|
||||
/// `FILMCRAFT_CONTROL_PORT`, used when `--control` is not given (an empty value counts as unset).
|
||||
///
|
||||
/// An option we don't know, or one without a usable value, is an error: opening a window for a
|
||||
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
|
||||
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
||||
/// too, and for a port from the environment exactly as for one from `--control`.
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||
let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
||||
let (mut help, mut version) = (false, false);
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"-h" | "--help" => help = true,
|
||||
"--version" => version = true,
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port number")?;
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
|
||||
}
|
||||
"--demo" => (l.demo, l.startup_flag) = (true, true),
|
||||
"--empty" => (l.demo, l.startup_flag) = (false, true),
|
||||
"--recover" => l.recover = Some(true),
|
||||
"--no-recover" => l.recover = Some(false),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
// everything after `--` is a file, whatever it looks like
|
||||
"--" => l.files.extend(args.by_ref()),
|
||||
// macOS adds a process serial number when it launches an app bundle
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
|
||||
_ => l.files.push(a),
|
||||
}
|
||||
}
|
||||
if help {
|
||||
return Ok(Cli::Help);
|
||||
}
|
||||
if version {
|
||||
return Ok(Cli::Version);
|
||||
}
|
||||
if l.control_port.is_none()
|
||||
&& let Some(port) = env_control_port.as_deref().map(str::trim).filter(|p| !p.is_empty())
|
||||
{
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("FILMCRAFT_CONTROL_PORT needs a port number, got `{port}`"))?);
|
||||
}
|
||||
Ok(Cli::Run(l))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
|
||||
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
|
||||
}
|
||||
|
||||
fn parse_args(args: &[&str]) -> Result<Cli, String> {
|
||||
parse_env(None, args)
|
||||
}
|
||||
|
||||
fn launch(args: &[&str]) -> Launch {
|
||||
match parse_args(args) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
other => panic!("{args:?} should start the app, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `filmcraft --help` used to open the app with "--help" as a media file to import.
|
||||
#[test]
|
||||
fn help_and_version_do_not_start_the_app() {
|
||||
assert_eq!(parse_args(&["--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["-h"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["--empty", "--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["--version"]), Ok(Cli::Version));
|
||||
assert_eq!(parse_args(&["--version", "--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_args(&["a.mp4", "--version"]), Ok(Cli::Version));
|
||||
for flag in ["--control", "--demo", "--empty", "--recover", "--no-recover", "--data-dir", "--version", "--help"] {
|
||||
assert!(USAGE.contains(flag), "usage lists {flag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_errors() {
|
||||
assert_eq!(parse_args(&["--frobnicate"]), Err("unknown option `--frobnicate`".to_string()));
|
||||
assert!(parse_args(&["a.mp4", "-x"]).is_err());
|
||||
assert!(parse_args(&["--Help"]).is_err());
|
||||
assert!(parse_args(&["--control"]).is_err(), "missing port");
|
||||
assert!(parse_args(&["--control", "abc"]).unwrap_err().contains("abc"));
|
||||
assert!(parse_args(&["--control", "70000"]).is_err(), "not a port");
|
||||
assert!(parse_args(&["--data-dir"]).is_err(), "missing folder");
|
||||
// the whole line is checked, whatever comes first
|
||||
assert_eq!(parse_args(&["--version", "--bogus"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert_eq!(parse_args(&["--bogus", "--version"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert_eq!(parse_args(&["--help", "--bogus"]), Err("unknown option `--bogus`".to_string()));
|
||||
assert!(parse_args(&["--help", "--control"]).is_err());
|
||||
}
|
||||
|
||||
/// A bad `FILMCRAFT_CONTROL_PORT` used to start the app without the control server, silently.
|
||||
#[test]
|
||||
fn a_bad_port_in_the_environment_is_an_error_like_a_bad_control_flag() {
|
||||
for bad in ["abc", "70000", "-1", "98 76", "9876x", "\u{fffd}"] {
|
||||
let e = parse_env(Some(bad), &[]).unwrap_err();
|
||||
assert!(e.contains("FILMCRAFT_CONTROL_PORT") && e.contains(bad), "{bad}: {e}");
|
||||
assert!(parse_env(Some(bad), &["--empty", "a.mp4"]).is_err(), "{bad}");
|
||||
// the flag wins, so the environment's value is not looked at; nor for --help / --version
|
||||
assert_eq!(parse_env(Some(bad), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true), "{bad}");
|
||||
assert_eq!(parse_env(Some(bad), &["--help"]), Ok(Cli::Help));
|
||||
assert_eq!(parse_env(Some(bad), &["--version"]), Ok(Cli::Version));
|
||||
}
|
||||
// unset or empty: no control server
|
||||
assert_eq!(launch(&[]).control_port, None);
|
||||
assert_eq!(parse_env(Some(""), &[]), Ok(Cli::Run(launch(&[]))));
|
||||
assert_eq!(parse_env(Some(" "), &[]), Ok(Cli::Run(launch(&[]))));
|
||||
// a good one is the default
|
||||
assert_eq!(parse_env(Some("1234"), &[]), Ok(Cli::Run(Launch { control_port: Some(1234), ..launch(&[]) })));
|
||||
assert_eq!(parse_env(Some("1234"), &["--control", "9"]).map(|c| matches!(c, Cli::Run(l) if l.control_port == Some(9))), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_options_and_files_parse_as_before() {
|
||||
let none = launch(&[]);
|
||||
assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None });
|
||||
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
|
||||
assert_eq!(l.control_port, Some(9876));
|
||||
assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false)));
|
||||
assert_eq!(l.data_dir, Some(std::path::PathBuf::from("/tmp/fc")));
|
||||
assert_eq!(l.files, ["cut.fcproj", "a.mp4"]);
|
||||
assert_eq!((launch(&["--demo"]).demo, launch(&["--demo"]).startup_flag, launch(&["--recover"]).recover), (true, true, Some(true)));
|
||||
// `--` ends the options; a lone `-` and macOS' process serial number are not options
|
||||
assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]);
|
||||
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
|
||||
}
|
||||
}
|
||||
+80
-28
@@ -1,21 +1,26 @@
|
||||
//! FilmCraft desktop app.
|
||||
//!
|
||||
//! Usage: `filmcraft [--control <port>] [--demo|--empty] [--recover|--no-recover] [--data-dir <dir>]
|
||||
//! [project.fcproj | media files…]`
|
||||
//! [project.fcproj | media files…]`; `--help` prints the options, `--version` the version. An
|
||||
//! unknown option is an error (exit code 2), not a file to open.
|
||||
//!
|
||||
//! Without a project, `--demo` or `--empty`, Settings ▸ General ▸ At Startup decides: Show Home
|
||||
//! (the demo project), Open Most Recent, or an empty project.
|
||||
//!
|
||||
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `filmcraft_ui_egui::control` for the methods.
|
||||
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
|
||||
//! either source.
|
||||
//!
|
||||
//! Auto-save and the crash-recovery journal run in every session (data in `--data-dir`, else
|
||||
//! `FILMCRAFT_DATA_DIR`, else the per-user application data folder). If a previous session died
|
||||
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
@@ -23,6 +28,7 @@ mod control_server;
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
|
||||
use args::{Cli, Launch};
|
||||
use chrono::TimeZone;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
|
||||
@@ -43,31 +49,47 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
app_nap::disable();
|
||||
let mut control_port: Option<u16> = std::env::var("FILMCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
// --demo / --empty given: skip Settings ▸ General ▸ At Startup
|
||||
let mut startup_flag = false;
|
||||
let mut recover: Option<bool> = None;
|
||||
let mut data_dir = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => (demo, startup_flag) = (true, true),
|
||||
"--empty" => (demo, startup_flag) = (false, true),
|
||||
"--recover" => recover = Some(true),
|
||||
"--no-recover" => recover = Some(false),
|
||||
"--data-dir" => data_dir = args.next().map(std::path::PathBuf::from),
|
||||
"--version" => {
|
||||
println!("filmcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
/// Print `text` (stdout, or stderr for an error). A Windows release build has no console
|
||||
/// (`windows_subsystem = "windows"`), so there it is also shown in a message box: otherwise `--help`
|
||||
/// would print nothing and a bad option would quit without a word.
|
||||
fn tell(text: &str, error: bool) {
|
||||
if error {
|
||||
eprint!("{text}");
|
||||
} else {
|
||||
print!("{text}");
|
||||
}
|
||||
#[cfg(all(target_os = "windows", not(debug_assertions)))]
|
||||
{
|
||||
let level = if error { rfd::MessageLevel::Error } else { rfd::MessageLevel::Info };
|
||||
let _ = rfd::MessageDialog::new().set_level(level).set_title("FilmCraft").set_description(text).show();
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
Ok(Cli::Help) => {
|
||||
tell(args::USAGE, false);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Cli::Version) => {
|
||||
tell(&format!("filmcraft {}\n", env!("CARGO_PKG_VERSION")), false);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
tell(&format!("filmcraft: {e}\n\n{}", args::USAGE), true);
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
app_nap::disable();
|
||||
// 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")));
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||
register_hardware_decoders();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
@@ -85,7 +107,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 +200,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,
|
||||
@@ -208,6 +239,15 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
||||
cmd.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Put the OS hardware video decoders in front of our own. Registered in a statement of its own:
|
||||
/// a log macro does not evaluate its arguments while no logger takes its level, which left the
|
||||
/// hardware decoders out of every run.
|
||||
fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
let hardware = filmcraft_platform::register();
|
||||
log::info!("hardware decoding: {hardware:?}");
|
||||
hardware
|
||||
}
|
||||
|
||||
/// Local UTC offset at a unix time (auto-save file names and recovery times use local time).
|
||||
fn local_offset(unix: i64) -> i32 {
|
||||
chrono::Local.timestamp_opt(unix, 0).single().map(|d| d.offset().local_minus_utc()).unwrap_or(0)
|
||||
@@ -226,3 +266,15 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
let _ = agent;
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
|
||||
/// installed here, as in a normal run).
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(target_os = "macos"), "{hardware:?}");
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,12 @@ pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
|
||||
let items = menu_items(app);
|
||||
let mut native: Vec<(String, MenuItem)> = Vec::new();
|
||||
let mut titles: Vec<(String, Submenu)> = Vec::new();
|
||||
let bar = Menu::new();
|
||||
let app_menu = Submenu::new("FilmCraft", true);
|
||||
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
|
||||
let settings = Submenu::new("Settings", true);
|
||||
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
|
||||
titles.push(("Settings".into(), settings.clone()));
|
||||
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
|
||||
let label = it.label.trim_end_matches('…').to_string();
|
||||
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
|
||||
@@ -48,7 +50,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
let sub = Submenu::new(top, true);
|
||||
let sub = Submenu::new(app.ui.language.tr(top), true);
|
||||
titles.push((top.into(), sub.clone()));
|
||||
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top) && !i.id.starts_with("app.settings.")).collect();
|
||||
// submenus by path prefix (e.g. Clip ▸ Video Options ▸ Time Interpolation), created where
|
||||
// their first item appears
|
||||
@@ -62,7 +65,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
parent = match subs.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, s)) => s.clone(),
|
||||
None => {
|
||||
let s = Submenu::new(&it.path[depth], true);
|
||||
let s = Submenu::new(app.ui.language.tr(&it.path[depth]), true);
|
||||
titles.push((it.path[depth].clone(), s.clone()));
|
||||
let _ = parent.append(&s);
|
||||
subs.push((key, s.clone()));
|
||||
s
|
||||
@@ -88,8 +92,17 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
}
|
||||
});
|
||||
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
|
||||
let language = if items.iter().any(|it| it.id == "app.language.japanese" && it.checked == Some(true)) {
|
||||
filmcraft_ui_egui::i18n::Language::Ja
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
for (id, mi) in &native {
|
||||
if let Some(it) = items.iter().find(|i| &i.id == id) {
|
||||
mi.set_text(&it.label);
|
||||
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +8,10 @@
|
||||
//! (`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 long_job;
|
||||
pub mod server;
|
||||
|
||||
pub use bridge::BridgeClient;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Long exports over MCP (docs/agents.md § Long exports): `file.exportMedia` with `wait: true`
|
||||
//! runs as a background engine job; the server reports its progress as MCP
|
||||
//! `notifications/progress` (when the request carried a `progressToken`) and stops it on
|
||||
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
|
||||
//! short polls, so other requests are answered while the export runs.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use rmcp::RoleServer;
|
||||
use rmcp::model::{CallToolResult, ContentBlock as Content, ProgressNotificationParam, ProgressToken};
|
||||
use rmcp::service::{Peer, RequestContext};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::server::FilmcraftMcp;
|
||||
|
||||
/// Engine commands that block until a render is written when called with `wait: true`.
|
||||
pub const LONG_COMMANDS: &[&str] = &["file.exportMedia"];
|
||||
|
||||
/// How often the job is polled (and at most how often progress is reported).
|
||||
const POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Whether `command_run {id, params}` is a long call this module runs.
|
||||
pub fn is_long(id: &str, params: &Value) -> bool {
|
||||
LONG_COMMANDS.contains(&id) && params.get("wait").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Sends `notifications/progress` for one request; progress only ever increases.
|
||||
struct Reporter {
|
||||
peer: Peer<RoleServer>,
|
||||
token: Option<ProgressToken>,
|
||||
last: f64,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
async fn report(&mut self, done: f64, total: f64, message: &str) {
|
||||
let Some(token) = self.token.clone() else { return };
|
||||
if total <= 0.0 || done <= self.last {
|
||||
return;
|
||||
}
|
||||
self.last = done;
|
||||
let mut p = ProgressNotificationParam::new(token, done).with_total(total);
|
||||
if !message.is_empty() {
|
||||
p = p.with_message(message);
|
||||
}
|
||||
let _ = self.peer.notify_progress(p).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl FilmcraftMcp {
|
||||
/// Run `id` (a [`LONG_COMMANDS`] entry, `wait: true`) as a job, reporting progress and
|
||||
/// honouring cancellation.
|
||||
pub(crate) async fn run_long(&self, id: &str, mut params: Value, context: &RequestContext<RoleServer>) -> CallToolResult {
|
||||
let started = SystemTime::now();
|
||||
if let Some(p) = params.as_object_mut() {
|
||||
p.insert("wait".into(), json!(false));
|
||||
}
|
||||
let start = match self.run(id, params).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return CallToolResult::error(vec![Content::text(e.to_string())]),
|
||||
};
|
||||
let Some(job) = start.get("job").and_then(Value::as_u64) else {
|
||||
return CallToolResult::error(vec![Content::text(format!("{id} started no job: {start}"))]);
|
||||
};
|
||||
let path = start.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let mut rep = Reporter { peer: context.peer.clone(), token: context.meta.get_progress_token(), last: 0.0 };
|
||||
loop {
|
||||
let state = self.job_state(job).await;
|
||||
let finished = state.as_ref().is_none_or(|j| j["finished"].as_bool() == Some(true));
|
||||
if let Some(j) = &state {
|
||||
let (done, total) = (j["done"].as_f64().unwrap_or(0.0), j["total"].as_f64().unwrap_or(0.0));
|
||||
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
|
||||
}
|
||||
if finished {
|
||||
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
|
||||
}
|
||||
let mut out = start;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
}
|
||||
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
|
||||
}
|
||||
tokio::select! {
|
||||
_ = context.ct.cancelled() => break,
|
||||
_ = tokio::time::sleep(POLL) => {}
|
||||
}
|
||||
}
|
||||
// Cancelled: stop the encode at the next batch, wait for the worker, delete the partial file.
|
||||
let _ = self.run("jobs.cancel", json!({"job": job})).await;
|
||||
for _ in 0..600 {
|
||||
if self.job_state(job).await.is_none_or(|j| j["finished"].as_bool() == Some(true)) {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(POLL).await;
|
||||
}
|
||||
remove_partial(&path, started);
|
||||
CallToolResult::error(vec![Content::text("cancelled")])
|
||||
}
|
||||
|
||||
/// The job's `jobs.list` entry.
|
||||
async fn job_state(&self, job: u64) -> Option<Value> {
|
||||
let jobs = self.run("jobs.list", json!({})).await.ok()?;
|
||||
jobs.as_array()?.iter().find(|j| j["id"].as_u64() == Some(job)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
|
||||
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, …) are never touched, even when they
|
||||
/// were saved during the export.
|
||||
pub fn remove_partial(path: &str, since: SystemTime) {
|
||||
let p = Path::new(path);
|
||||
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
|
||||
return;
|
||||
};
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
// file times are coarser than the clock
|
||||
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if is_export_output(name, file, stem, ext) && e.metadata().is_ok_and(|m| m.is_file() && m.modified().is_ok_and(|t| t >= since)) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is a file an export to `file` (`stem` + `.ext`) writes.
|
||||
fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
||||
if name == file {
|
||||
return true;
|
||||
}
|
||||
let Some(rest) = name.strip_prefix(stem) else { return false };
|
||||
if let Some(sidecar) = rest.strip_prefix('.') {
|
||||
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
|
||||
}
|
||||
// image sequence: one or more digits, then the output's own extension
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_exports_own_files_are_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
let ours = ["trailer.png", "trailer000.png", "trailer123.png", "trailer.srt", "trailer.vtt"];
|
||||
// same stem, written during the export, but not the export's
|
||||
let theirs = ["trailer.aep", "trailer2.psd", "trailer.docx", "trailer000.jpg", "trailers.png", "trailer.png.bak", "other.png"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("trailer.png").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_older_than_the_export_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("clip.mp4"), b"x").unwrap();
|
||||
remove_partial(&dir.join("clip.mp4").to_string_lossy(), SystemTime::now() + Duration::from_secs(60));
|
||||
assert!(dir.join("clip.mp4").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+546
-28
@@ -1,6 +1,6 @@
|
||||
//! The MCP server. Tool names are snake_case.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use base64::Engine as _;
|
||||
use filmcraft_engine::Session;
|
||||
@@ -19,6 +19,19 @@ pub enum Backend {
|
||||
Bridge(Arc<BridgeClient>),
|
||||
}
|
||||
|
||||
/// A panic payload as text.
|
||||
fn panic_message(p: Box<dyn std::any::Any + Send>) -> String {
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// A failed blocking task: a panic is reported as an internal error and the session stays usable.
|
||||
fn join_error(e: tokio::task::JoinError) -> AutomationError {
|
||||
match e.try_into_panic() {
|
||||
Ok(p) => AutomationError::Other(format!("internal error: {}", panic_message(p))),
|
||||
Err(e) => AutomationError::Other(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FilmcraftMcp {
|
||||
backend: Arc<Backend>,
|
||||
@@ -44,6 +57,24 @@ pub struct RunParams {
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchStep {
|
||||
/// Command id.
|
||||
pub id: String,
|
||||
/// Parameters object.
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct BatchParams {
|
||||
/// Commands to run in order.
|
||||
pub steps: Vec<BatchStep>,
|
||||
/// Stop at the first failing step (default true).
|
||||
#[serde(default)]
|
||||
pub stop_on_error: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct RenderParams {
|
||||
/// Timeline time in seconds (default: the playhead).
|
||||
@@ -148,9 +179,60 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
pub async fn serve_stdio(self) -> Result<(), AutomationError> {
|
||||
let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
Ok(())
|
||||
self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await
|
||||
}
|
||||
|
||||
/// Serve MCP over any line stream. A line that isn't JSON gets a JSON-RPC parse error
|
||||
/// (`-32700`, id null) instead of being dropped silently; the rest go to the rmcp service. One
|
||||
/// writer task owns `output`, so replies never interleave.
|
||||
pub async fn serve_io(
|
||||
self,
|
||||
input: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
output: impl tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
) -> Result<(), AutomationError> {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
let (mut to_service, service_in) = tokio::io::duplex(1 << 20);
|
||||
let (service_out, from_service) = tokio::io::duplex(1 << 20);
|
||||
let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let reader = tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<Value>(&line) {
|
||||
Ok(_) => {
|
||||
if to_service.write_all(format!("{line}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let reply = json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32700, "message": format!("parse error: {e}")}});
|
||||
let _ = err_tx.send(reply.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF: closing the pipe ends the service
|
||||
let _ = to_service.shutdown().await;
|
||||
});
|
||||
let writer = tokio::spawn(async move {
|
||||
let mut output = output;
|
||||
let mut replies = BufReader::new(from_service).lines();
|
||||
loop {
|
||||
let line = tokio::select! {
|
||||
l = replies.next_line() => match l { Ok(Some(l)) => l, _ => break },
|
||||
Some(e) = err_rx.recv() => e,
|
||||
};
|
||||
if output.write_all(format!("{line}\n").as_bytes()).await.is_err() || output.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let running = self.serve((service_in, service_out)).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?;
|
||||
let r = running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()));
|
||||
reader.abort();
|
||||
let _ = writer.await;
|
||||
r.map(|_| ())
|
||||
}
|
||||
|
||||
/// Run an engine command on whichever backend.
|
||||
@@ -160,16 +242,24 @@ impl FilmcraftMcp {
|
||||
let s = s.clone();
|
||||
let id = id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut g = s.lock().map_err(|_| AutomationError::Other("session lock poisoned".into()))?;
|
||||
// A command that panicked earlier poisoned the lock; the session is still usable.
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.execute(&id, params).map_err(AutomationError::from)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AutomationError::Other(e.to_string()))?
|
||||
.map_err(join_error)?
|
||||
}
|
||||
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The project tree plus the active sequence (null when there is none).
|
||||
async fn document(&self) -> Result<Value, AutomationError> {
|
||||
let project = self.run("project.inspect", json!({})).await?;
|
||||
let sequence = self.run("sequence.inspect", json!({})).await.ok();
|
||||
Ok(json!({"project": project, "sequence": sequence}))
|
||||
}
|
||||
|
||||
fn bridge_client(&self) -> Option<Arc<BridgeClient>> {
|
||||
match &*self.backend {
|
||||
Backend::Bridge(b) => Some(b.clone()),
|
||||
@@ -190,7 +280,9 @@ impl FilmcraftMcp {
|
||||
#[tool_router]
|
||||
impl FilmcraftMcp {
|
||||
#[tool(
|
||||
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids."
|
||||
title = "List commands",
|
||||
description = "List every command (id, label, menu path, shortcut, parameter doc, enabled now). Menus, shortcuts, panels and timeline gestures all map to these ids.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
|
||||
let r = self.run("command.list", json!({})).await.map(|v| {
|
||||
@@ -213,38 +305,99 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History."
|
||||
title = "Run a command",
|
||||
description = "Run a command by id with JSON params, e.g. {\"id\":\"timeline.razor\",\"params\":{\"seconds\":3.5}} or {\"id\":\"effects.apply\",\"params\":{\"effect\":\"Gaussian Blur\"}}. Undoable edits land in History.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run(&p.id, p.params.unwrap_or(json!({}))).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.")]
|
||||
#[tool(
|
||||
title = "Run several commands",
|
||||
description = "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}; each edit is its own undo step.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
|
||||
let stop = p.stop_on_error.unwrap_or(true);
|
||||
let (mut completed, mut failed, mut results) = (0u32, 0u32, Vec::new());
|
||||
for st in p.steps {
|
||||
match self.run(&st.id, st.params.unwrap_or(json!({}))).await {
|
||||
Ok(v) => {
|
||||
completed += 1;
|
||||
results.push(json!({"ok": true, "result": v}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(json!({"ok": false, "error": e.to_string()}));
|
||||
if stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = json!({"completed": completed, "failed": failed, "results": results});
|
||||
Ok(if failed > 0 { CallToolResult::error(vec![Content::text(v.to_string())]) } else { ok_json(&v) })
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Inspect the document",
|
||||
description = "The project as JSON: the tree (as project_inspect) plus the active sequence (as sequence_inspect; null when there is none).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn doc_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.document().await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Render a preview",
|
||||
description = "The active sequence's frame at `seconds` (default: the playhead) as PNG, same as render_frame (bridge mode: Program monitor screenshot).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn render_preview(&self, p: Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
|
||||
self.render_frame(p).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
title = "Inspect the project tree",
|
||||
description = "Project tree (bins and items with ids, types, durations), active sequence, source clip.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn project_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("project.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second."
|
||||
title = "Inspect the active sequence",
|
||||
description = "The active sequence as JSON: settings, tracks, clips (ids, start/duration in ticks and frames, effects), transitions, markers, in/out, playhead, selection. 254016000000 ticks = 1 second.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn sequence_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("sequence.inspect", json!({})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).")]
|
||||
#[tool(
|
||||
title = "Import media",
|
||||
description = "Import media files by absolute path (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).",
|
||||
annotations(read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn media_import(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
let paths: Vec<&str> = p.text.split('\n').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
wrap(self.run("file.import", json!({"paths": paths})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.")]
|
||||
#[tool(
|
||||
title = "Render a frame",
|
||||
description = "Render the active sequence's frame (at `seconds` or the playhead) and return it as PNG.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn render_frame(&self, Parameters(p): Parameters<RenderParams>) -> Result<CallToolResult, McpError> {
|
||||
let max = p.max_side.unwrap_or(960);
|
||||
match &*self.backend {
|
||||
Backend::Headless(s) => {
|
||||
let s = s.clone();
|
||||
let r = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AutomationError> {
|
||||
let mut g = s.lock().map_err(|_| AutomationError::Other("lock".into()))?;
|
||||
let mut g = s.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(sec) = p.seconds {
|
||||
g.set_playhead(filmcraft_time::Tick::from_seconds_f64(sec));
|
||||
}
|
||||
@@ -255,7 +408,7 @@ impl FilmcraftMcp {
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
.unwrap_or_else(|e| Err(join_error(e)));
|
||||
Ok(match r {
|
||||
Ok(png) => png_result(&png, "rendered frame"),
|
||||
Err(e) => fail(e),
|
||||
@@ -272,17 +425,29 @@ impl FilmcraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).")]
|
||||
#[tool(
|
||||
title = "Live app: UI state",
|
||||
description = "Live app: UI state (tool, workspace, panels, timeline zoom, playback, selection, fps).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.inspect", json!({})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.")]
|
||||
#[tool(
|
||||
title = "Live app: on-screen elements",
|
||||
description = "Live app: every on-screen interactive element with id, label and rect (points). Use ids with ui_click/ui_drag.",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_elements(&self, Parameters(p): Parameters<ElementsParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.elements", json!({"prefix": p.prefix.unwrap_or_default()})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.")]
|
||||
#[tool(
|
||||
title = "Live app: click",
|
||||
description = "Live app: click an element by id or at x,y (points). Supports right/middle button, double-click (count=2) and modifiers.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_click(&self, Parameters(p): Parameters<ClickParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut v = json!({"button": p.button, "count": p.count, "modifiers": p.modifiers});
|
||||
if let Some(id) = p.id {
|
||||
@@ -295,23 +460,37 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks)."
|
||||
title = "Live app: drag",
|
||||
description = "Live app: press-drag-release from one point/element to another (move clips, trim edges, scrub, resize panels, drop project items onto tracks).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_drag(&self, Parameters(p): Parameters<DragParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.drag", json!({"from": p.from, "to": p.to, "steps": p.steps, "modifiers": p.modifiers})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).")]
|
||||
#[tool(
|
||||
title = "Live app: press a key",
|
||||
description = "Live app: press a key or shortcut (e.g. `Space`, `L`, `Cmd+K`, `Shift+Delete`, `Cmd+Z`).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_key(&self, Parameters(p): Parameters<KeyParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.key", json!({"key": p.key})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: type text into the focused field.")]
|
||||
#[tool(
|
||||
title = "Live app: type text",
|
||||
description = "Live app: type text into the focused field.",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_type(&self, Parameters(p): Parameters<TypeParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui("ui.type", json!({"text": p.text})).await
|
||||
}
|
||||
|
||||
#[tool(description = "Live app: screenshot of the window or one panel (PNG).")]
|
||||
#[tool(
|
||||
title = "Live app: screenshot",
|
||||
description = "Live app: screenshot of the window or one panel (PNG).",
|
||||
annotations(read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_screenshot(&self, Parameters(p): Parameters<ShotParams>) -> Result<CallToolResult, McpError> {
|
||||
match self.bridge_client() {
|
||||
Some(b) => self.screenshot(&b, p.panel, p.max_side.unwrap_or(1600)).await,
|
||||
@@ -320,7 +499,9 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …)."
|
||||
title = "Live app: control-channel call",
|
||||
description = "Live app: call any control-channel method directly (ui.set, ui.scroll, ui.timeline.hit, ui.timeline.locate, ui.playback, ui.panel.show, ui.menu.list, …).",
|
||||
annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false)
|
||||
)]
|
||||
async fn ui_control(&self, Parameters(p): Parameters<ControlParams>) -> Result<CallToolResult, McpError> {
|
||||
self.ui(&p.method, p.params.unwrap_or(json!({}))).await
|
||||
@@ -350,12 +531,104 @@ impl FilmcraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(
|
||||
router = self.tool_router,
|
||||
name = "filmcraft",
|
||||
instructions = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable). `project_inspect`/`sequence_inspect` return ids you can pass to commands; `render_frame` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`)."
|
||||
)]
|
||||
impl ServerHandler for FilmcraftMcp {}
|
||||
impl FilmcraftMcp {
|
||||
/// The first argument `tool` doesn't take, as a message naming the ones it does.
|
||||
fn unknown_arg(&self, tool: &str, args: Option<&rmcp::model::JsonObject>) -> Option<String> {
|
||||
let def = self.tool_router.get(tool)?;
|
||||
let props = def.input_schema.get("properties").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let bad = args?.keys().find(|k| !props.contains_key(*k))?;
|
||||
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
|
||||
let accepted = if accepted.is_empty() { "no arguments".to_string() } else { accepted.join(", ") };
|
||||
Some(format!("unknown argument \"{bad}\" for {tool}; expected: {accepted}"))
|
||||
}
|
||||
}
|
||||
|
||||
const INSTRUCTIONS: &str = "FilmCraft video editor (Premiere Pro-class). Every edit is an engine command: `command_list` to discover ids/params, `command_run` to execute (undoable; `command_batch` runs several). `doc_inspect` (or `project_inspect`/`sequence_inspect`) returns ids you can pass to commands; `render_preview` shows the result. In bridge mode the `ui_*` tools drive the live app: `ui_elements` lists clickable ids, `ui_click`/`ui_drag`/`ui_key` operate it, `ui_screenshot` shows it. Time is in ticks: 254016000000 per second (commands also accept `seconds`, `frame` or `timecode`).";
|
||||
|
||||
/// Resources: the project (as `doc_inspect`) and the command catalog (as `command_list`).
|
||||
const DOCUMENT_URI: &str = "filmcraft://document";
|
||||
const COMMANDS_URI: &str = "filmcraft://commands";
|
||||
|
||||
/// MCP 2026-07-28 clients (current Claude Code) require `ttlMs` and `cacheScope` on list and read
|
||||
/// results; rmcp fills `resultType` and leaves the cache hints to the server, as the generated
|
||||
/// `tools/list` does. The resource list never changes; the document does, so reads aren't cached.
|
||||
fn cache_hints(context: &rmcp::service::RequestContext<rmcp::RoleServer>, ttl_ms: u64) -> Option<(u64, rmcp::model::CacheScope)> {
|
||||
context.protocol_version().is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28).then_some((ttl_ms, rmcp::model::CacheScope::Private))
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for FilmcraftMcp {
|
||||
fn get_info(&self) -> rmcp::model::ServerConfig {
|
||||
rmcp::model::ServerConfig::new(rmcp::model::ServerCapabilities::builder().enable_tools().enable_resources().build())
|
||||
.with_server_info(rmcp::model::Implementation::new("filmcraft", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(INSTRUCTIONS.to_string())
|
||||
}
|
||||
|
||||
/// Strict arguments and long exports in front of the tool router.
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, McpError> {
|
||||
if let Some(m) = self.unknown_arg(&request.name, request.arguments.as_ref()) {
|
||||
return Err(McpError::invalid_params(m, None));
|
||||
}
|
||||
// A blocking export reports progress and can be cancelled (docs/agents.md § Long exports).
|
||||
if request.name == "command_run"
|
||||
&& matches!(&*self.backend, Backend::Headless(_))
|
||||
&& let Some(a) = &request.arguments
|
||||
&& let Some(id) = a.get("id").and_then(Value::as_str)
|
||||
{
|
||||
let params = a.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
if crate::long_job::is_long(id, ¶ms) {
|
||||
return Ok(self.run_long(id, params, &context).await.into());
|
||||
}
|
||||
}
|
||||
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
|
||||
self.tool_router.call(tcc).await
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::ListResourcesResult, McpError> {
|
||||
use rmcp::model::Resource;
|
||||
let mut r = rmcp::model::ListResourcesResult::with_all_items(vec![
|
||||
Resource::new(DOCUMENT_URI, "document")
|
||||
.with_title("Project")
|
||||
.with_description("The project tree and the active sequence (same as doc_inspect).")
|
||||
.with_mime_type("application/json"),
|
||||
Resource::new(COMMANDS_URI, "commands")
|
||||
.with_title("Command catalog")
|
||||
.with_description("Every command with id, label, menu path, shortcut, params and enabled state (same as command_list).")
|
||||
.with_mime_type("application/json"),
|
||||
]);
|
||||
if let Some((ttl, scope)) = cache_hints(&context, 600_000) {
|
||||
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: rmcp::model::ReadResourceRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::ReadResourceResponse, McpError> {
|
||||
let v = match request.uri.as_str() {
|
||||
DOCUMENT_URI => self.document().await,
|
||||
COMMANDS_URI => self.run("command.list", json!({})).await,
|
||||
other => return Err(McpError::resource_not_found(format!("resource not found: {other}"), None)),
|
||||
}
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let text = serde_json::to_string_pretty(&v).map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
let mut r = rmcp::model::ReadResourceResult::new(vec![rmcp::model::ResourceContents::text(text, request.uri).with_mime_type("application/json")]);
|
||||
if let Some((ttl, scope)) = cache_hints(&context, 0) {
|
||||
r = r.with_ttl_ms(ttl).with_cache_scope(scope);
|
||||
}
|
||||
Ok(r.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -371,4 +644,249 @@ mod tests {
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(1.0), max_side: Some(320) })).await.unwrap();
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
|
||||
/// One request/response exchange over `serve_io`, as an MCP client sees it.
|
||||
struct Client {
|
||||
tx: tokio::io::DuplexStream,
|
||||
rx: tokio::io::Lines<tokio::io::BufReader<tokio::io::DuplexStream>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn start(s: Session) -> Self {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let (tx, server_in) = tokio::io::duplex(1 << 22);
|
||||
let (server_out, rx) = tokio::io::duplex(1 << 22);
|
||||
tokio::spawn(FilmcraftMcp::headless(s).serve_io(server_in, server_out));
|
||||
Self { tx, rx: tokio::io::BufReader::new(rx).lines() }
|
||||
}
|
||||
async fn send(&mut self, line: &str) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
self.tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
}
|
||||
async fn next(&mut self) -> Value {
|
||||
serde_json::from_str(&self.rx.next_line().await.unwrap().unwrap()).unwrap()
|
||||
}
|
||||
async fn ask(&mut self, msg: Value) -> Value {
|
||||
self.send(&msg.to_string()).await;
|
||||
self.next().await
|
||||
}
|
||||
async fn init(&mut self) -> Value {
|
||||
let r = self.ask(json!({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})).await;
|
||||
self.send(&json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string()).await;
|
||||
r
|
||||
}
|
||||
async fn call(&mut self, id: u64, name: &str, arguments: Value) -> Value {
|
||||
self.ask(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":arguments}})).await
|
||||
}
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Every listed tool has a title and the four hints, and the core tools are listed.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tools_are_titled_and_annotated() {
|
||||
let mut c = Client::start(demo());
|
||||
let init = c.init().await;
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft");
|
||||
let tools = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"})).await["result"]["tools"].as_array().cloned().unwrap();
|
||||
for t in &tools {
|
||||
assert!(t["title"].is_string(), "{} has no title", t["name"]);
|
||||
for h in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
|
||||
assert!(t["annotations"][h].is_boolean(), "{} lacks {h}", t["name"]);
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
for want in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "project_inspect", "render_frame", "ui_click"] {
|
||||
assert!(names.contains(&want), "{want} not listed");
|
||||
}
|
||||
let ro = |n: &str| tools.iter().find(|t| t["name"] == n).map(|t| t["annotations"]["readOnlyHint"].clone());
|
||||
assert_eq!((ro("command_list"), ro("command_run")), (Some(json!(true)), Some(json!(false))));
|
||||
}
|
||||
|
||||
/// Strict arguments, malformed JSON, batch, doc_inspect and render_preview over the raw line
|
||||
/// protocol.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn strict_arguments_and_parse_errors() {
|
||||
let mut c = Client::start(demo());
|
||||
c.init().await;
|
||||
let r = c.call(1, "command_list", json!({"filter": "razor", "enabled_only": true})).await;
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
|
||||
// an unknown argument names itself and the accepted ones
|
||||
let r = c.call(2, "command_list", json!({"filtr": "x"})).await;
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
let m = r["error"]["message"].as_str().unwrap();
|
||||
assert!(m.contains("\"filtr\"") && m.contains("filter") && m.contains("enabled_only"), "{m}");
|
||||
let r = c.call(3, "doc_inspect", json!({"verbose": true})).await;
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
// command params stay free-form
|
||||
let r = c.call(4, "command_run", json!({"id": "playhead.set", "params": {"seconds": 0.5}})).await;
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
// malformed JSON: a parse error, and the server keeps serving
|
||||
c.send("{\"jsonrpc\": \"2.0\", \"id\": 5, BROKEN").await;
|
||||
let r = c.next().await;
|
||||
assert_eq!((r["error"]["code"].as_i64(), r["id"].is_null()), (Some(-32700), true), "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":6,"method":"ping"})).await;
|
||||
assert!(r["result"].is_object(), "{r}");
|
||||
// batch: stops at the first failure and reports both
|
||||
let r =
|
||||
c.call(7, "command_batch", json!({"steps": [{"id": "playhead.set", "params": {"seconds": 0.5}}, {"id": "no.such"}, {"id": "playhead.set"}]})).await;
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!((v["completed"].as_u64(), v["failed"].as_u64(), v["results"].as_array().map(Vec::len)), (Some(1), Some(1), Some(2)), "{v}");
|
||||
let r = c.call(8, "doc_inspect", json!({})).await;
|
||||
let v: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
|
||||
let r = c.call(9, "render_preview", json!({"max_side": 64})).await;
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
||||
}
|
||||
|
||||
/// `filmcraft://document` and `filmcraft://commands` are listed and readable as JSON.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn resources_document_and_commands() {
|
||||
let mut c = Client::start(demo());
|
||||
let init = c.init().await;
|
||||
assert!(init["result"]["capabilities"]["resources"].is_object(), "{init}");
|
||||
assert!(init["result"]["instructions"].as_str().unwrap().contains("doc_inspect"), "{init}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"resources/list"})).await;
|
||||
let uris: Vec<&str> = r["result"]["resources"].as_array().unwrap().iter().filter_map(|x| x["uri"].as_str()).collect();
|
||||
assert_eq!(uris, [DOCUMENT_URI, COMMANDS_URI], "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":DOCUMENT_URI}})).await;
|
||||
assert_eq!(r["result"]["contents"][0]["mimeType"], "application/json", "{r}");
|
||||
let v: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(v["project"].is_object() && v["sequence"].is_object(), "{v}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":COMMANDS_URI}})).await;
|
||||
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline.razor"), "{r}");
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"filmcraft://nope"}})).await;
|
||||
assert!(r["error"]["code"].is_i64(), "{r}");
|
||||
}
|
||||
|
||||
/// MCP 2026-07-28 clients (current Claude Code) negotiate per request and reject list and read
|
||||
/// results without `resultType`, `ttlMs` and `cacheScope`; sessions that negotiated an older
|
||||
/// revision through `initialize` get the old shape.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn modern_clients_get_result_type_and_cache_hints() {
|
||||
let mut c = Client::start(Session::default());
|
||||
let meta = json!({
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
});
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":meta}})).await;
|
||||
assert!(r["result"]["supportedVersions"].as_array().unwrap().contains(&json!("2026-07-28")), "{r}");
|
||||
for (id, method, params) in [
|
||||
(2, "tools/list", json!({"_meta": meta})),
|
||||
(3, "resources/list", json!({"_meta": meta})),
|
||||
(4, "resources/read", json!({"_meta": meta, "uri": DOCUMENT_URI})),
|
||||
] {
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
|
||||
assert_eq!(r["result"]["resultType"], "complete", "{method}: {r}");
|
||||
assert!(r["result"]["ttlMs"].is_u64(), "{method}: {r}");
|
||||
assert!(matches!(r["result"]["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
|
||||
}
|
||||
let mut c = Client::start(Session::default());
|
||||
c.init().await;
|
||||
for (id, method, params) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": DOCUMENT_URI}))] {
|
||||
let r = c.ask(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})).await;
|
||||
assert!(r["result"].is_object() && r["result"].get("resultType").is_none() && r["result"].get("ttlMs").is_none(), "{method}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/agents.md § Long exports: a blocking export reports `notifications/progress` for its
|
||||
/// token (none without one), answers `ping` meanwhile, and `notifications/cancelled` stops
|
||||
/// it, deletes the partial file and sends no response.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn export_progress_and_cancel() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-mcp-progress-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut c = Client::start(demo());
|
||||
c.init().await;
|
||||
let export = |id: u64, name: &str, token: Option<Value>, seconds: f64| {
|
||||
let path = dir.join(name).to_string_lossy().to_string();
|
||||
let params = json!({"path": path, "format": "h264", "width": 640, "height": 360, "audio": false, "range": "custom", "startSeconds": 0, "endSeconds": seconds, "wait": true});
|
||||
let mut call =
|
||||
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.exportMedia","params":params}}});
|
||||
if let Some(t) = token {
|
||||
call["params"]["_meta"] = json!({"progressToken": t});
|
||||
}
|
||||
call.to_string()
|
||||
};
|
||||
// no token: no notifications, the same response as before
|
||||
c.send(&export(1, "quiet.mp4", None, 0.5)).await;
|
||||
let m = c.next().await;
|
||||
assert_eq!((m["id"].as_u64(), &m["result"]["isError"]), (Some(1), &json!(false)), "{m}");
|
||||
// progress, strictly increasing, with a total; ping answered while the export runs
|
||||
c.send(&export(2, "a.mp4", Some(json!("tok")), 1.0)).await;
|
||||
let (mut last, mut notes, mut pinged, mut pong) = (-1.0, 0, false, false);
|
||||
let done = loop {
|
||||
let m = c.next().await;
|
||||
if m["method"] == "notifications/progress" {
|
||||
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
|
||||
let p = m["params"]["progress"].as_f64().unwrap();
|
||||
assert!(p > last && m["params"]["total"].as_f64().unwrap() >= p, "{m}");
|
||||
last = p;
|
||||
notes += 1;
|
||||
if !pinged {
|
||||
pinged = true;
|
||||
c.send(r#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#).await;
|
||||
}
|
||||
} else if m["id"] == 3 {
|
||||
pong = true;
|
||||
} else if m["id"] == 2 {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
assert_eq!(done["result"]["isError"], false, "{done}");
|
||||
assert!(notes >= 2 && pong, "{notes} notifications, ping answered during the export: {pong}");
|
||||
assert!(dir.join("a.mp4").exists());
|
||||
// cancel after the first notification: no response, no partial file
|
||||
c.send(&export(4, "b.mp4", Some(json!(7)), 8.0)).await;
|
||||
loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "finished before the cancel: {m}");
|
||||
if m["method"] == "notifications/progress" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
c.send(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}}"#).await;
|
||||
let t0 = std::time::Instant::now();
|
||||
while dir.join("b.mp4").exists() || t0.elapsed() < std::time::Duration::from_millis(500) {
|
||||
assert!(t0.elapsed().as_secs() < 30, "partial file still there");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
c.send(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"jobs.list"}}}"#).await;
|
||||
let m = loop {
|
||||
let m = c.next().await;
|
||||
assert_ne!(m["id"], 4, "no response for a cancelled request: {m}");
|
||||
if m["id"] == 5 {
|
||||
break m;
|
||||
}
|
||||
};
|
||||
let jobs: Value = serde_json::from_str(m["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
let last = jobs.as_array().and_then(|a| a.last()).cloned().unwrap_or_default();
|
||||
assert_eq!(last["result"]["error"], "cancelled", "{jobs}");
|
||||
assert!(!dir.join("b.mp4").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A command that panicked used to poison the session lock, and every later call failed with
|
||||
/// "session lock poisoned" until the server was restarted.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_poisoned_session_lock_keeps_serving() {
|
||||
let m = FilmcraftMcp::headless(demo());
|
||||
let Backend::Headless(s) = &*m.backend else { panic!("headless backend") };
|
||||
let s = s.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _g = s.lock().unwrap();
|
||||
panic!("a command panicked");
|
||||
})
|
||||
.join();
|
||||
let v = m.run("sequence.inspect", json!({})).await.unwrap();
|
||||
assert!(v.is_object(), "{v}");
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
}
|
||||
+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];
|
||||
|
||||
+71
-16
@@ -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 {
|
||||
@@ -184,28 +184,45 @@ struct Timing {
|
||||
tick_rate: i64,
|
||||
}
|
||||
|
||||
fn positive_integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<i64>().ok().filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
|
||||
let mut it = s.split_whitespace().map(|x| x.parse::<i64>().ok());
|
||||
let mut it = s.split_whitespace().map(positive_integer);
|
||||
let a = it.next()??;
|
||||
let b = it.next().flatten().unwrap_or(1);
|
||||
(a > 0 && b > 0).then_some((a, b))
|
||||
let b = it.next()??;
|
||||
it.next().is_none().then_some((a, b))
|
||||
}
|
||||
|
||||
fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
||||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Timing {
|
||||
let base = attr(root, "frameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(30);
|
||||
let (mn, md) = attr(root, "frameRateMultiplier").and_then(parse_ratio).unwrap_or((1, 1));
|
||||
let rate = FrameRate::new(base * mn, md);
|
||||
let sub = attr(root, "subFrameRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(1);
|
||||
let tick_rate = attr(root, "tickRate").and_then(|v| v.trim().parse::<i64>().ok()).filter(|v| *v > 0).unwrap_or(if attr(root, "frameRate").is_some() {
|
||||
base * sub
|
||||
} else {
|
||||
1
|
||||
});
|
||||
Timing { rate, base, sub_frame_rate: sub, tick_rate }
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
let integer = |name: &str, default| match attr(root, name) {
|
||||
Some(v) => positive_integer(v).ok_or_else(|| invalid(name)),
|
||||
None => Ok(default),
|
||||
};
|
||||
let base = integer("frameRate", 30)?;
|
||||
let (mn, md) = match attr(root, "frameRateMultiplier") {
|
||||
Some(v) => parse_ratio(v).ok_or_else(|| invalid("frameRateMultiplier"))?,
|
||||
None => (1, 1),
|
||||
};
|
||||
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
|
||||
let sub = integer("subFrameRate", 1)?;
|
||||
let tick_rate = match attr(root, "tickRate") {
|
||||
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
|
||||
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
|
||||
None => 1,
|
||||
};
|
||||
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
|
||||
}
|
||||
|
||||
/// Seconds as an exact decimal string → ticks.
|
||||
@@ -383,7 +400,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
if root.tag_name().name() != "tt" {
|
||||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root);
|
||||
let tm = timing(root)?;
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
@@ -478,6 +495,44 @@ mod tests {
|
||||
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_timing_parameters_return_an_error() {
|
||||
for params in [
|
||||
r#"ttp:frameRate="0""#,
|
||||
r#"ttp:frameRate="-25""#,
|
||||
r#"ttp:frameRate="""#,
|
||||
r#"ttp:subFrameRate="0""#,
|
||||
r#"ttp:tickRate="-1""#,
|
||||
r#"ttp:frameRateMultiplier="1 0""#,
|
||||
r#"ttp:frameRateMultiplier="1""#,
|
||||
r#"ttp:frameRateMultiplier="1 2 3""#,
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="1s" end="2s">Hello</p></div></body></tt>"#
|
||||
);
|
||||
assert!(crate::parse(xml.as_bytes(), crate::Format::Ttml).is_err(), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timing_parameters_keep_defaults() {
|
||||
for (params, begin, end) in [
|
||||
("", "1s", "2s"),
|
||||
(r#"ttp:frameRate="25""#, "25f", "50f"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
|
||||
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50f", "100f"),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = crate::parse(xml.as_bytes(), crate::Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1, "{params}");
|
||||
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND), "{params}");
|
||||
assert_eq!(doc.cues[0].end, Tick(2 * TICKS_PER_SECOND), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_expressions() {
|
||||
let t = tm(FrameRate::FPS_25);
|
||||
|
||||
@@ -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 }
|
||||
+405
-27
@@ -5,8 +5,11 @@
|
||||
//! for the next frames therefore hit the cache or continue the running decoder without re-seeking.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, TryLockError, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use web_time::Instant;
|
||||
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::VideoFrame;
|
||||
@@ -33,6 +36,9 @@ pub struct GopStats {
|
||||
pub skipped: u64,
|
||||
/// Frames decoded in draft mode (reduced-resolution playback, [`VideoDecoder::set_draft`]).
|
||||
pub draft: u64,
|
||||
/// Pictures decoders output (hardware and software; [`crate::hw::hw_stats`] counts the
|
||||
/// hardware ones).
|
||||
pub frames: u64,
|
||||
}
|
||||
|
||||
impl GopStats {
|
||||
@@ -56,10 +62,16 @@ static EVICTED: AtomicU64 = AtomicU64::new(0);
|
||||
static DECODE_NS: AtomicU64 = AtomicU64::new(0);
|
||||
static SKIPPED: AtomicU64 = AtomicU64::new(0);
|
||||
static DRAFT: AtomicU64 = AtomicU64::new(0);
|
||||
static FRAMES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Count pictures a decoder call output.
|
||||
fn count_frames<T>(out: &[T]) {
|
||||
FRAMES.fetch_add(out.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Run a decoder call, adding its wall time to the process-wide counter.
|
||||
fn timed<R>(f: impl FnOnce() -> R) -> R {
|
||||
let t0 = web_time::Instant::now();
|
||||
let t0 = Instant::now();
|
||||
let r = f();
|
||||
DECODE_NS.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
|
||||
r
|
||||
@@ -76,6 +88,7 @@ pub fn gop_stats() -> GopStats {
|
||||
decode_ns: DECODE_NS.load(Ordering::Relaxed),
|
||||
skipped: SKIPPED.load(Ordering::Relaxed),
|
||||
draft: DRAFT.load(Ordering::Relaxed),
|
||||
frames: FRAMES.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +104,7 @@ impl std::ops::Sub for GopStats {
|
||||
decode_ns: self.decode_ns - o.decode_ns,
|
||||
skipped: self.skipped - o.skipped,
|
||||
draft: self.draft - o.draft,
|
||||
frames: self.frames - o.frames,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,9 +138,35 @@ struct State {
|
||||
/// The cached frames that were decoded in draft mode: served to draft requests only.
|
||||
drafts: std::collections::BTreeSet<i64>,
|
||||
bytes: usize,
|
||||
/// When a frame was last asked of this cache.
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Give up the decoder (and the spare intra decoders): the next request that has to decode
|
||||
/// makes a new one and restarts at a sync sample. Returned so they are dropped off the lock.
|
||||
fn release_decoder(&mut self, pool: &Pool) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut out = std::mem::take(&mut self.spare);
|
||||
if let Some(d) = self.decoder.take() {
|
||||
pool.decoders.fetch_sub(1, Ordering::Relaxed);
|
||||
out.push(d);
|
||||
}
|
||||
self.next = usize::MAX;
|
||||
out
|
||||
}
|
||||
|
||||
/// Evict frames, earliest first, while `more` says so.
|
||||
fn release_frames(&mut self, pool: &Pool, more: impl Fn() -> bool) {
|
||||
while more() {
|
||||
let Some((pts, f)) = self.frames.pop_first() else { break };
|
||||
self.drafts.remove(&pts);
|
||||
self.bytes = self.bytes.saturating_sub(f.byte_size());
|
||||
pool.bytes.fetch_sub(f.byte_size(), Ordering::Relaxed);
|
||||
EVICTED.fetch_add(1, Ordering::Relaxed);
|
||||
filmcraft_frame::pool::recycle(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached frame at `pts`, unless it is a draft frame and the request wants the exact one.
|
||||
fn cached(&self, pts: i64, draft_ok: bool) -> Option<&Arc<VideoFrame>> {
|
||||
self.frames.get(&pts).filter(|_| draft_ok || !self.drafts.contains(&pts))
|
||||
@@ -167,11 +207,144 @@ impl Drop for DecodingGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoder keeps its reference and in-flight pictures (hundreds of MB for a frame-threaded
|
||||
/// 4K decoder), so only this many caches keep theirs once idle: a timeline's other clips make a
|
||||
/// new one when the playhead reaches them, which costs what a seek costs.
|
||||
const MAX_LIVE_DECODERS: usize = 4;
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited,
|
||||
/// the next clip being prefetched): it keeps its decoder whatever the count.
|
||||
const IDLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Decoded frames all caches may hold together before idle ones give theirs up. Each cache also
|
||||
/// has its own budget, which alone let every clip on a timeline keep hundreds of MB of frames
|
||||
/// nobody was looking at.
|
||||
pub const FRAME_BUDGET: usize = 1 << 30;
|
||||
|
||||
/// What the caches of a process share: the cap on live decoders and the frame budget.
|
||||
struct Pool {
|
||||
caches: Mutex<Vec<Weak<Shared>>>,
|
||||
/// Caches that hold a decoder.
|
||||
decoders: AtomicUsize,
|
||||
max_decoders: usize,
|
||||
idle: Duration,
|
||||
/// Bytes of the frames the caches hold.
|
||||
bytes: AtomicUsize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
/// The part of a [`GopCache`] its pool reaches.
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
pool: Arc<Pool>,
|
||||
}
|
||||
|
||||
impl Drop for Shared {
|
||||
fn drop(&mut self) {
|
||||
let st = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
|
||||
drop(st.release_decoder(&self.pool));
|
||||
self.pool.bytes.fetch_sub(st.bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache's state unless another thread is using it (it is then decoding: not idle).
|
||||
fn try_state(c: &Shared) -> Option<MutexGuard<'_, State>> {
|
||||
match c.state.try_lock() {
|
||||
Ok(g) => Some(g),
|
||||
Err(TryLockError::Poisoned(e)) => Some(e.into_inner()),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
fn new(max_decoders: usize, idle: Duration, budget: usize) -> Self {
|
||||
Self { caches: Mutex::new(Vec::new()), decoders: AtomicUsize::new(0), max_decoders, idle, bytes: AtomicUsize::new(0), budget }
|
||||
}
|
||||
|
||||
/// The pool of every cache in the process.
|
||||
fn global() -> Arc<Pool> {
|
||||
static POOL: std::sync::OnceLock<Arc<Pool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET))).clone()
|
||||
}
|
||||
|
||||
fn register(&self, c: &Arc<Shared>) {
|
||||
let mut g = self.caches.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
g.retain(|w| w.strong_count() > 0);
|
||||
g.push(Arc::downgrade(c));
|
||||
}
|
||||
|
||||
/// The idle caches other than `me` that `keep` selects, least recently used first. Other
|
||||
/// caches are only ever try-locked, so two caches trimming each other cannot deadlock.
|
||||
fn idle_others(&self, me: &Shared, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut idle: Vec<(Instant, Arc<Shared>)> = all
|
||||
.into_iter()
|
||||
.filter(|c| !std::ptr::eq(Arc::as_ptr(c), me))
|
||||
.filter_map(|c| {
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= self.idle).map(|st| st.last_used)?;
|
||||
Some((used, c))
|
||||
})
|
||||
.collect();
|
||||
idle.sort_by_key(|(used, _)| *used);
|
||||
idle.into_iter().map(|(_, c)| c).collect()
|
||||
}
|
||||
|
||||
/// Take the decoders of idle caches, least recently used first, down to the cap. The caller
|
||||
/// drops them with no cache lock held: dropping a decoder can block (a hardware session waits
|
||||
/// for its frames in flight).
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn trim_decoders(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut released = Vec::new();
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
return released;
|
||||
}
|
||||
for c in self.idle_others(me, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Evict the frames of idle caches, least recently used first, down to the budget. Caches in
|
||||
/// use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// re-decode from the keyframe.
|
||||
fn trim_frames(&self, me: &Shared) {
|
||||
let over = || self.bytes.load(Ordering::Relaxed) > self.budget;
|
||||
if !over() {
|
||||
return;
|
||||
}
|
||||
for c in self.idle_others(me, |st| !st.frames.is_empty()) {
|
||||
if !over() {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
st.release_frames(self, over);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches that hold a decoder right now, process-wide (`perf.stats`).
|
||||
pub fn live_decoders() -> usize {
|
||||
Pool::global().decoders.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bytes of decoded frames the caches hold right now, process-wide (`perf.stats`).
|
||||
pub fn cached_bytes() -> usize {
|
||||
Pool::global().bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Decoder + decoded-frame cache for one video track.
|
||||
pub struct GopCache {
|
||||
state: Mutex<State>,
|
||||
shared: Arc<Shared>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -187,7 +360,13 @@ const CONTINUE_THROUGH: usize = 48;
|
||||
|
||||
impl GopCache {
|
||||
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
|
||||
Self {
|
||||
// unit tests count decoder restarts: each of their caches gets a pool of its own
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET)) } else { Pool::global() };
|
||||
Self::in_pool(explicit_color, pool)
|
||||
}
|
||||
|
||||
fn in_pool(explicit_color: Option<ColorInfo>, pool: Arc<Pool>) -> Self {
|
||||
let shared = Arc::new(Shared {
|
||||
state: Mutex::new(State {
|
||||
decoder: None,
|
||||
intra: false,
|
||||
@@ -198,23 +377,34 @@ impl GopCache {
|
||||
frames: BTreeMap::new(),
|
||||
drafts: Default::default(),
|
||||
bytes: 0,
|
||||
last_used: Instant::now(),
|
||||
}),
|
||||
explicit_color,
|
||||
budget: 384 << 20,
|
||||
}
|
||||
pool: pool.clone(),
|
||||
});
|
||||
pool.register(&shared);
|
||||
Self { shared, 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);
|
||||
@@ -222,25 +412,36 @@ impl GopCache {
|
||||
st.drafts.remove(&pts);
|
||||
}
|
||||
let budget = self.budget.max(MIN_FRAMES * f.byte_size());
|
||||
let before = st.bytes;
|
||||
st.bytes += f.byte_size();
|
||||
if let Some(old) = st.frames.insert(pts, Arc::new(f)) {
|
||||
st.bytes -= old.byte_size();
|
||||
filmcraft_frame::pool::recycle(old);
|
||||
}
|
||||
// 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);
|
||||
st.bytes -= v.byte_size();
|
||||
EVICTED.fetch_add(1, Ordering::Relaxed);
|
||||
// its planes serve the next decoded pictures instead of going back to the allocator
|
||||
filmcraft_frame::pool::recycle(v);
|
||||
}
|
||||
}
|
||||
let pool = &self.shared.pool;
|
||||
if st.bytes >= before {
|
||||
pool.bytes.fetch_add(st.bytes - before, Ordering::Relaxed);
|
||||
} else {
|
||||
pool.bytes.fetch_sub(before - st.bytes, Ordering::Relaxed);
|
||||
}
|
||||
pool.trim_frames(&self.shared);
|
||||
}
|
||||
|
||||
/// Store decoder output (in presentation order) and advance `out_max`.
|
||||
fn store_output(&self, st: &mut State, out: Vec<crate::video::DecodedFrame>) {
|
||||
count_frames(&out);
|
||||
for d in out {
|
||||
st.out_max = st.out_max.max(d.pts);
|
||||
self.store(st, d.pts, d.frame, d.draft);
|
||||
@@ -274,8 +475,13 @@ impl GopCache {
|
||||
return self.private_frame(s, i, want_pts, n);
|
||||
}
|
||||
let draft = filmcraft_media::cancel::draft();
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Idle caches' decoders released below. Declared before the lock guard, so it is dropped
|
||||
// after the guard on every return path: dropping a decoder can block (a hardware session
|
||||
// waits for its frames in flight) and must not hold this cache's lock.
|
||||
let mut released: Vec<Box<dyn VideoDecoder>> = Vec::new();
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _decoding = DecodingGuard::enter(me);
|
||||
st.last_used = Instant::now();
|
||||
if let Some(f) = st.cached(want_pts, draft) {
|
||||
HITS.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(f.clone());
|
||||
@@ -286,7 +492,9 @@ impl GopCache {
|
||||
st.intra = d.intra_only();
|
||||
st.decoder = Some(d);
|
||||
st.next = usize::MAX;
|
||||
self.shared.pool.decoders.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
released.extend(self.shared.pool.trim_decoders(&self.shared));
|
||||
// Nothing cached: the work ahead (possibly a seek and a GOP of decoding) is only worth it
|
||||
// while someone still wants the frame.
|
||||
if filmcraft_media::cancel::cancelled() {
|
||||
@@ -311,7 +519,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 +533,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 +544,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 +561,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 +575,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.
|
||||
@@ -403,6 +616,7 @@ impl GopCache {
|
||||
let k = *next;
|
||||
let data = s.read(k)?;
|
||||
let pics = timed(|| d.decode(&data, s.pts(k)))?;
|
||||
count_frames(&pics);
|
||||
*next += 1;
|
||||
found |= pics.iter().any(|p| p.pts == want_pts);
|
||||
out.extend(pics);
|
||||
@@ -411,7 +625,9 @@ impl GopCache {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out.extend(timed(|| d.flush()));
|
||||
let pics = timed(|| d.flush());
|
||||
count_frames(&pics);
|
||||
out.extend(pics);
|
||||
*next = usize::MAX;
|
||||
}
|
||||
Ok(out)
|
||||
@@ -431,11 +647,13 @@ impl GopCache {
|
||||
DECODED.fetch_add(1, Ordering::Relaxed);
|
||||
timed(|| dec.decode(&data, want_pts))
|
||||
});
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if st.spare.len() < 16 {
|
||||
st.spare.push(dec);
|
||||
}
|
||||
for d in res? {
|
||||
let res = res?;
|
||||
count_frames(&res);
|
||||
for d in res {
|
||||
self.store(&mut st, d.pts, d.frame, d.draft);
|
||||
}
|
||||
st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into()))
|
||||
@@ -447,7 +665,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::video::DecodedFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
/// `n` samples, a sync sample every `gop`, pts = 1000 · index (no reordering).
|
||||
struct Samples {
|
||||
@@ -683,4 +900,165 @@ mod tests {
|
||||
assert_eq!(index_of(&c.frame(&s, 7_000).expect("frame")), 7);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_caches_give_up_their_decoders_beyond_the_cap() {
|
||||
let pool = Arc::new(Pool::new(2, Duration::ZERO, FRAME_BUDGET));
|
||||
let live = || pool.decoders.load(Ordering::Relaxed);
|
||||
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
// a clip after another, as when clips are dropped on a timeline
|
||||
for k in 0..5 {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
assert!(live() <= 2, "{} live decoders after clip {k}", live());
|
||||
}
|
||||
assert_eq!(live(), 2);
|
||||
// the most recently used keep theirs and carry on without a seek
|
||||
assert_eq!(index_of(&c[4].frame(&s[4], 20_000).expect("frame")), 20);
|
||||
assert_eq!(s[4].resets.load(Ordering::Relaxed), 1);
|
||||
// a cache that lost its decoder still serves what it cached…
|
||||
let decodes = s[0].decodes.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 10_000).expect("cached")), 10);
|
||||
assert_eq!(s[0].decodes.load(Ordering::Relaxed), decodes);
|
||||
// …and decodes again with a new decoder, from the sync sample
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 20_000).expect("frame")), 20);
|
||||
assert_eq!(s[0].resets.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(live(), 2);
|
||||
// a dropped cache no longer counts
|
||||
drop(c);
|
||||
assert_eq!(live(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_in_use_keep_their_decoders_whatever_the_cap() {
|
||||
// every layer of one composited frame was asked moments ago: none is idle
|
||||
let pool = Arc::new(Pool::new(2, Duration::from_secs(3600), FRAME_BUDGET));
|
||||
let s: Vec<Samples> = (0..5).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..5).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for t in [10_000, 11_000, 12_000] {
|
||||
for k in 0..5 {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], t).expect("frame")) as i64, t / 1000);
|
||||
}
|
||||
}
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 5);
|
||||
assert!(s.iter().all(|s| s.resets.load(Ordering::Relaxed) == 1), "no decoder restarted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intra_caches_give_up_their_spare_decoders_too() {
|
||||
let pool = Arc::new(Pool::new(1, Duration::ZERO, FRAME_BUDGET));
|
||||
let (a, b) = (samples(50, 1, 0, true), samples(50, 1, 0, true));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&ca.frame(&a, 3_000).expect("frame")), 3);
|
||||
assert_eq!(index_of(&cb.frame(&b, 4_000).expect("frame")), 4);
|
||||
let st = ca.shared.state.lock().unwrap();
|
||||
assert!(st.decoder.is_none() && st.spare.is_empty());
|
||||
drop(st);
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(index_of(&ca.frame(&a, 5_000).expect("frame")), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_caches_give_up_their_frames_beyond_the_shared_budget() {
|
||||
// 1×1 RGBA pictures: 4 bytes each, a shared budget of ten of them
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::ZERO, 40));
|
||||
let held = || pool.bytes.load(Ordering::Relaxed);
|
||||
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
// the only cache keeps what its own budget allows
|
||||
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
|
||||
assert_eq!(held(), 44);
|
||||
// a second clip takes the room from the idle first one, earliest frames first
|
||||
assert_eq!(index_of(&cb.frame(&b, 5_000).expect("frame")), 5);
|
||||
assert_eq!(held(), 40);
|
||||
let decodes = b.decodes.load(Ordering::Relaxed);
|
||||
assert!((0..=5).all(|i| index_of(&cb.frame(&b, i * 1000).expect("cached")) as i64 == i));
|
||||
assert_eq!(b.decodes.load(Ordering::Relaxed), decodes, "the clip in use lost nothing");
|
||||
let st = ca.shared.state.lock().unwrap();
|
||||
assert_eq!((st.frames.len(), st.bytes), (4, 16));
|
||||
assert_eq!(st.frames.keys().next(), Some(&7_000));
|
||||
drop(st);
|
||||
// an evicted frame decodes again
|
||||
assert_eq!(index_of(&ca.frame(&a, 2_000).expect("frame")), 2);
|
||||
assert!(held() <= 40 + 4 * 3, "{} bytes held", held());
|
||||
drop((ca, cb));
|
||||
assert_eq!(held(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_in_use_keep_their_frames_whatever_the_shared_budget() {
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::from_secs(3600), 40));
|
||||
let (a, b) = (samples(300, 250, 0, false), samples(300, 250, 0, false));
|
||||
let (ca, cb) = (GopCache::in_pool(None, pool.clone()), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&ca.frame(&a, 10_000).expect("frame")), 10);
|
||||
assert_eq!(index_of(&cb.frame(&b, 10_000).expect("frame")), 10);
|
||||
assert_eq!(pool.bytes.load(Ordering::Relaxed), 88);
|
||||
let decodes = a.decodes.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&ca.frame(&a, 0).expect("cached")), 0);
|
||||
assert_eq!(a.decodes.load(Ordering::Relaxed), decodes);
|
||||
}
|
||||
|
||||
/// Pictures big enough for the plane pool (its own size, so that parallel tests cannot take
|
||||
/// the buffers), no reordering, one long GOP.
|
||||
struct Big(usize);
|
||||
const BIG: (u32, u32) = (200, 101);
|
||||
|
||||
impl VideoDecoder for Big {
|
||||
fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
|
||||
let mut px = filmcraft_frame::pool::take_u8((BIG.0 * BIG.1 * 4) as usize);
|
||||
px.resize((BIG.0 * BIG.1 * 4) as usize, (pts / 1000) as u8);
|
||||
Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(BIG.0, BIG.1, px), draft: false }])
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
Vec::new()
|
||||
}
|
||||
fn reset(&mut self) {}
|
||||
fn name(&self) -> &str {
|
||||
"big"
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoSamples for Big {
|
||||
fn count(&self) -> usize {
|
||||
self.0
|
||||
}
|
||||
fn pts(&self, i: usize) -> i64 {
|
||||
i as i64 * 1000
|
||||
}
|
||||
fn sync_before(&self, _i: usize) -> usize {
|
||||
0
|
||||
}
|
||||
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||
let i = (t / 1000) as usize;
|
||||
(i < self.0).then_some(i)
|
||||
}
|
||||
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||
Ok(vec![i as u8])
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
Ok(Box::new(Big(self.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_frames_are_decoded_into_again() {
|
||||
let s = Big(200);
|
||||
// room for MIN_FRAMES pictures only
|
||||
let mut c = GopCache::new(None);
|
||||
c.budget = 0;
|
||||
let reused = || filmcraft_frame::pool::stats().reused;
|
||||
let before = reused();
|
||||
// the cache holds MIN_FRAMES pictures: none is evicted yet, every plane is new
|
||||
for i in 0..MIN_FRAMES as i64 {
|
||||
assert_eq!(c.frame(&s, i * 1000).expect("frame").width, BIG.0);
|
||||
}
|
||||
assert_eq!(reused(), before);
|
||||
// from here on each picture evicts one, whose buffer the next picture is decoded into
|
||||
let more = 40;
|
||||
for i in MIN_FRAMES as i64..MIN_FRAMES as i64 + more {
|
||||
let f = c.frame(&s, i * 1000).expect("frame");
|
||||
assert!(matches!(&f.data, filmcraft_frame::PixelData::Rgba8(d) if d.iter().all(|&b| b == i as u8)), "frame {i} has its own pixels");
|
||||
}
|
||||
assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Support for hardware (OS) video decoders registered by `filmcraft-platform`: the
|
||||
//! Settings ▸ Playback ▸ Hardware decoding switch, the hardware counters `perf.stats` reports, and
|
||||
//! what such a decoder needs from an `avcC` / `hvcC` sample entry to behave exactly like our
|
||||
//! software decoder for the same stream (parameter sets, cropping, colour, pixel aspect, reorder
|
||||
//! depth, random-access and disposable samples).
|
||||
//!
|
||||
//! The software decoders stay the reference: a hardware factory declines streams it cannot
|
||||
//! decode, and a hardware decoder that fails mid-stream switches to
|
||||
//! [`crate::software_video_decoder`], so registering one never makes a file undecodable.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use filmcraft_isobmff::{CodecConfig, SampleEntry};
|
||||
|
||||
use crate::video::{avcc_length_size, h264_disposable, hevc_disposable, hvcc_length_size_and_tid, sar_par, vui_color};
|
||||
use crate::{CodecError, Result};
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Settings ▸ Playback ▸ Hardware decoding: `Auto` (true, the default) lets registered hardware
|
||||
/// decoders take streams they support; `Off` (false) makes every new decoder a software one.
|
||||
/// Decoders already created keep running: a source picks the change up when it is reopened.
|
||||
pub fn set_hardware_decoding(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether hardware decoders may be used for new decoders ([`set_hardware_decoding`]).
|
||||
pub fn hardware_decoding() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Process-wide hardware decoding counters (they only grow; subtract two snapshots to measure an
|
||||
/// interval). Frames decoded in software are `GopStats::frames - frames`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HwStats {
|
||||
/// Pictures output by hardware decoders.
|
||||
pub frames: u64,
|
||||
/// Hardware decoders created (one per decoder instance; a seek reuses it).
|
||||
pub sessions: u64,
|
||||
/// Streams a hardware factory handed to the software decoder (unsupported profile, size,
|
||||
/// chroma format or bit depth, no hardware, or the switch is Off).
|
||||
pub declined: u64,
|
||||
/// Hardware decoders that failed mid-stream and continued in software.
|
||||
pub fallbacks: u64,
|
||||
}
|
||||
|
||||
static FRAMES: AtomicU64 = AtomicU64::new(0);
|
||||
static SESSIONS: AtomicU64 = AtomicU64::new(0);
|
||||
static DECLINED: AtomicU64 = AtomicU64::new(0);
|
||||
static FALLBACKS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The counters so far.
|
||||
pub fn hw_stats() -> HwStats {
|
||||
HwStats {
|
||||
frames: FRAMES.load(Ordering::Relaxed),
|
||||
sessions: SESSIONS.load(Ordering::Relaxed),
|
||||
declined: DECLINED.load(Ordering::Relaxed),
|
||||
fallbacks: FALLBACKS.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// A hardware decoder output `n` pictures.
|
||||
pub fn note_hw_frames(n: usize) {
|
||||
FRAMES.fetch_add(n as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A hardware decoder was created.
|
||||
pub fn note_hw_session() {
|
||||
SESSIONS.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A hardware factory declined a stream it is responsible for.
|
||||
pub fn note_hw_declined() {
|
||||
DECLINED.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// A hardware decoder switched to software mid-stream.
|
||||
pub fn note_hw_fallback() {
|
||||
FALLBACKS.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The two codecs whose `avcC` / `hvcC` streams hardware decoders take.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NalCodec {
|
||||
H264,
|
||||
Hevc,
|
||||
}
|
||||
|
||||
/// What a decoder needs to know about a length-prefixed H.264 / HEVC stream, from its sample
|
||||
/// entry's configuration record and active sequence parameter set (parsed with the software
|
||||
/// decoders' own parsers, so both decoders derive the same values).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NalStreamInfo {
|
||||
pub codec: NalCodec,
|
||||
/// NAL unit length-prefix size of the samples (1, 2 or 4).
|
||||
pub length_size: usize,
|
||||
/// HEVC: highest TemporalId (numTemporalLayers - 1), when signalled.
|
||||
pub highest_tid: Option<u8>,
|
||||
/// The parameter-set NAL units of the record (header included, emulation prevention kept),
|
||||
/// in record order: H.264 SPS then PPS; HEVC VPS, SPS, PPS. SEI arrays are left out.
|
||||
pub parameter_sets: Vec<Vec<u8>>,
|
||||
/// Coded (decoded) picture size in luma samples.
|
||||
pub coded: (u32, u32),
|
||||
/// Output (cropped) rectangle in luma samples: x, y, width, height.
|
||||
pub crop: (u32, u32, u32, u32),
|
||||
/// 0 monochrome, 1 4:2:0, 2 4:2:2, 3 4:4:4.
|
||||
pub chroma_format_idc: u32,
|
||||
pub bit_depth_luma: u32,
|
||||
pub bit_depth_chroma: u32,
|
||||
/// Field (PAFF / MBAFF) coding (H.264 `frame_mbs_only_flag` 0).
|
||||
pub interlaced: bool,
|
||||
/// H.264 `profile_idc` / HEVC `general_profile_idc`.
|
||||
pub profile_idc: u8,
|
||||
/// Colour of every output picture, as the software decoder reports it.
|
||||
pub color: filmcraft_color::ColorInfo,
|
||||
/// Pixel aspect ratio, as the software decoder reports it.
|
||||
pub par: (u32, u32),
|
||||
/// Pictures that may precede a picture in decoding order and follow it in output order
|
||||
/// (H.264 `max_num_reorder_frames`, HEVC `sps_max_num_reorder_pics`).
|
||||
pub reorder: usize,
|
||||
}
|
||||
|
||||
/// Length-prefixed parameter-set entries (`u16` length + bytes) starting at `pos`.
|
||||
fn read_sets(rec: &[u8], pos: &mut usize, count: usize, out: &mut Vec<Vec<u8>>) -> Result<()> {
|
||||
for _ in 0..count {
|
||||
let len = rec
|
||||
.get(*pos..*pos + 2)
|
||||
.map(|b| u16::from_be_bytes([b[0], b[1]]) as usize)
|
||||
.ok_or_else(|| CodecError::Decode("configuration record truncated".into()))?;
|
||||
*pos += 2;
|
||||
let nal = rec.get(*pos..*pos + len).ok_or_else(|| CodecError::Decode("configuration record truncated".into()))?;
|
||||
*pos += len;
|
||||
if !nal.is_empty() {
|
||||
out.push(nal.to_vec());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl NalStreamInfo {
|
||||
/// The stream info of an `avcC` / `hvcC` sample entry (`None` for other codecs).
|
||||
pub fn from_entry(e: &SampleEntry) -> Option<Result<Self>> {
|
||||
match &e.codec {
|
||||
CodecConfig::Avc(a) => Some(Self::from_avcc(&a.to_bytes())),
|
||||
CodecConfig::Hevc(c) => Some(Self::from_hvcc(&c.to_bytes())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// From an `avcC` (AVCDecoderConfigurationRecord) payload.
|
||||
pub fn from_avcc(avcc: &[u8]) -> Result<Self> {
|
||||
if avcc.len() < 7 || avcc[0] != 1 {
|
||||
return Err(CodecError::Decode("bad avcC record".into()));
|
||||
}
|
||||
let mut sets = Vec::new();
|
||||
let mut pos = 6;
|
||||
read_sets(avcc, &mut pos, (avcc[5] & 0x1f) as usize, &mut sets)?;
|
||||
let nsps = sets.len();
|
||||
let npps = *avcc.get(pos).ok_or_else(|| CodecError::Decode("avcC truncated".into()))? as usize;
|
||||
pos += 1;
|
||||
read_sets(avcc, &mut pos, npps, &mut sets)?;
|
||||
let sps_nal = sets.iter().take(nsps).find(|n| n.first().is_some_and(|h| h & 0x1f == 7)).ok_or_else(|| CodecError::Decode("avcC has no SPS".into()))?;
|
||||
if sets.len() == nsps {
|
||||
return Err(CodecError::Decode("avcC has no PPS".into()));
|
||||
}
|
||||
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(sps_nal.get(1..).unwrap_or_default()))
|
||||
.map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let crop = sps.crop_rect();
|
||||
let vui = sps.vui.clone().unwrap_or_default();
|
||||
Ok(Self {
|
||||
codec: NalCodec::H264,
|
||||
length_size: avcc_length_size(avcc),
|
||||
highest_tid: None,
|
||||
parameter_sets: sets,
|
||||
coded: (sps.width(), sps.height()),
|
||||
crop,
|
||||
chroma_format_idc: sps.chroma_format_idc,
|
||||
bit_depth_luma: sps.bit_depth_luma,
|
||||
bit_depth_chroma: sps.bit_depth_chroma,
|
||||
interlaced: !sps.frame_mbs_only,
|
||||
profile_idc: sps.profile_idc,
|
||||
color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
|
||||
par: sar_par(vui.sar),
|
||||
// as the software decoder's DPB (`max_reorder`)
|
||||
reorder: sps.max_num_reorder_frames().min(sps.max_dpb_frames()),
|
||||
})
|
||||
}
|
||||
|
||||
/// From an `hvcC` (HEVCDecoderConfigurationRecord) payload.
|
||||
pub fn from_hvcc(hvcc: &[u8]) -> Result<Self> {
|
||||
if hvcc.len() < 23 || hvcc[0] != 1 {
|
||||
return Err(CodecError::Decode("bad hvcC record".into()));
|
||||
}
|
||||
let (length_size, highest_tid) = hvcc_length_size_and_tid(hvcc);
|
||||
let mut sets = Vec::new();
|
||||
let mut pos = 23;
|
||||
for _ in 0..hvcc[22] {
|
||||
let hdr = hvcc.get(pos..pos + 3).ok_or_else(|| CodecError::Decode("hvcC truncated".into()))?;
|
||||
let (kind, n) = (hdr[0] & 0x3f, u16::from_be_bytes([hdr[1], hdr[2]]) as usize);
|
||||
pos += 3;
|
||||
let mut arr = Vec::new();
|
||||
read_sets(hvcc, &mut pos, n, &mut arr)?;
|
||||
if (32..=34).contains(&kind) {
|
||||
sets.extend(arr);
|
||||
}
|
||||
}
|
||||
let nal_type = |n: &[u8]| n.first().map(|h| (h >> 1) & 0x3f);
|
||||
let sps_nal = sets.iter().find(|n| nal_type(n) == Some(33)).ok_or_else(|| CodecError::Decode("hvcC has no SPS".into()))?;
|
||||
if !sets.iter().any(|n| nal_type(n) == Some(32)) || !sets.iter().any(|n| nal_type(n) == Some(34)) {
|
||||
return Err(CodecError::Decode("hvcC lacks a VPS or PPS".into()));
|
||||
}
|
||||
let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(sps_nal.get(2..).unwrap_or_default()))
|
||||
.map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let crop = sps.crop_rect();
|
||||
let vui = sps.vui.clone().unwrap_or_default();
|
||||
Ok(Self {
|
||||
codec: NalCodec::Hevc,
|
||||
length_size,
|
||||
highest_tid,
|
||||
parameter_sets: sets,
|
||||
coded: (sps.width, sps.height),
|
||||
crop,
|
||||
chroma_format_idc: sps.chroma_format_idc,
|
||||
bit_depth_luma: sps.bit_depth_luma,
|
||||
bit_depth_chroma: sps.bit_depth_chroma,
|
||||
interlaced: false,
|
||||
profile_idc: sps.ptl.profile_idc,
|
||||
color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range),
|
||||
par: sar_par(vui.sar),
|
||||
reorder: sps.max_num_reorder as usize,
|
||||
})
|
||||
}
|
||||
|
||||
/// NAL unit types of a sample (stops at the first malformed length prefix).
|
||||
pub fn nal_types(&self, sample: &[u8]) -> Vec<u8> {
|
||||
self.nals(sample).into_iter().filter_map(|n| n.first().map(|&h| self.nal_type(h))).collect()
|
||||
}
|
||||
|
||||
/// The NAL units of a length-prefixed sample (stops at the first malformed length prefix).
|
||||
pub fn nals<'a>(&self, sample: &'a [u8]) -> Vec<&'a [u8]> {
|
||||
let ls = self.length_size;
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while let Some(pre) = sample.get(pos..pos.saturating_add(ls)) {
|
||||
if !(1..=4).contains(&ls) {
|
||||
break;
|
||||
}
|
||||
let len = pre.iter().fold(0usize, |a, &b| (a << 8) | b as usize);
|
||||
pos += ls;
|
||||
let Some(n) = sample.get(pos..pos.saturating_add(len)) else { break };
|
||||
pos += len;
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The NAL unit type in a NAL header byte.
|
||||
pub fn nal_type(&self, header: u8) -> u8 {
|
||||
match self.codec {
|
||||
NalCodec::H264 => header & 0x1f,
|
||||
NalCodec::Hevc => (header >> 1) & 0x3f,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `t` is a parameter-set NAL unit type (H.264 SPS / PPS / SPS extension, HEVC VPS /
|
||||
/// SPS / PPS).
|
||||
pub fn is_parameter_set(&self, t: u8) -> bool {
|
||||
match self.codec {
|
||||
NalCodec::H264 => matches!(t, 7 | 8 | 13 | 15),
|
||||
NalCodec::Hevc => (32..=34).contains(&t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether decoding can restart at `sample` with no earlier state: an IDR access unit (H.264)
|
||||
/// or an IRAP one (HEVC IDR / CRA / BLA).
|
||||
pub fn is_irap(&self, sample: &[u8]) -> bool {
|
||||
self.nal_types(sample).iter().any(|&t| match self.codec {
|
||||
NalCodec::H264 => t == 5,
|
||||
NalCodec::Hevc => (16..=23).contains(&t),
|
||||
})
|
||||
}
|
||||
|
||||
/// As the software decoder's [`crate::VideoDecoder::is_random_access`] for this stream
|
||||
/// (length-prefixed samples: unknown, the container's sync flags are trusted).
|
||||
pub fn is_random_access(&self, _sample: &[u8]) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// As the software decoder's [`crate::VideoDecoder::is_disposable`] for this stream.
|
||||
pub fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
match self.codec {
|
||||
NalCodec::H264 => h264_disposable(sample, self.length_size),
|
||||
NalCodec::Hevc => hevc_disposable(sample, self.length_size, self.highest_tid),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output picture size (cropped).
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.crop.2, self.crop.3)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn switch_defaults_to_auto() {
|
||||
// other tests in this process may flip it; only check the round trip
|
||||
let was = hardware_decoding();
|
||||
set_hardware_decoding(false);
|
||||
assert!(!hardware_decoding());
|
||||
set_hardware_decoding(true);
|
||||
assert!(hardware_decoding());
|
||||
set_hardware_decoding(was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_records_are_errors() {
|
||||
for rec in [&[][..], &[1, 2, 3], &[1, 0x64, 0, 0x1f, 0xff, 0xe1, 0, 9, 0x67], &[0; 30]] {
|
||||
assert!(NalStreamInfo::from_avcc(rec).is_err());
|
||||
assert!(NalStreamInfo::from_hvcc(rec).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nal_splitting_is_bounded() {
|
||||
let info = NalStreamInfo {
|
||||
codec: NalCodec::Hevc,
|
||||
length_size: 4,
|
||||
highest_tid: None,
|
||||
parameter_sets: Vec::new(),
|
||||
coded: (16, 16),
|
||||
crop: (0, 0, 16, 16),
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma: 8,
|
||||
bit_depth_chroma: 8,
|
||||
interlaced: false,
|
||||
profile_idc: 1,
|
||||
color: filmcraft_color::ColorInfo::REC709,
|
||||
par: (1, 1),
|
||||
reorder: 0,
|
||||
};
|
||||
// CRA (21) then a length that runs past the end
|
||||
let s = [0, 0, 0, 2, 21 << 1, 1, 0xff, 0xff, 0xff, 0xff, 1];
|
||||
assert_eq!(info.nal_types(&s), vec![21]);
|
||||
assert!(info.is_irap(&s));
|
||||
assert!(!info.is_irap(&[0, 0, 0, 2, 1 << 1, 1]));
|
||||
}
|
||||
}
|
||||
+33
-13
@@ -16,8 +16,11 @@
|
||||
//! - [`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 hw;
|
||||
pub mod mkv;
|
||||
pub mod mp4;
|
||||
pub mod mpeg;
|
||||
@@ -28,7 +31,7 @@ pub mod video;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
pub use audio::AudioFileSource;
|
||||
pub use gop::{GopStats, gop_stats};
|
||||
pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders};
|
||||
pub use mkv::MkvSource;
|
||||
pub use mp4::Mp4Source;
|
||||
pub use mpeg::MpegSource;
|
||||
@@ -61,20 +64,21 @@ impl From<CodecError> for filmcraft_media::MediaError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Our own (pure-Rust) decoders, in the order they are tried.
|
||||
const BUILTIN_FACTORIES: [VideoDecoderFactory; 8] = [
|
||||
video::h264_factory,
|
||||
video::hevc_factory,
|
||||
video::vp9_factory,
|
||||
video::av1_factory,
|
||||
video::prores_factory,
|
||||
video::dnx_factory,
|
||||
video::mjpeg_factory,
|
||||
video::mpeg2_factory,
|
||||
];
|
||||
|
||||
fn factories() -> &'static RwLock<Vec<VideoDecoderFactory>> {
|
||||
static F: std::sync::OnceLock<RwLock<Vec<VideoDecoderFactory>>> = std::sync::OnceLock::new();
|
||||
F.get_or_init(|| {
|
||||
RwLock::new(vec![
|
||||
video::h264_factory,
|
||||
video::hevc_factory,
|
||||
video::vp9_factory,
|
||||
video::av1_factory,
|
||||
video::prores_factory,
|
||||
video::dnx_factory,
|
||||
video::mjpeg_factory,
|
||||
video::mpeg2_factory,
|
||||
])
|
||||
})
|
||||
F.get_or_init(|| RwLock::new(BUILTIN_FACTORIES.to_vec()))
|
||||
}
|
||||
|
||||
/// Register a video decoder factory (tried before previously registered ones).
|
||||
@@ -85,6 +89,11 @@ pub fn register_video_decoder(f: VideoDecoderFactory) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `f` is among the registered video decoder factories (startup diagnostics, tests).
|
||||
pub fn video_decoder_registered(f: VideoDecoderFactory) -> bool {
|
||||
factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
|
||||
}
|
||||
|
||||
/// Create a decoder for a sample entry.
|
||||
pub fn make_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<Box<dyn VideoDecoder>> {
|
||||
let g = factories().read().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -96,6 +105,17 @@ pub fn make_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<Box<
|
||||
Err(CodecError::Unsupported(format!("no decoder for {} video", entry.codec.name())))
|
||||
}
|
||||
|
||||
/// Create one of our own (software) decoders for a sample entry, skipping registered factories:
|
||||
/// what a hardware decoder falls back to when it fails mid-stream.
|
||||
pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<Box<dyn VideoDecoder>> {
|
||||
for f in BUILTIN_FACTORIES {
|
||||
if let Some(r) = f(entry) {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
Err(CodecError::Unsupported(format!("no decoder for {} video", entry.codec.name())))
|
||||
}
|
||||
|
||||
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
|
||||
/// MPEG-1/2 video elementary streams, standalone audio).
|
||||
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+152
-10
@@ -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,
|
||||
@@ -333,7 +359,8 @@ impl MediaSource for Mp4Source {
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let s0 = (start as f64 * ratio).floor() as i64 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
|
||||
let p0 = (start as f64 * ratio).floor() as i64;
|
||||
let s0 = p0 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
// Samples-per-unit: the track timescale is usually the sample rate for audio.
|
||||
let unit = src_rate as f64 / track.timescale.max(1) as f64;
|
||||
@@ -367,8 +394,9 @@ impl MediaSource for Mp4Source {
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
// resample the local window
|
||||
let frac = s0 as f64 - start as f64 * ratio;
|
||||
// Resample the local window. Its first sample is presentation sample `p0`; the edit list
|
||||
// offset is already in `s0` and must not shift the read position a second time.
|
||||
let frac = p0 as f64 - start as f64 * ratio;
|
||||
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
|
||||
for k in 0..frames {
|
||||
let pos = k as f64 * ratio - frac;
|
||||
@@ -462,3 +490,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 {
|
||||
|
||||
+70
-35
@@ -128,6 +128,42 @@ pub(crate) fn tight_plane<T: Copy>(src: Vec<T>, stride: usize, w: usize, h: usiz
|
||||
out
|
||||
}
|
||||
|
||||
/// Colour of an H.264 / HEVC picture from its VUI code points (ITU-T H.273), as the decoders
|
||||
/// report it: BT.709 by default with the matrix guessed from the size, the signalled matrix and
|
||||
/// transfer when known, and full range when flagged. Shared by the software and hardware
|
||||
/// decoders so the two are interchangeable.
|
||||
pub fn vui_color(width: u32, height: u32, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo {
|
||||
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..filmcraft_color::ColorInfo::REC709 };
|
||||
if let Some(m) = filmcraft_color::Matrix::from_code(matrix) {
|
||||
color.matrix = m;
|
||||
}
|
||||
if let Some(t) = filmcraft_color::Transfer::from_code(transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
if full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
color
|
||||
}
|
||||
|
||||
/// Pixel aspect ratio from a VUI sample aspect ratio ((0, 0) = unspecified = square).
|
||||
pub fn sar_par(sar: (u16, u16)) -> (u32, u32) {
|
||||
if sar.0 > 0 && sar.1 > 0 { (sar.0 as u32, sar.1 as u32) } else { (1, 1) }
|
||||
}
|
||||
|
||||
/// NAL length-prefix size of an `avcC` record (byte 4: lengthSizeMinusOne).
|
||||
pub fn avcc_length_size(avcc: &[u8]) -> usize {
|
||||
avcc.get(4).map_or(4, |b| (b & 3) as usize + 1)
|
||||
}
|
||||
|
||||
/// NAL length-prefix size and highest TemporalId of an `hvcC` record.
|
||||
pub fn hvcc_length_size_and_tid(hvcc: &[u8]) -> (usize, Option<u8>) {
|
||||
// hvcC byte 21: constantFrameRate(2) numTemporalLayers(3) temporalIdNested(1) lengthSizeMinusOne(2)
|
||||
let length_size = hvcc.get(21).map_or(4, |b| (b & 3) as usize + 1);
|
||||
let highest_tid = hvcc.get(21).map(|b| (b >> 3) & 7).filter(|&n| n > 0).map(|n| n - 1);
|
||||
(length_size, highest_tid)
|
||||
}
|
||||
|
||||
/// A factory returns `None` when it does not handle the entry.
|
||||
pub type VideoDecoderFactory = fn(&SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>>;
|
||||
|
||||
@@ -168,12 +204,14 @@ pub struct H264Decoder {
|
||||
|
||||
impl H264Decoder {
|
||||
pub fn new(avcc: Vec<u8>) -> Result<Self> {
|
||||
recycle_h264_planes();
|
||||
let dec = filmcraft_h264::Decoder::from_avcc(&avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let length_size = avcc.get(4).map_or(4, |b| (b & 3) as usize + 1);
|
||||
let length_size = avcc_length_size(&avcc);
|
||||
Ok(Self { avcc, dec, length_size, draft: false })
|
||||
}
|
||||
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: MXF, TS).
|
||||
pub fn annexb() -> Self {
|
||||
recycle_h264_planes();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::new(), length_size: 0, draft: false }
|
||||
}
|
||||
fn convert(p: filmcraft_h264::Picture) -> DecodedFrame {
|
||||
@@ -184,17 +222,8 @@ impl H264Decoder {
|
||||
let y = tight_plane(p.y, p.y_stride, w, h);
|
||||
let u = tight_plane(p.u, p.uv_stride, cw, ch);
|
||||
let v = tight_plane(p.v, p.uv_stride, cw, ch);
|
||||
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(p.width, p.height), ..filmcraft_color::ColorInfo::REC709 };
|
||||
if let Some(m) = filmcraft_color::Matrix::from_code(p.color.matrix) {
|
||||
color.matrix = m;
|
||||
}
|
||||
if let Some(t) = filmcraft_color::Transfer::from_code(p.color.transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
if p.color.full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
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 color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range);
|
||||
let par = sar_par(p.sar);
|
||||
let frame = VideoFrame {
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
@@ -242,6 +271,12 @@ impl VideoDecoder for H264Decoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The H.264 decoder's output planes come from the frames the caches evicted
|
||||
/// (`filmcraft_frame::pool`) instead of the allocator.
|
||||
fn recycle_h264_planes() {
|
||||
filmcraft_h264::set_plane_allocator(filmcraft_frame::pool::take_u8);
|
||||
}
|
||||
|
||||
/// Worker threads each H.264 decoder uses (frame threading; 1 on wasm).
|
||||
pub fn h264_threads() -> usize {
|
||||
filmcraft_h264::default_threads()
|
||||
@@ -265,9 +300,7 @@ pub struct HevcDecoder {
|
||||
impl HevcDecoder {
|
||||
pub fn new(hvcc: Vec<u8>) -> Result<Self> {
|
||||
let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
// hvcC byte 21: constantFrameRate(2) numTemporalLayers(3) temporalIdNested(1) lengthSizeMinusOne(2)
|
||||
let length_size = hvcc.get(21).map_or(4, |b| (b & 3) as usize + 1);
|
||||
let highest_tid = hvcc.get(21).map(|b| (b >> 3) & 7).filter(|&n| n > 0).map(|n| n - 1);
|
||||
let (length_size, highest_tid) = hvcc_length_size_and_tid(&hvcc);
|
||||
Ok(Self { hvcc, dec, length_size, highest_tid })
|
||||
}
|
||||
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: TS).
|
||||
@@ -299,19 +332,10 @@ impl HevcDecoder {
|
||||
alpha: None,
|
||||
},
|
||||
};
|
||||
let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(p.width, p.height), ..filmcraft_color::ColorInfo::REC709 };
|
||||
if let Some(m) = filmcraft_color::Matrix::from_code(p.color.matrix) {
|
||||
color.matrix = m;
|
||||
}
|
||||
if let Some(t) = filmcraft_color::Transfer::from_code(p.color.transfer) {
|
||||
color.transfer = t;
|
||||
}
|
||||
if p.color.full_range {
|
||||
color.range = filmcraft_color::Range::Full;
|
||||
}
|
||||
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 color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range);
|
||||
let par = sar_par(p.sar);
|
||||
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 +360,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 +410,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 +438,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 +448,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 +480,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 +503,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 +533,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 +573,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 +612,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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! MP4 audio against ffmpeg: ffmpeg writes an AAC track (with the edit list that skips the encoder
|
||||
//! priming), and reads at another sample rate must land on the same source samples as the native
|
||||
//! decode, however the requests are cut.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
/// A source whose rate differs from the sequence's is resampled per request. An AAC track in MP4
|
||||
/// carries an edit list that skips the encoder priming (ffmpeg: 1024 samples), and every request
|
||||
/// must still land on the same source samples: reading at 44.1 kHz in video-frame-sized pieces (as
|
||||
/// an export does) equals the 48 kHz decode interpolated at the same times.
|
||||
#[test]
|
||||
fn aac_mp4_resampled_reads_follow_the_edit_list() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let Some(f) = fixture(&ff, "tone_aac_48k.mp4", &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=3", "-c:a", "aac", "-b:a", "128k"]) else {
|
||||
panic!("could not write the AAC fixture")
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes("tone_aac_48k.mp4", bytes(&f)).unwrap();
|
||||
let native = src.audio(0, 2 * 48_000, 48_000).unwrap().channels[0].clone();
|
||||
let ratio = 48_000.0 / 44_100.0;
|
||||
// 30 fps at 44.1 kHz, starting mid-file and off the 48 kHz grid
|
||||
let (mut start, piece) = (22_051i64, 1470usize);
|
||||
let (mut worst, mut peak) = (0f32, 0f32);
|
||||
for _ in 0..20 {
|
||||
let got = src.audio(start, piece, 44_100).unwrap();
|
||||
for (k, &v) in got.channels[0].iter().enumerate() {
|
||||
let pos = (start + k as i64) as f64 * ratio;
|
||||
let i0 = pos.floor() as usize;
|
||||
let want = native[i0] + (native[i0 + 1] - native[i0]) * (pos - i0 as f64) as f32;
|
||||
worst = worst.max((v - want).abs());
|
||||
peak = peak.max(v.abs());
|
||||
}
|
||||
start += piece as i64;
|
||||
}
|
||||
let level = native.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak <= level * 1.01, "resampled peak {peak} above the source's {level}");
|
||||
assert!(worst < 1e-4, "resampled read differs from the source by {worst}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Decoded pictures are written into recycled plane buffers (`filmcraft_frame::pool`). A recycled
|
||||
//! buffer still holds an old picture, so the decoder must overwrite every byte: here the pool is
|
||||
//! primed with buffers full of 0xAA before decoding, and every frame must still match ffmpeg bit
|
||||
//! for bit. (Its own process: the pool is global.)
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_media::FrameRequest;
|
||||
|
||||
#[test]
|
||||
fn dirty_recycled_planes_decode_bit_exact() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
// 640×480: luma and chroma planes are both large enough to be pooled
|
||||
let Some(file) = fixture(
|
||||
&ff,
|
||||
"pool_h264.mp4",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=640x480:r=24:d=1,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-x264-params",
|
||||
"bframes=2:keyint=12",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (640usize, 480usize);
|
||||
let raw = ffmpeg_frames(&ff, &file, "yuv420p");
|
||||
let fsize = w * h * 3 / 2;
|
||||
let n = raw.len() / fsize;
|
||||
assert!(n >= 20, "{n} frames");
|
||||
let reference = |i: usize| raw_planes(&raw[i * fsize..(i + 1) * fsize], w, h, w / 2, h / 2, 1);
|
||||
|
||||
// prime the pool with dirty buffers of exactly the plane sizes the decoder asks for
|
||||
for _ in 0..8 {
|
||||
let dirty = |len: usize| Arc::new(vec![0xAAu8; len]);
|
||||
let f = VideoFrame {
|
||||
data: PixelData::Yuv8 { planes: [dirty(w * h), dirty(w * h / 4), dirty(w * h / 4)], chroma: Chroma::C420, alpha: None },
|
||||
..VideoFrame::rgba8(w as u32, h as u32, vec![0; w * h * 4])
|
||||
};
|
||||
filmcraft_frame::pool::recycle(Arc::new(f));
|
||||
}
|
||||
let reused_before = filmcraft_frame::pool::stats().reused;
|
||||
|
||||
let src = filmcraft_codecs::open_bytes("pool_h264.mp4", bytes(&file)).expect("open");
|
||||
let rate = src.info().frame_rate();
|
||||
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, "frame {i} decoded into a recycled buffer");
|
||||
}
|
||||
assert!(filmcraft_frame::pool::stats().reused > reused_before, "the decoder took the dirty pooled buffers");
|
||||
}
|
||||
+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)> {
|
||||
|
||||
+208
-53
@@ -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);
|
||||
@@ -317,21 +351,66 @@ pub fn delete_items(seq: &mut Sequence, items: &[ClipId]) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Ripple delete track items: remove them and close the resulting gaps.
|
||||
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
|
||||
// Collect per-track ranges to close (merged), processed right-to-left.
|
||||
let mut ranges: Vec<(TrackId, TimeRange)> = Vec::new();
|
||||
/// The stretches a ripple delete of `items` closes, in time order: the time the deleted items
|
||||
/// covered, less whatever stays on the tracks that lose an item. Every such track then moves up
|
||||
/// by the same amount, into time that is free on all of them, so later clips stay in sync and
|
||||
/// nothing is overwritten. A clip and its linked sound with a split edit have different edges;
|
||||
/// closing each one's own range would move the tracks twice. Locked tracks lose nothing and do
|
||||
/// not count.
|
||||
fn ripple_delete_spans(seq: &Sequence, items: &[ClipId]) -> Vec<TimeRange> {
|
||||
let (mut deleted, mut kept) = (Vec::new(), Vec::new());
|
||||
for tr in seq.all_tracks().filter(|t| !t.locked && t.items.iter().any(|i| items.contains(&i.id))) {
|
||||
for i in &tr.items {
|
||||
if items.contains(&i.id) { deleted.push(i.range()) } else { kept.push(i.range()) }
|
||||
}
|
||||
}
|
||||
let kept = merged(kept);
|
||||
let mut spans = Vec::new();
|
||||
for r in merged(deleted) {
|
||||
let mut at = r.start;
|
||||
for k in kept.iter().filter(|k| k.overlaps(&r)) {
|
||||
if k.start > at {
|
||||
spans.push(TimeRange::from_bounds(at, k.start));
|
||||
}
|
||||
at = at.max(k.end());
|
||||
}
|
||||
if at < r.end() {
|
||||
spans.push(TimeRange::from_bounds(at, r.end()));
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// `ranges` in time order, with the ones that touch or overlap joined.
|
||||
fn merged(mut ranges: Vec<TimeRange>) -> Vec<TimeRange> {
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut out: Vec<TimeRange> = Vec::new();
|
||||
for r in ranges {
|
||||
match out.last_mut() {
|
||||
Some(last) if r.start <= last.end() => *last = TimeRange::from_bounds(last.start, last.end().max(r.end())),
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ripple delete track items: remove them and close the resulting gaps. Returns the stretches
|
||||
/// that were closed, in time order. Refused when no gap can close (what stays on the items'
|
||||
/// tracks covers all of their time): that would be a plain delete.
|
||||
pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<Vec<TimeRange>> {
|
||||
// Collect the tracks that lose an item; the gaps close right-to-left.
|
||||
let mut affected: Vec<TrackId> = Vec::new();
|
||||
for c in items {
|
||||
let (tid, it) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
|
||||
ranges.push((tid, it.range()));
|
||||
let (tid, _) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;
|
||||
affected.push(tid);
|
||||
}
|
||||
let spans = ripple_delete_spans(seq, items);
|
||||
if spans.is_empty() && !items.is_empty() {
|
||||
let locked = affected.iter().all(|t| seq.track(*t).is_some_and(|tr| tr.locked));
|
||||
return Err(if locked { EditError::Locked } else { EditError::Other("no gap can close: other clips on these tracks cover the deleted time".into()) });
|
||||
}
|
||||
// Group identical ranges across tracks (linked A/V) into one ripple.
|
||||
let mut spans: Vec<TimeRange> = ranges.iter().map(|r| r.1).collect();
|
||||
spans.sort_by_key(|r| (r.start, r.duration));
|
||||
spans.dedup();
|
||||
let mut work = seq.clone();
|
||||
delete_items(&mut work, items);
|
||||
let affected: Vec<TrackId> = ranges.iter().map(|r| r.0).collect();
|
||||
for span in spans.iter().rev() {
|
||||
// the gap actually closable: from span.start to the next material on affected tracks
|
||||
for tr in work.all_tracks_mut() {
|
||||
@@ -349,7 +428,7 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
/// Close the gap containing `t` on a track (Ripple Delete on a gap).
|
||||
@@ -412,6 +491,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 +522,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 +595,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;
|
||||
@@ -516,8 +604,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
||||
|
||||
/// Ripple-trim one edge of a group of linked items (e.g. a clip and its audio partners) as a single
|
||||
/// edit: every member's edge moves by the same delta, later material on the members' tracks and on
|
||||
/// sync-locked tracks shifts by the change. Only tracks without a group member can block the edit
|
||||
/// (a sync-locked track whose material would be overwritten).
|
||||
/// sync-locked tracks shifts by the change, and so does a clip that starts before the cut, reaches
|
||||
/// past it and is linked to a clip that shifts (a split edit). The edit is refused when it would
|
||||
/// overwrite material on a sync-locked track, or when such a linked clip has no room to follow.
|
||||
pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
let Some(&first) = clips.first() else { return Ok(Tick::ZERO) };
|
||||
let mut d = delta;
|
||||
@@ -563,28 +652,56 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
||||
origins.iter().find(|(t, _)| *t == tid).map(|o| o.1).unwrap_or_default()
|
||||
};
|
||||
let shift = if edge == Edge::In { -d } else { d };
|
||||
for tr in work.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
continue;
|
||||
}
|
||||
let member = origins.iter().find(|(t, _)| *t == tr.id).map(|o| o.1);
|
||||
if member.is_none() && !tr.sync_lock {
|
||||
continue;
|
||||
}
|
||||
let from = member.unwrap_or(main_from);
|
||||
if member.is_none() && shift < Tick::ZERO {
|
||||
// where the shift starts on each track that ripples: the members' tracks and the sync-locked ones
|
||||
let rippling: Vec<(TrackId, Tick, bool)> = work
|
||||
.all_tracks()
|
||||
.filter(|tr| !tr.locked)
|
||||
.filter_map(|tr| match origins.iter().find(|(t, _)| *t == tr.id) {
|
||||
Some(o) => Some((tr.id, o.1, true)),
|
||||
None => tr.sync_lock.then_some((tr.id, main_from, false)),
|
||||
})
|
||||
.collect();
|
||||
// A split edit: the linked partner of a later clip can start before the shift does and reach
|
||||
// past it (its sound leads the cut). It follows the clip it is linked to, or the two would
|
||||
// drift apart.
|
||||
// (the trimmed clips' own partners are trimmed, or left alone, by the caller: they never follow)
|
||||
let own_links: Vec<u64> = clips.iter().filter_map(|c| work.find_item(*c).and_then(|(_, i)| i.link)).collect();
|
||||
let mut shifting_links: Vec<u64> = Vec::new();
|
||||
for (tid, from, _) in &rippling {
|
||||
let Some(tr) = work.track(*tid) else { continue };
|
||||
let later = tr.items.iter().filter(|i| !clips.contains(&i.id) && i.start >= *from);
|
||||
shifting_links.extend(later.filter_map(|i| i.link).filter(|l| !own_links.contains(l)));
|
||||
}
|
||||
for (tid, from, member) in rippling {
|
||||
let Some(tr) = work.track_mut(tid) else { continue };
|
||||
let follows = |i: &TrackItem| i.start < from && i.end() > from && i.link.is_some_and(|l| shifting_links.contains(&l));
|
||||
if !member && shift < Tick::ZERO {
|
||||
let at = if edge == Edge::In { from - Tick(1) } else { from };
|
||||
if !track_range_empty(tr, TimeRange::new(at + shift, -shift)) {
|
||||
let closing = TimeRange::new(at + shift, -shift);
|
||||
if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i)) {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
}
|
||||
let followers: Vec<ClipId> = tr.items.iter().filter(|i| !clips.contains(&i.id) && follows(i)).map(|i| i.id).collect();
|
||||
for i in &mut tr.items {
|
||||
if !clips.contains(&i.id) && i.start >= from {
|
||||
if !clips.contains(&i.id) && (i.start >= from || followers.contains(&i.id)) {
|
||||
i.start += shift;
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
// a transition into a follower from a clip that stayed behind no longer sits on a cut
|
||||
let ends: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.end())).collect();
|
||||
let starts: Vec<(ClipId, Tick)> = tr.items.iter().map(|i| (i.id, i.start)).collect();
|
||||
tr.transitions.retain(|t| match (t.from, t.to) {
|
||||
(Some(a), Some(b)) if followers.contains(&b) => ends.iter().find(|e| e.0 == a).map(|e| e.1) == starts.iter().find(|x| x.0 == b).map(|x| x.1),
|
||||
_ => true,
|
||||
});
|
||||
// a follower with no room (a clip in its way, or the sequence start) blocks the edit
|
||||
if tr.items.first().is_some_and(|i| i.start < Tick::ZERO) || tr.items.windows(2).any(|w| w[0].end() > w[1].start) {
|
||||
return Err(EditError::SyncLockConflict);
|
||||
}
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
work.check().map_err(EditError::Other)?;
|
||||
*seq = work;
|
||||
Ok(d)
|
||||
@@ -603,11 +720,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 +737,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 +746,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 +773,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 +794,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)
|
||||
}
|
||||
|
||||
@@ -702,37 +824,70 @@ pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, c
|
||||
|
||||
/// Set speed/duration (Clip ▸ Speed/Duration…). `ripple` shifts following material.
|
||||
pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
|
||||
if speed <= 0.0 {
|
||||
set_speed_group(seq, &[clip], speed, reverse, ripple, ctx)
|
||||
}
|
||||
|
||||
/// Speed/Duration on a clip and its linked partners on other tracks (its sound) as a single edit.
|
||||
/// `clips` holds at most one clip per track. Every member takes the speed, and with `ripple` the
|
||||
/// later material moves once, on the members' tracks and on sync-locked tracks. Rippling each
|
||||
/// member on its own would move the later clips once per member. When the members' lengths change
|
||||
/// by different amounts (a split edit), the later material moves by the largest change, so no
|
||||
/// member is overlapped and the tracks stay in sync. Nothing changes when the edit fails.
|
||||
pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> {
|
||||
if !(speed.is_finite() && speed > 0.0) {
|
||||
return Err(EditError::Other("speed must be positive".into()));
|
||||
}
|
||||
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let src_len = it.source_out() - it.source_in;
|
||||
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).max(ctx.min_duration);
|
||||
let old_end = it.end();
|
||||
let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
let (_, next_start) = neighbours(tr, clip);
|
||||
if !ripple {
|
||||
new_dur = new_dur.min(next_start - it.start);
|
||||
}
|
||||
let delta = new_dur - it.duration;
|
||||
{
|
||||
let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
let mut work = seq.clone();
|
||||
// per member track: where its later material starts, and by how much the member's length changed
|
||||
let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new();
|
||||
for clip in clips {
|
||||
let (tid, it) = work.find_item(*clip).ok_or(EditError::NoItem(*clip))?;
|
||||
if origins.iter().any(|o| o.0 == tid) {
|
||||
return Err(EditError::Other("clips on one track change speed one at a time".into()));
|
||||
}
|
||||
let src_len = it.source_out() - it.source_in;
|
||||
// a tiny speed saturates the cast: keep the clip's end representable
|
||||
let room = Tick(Tick::MAX.0.saturating_sub(it.start.0.max(0)));
|
||||
let mut new_dur = Tick((src_len.0 as f64 / speed).round() as i64).min(room).max(ctx.min_duration);
|
||||
let old_end = it.end();
|
||||
let tr = work.track(tid).ok_or(EditError::NoTrack(tid))?;
|
||||
let (_, next_start) = neighbours(tr, *clip);
|
||||
if !ripple {
|
||||
new_dur = new_dur.min(next_start - it.start);
|
||||
}
|
||||
let delta = new_dur - it.duration;
|
||||
let (_, it) = work.find_item_mut(*clip).ok_or(EditError::NoItem(*clip))?;
|
||||
it.speed = speed;
|
||||
it.reverse = reverse;
|
||||
it.duration = new_dur;
|
||||
origins.push((tid, old_end, delta));
|
||||
}
|
||||
if ripple && delta != Tick::ZERO {
|
||||
for tr in seq.all_tracks_mut() {
|
||||
if !tr.locked && (tr.id == tid || tr.sync_lock) {
|
||||
for i in &mut tr.items {
|
||||
if i.id != clip && i.start >= old_end {
|
||||
i.start += delta;
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
let main_from = origins.first().map(|o| o.1).unwrap_or_default();
|
||||
let shift = origins.iter().map(|o| o.2).max().unwrap_or_default();
|
||||
if ripple && shift != Tick::ZERO {
|
||||
for tr in work.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
continue;
|
||||
}
|
||||
let member = origins.iter().find(|o| o.0 == tr.id).map(|o| o.1);
|
||||
if member.is_none() && !tr.sync_lock {
|
||||
continue;
|
||||
}
|
||||
let from = member.unwrap_or(main_from);
|
||||
for i in &mut tr.items {
|
||||
if !clips.contains(&i.id) && i.start >= from {
|
||||
// later clips must still end on the representable timeline
|
||||
let start = i.start.0.checked_add(shift.0).filter(|s| s.checked_add(i.duration.0).is_some());
|
||||
i.start = Tick(start.ok_or_else(|| EditError::Other("the speed change would move later clips past the end of the timeline".into()))?);
|
||||
}
|
||||
}
|
||||
tr.sort();
|
||||
}
|
||||
}
|
||||
if ripple {
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+252
-1
@@ -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
|
||||
@@ -148,6 +148,81 @@ fn ripple_delete_and_conflict() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_delete_closes_the_time_free_on_every_track() {
|
||||
// four clips on V1 with their sounds on A1; the second sound starts 2 early and ends 2 late
|
||||
let build = || {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let v: Vec<ClipId> = [(0, 10), (10, 10), (20, 10), (30, 10)].iter().map(|(s, d)| fx.put(v1, *s, *d, 0)).collect();
|
||||
let a: Vec<ClipId> = [(0, 8), (8, 14), (22, 8), (30, 10)].iter().map(|(s, d)| fx.put(a1, *s, *d, 0)).collect();
|
||||
(fx, v, a)
|
||||
};
|
||||
let span = |start, len| TimeRange::new(f(start), f(len));
|
||||
// one clip with a split edit on both sides: the picture's stretch closes, everything later stays in sync
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1]]), Ok(vec![span(10, 10)]));
|
||||
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10), (20, 10)]);
|
||||
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (12, 8), (20, 10)]);
|
||||
fx.seq.check().unwrap();
|
||||
// two neighbours with a split edit between them close as one stretch
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[1], a[1], v[2], a[2]]), Ok(vec![span(10, 20)]));
|
||||
assert_eq!(fx.spans(fx.v(0)), vec![(0, 10), (10, 10)]);
|
||||
assert_eq!(fx.spans(fx.a(0)), vec![(0, 8), (10, 10)]);
|
||||
// clips apart in time each close their own gap
|
||||
let (mut fx, v, a) = build();
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], a[0], v[3], a[3]]), Ok(vec![span(0, 8), span(30, 10)]));
|
||||
fx.seq.check().unwrap();
|
||||
|
||||
// straight cuts with the sounds on alternating tracks: V1 [0,10) [10,20) [20,30), A1 [0,10) [20,30), A2 [10,20)
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1, a2) = (fx.v(0), fx.a(0), fx.a(1));
|
||||
let v: Vec<ClipId> = [0, 10, 20].iter().map(|s| fx.put(v1, *s, 10, 0)).collect();
|
||||
let (s0, s1) = (fx.put(a1, 0, 10, 0), fx.put(a2, 10, 10, 0));
|
||||
fx.put(a1, 20, 10, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[v[0], s0, v[1], s1]), Ok(vec![span(0, 20)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1), fx.spans(a2)), (vec![(0, 10)], vec![(0, 10)], vec![]));
|
||||
|
||||
// a sound shorter than its picture with nothing round it: the whole picture closes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 20, 10, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
|
||||
// with the neighbours' sound right up against it, only the sound's stretch can close
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
fx.put(a1, 0, 12, 0);
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.put(a1, 18, 12, 0);
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(12, 6)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(14, 10)], vec![(0, 12), (12, 12)]));
|
||||
|
||||
// a linked sound on a locked track stays, and does not shrink the gap that closes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let (p, s) = (fx.put(v1, 10, 10, 0), fx.put(a1, 12, 6, 0));
|
||||
fx.put(v1, 20, 10, 0);
|
||||
fx.seq.track_mut(a1).unwrap().locked = true;
|
||||
fx.seq.track_mut(a1).unwrap().sync_lock = false;
|
||||
assert_eq!(ripple_delete_items(&mut fx.seq, &[p, s]), Ok(vec![span(10, 10)]));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(10, 10)], vec![(12, 6)]));
|
||||
|
||||
// nothing can close: what stays on each track covers the other track's deleted time. Refused, unchanged.
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let x = fx.put(v1, 10, 10, 0);
|
||||
fx.put(v1, 30, 10, 0);
|
||||
fx.put(a1, 10, 10, 0);
|
||||
let y = fx.put(a1, 30, 10, 0);
|
||||
let before = fx.seq.clone();
|
||||
assert!(matches!(ripple_delete_items(&mut fx.seq, &[x, y]), Err(EditError::Other(_))));
|
||||
assert_eq!(fx.seq, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_gap_works() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -192,6 +267,79 @@ fn ripple_trim_shifts_following() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 12), (12, 10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_trim_group_takes_a_split_edit_along() {
|
||||
// V1: a [0,10) then b [10,20). b's linked sound leads it by 4 on sync-locked A1: [6,20).
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let sound = fx.put(a1, 6, 14, 0);
|
||||
for c in [b, sound] {
|
||||
fx.seq.find_item_mut(c).unwrap().1.link = Some(7);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 13), (13, 10)], vec![(9, 14)]), "longer: the early sound follows its picture");
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(4, 14)]), "shorter: it follows too");
|
||||
// the sound would start before the sequence does: refused, nothing changes
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(5), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
// another clip right before the sound leaves it no room either
|
||||
fx.put(a1, 0, 4, 0);
|
||||
assert_eq!(ripple_trim_group(&mut fx.seq, &[a], Edge::Out, -f(2), &mut Fx::ctx(&mut n)), Err(EditError::SyncLockConflict));
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 8), (8, 10)], vec![(0, 4), (4, 14)]), "unchanged on failure");
|
||||
// a linked clip that ends before the cut is not a split edit and stays where it is
|
||||
let early = fx.put(fx.a(1), 0, 3, 0);
|
||||
fx.seq.find_item_mut(early).unwrap().1.link = Some(7);
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(fx.a(1)), vec![(0, 3)]);
|
||||
fx.seq.check().unwrap();
|
||||
|
||||
// In edge: V1 b [10,20), c [20,30); c's sound starts 12 early on A1: [8,30). b loses 3 at its head.
|
||||
let mut fx = Fx::new();
|
||||
let (v1, v2, a1, a2) = (fx.v(0), fx.v(1), fx.a(0), fx.a(1));
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let c = fx.put(v1, 20, 10, 0);
|
||||
let c_sound = fx.put(a1, 8, 22, 0);
|
||||
// two more clips linked to b that the caller did not put in the group
|
||||
let b_sound = fx.put(a2, 10, 10, 0);
|
||||
let b_top = fx.put(v2, 12, 8, 0);
|
||||
for (x, link) in [(c, 7), (c_sound, 7), (b, 8), (b_sound, 8), (b_top, 8)] {
|
||||
fx.seq.find_item_mut(x).unwrap().1.link = Some(link);
|
||||
}
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[b], Edge::In, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(v1), fx.spans(a1)), (vec![(0, 10), (10, 7), (17, 10)], vec![(5, 22)]), "c and its early sound move up together");
|
||||
assert_eq!((fx.spans(v2), fx.spans(a2)), (vec![(9, 8)], vec![(10, 10)]), "the trimmed clip's own partners never follow");
|
||||
|
||||
// a crossfade into the early sound from a clip that stays behind no longer sits on a cut: it goes
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 10, 10, 0);
|
||||
let stays = fx.put(a1, 0, 6, 0);
|
||||
let sound = fx.put(a1, 6, 14, 0);
|
||||
for x in [b, sound] {
|
||||
fx.seq.find_item_mut(x).unwrap().1.link = Some(7);
|
||||
}
|
||||
fx.seq.track_mut(a1).unwrap().transitions = vec![Transition {
|
||||
id: TransitionId(1),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(4),
|
||||
duration: f(4),
|
||||
from: Some(stays),
|
||||
to: Some(sound),
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
}];
|
||||
let mut n = fx.next;
|
||||
ripple_trim_group(&mut fx.seq, &[a], Edge::Out, f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(a1), vec![(0, 6), (9, 14)]);
|
||||
assert!(fx.seq.track(a1).unwrap().transitions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_slip_slide() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -210,6 +358,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();
|
||||
@@ -222,6 +417,44 @@ fn rate_stretch_and_speed() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_on_a_linked_group_ripples_once() {
|
||||
// V1: a [0,10), b [10,20). A1: a's sound [0,10), b's sound [10,20).
|
||||
let build = |sound_len: i64| {
|
||||
let mut fx = Fx::new();
|
||||
let (v1, a1) = (fx.v(0), fx.a(0));
|
||||
let a = fx.put(v1, 0, 10, 0);
|
||||
fx.put(v1, 10, 10, 0);
|
||||
let sound = fx.put(a1, 0, sound_len, 0);
|
||||
fx.put(a1, sound_len, 10, 0);
|
||||
(fx, a, sound)
|
||||
};
|
||||
for (speed, picture, later) in [(0.5, (0, 20), (20, 10)), (2.0, (0, 5), (5, 10))] {
|
||||
let (mut fx, a, sound) = build(10);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], speed, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![picture, later], vec![picture, later]), "{speed}");
|
||||
}
|
||||
// a split edit: the sound is 4 longer. Later clips move by the larger change, on both tracks.
|
||||
let (mut fx, a, sound) = build(14);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], 0.5, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 20), (24, 10)], vec![(0, 28), (28, 10)]));
|
||||
let (mut fx, a, sound) = build(14);
|
||||
let mut n = fx.next;
|
||||
set_speed_group(&mut fx.seq, &[a, sound], 2.0, false, true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((fx.spans(fx.v(0)), fx.spans(fx.a(0))), (vec![(0, 5), (5, 10)], vec![(0, 7), (9, 10)]));
|
||||
fx.seq.check().unwrap();
|
||||
// two clips of one track are not one group; a missing clip changes nothing
|
||||
let (mut fx, a, _) = build(10);
|
||||
let b = fx.seq.video_tracks[0].items[1].id;
|
||||
let before = fx.seq.clone();
|
||||
let mut n = fx.next;
|
||||
assert!(set_speed_group(&mut fx.seq, &[a, b], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
|
||||
assert!(set_speed_group(&mut fx.seq, &[a, ClipId(1)], 2.0, false, true, &mut Fx::ctx(&mut n)).is_err());
|
||||
assert_eq!(fx.seq, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_with_insert_and_overwrite() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -320,3 +553,21 @@ fn merge(v: Vec<(i64, i64)>) -> Vec<(i64, i64)> {
|
||||
fn unused_kind_import() {
|
||||
let _ = TrackKind::Video;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_speeds_are_refused_or_clamped_never_overflow() {
|
||||
// a tiny speed used to saturate the new duration to i64::MAX, then the ripple shift overflowed
|
||||
for speed in [1e-7, 1e-300, f64::MIN_POSITIVE, f64::NAN, f64::INFINITY, -1.0, 0.0] {
|
||||
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 before = fx.seq.clone();
|
||||
let mut n = fx.next;
|
||||
let r = set_speed_group(&mut fx.seq, &[a], speed, false, true, &mut Fx::ctx(&mut n));
|
||||
match r {
|
||||
Ok(()) => fx.seq.check().unwrap(),
|
||||
Err(_) => assert_eq!(format!("{:?}", fx.seq), format!("{before:?}"), "{speed}: a refused edit changes nothing"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -82,6 +82,11 @@ pub struct PlaybackPrefs {
|
||||
/// skip deblocking (faster; those frames are approximate). Never used for exports, renders or
|
||||
/// a paused frame. Off by default.
|
||||
pub draft_decode: bool,
|
||||
/// "Hardware decoding": `auto` (default: the system's hardware decoder for streams it
|
||||
/// supports, e.g. VideoToolbox H.264 / HEVC on macOS, falling back to FilmCraft's own decoder)
|
||||
/// or `off` (always FilmCraft's own decoders). Media already open keeps its decoder until it
|
||||
/// is reopened.
|
||||
pub hardware_decoding: String,
|
||||
}
|
||||
|
||||
impl Default for PlaybackPrefs {
|
||||
@@ -94,6 +99,7 @@ impl Default for PlaybackPrefs {
|
||||
enable_transmit: false,
|
||||
disable_video_in_background: true,
|
||||
draft_decode: false,
|
||||
hardware_decoding: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
-13
@@ -237,9 +237,15 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
can_replace_from_source,
|
||||
|s, p| replace_with(s, p, Replace::MatchFrame),
|
||||
),
|
||||
spec("clip.replaceFromBin", "From Bin", &["Clip", "Replace With Clip"], None, r#"{"clips":[id]?,"item":id?}"#, can_replace_from_bin, |s, p| {
|
||||
replace_with(s, p, Replace::Bin)
|
||||
}),
|
||||
spec(
|
||||
"clip.replaceFromBin",
|
||||
"From Bin",
|
||||
&["Clip", "Replace With Clip"],
|
||||
None,
|
||||
r#"{"clips":[id]?,"item":id?,"keepSourceIn":bool=false}"#,
|
||||
can_replace_from_bin,
|
||||
|s, p| replace_with(s, p, Replace::Bin),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -424,6 +430,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 +763,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 +1103,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 +1188,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 +1252,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 +1609,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")))?;
|
||||
@@ -1582,12 +1670,22 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
Replace::Bin => item_range(&s.project, new_item, s.prefs.timeline.still_duration(s.sequence_rate())).map(|r| r.start).unwrap_or_default(),
|
||||
_ => source_range(s).map(|(_, r)| r.start).unwrap_or_default(),
|
||||
};
|
||||
// From Bin starts at the item's In point, as Premiere does; `keepSourceIn` keeps each clip's own
|
||||
let keep_in = how == Replace::Bin && bool_p(p, "keepSourceIn").unwrap_or(false);
|
||||
// ...but a subclip that restricts trims has no media outside its range: a kept In stays on
|
||||
// one of its frames (first frame, last frame)
|
||||
let keep_within = match &pi.kind {
|
||||
ItemKind::Subclip { range, restrict_trims: true, .. } => {
|
||||
Some((range.start, (range.end() - item_rate(&s.project, new_item).frame_duration()).max(range.start)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let src_ph = s.state.source_playhead;
|
||||
let ph = s.playhead();
|
||||
let targets = with_links(s, &clips_p(s, p));
|
||||
let src_channels = pi.as_media().and_then(|m| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default();
|
||||
let replaced = s.edit_sequence("Replace With Clip", |q, _, _| {
|
||||
let mut out = Vec::new();
|
||||
let (replaced, clamped) = s.edit_sequence("Replace With Clip", |q, _, _| {
|
||||
let (mut out, mut clamped) = (Vec::new(), Vec::new());
|
||||
for c in &targets {
|
||||
let Some((tid, _)) = q.find_item(*c) else { continue };
|
||||
let kind = q.track(tid).map(|t| t.kind).unwrap_or_default();
|
||||
@@ -1598,6 +1696,13 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
let src_in = match how {
|
||||
// the Source monitor frame lines up with the sequence playhead
|
||||
Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64),
|
||||
_ if keep_in => {
|
||||
let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in);
|
||||
if kept != it.source_in {
|
||||
clamped.push(c.0);
|
||||
}
|
||||
kept
|
||||
}
|
||||
_ => in_point,
|
||||
};
|
||||
if src_in < Tick::ZERO {
|
||||
@@ -1617,7 +1722,37 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result<Value> {
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("the replacement has no video or audio for the selected clips".into()));
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, clamped))
|
||||
})?;
|
||||
Ok(json!({"clips": replaced, "item": new_item.0}))
|
||||
let mut result = json!({"clips": replaced, "item": new_item.0, "short": short_clips(s, &replaced)});
|
||||
if keep_in {
|
||||
// the clips whose In was outside a trims-restricted subclip and now sits on its nearest frame
|
||||
result["sourceInClamped"] = json!(clamped);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The clips among `clips` that play past the end of their media, with how many sequence frames
|
||||
/// of the clip have no media (at most the clip's own length). The edit is left as it is (the
|
||||
/// renderer holds the last frame there); this is so whoever replaced the clip knows.
|
||||
fn short_clips(s: &Session, clips: &[u64]) -> Vec<Value> {
|
||||
let Some(q) = s.active_sequence() else { return Vec::new() };
|
||||
let frame = q.settings.frame_rate.frame_duration().0.max(1);
|
||||
clips
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let (_, it) = q.find_item(ClipId(*c))?;
|
||||
// stills, mattes and other sources without an end are never short; nor is a held frame
|
||||
let media_end = crate::media_duration(&s.project, &s.media, it.item)?;
|
||||
let over = it.source_out().0.saturating_sub(media_end.0);
|
||||
if over <= 0 || it.frame_hold.is_some() {
|
||||
return None;
|
||||
}
|
||||
// media ticks past the end, as sequence ticks of this clip
|
||||
let length = it.duration.0.max(0);
|
||||
let ticks = ((over as f64 / it.speed.abs().max(1e-9)).round().min(length as f64) as i64).min(length);
|
||||
let frames = ticks.saturating_add(frame - 1) / frame;
|
||||
(frames > 0).then(|| json!({"clip": c, "shortByFrames": frames}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -602,6 +602,154 @@ fn replace_with_clip_from_source_match_frame_and_bin() {
|
||||
assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err());
|
||||
}
|
||||
|
||||
/// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does);
|
||||
/// `keepSourceIn` keeps the clip's place in the media instead.
|
||||
#[test]
|
||||
fn replace_from_bin_keeps_the_source_in_on_request() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let c = v1(&s)[3].clone();
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
// disabled without a clip and a replacement, with or without the new parameter
|
||||
s.execute("timeline.select", json!({"clips": []})).unwrap();
|
||||
assert!(matches!(s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})), Err(crate::EngineError::Disabled(..))));
|
||||
// give the clip a source In that is not the start of its media
|
||||
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
|
||||
let trimmed = clip(&s, c.id.0);
|
||||
assert_eq!(trimmed.source_in, c.source_in + rate.tick_of(7));
|
||||
select(&mut s, &[c.id.0]);
|
||||
s.execute("project.select", json!({"items": [dunes.0]})).unwrap();
|
||||
let before = s.project.clone();
|
||||
// default: the replacement starts at the item's In point, and the result is what it was
|
||||
let r = s.execute("clip.replaceFromBin", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).item, clip(&s, c.id.0).source_in), (dunes, Tick::ZERO));
|
||||
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": []}));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
// keepSourceIn: the same place in the new media, everything else as before
|
||||
let r = s.execute("clip.replaceFromBin", json!({"keepSourceIn": true})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!((got.item, got.source_in, got.start, got.duration), (dunes, trimmed.source_in, trimmed.start, trimmed.duration));
|
||||
assert_eq!(r, json!({"clips": [c.id.0], "item": dunes.0, "short": [], "sourceInClamped": []}));
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("clip.replaceFromBin", json!({"keepSourceIn": false})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).source_in, Tick::ZERO);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// a subclip that restricts trims has no media outside its range: a kept In before or after it
|
||||
// moves to its first or last frame, and the result names the clip
|
||||
let sub = |s: &mut Session, from: Tick, to: Tick, restrict: bool| {
|
||||
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": from.0, "end": to.0, "restrictTrims": restrict})).unwrap();
|
||||
ItemId(r["item"].as_u64().unwrap())
|
||||
};
|
||||
let kept = trimmed.source_in;
|
||||
let media_frame = s.project.item(forest).unwrap().as_media().unwrap().frame_rate().frame_duration();
|
||||
assert!(kept > rate.tick_of(10) && media_frame > Tick::ZERO);
|
||||
let later = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), true);
|
||||
let earlier = sub(&mut s, Tick::ZERO, kept - rate.tick_of(2), true);
|
||||
let loose = sub(&mut s, kept + rate.tick_of(3), kept + rate.tick_of(15), false);
|
||||
for (item, want, clamped) in [(later, kept + rate.tick_of(3), true), (earlier, kept - rate.tick_of(2) - media_frame, true), (loose, kept, false)] {
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": item.0, "keepSourceIn": true})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!((got.item, got.source_in, got.start, got.duration), (item, want, trimmed.start, trimmed.duration));
|
||||
assert_eq!(r["sourceInClamped"], if clamped { json!([c.id.0]) } else { json!([]) });
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
// a kept In that is already inside the subclip stays where it is
|
||||
let around = sub(&mut s, kept - rate.tick_of(5), kept + rate.tick_of(23), true);
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": around.0, "keepSourceIn": true})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).source_in, &r["sourceInClamped"]), (kept, &json!([])));
|
||||
// hostile values of the flag fall back to the default
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
for junk in [json!("yes"), json!(1), json!(null), json!([true])] {
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": dunes.0, "keepSourceIn": junk})).unwrap();
|
||||
assert_eq!((clip(&s, c.id.0).source_in, r.get("sourceInClamped")), (Tick::ZERO, None));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A replacement shorter than the clip's slot left the clip running past the end of its media
|
||||
/// without a word. The three Replace With Clip commands now say which clips are short, by how
|
||||
/// many sequence frames.
|
||||
#[test]
|
||||
fn replace_with_clip_reports_clips_that_run_past_the_new_media() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let demo_seq = s.state.active_sequence.unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let c = v1(&s)[3].clone();
|
||||
let slot = rate.frame_at(c.duration);
|
||||
let forest = item_named(&s, "Misty_Forest.mp4");
|
||||
let forest_len = s.project.item(forest).unwrap().as_media().unwrap().info.duration;
|
||||
assert!(slot > 12 && forest_len > c.duration + rate.tick_of(40), "the clip is longer than 12 frames and shorter than the media");
|
||||
let state = |s: &Session| {
|
||||
let it = clip(s, c.id.0);
|
||||
(it.item, it.source_in, it.start, it.duration)
|
||||
};
|
||||
select(&mut s, &[c.id.0]);
|
||||
|
||||
// From Bin, with 12 frames of media (a subclip that restricts trims): the edit is what it
|
||||
// always was, and the result says the clip is short
|
||||
let r = s.execute("clip.makeSubclip", json!({"item": forest.0, "start": rate.tick_of(10).0, "end": rate.tick_of(22).0})).unwrap();
|
||||
let short = ItemId(r["item"].as_u64().unwrap());
|
||||
let before = s.project.clone();
|
||||
let r = s.execute("clip.replaceFromBin", json!({"item": short.0})).unwrap();
|
||||
assert_eq!(state(&s), (short, rate.tick_of(10), c.start, c.duration), "the edit itself is not changed");
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 12}]));
|
||||
let after = s.project.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *after);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// long enough media: nothing is short
|
||||
assert_eq!(s.execute("clip.replaceFromBin", json!({"item": forest.0})).unwrap()["short"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// From Source Monitor, with the Source In 20 frames before the end of the media
|
||||
let frames = |t: Tick| (t.0 + rate.frame_duration().0 - 1) / rate.frame_duration().0;
|
||||
s.execute("source.open", json!({"item": forest.0})).unwrap();
|
||||
let mark = forest_len - rate.tick_of(20);
|
||||
s.execute("project.setMarks", json!({"item": forest.0, "in": mark.0})).unwrap();
|
||||
let r = s.execute("clip.replaceFromSource", json!({})).unwrap();
|
||||
let got = clip(&s, c.id.0);
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": frames(got.source_out() - forest_len)}]));
|
||||
assert_eq!(frames(got.source_out() - forest_len), slot - 20);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// Match Frame: the last frame of the media on the clip's first frame
|
||||
s.execute("playhead.set", json!({"time": c.start.0})).unwrap();
|
||||
s.execute("source.setPlayhead", json!({"time": (forest_len - rate.tick_of(1)).0})).unwrap();
|
||||
let r = s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap();
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": slot - 1}]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// with enough media after the frame, nothing is short
|
||||
s.execute("source.setPlayhead", json!({"frame": 5})).unwrap();
|
||||
assert_eq!(s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap()["short"], json!([]));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// never more than the clip itself: an In that lies past the end of the new media altogether
|
||||
s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "in", "mode": "regular", "deltaFrames": 7})).unwrap();
|
||||
let trimmed = clip(&s, c.id.0);
|
||||
s.execute("file.newSequence", json!({"name": "Brief"})).unwrap();
|
||||
let brief = s.state.active_sequence.unwrap();
|
||||
s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap();
|
||||
let placed = s.active_sequence().unwrap().video_tracks[0].items[0].clone();
|
||||
s.execute("timeline.trim", json!({"clip": placed.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(3) - placed.duration).0})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap();
|
||||
select(&mut s, &[c.id.0]);
|
||||
assert!(trimmed.source_in > s.project.sequence(brief).unwrap().duration(), "the kept In is past the end of the nested sequence");
|
||||
let r = s.execute("clip.replaceFromBin", json!({"clips": [c.id.0], "item": brief.0, "keepSourceIn": true})).unwrap();
|
||||
assert_eq!(clip(&s, c.id.0).source_in, trimmed.source_in);
|
||||
assert_eq!(r["short"], json!([{"clip": c.id.0, "shortByFrames": rate.frame_at(trimmed.duration)}]), "all of the clip, not more");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_source_channels_pick_what_a_clip_plays() {
|
||||
let mut it = v1(&demo())[0].clone();
|
||||
@@ -614,3 +762,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, .. }));
|
||||
}
|
||||
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user