Merge origin/main into #260; Chinese and Japanese for "A file dialog is already open"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7d6c53d03d
|
|
@ -31,10 +31,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
|
@ -64,6 +64,11 @@ jobs:
|
|||
assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
# Both install the app and the CLI/MCP server (#346).
|
||||
import re
|
||||
def programs(m):
|
||||
return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)}
|
||||
assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD package layout (stub binaries)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
|
|
@ -71,17 +71,16 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
|
|
@ -101,7 +100,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
|
|
@ -129,17 +128,16 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
|
|
@ -163,7 +161,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
|
|
@ -187,9 +185,8 @@ jobs:
|
|||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
|
|
@ -203,8 +200,8 @@ jobs:
|
|||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
|
|
@ -228,7 +225,7 @@ jobs:
|
|||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
|
|
@ -251,8 +248,8 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
|
|
@ -266,7 +263,7 @@ jobs:
|
|||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
|
|
@ -284,16 +281,15 @@ jobs:
|
|||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
|
@ -318,7 +314,7 @@ jobs:
|
|||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
|
|
@ -334,17 +330,16 @@ jobs:
|
|||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
|
|
@ -362,7 +357,7 @@ jobs:
|
|||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
|
|
@ -381,7 +376,7 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ jobs:
|
|||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
|
|
@ -64,7 +64,7 @@ jobs:
|
|||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
|
|||
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
|
||||
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It
|
||||
video decoding through VideoToolbox, Media Foundation / Direct3D 11, VA-API and NVDEC; hardware encoding) 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)).
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
|||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
|
||||
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
|
|
|
|||
|
|
@ -1674,6 +1674,7 @@ dependencies = [
|
|||
"filmcraft-engine",
|
||||
"filmcraft-platform",
|
||||
"filmcraft-ui-egui",
|
||||
"glob",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
|
|
@ -1684,6 +1685,7 @@ dependencies = [
|
|||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2825,6 +2827,12 @@ dependencies = [
|
|||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
|||
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
|||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no 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.
|
||||
|
|
|
|||
|
|
@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.chinese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/<code>.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn stdout_failed(e: std::io::Error) {
|
|||
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
eprintln!("filmcraft-cli: can't write to stdout: {e}");
|
||||
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
|
||||
STDOUT_ERROR.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
|
@ -115,13 +115,23 @@ EXIT STATUS
|
|||
0 success · 1 a command failed · 2 usage error
|
||||
";
|
||||
|
||||
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
|
||||
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
|
||||
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
|
||||
let _ = writeln!(w, "{msg}");
|
||||
}
|
||||
|
||||
fn diag(msg: std::fmt::Arguments) {
|
||||
write_diag(&mut std::io::stderr(), msg);
|
||||
}
|
||||
|
||||
fn usage(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
|
||||
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}");
|
||||
diag(format_args!("filmcraft-cli: {msg}"));
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -223,9 +233,15 @@ async fn cli() {
|
|||
}
|
||||
register_hardware_decoders();
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
|
||||
// the parser takes it as a flag option, so it never reaches the subcommand match.
|
||||
if a.flag("--help") {
|
||||
out!("{HELP}");
|
||||
return;
|
||||
}
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "--help" | "-h" => out!("{HELP}"),
|
||||
"help" | "-h" => out!("{HELP}"),
|
||||
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"probe" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
|
||||
|
|
@ -373,11 +389,7 @@ async fn cli() {
|
|||
if let Some(r) = a.opt("--range") {
|
||||
p["range"] = json!(r);
|
||||
}
|
||||
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
|
||||
p["range"] = json!("custom");
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
|
||||
if let Some(js) = a.opt("--settings") {
|
||||
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
|
||||
}
|
||||
|
|
@ -399,7 +411,7 @@ async fn cli() {
|
|||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64());
|
||||
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
|
|
@ -414,11 +426,11 @@ async fn cli() {
|
|||
let out = a.opt("--out").unwrap_or("frame.png");
|
||||
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
|
||||
let t0 = std::time::Instant::now();
|
||||
let Some(img) = s.render_program(scale) else { fail("no sequence") };
|
||||
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let dt = t0.elapsed();
|
||||
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
|
||||
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
|
||||
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
|
||||
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
|
||||
}
|
||||
"mcp" => {
|
||||
let server = match a.opt("--bridge") {
|
||||
|
|
@ -454,19 +466,68 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
|||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
|
||||
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
|
||||
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
|
||||
/// instead of the export silently running over the whole sequence.
|
||||
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
|
||||
if start.is_none() && end.is_none() {
|
||||
return;
|
||||
}
|
||||
p["range"] = json!("custom");
|
||||
if let Some(s0) = start {
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
}
|
||||
if let Some(s1) = end {
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn render_reports_the_real_reason() {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
|
||||
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
for scale in [0.0, -1.0, f32::NAN] {
|
||||
let e = super::render_at_playhead(&s, scale).unwrap_err();
|
||||
assert!(e.contains("finite and positive"), "{e}");
|
||||
}
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
|
||||
}
|
||||
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
/// decoders at start-up.
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders() {
|
||||
let hardware = super::register_hardware_decoders();
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_diagnostic_stream_does_not_panic() {
|
||||
struct Closed;
|
||||
impl std::io::Write for Closed {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
}
|
||||
super::write_diag(&mut Closed, format_args!("exported {}", "a.wav"));
|
||||
let mut ok = Vec::new();
|
||||
super::write_diag(&mut ok, format_args!("hi"));
|
||||
assert_eq!(ok, b"hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_message_names_the_written_files() {
|
||||
use serde_json::json;
|
||||
|
|
@ -478,6 +539,23 @@ mod format_tests {
|
|||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_lone_bound_is_forwarded_as_custom_range() {
|
||||
use serde_json::json;
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, Some("0.25"), None);
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["startSeconds"], 0.25);
|
||||
assert!(p.get("endSeconds").is_none());
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, Some("0.25"));
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["endSeconds"], 0.25);
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, None);
|
||||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ fn help_and_usage_errors() {
|
|||
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_help_matches_help_command() {
|
||||
let reference = cli(&["help"]);
|
||||
assert!(reference.status.success());
|
||||
let long_help = cli(&["--help"]);
|
||||
assert!(long_help.status.success(), "--help failed: {}", String::from_utf8_lossy(&long_help.stderr));
|
||||
assert_eq!(long_help.stdout, reference.stdout);
|
||||
assert!(long_help.stderr.is_empty());
|
||||
let short_help = cli(&["-h"]);
|
||||
assert!(short_help.status.success());
|
||||
assert_eq!(short_help.stdout, reference.stdout);
|
||||
let sub_help = cli(&["export", "--help"]);
|
||||
assert!(sub_help.status.success(), "export --help failed: {}", String::from_utf8_lossy(&sub_help.stderr));
|
||||
assert_eq!(sub_help.stdout, reference.stdout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_mpeg_transport_and_program_streams() {
|
||||
let Some(ffmpeg) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) else {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ cpal = "0.16"
|
|||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }
|
||||
|
|
@ -36,6 +39,11 @@ objc2 = "0.6"
|
|||
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
|
||||
# portal file dialogs (rfd) and accesskit already use.
|
||||
zbus = "5.19"
|
||||
|
||||
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
//! The system's light or dark appearance for Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with
|
||||
//! system, on Linux desktops where winit reports none (Wayland compositors send no theme).
|
||||
//!
|
||||
//! The XDG desktop portal is read once (`org.freedesktop.portal.Settings.Read` of
|
||||
//! `org.freedesktop.appearance` / `color-scheme`), then its `SettingChanged` signal is followed on a
|
||||
//! worker thread, which wakes the UI only when the value changes. There is no polling. Without a
|
||||
//! portal answer, `gsettings` runs once at start-up, by absolute path. Elsewhere the host returns no
|
||||
//! service and the UI uses what egui reports.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The `u32` inside a portal reply. `Read` returns a variant, and some portals nest another
|
||||
/// variant inside it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme`: 0 no preference, 1 prefer dark, 2 prefer light.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot fallback for desktops without the settings portal: runs once, by absolute path.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish initial and subsequent answers alike: the initial Read may finish after the UI's
|
||||
/// bounded startup wait, so it must also wake an already-created context.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn publish(value: &std::sync::atomic::AtomicU8, code: u8, repaint: impl FnOnce()) {
|
||||
if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code {
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The host hook (`HostHooks::system_theme`), or `None` where egui's own report is used.
|
||||
pub fn service() -> Option<filmcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let worker = move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before Read so a change during startup cannot be missed.
|
||||
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
publish(&worker_value, code_of(initial), || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
// Sleeps in the signal iterator: no polling.
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
publish(&worker_value, code, || {
|
||||
if let Some(ctx) = worker_wake.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
let spawned = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(worker)).is_err() {
|
||||
log::warn!("the system appearance watcher stopped; Sync with system keeps the last value");
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
return None;
|
||||
}
|
||||
// The first reading, so the first frame already shows the right theme (bounded wait).
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx: &egui::Context| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn initial_and_signal_answers_repaint_only_on_changes() {
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
let repaints = AtomicUsize::new(0);
|
||||
let value = AtomicU8::new(0);
|
||||
let repaint = || {
|
||||
repaints.fetch_add(1, Ordering::Relaxed);
|
||||
};
|
||||
// A late initial Read takes the same publication path as later signals.
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 2, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
super::publish(&value, 1, repaint);
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(value.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_and_gsettings_values_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
let plain = zbus::zvariant::OwnedValue::from(1u32);
|
||||
assert_eq!(super::portal_code(plain), Some(1));
|
||||
let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap();
|
||||
assert_eq!(super::portal_code(wrong), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::parse_gtk_scheme(""), None);
|
||||
assert_eq!((super::code_of(None), super::code_of(Some(egui::Theme::Dark)), super::code_of(Some(egui::Theme::Light))), (0, 1, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
|
||||
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use filmcraft_engine::settings::AudioHardwarePrefs;
|
||||
use filmcraft_ui_egui::{AudioDevices, AudioOut};
|
||||
|
|
@ -17,6 +18,31 @@ pub struct CpalOut {
|
|||
hw: AudioHardwarePrefs,
|
||||
/// Sequence sample rate for "Attempt to force hardware to document sample rate".
|
||||
document_rate: Option<u32>,
|
||||
/// Status-bar note: the host's default device could not be opened and a fallback device is
|
||||
/// in use, or no output device was found (playing without sound).
|
||||
note: Option<Note>,
|
||||
/// The fallback picked when the default device could not be opened, as (host, device) names,
|
||||
/// so later lookups (`devices`, `configure`, `start`) reuse it instead of probing every PCM.
|
||||
fallback: Mutex<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// A hardware note for the status bar, translated when it is shown.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Note {
|
||||
/// The default output device cannot be opened; this named device is used instead.
|
||||
Fallback(String),
|
||||
/// No output device at all: playback runs without sound.
|
||||
NoDevice,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
fn text(&self) -> String {
|
||||
use filmcraft_ui_egui::i18n::{fmt, t};
|
||||
match self {
|
||||
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
|
||||
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host named in the settings (empty or unknown = the default host).
|
||||
|
|
@ -30,21 +56,76 @@ fn host(name: &str) -> cpal::Host {
|
|||
cpal::default_host()
|
||||
}
|
||||
|
||||
/// The output device named in the settings (empty or missing = the host's default).
|
||||
fn output_device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
||||
/// The output device the settings ask for, with a status-bar note when a fallback is in use.
|
||||
struct DeviceChoice {
|
||||
device: cpal::Device,
|
||||
/// The host's default device could not be opened: this device is the fallback.
|
||||
fallback: Option<Note>,
|
||||
}
|
||||
|
||||
/// Fallback order when the default device cannot be opened: the sound-server bridges first
|
||||
/// (`pipewire`, `pulse`: they share the card with every other app), then other named PCMs, and
|
||||
/// raw `hw:`/`plughw:` devices last (opening one takes the card exclusively and silences the
|
||||
/// rest of the desktop).
|
||||
fn fallback_rank(name: &str) -> u8 {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n == "pipewire" || n.starts_with("pipewire:") {
|
||||
0
|
||||
} else if n == "pulse" || n.starts_with("pulse:") {
|
||||
1
|
||||
} else if n.starts_with("hw:") || n.starts_with("plughw:") {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// The output device named in the settings (empty or missing = the host's default). When the
|
||||
/// default cannot be opened — a broken ALSA `default` (missing `99-pipewire-default.conf`,
|
||||
/// #23/#106) — a fallback that can, so the app is not silently mute. The fallback list is probed
|
||||
/// only after the default fails, in [`fallback_rank`] order, and the pick is cached in `cache`.
|
||||
fn output_device(h: &cpal::Host, name: &str, cache: &Mutex<Option<(String, String)>>) -> Option<DeviceChoice> {
|
||||
if !name.is_empty()
|
||||
&& let Ok(mut devs) = h.output_devices()
|
||||
&& let Some(d) = devs.find(|d| d.name().is_ok_and(|n| n == name))
|
||||
{
|
||||
return Some(d);
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
}
|
||||
h.default_output_device()
|
||||
if let Some(d) = h.default_output_device()
|
||||
&& d.default_output_config().is_ok()
|
||||
{
|
||||
return Some(DeviceChoice { device: d, fallback: None });
|
||||
}
|
||||
let host_name = h.id().name().to_string();
|
||||
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut devs: Vec<(String, cpal::Device)> = h.output_devices().ok()?.filter_map(|d| d.name().ok().map(|n| (n, d))).collect();
|
||||
if let Some((cached_host, cached)) = cache.as_ref()
|
||||
&& *cached_host == host_name
|
||||
&& let Some(i) = devs.iter().position(|(n, _)| n == cached)
|
||||
{
|
||||
let (found, d) = devs.swap_remove(i);
|
||||
return Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) });
|
||||
}
|
||||
devs.sort_by_key(|(n, _)| fallback_rank(n));
|
||||
let (found, d) = devs.into_iter().find(|(_, d)| d.default_output_config().is_ok())?;
|
||||
log::warn!("the default audio output device cannot be opened; falling back to '{found}'");
|
||||
*cache = Some((host_name, found.clone()));
|
||||
Some(DeviceChoice { device: d, fallback: Some(Note::Fallback(found)) })
|
||||
}
|
||||
|
||||
impl CpalOut {
|
||||
pub fn new() -> Self {
|
||||
let host = cpal::default_host();
|
||||
let cfg = host.default_output_device().and_then(|dev| dev.default_output_config().ok());
|
||||
let fallback = Mutex::new(None);
|
||||
let choice = output_device(&host, "", &fallback);
|
||||
let cfg = choice.as_ref().and_then(|c| c.device.default_output_config().ok());
|
||||
let note = match choice {
|
||||
Some(c) => c.fallback,
|
||||
None => {
|
||||
log::warn!("no audio output device found");
|
||||
Some(Note::NoDevice)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
stream: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
|
|
@ -53,6 +134,8 @@ impl CpalOut {
|
|||
channels: cfg.as_ref().map_or(2, |c| c.channels()),
|
||||
hw: AudioHardwarePrefs::default(),
|
||||
document_rate: None,
|
||||
note,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +207,8 @@ impl AudioOut for CpalOut {
|
|||
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.stop();
|
||||
let host = host(&self.hw.device_class);
|
||||
let dev = output_device(&host, &self.hw.default_output).ok_or("no output device")?;
|
||||
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
|
||||
self.note = fallback;
|
||||
let cfg = self.config(&dev)?;
|
||||
let channels = cfg.channels() as usize;
|
||||
if channels == 0 || cfg.sample_rate().0 == 0 {
|
||||
|
|
@ -185,7 +269,8 @@ impl AudioOut for CpalOut {
|
|||
let h = host(&self.hw.device_class);
|
||||
let outputs = h.output_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let inputs = h.input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default();
|
||||
let output_channels = output_device(&h, &self.hw.default_output).and_then(|d| self.config(&d).ok()).map(|c| c.channels()).unwrap_or(0);
|
||||
let output_channels =
|
||||
output_device(&h, &self.hw.default_output, &self.fallback).and_then(|c| self.config(&c.device).ok()).map(|cfg| cfg.channels()).unwrap_or(0);
|
||||
AudioDevices { hosts: cpal::available_hosts().iter().map(|h| h.name().to_string()).collect(), inputs, outputs, output_channels }
|
||||
}
|
||||
fn configure(&mut self, hw: &AudioHardwarePrefs, document_rate: Option<u32>) {
|
||||
|
|
@ -193,13 +278,17 @@ impl AudioOut for CpalOut {
|
|||
self.document_rate = document_rate;
|
||||
// the rate playback mixes at must be known before `start`
|
||||
let host = host(&self.hw.device_class);
|
||||
if let Some(dev) = output_device(&host, &self.hw.default_output)
|
||||
&& let Ok(cfg) = self.config(&dev)
|
||||
if let Some(choice) = output_device(&host, &self.hw.default_output, &self.fallback)
|
||||
&& let Ok(cfg) = self.config(&choice.device)
|
||||
{
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.note = choice.fallback;
|
||||
}
|
||||
}
|
||||
fn note(&self) -> Option<String> {
|
||||
self.note.as_ref().map(Note::text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -207,6 +296,19 @@ mod tests {
|
|||
use super::*;
|
||||
use cpal::Sample;
|
||||
|
||||
#[test]
|
||||
fn fallback_prefers_sound_servers_over_raw_hardware() {
|
||||
let mut names = vec!["plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0", "sysdefault:CARD=PCH", "pulse", "pipewire"];
|
||||
names.sort_by_key(|n| fallback_rank(n));
|
||||
assert_eq!(names, ["pipewire", "pulse", "sysdefault:CARD=PCH", "plughw:CARD=PCH,DEV=0", "hw:CARD=PCH,DEV=0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_name_the_fallback_device() {
|
||||
assert!(Note::Fallback("pipewire".into()).text().contains("'pipewire'"));
|
||||
assert!(!Note::NoDevice.text().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_conversion_clips_and_preserves_interleaved_channels() {
|
||||
let mut signed = [0_i16; 8];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
//! Extension filters for native file dialogs.
|
||||
//!
|
||||
//! Every filtered file dialog (import, relink, open file/preset, open/save project) builds
|
||||
//! its filter with [`extensions`]. On Linux and the BSDs, rfd's XDG portal, GTK and Zenity
|
||||
//! backends turn each extension into a case-sensitive `*.{ext}` glob, so camera files such
|
||||
//! as `shot.MP4` or `shot.Mp4` would be hidden. The helper appends one bracket pattern per
|
||||
//! extension (`mp4` -> `[mM][pP]4`) after the literal extensions, which stay first so save
|
||||
//! dialogs keep their normal default suffix. macOS and Windows dialogs already ignore case
|
||||
//! and receive the plain list.
|
||||
|
||||
pub fn extensions(exts: &[&str]) -> Vec<String> {
|
||||
let extensions: Vec<_> = exts.iter().map(|ext| (*ext).to_owned()).collect();
|
||||
// XDG portal/Zenity globs are case-sensitive. Keep the literal extensions first so
|
||||
// Save dialogs can still infer a normal default suffix, then cover mixed case too.
|
||||
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
let mut extensions = extensions;
|
||||
for ext in exts {
|
||||
if !ext.bytes().any(|b| b.is_ascii_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
let mut pattern = String::new();
|
||||
for c in ext.chars() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
pattern.extend(['[', c.to_ascii_lowercase(), c.to_ascii_uppercase(), ']']);
|
||||
} else {
|
||||
pattern.push(c);
|
||||
}
|
||||
}
|
||||
extensions.push(pattern);
|
||||
}
|
||||
extensions
|
||||
}
|
||||
// Native macOS and Windows filters take ordinary extensions.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extensions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extensions;
|
||||
|
||||
// rfd's XDG portal and Zenity backends turn each extension into `*.{ext}`.
|
||||
// Exercise those case-sensitive patterns against filenames, as the picker does.
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
#[test]
|
||||
fn camera_files_are_visible_in_every_capitalization() {
|
||||
let filters: Vec<_> = extensions(&["mp4", "mov", "m2ts", "wav", "fcproj"]).iter().map(|ext| glob::Pattern::new(&format!("*.{ext}")).unwrap()).collect();
|
||||
for name in ["camera.mp4", "camera.MP4", "camera.Mp4", "clip.MOV", "clip.moV", "take.M2TS", "sound.WaV", "Edit.FCPROJ", "Edit.FcProj"] {
|
||||
assert!(filters.iter().any(|f| f.matches(name)), "the picker hides {name}");
|
||||
}
|
||||
for name in ["camera.mp4.bak", "camera.mp44", "camera.xmp4", "notes.txt", "mp4"] {
|
||||
assert!(!filters.iter().any(|f| f.matches(name)), "the picker admits {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_filters_keep_the_literal_default_extension() {
|
||||
assert_eq!(extensions(&["mp4", "mov"]).first().map(String::as_str), Some("mp4"));
|
||||
assert!(extensions(&[]).is_empty());
|
||||
assert_eq!(extensions(&["*", "", "123"]), ["*", "", "123"]);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
#[test]
|
||||
fn native_filters_receive_only_literal_extensions() {
|
||||
assert_eq!(extensions(&["mp4", "mov", "fcproj"]), ["mp4", "mov", "fcproj"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,10 +23,12 @@
|
|||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod app_nap;
|
||||
mod appearance;
|
||||
mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
mod file_filters;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
|
|
@ -198,7 +200,7 @@ fn main() -> eframe::Result {
|
|||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), exts)
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
|
|
@ -207,18 +209,21 @@ fn main() -> eframe::Result {
|
|||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t("Media"), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), exts)
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
|
|
@ -235,16 +240,23 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
|
||||
// compositor reports no theme to winit (no polling: see appearance.rs).
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
|
|
@ -390,7 +402,7 @@ mod tests {
|
|||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
|||
filmcraft_ui_egui::i18n::Language::Es
|
||||
} else if checked("app.language.portuguese") {
|
||||
filmcraft_ui_egui::i18n::Language::PtBr
|
||||
} else if checked("app.language.ukrainian") {
|
||||
filmcraft_ui_egui::i18n::Language::Uk
|
||||
} else if checked("app.language.chinese") {
|
||||
filmcraft_ui_egui::i18n::Language::ZhCn
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,3 +40,14 @@ sync_github_name = true
|
|||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
[people.Hathor875]
|
||||
real_name = "Krzysztof Cieślik"
|
||||
sync_github_name = true
|
||||
|
||||
[people.ReReddo]
|
||||
display_name = "ReReddo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.liangforstudy]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -139,6 +139,30 @@ pub struct TypeParams {
|
|||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct MediaImportParams {
|
||||
/// Absolute paths to media files (MP4/MOV, WAV/MP3/FLAC/AIFF/Ogg, PNG/JPEG/…).
|
||||
pub paths: Vec<String>,
|
||||
/// Bin ID to import into (optional).
|
||||
#[serde(default)]
|
||||
pub bin: Option<u64>,
|
||||
/// Import numbered stills as an image sequence (optional, default false).
|
||||
#[serde(default)]
|
||||
pub image_sequence: Option<bool>,
|
||||
}
|
||||
|
||||
/// `file.import` params for a `media_import` call.
|
||||
fn media_import_params(p: MediaImportParams) -> Value {
|
||||
let mut params = json!({"paths": p.paths});
|
||||
if let Some(bin) = p.bin {
|
||||
params["bin"] = json!(bin);
|
||||
}
|
||||
if let Some(seq) = p.image_sequence {
|
||||
params["imageSequence"] = json!(seq);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct ElementsParams {
|
||||
/// Only element ids starting with this prefix (e.g. `timeline.clip.`).
|
||||
|
|
@ -381,9 +405,8 @@ impl FilmcraftMcp {
|
|||
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)
|
||||
async fn media_import(&self, Parameters(p): Parameters<MediaImportParams>) -> Result<CallToolResult, McpError> {
|
||||
wrap(self.run("file.import", media_import_params(p)).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -402,7 +425,7 @@ impl FilmcraftMcp {
|
|||
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
|
||||
let (w, h) =
|
||||
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let scale = (max as f32 / w.max(h) as f32).min(1.0);
|
||||
let scale = render_scale(max, w, h);
|
||||
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
|
|
@ -648,10 +671,26 @@ impl ServerHandler for FilmcraftMcp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
|
||||
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
|
||||
if max_side == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_scale_zero_means_no_limit() {
|
||||
assert_eq!(render_scale(0, 1920, 1080), 1.0);
|
||||
assert_eq!(render_scale(960, 1920, 1080), 0.5);
|
||||
assert_eq!(render_scale(960, 640, 360), 1.0);
|
||||
assert_eq!(render_scale(960, 0, 0), 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_commands_and_render() {
|
||||
let mut s = Session::default();
|
||||
|
|
@ -719,6 +758,21 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// #499: `media_import` takes `paths` (a required array), and forwards `bin` / `image_sequence` to `file.import`.
|
||||
#[test]
|
||||
fn media_import_schema_and_params() {
|
||||
let m = FilmcraftMcp::headless(Session::default());
|
||||
let def = m.tool_router.get("media_import").unwrap();
|
||||
let schema = Value::Object((*def.input_schema).clone());
|
||||
assert_eq!(schema["properties"]["paths"]["type"], "array", "{schema}");
|
||||
assert!(schema["required"].as_array().unwrap().iter().any(|r| r == "paths"), "{schema}");
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.png"], "bin": 7, "image_sequence": true})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.png"], "bin": 7, "imageSequence": true}));
|
||||
let p: MediaImportParams = serde_json::from_value(json!({"paths": ["/a.wav"]})).unwrap();
|
||||
assert_eq!(media_import_params(p), json!({"paths": ["/a.wav"]}));
|
||||
assert!(serde_json::from_value::<MediaImportParams>(json!({"text": "/a.wav"})).is_err());
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use common::*;
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn adhoc_compare() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(path) = std::env::var_os("AV1_IVF") else { return };
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let pics = decode_all(&path).unwrap();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,27 @@ use filmcraft_av1::{Decoder, Picture};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
|
||||
pub fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
|
||||
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libdav1d, for checks that decode existing files only.
|
||||
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
|
||||
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
|
||||
for &(list, lib) in needs {
|
||||
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
|
||||
if !out.contains(lib) {
|
||||
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(ff)
|
||||
}
|
||||
|
||||
pub fn fixture_dir() -> PathBuf {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const VECTORS: &[&str] =
|
|||
|
||||
#[test]
|
||||
fn conformance_vectors() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
for name in VECTORS {
|
||||
let Some(path) = test_vector(name) else { return };
|
||||
check_file(&ff, name, &path);
|
||||
|
|
@ -21,7 +21,7 @@ fn conformance_vectors() {
|
|||
#[test]
|
||||
#[ignore]
|
||||
fn conformance_vectors_extended() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
|
||||
Ok(v) => v.split(',').map(str::to_string).collect(),
|
||||
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
|
||||
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
|
||||
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
|
||||
//! checksum that makes the packet's byte sum 0 mod 256.
|
||||
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
|
||||
//! (low 8 bits of DID + SDID + data count + CDP bytes).
|
||||
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
|
||||
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
|
||||
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
|
||||
|
|
@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
|
|||
cdp.push(((256 - sum % 256) % 256) as u8);
|
||||
let mut out = vec![0x61, 0x01, cdp.len() as u8];
|
||||
out.extend(cdp);
|
||||
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
|
||||
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
|
||||
out.push((anc_sum & 0xff) as u8);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -661,7 +665,11 @@ mod tests {
|
|||
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
|
||||
let b = build_cdp(7, &d);
|
||||
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
|
||||
let cdp = &b[3..];
|
||||
// prefix (3) + CDP (73) + ancillary checksum byte (1)
|
||||
assert_eq!(b.len(), 77);
|
||||
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
|
||||
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
|
||||
let cdp = &b[3..76];
|
||||
assert_eq!(cdp.len(), 73);
|
||||
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
|
||||
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use std::time::Duration;
|
|||
use web_time::Instant;
|
||||
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::VideoFrame;
|
||||
use filmcraft_frame::{Region, VideoFrame};
|
||||
|
||||
use crate::CodecError;
|
||||
use crate::video::VideoDecoder;
|
||||
|
|
@ -410,6 +410,8 @@ pub struct GopCache {
|
|||
explicit_color: Option<ColorInfo>,
|
||||
/// Clockwise quarter turns from the container's display matrix, applied to every frame.
|
||||
rotation: u8,
|
||||
/// The container's clean aperture, cut from every frame before the rotation.
|
||||
crop: Option<Region>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +450,7 @@ impl GopCache {
|
|||
pool: pool.clone(),
|
||||
});
|
||||
pool.register(&shared);
|
||||
Self { shared, explicit_color, rotation: 0, budget: 384 << 20 }
|
||||
Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 }
|
||||
}
|
||||
|
||||
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
|
||||
|
|
@ -458,14 +460,24 @@ impl GopCache {
|
|||
self
|
||||
}
|
||||
|
||||
/// Cut every decoded frame to `region` (the container's clean aperture, in stored picture
|
||||
/// coordinates) before the rotation, as cropping and the display matrix compose.
|
||||
pub fn with_crop(mut self, region: Option<Region>) -> Self {
|
||||
self.crop = region;
|
||||
self
|
||||
}
|
||||
|
||||
/// Colour signalled by the container wins over the bitstream's (YUV frames only); the
|
||||
/// container's display rotation is applied.
|
||||
/// container's clean aperture and then its display rotation are 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 let Some(r) = self.crop {
|
||||
f = f.cropped(r);
|
||||
}
|
||||
if self.rotation != 0 { f.rotated(self.rotation) } else { f }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ pub struct FrameStreamInfo {
|
|||
}
|
||||
|
||||
/// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one.
|
||||
fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while let Some(&h) = data.get(pos) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod mp4;
|
|||
pub mod mpeg;
|
||||
pub mod mxf;
|
||||
pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
|
@ -178,4 +179,6 @@ mod audio_timing_tests;
|
|||
#[cfg(test)]
|
||||
mod rounded_pts_tests;
|
||||
#[cfg(test)]
|
||||
mod stream_color_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_color::{ColorInfo, Matrix, Transfer};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig};
|
||||
use filmcraft_matroska::{Codec, MkvFile, TrackKind};
|
||||
|
|
@ -24,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick};
|
|||
|
||||
use crate::audio::{PacketDecoder, decode_pcm};
|
||||
use crate::gop::{GopCache, VideoSamples};
|
||||
use crate::stream_color::ColorCodes;
|
||||
use crate::video::VideoDecoder;
|
||||
use crate::{CodecError, make_video_decoder};
|
||||
|
||||
|
|
@ -218,32 +219,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMe
|
|||
})
|
||||
}
|
||||
|
||||
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) };
|
||||
let mut explicit = false;
|
||||
if let Some(m) = col.matrix_coefficients.and_then(|m| Matrix::from_code(m as u8)) {
|
||||
c.matrix = m;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(t) = col.transfer_characteristics.and_then(|t| Transfer::from_code(t as u8)) {
|
||||
c.transfer = t;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(p) = col.primaries {
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
/// Colour of a video track: the `Colour` element wins; what it leaves unspecified, or all of it
|
||||
/// when there is none, comes from the stream's own description (the SPS VUI in an `avcC` /
|
||||
/// `hvcC`, the sequence header in an `av1C`); the rest defaults by frame size. The flag says
|
||||
/// whether `Colour` signals anything that overrides the decoder.
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option<ColorCodes>) -> (ColorInfo, bool) {
|
||||
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
|
||||
let code = |c: Option<u64>| c.unwrap_or(2);
|
||||
let container = v.colour.as_ref().map(|col| {
|
||||
let range = match col.range {
|
||||
Some(1) => Some(false),
|
||||
Some(2) => Some(true),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if col.full_range() {
|
||||
c.range = Range::Full;
|
||||
explicit = true;
|
||||
}
|
||||
(c, explicit)
|
||||
ColorCodes::from_wide(code(col.primaries), code(col.transfer_characteristics), code(col.matrix_coefficients), range)
|
||||
});
|
||||
let explicit = container.is_some_and(|c| Matrix::from_code(c.matrix).is_some() || Transfer::from_code(c.transfer).is_some() || c.full_range == Some(true));
|
||||
let sources: Vec<ColorCodes> = [container, stream].into_iter().flatten().collect();
|
||||
(crate::stream_color::resolve(w, h, &sources), explicit)
|
||||
}
|
||||
|
||||
fn codec_label(c: &Codec) -> String {
|
||||
|
|
@ -312,11 +305,11 @@ impl MkvSource {
|
|||
FrameRate::from_f64(1.0 / step)
|
||||
}
|
||||
};
|
||||
let (color, explicit) = color_of(&v, w, h);
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec)));
|
||||
if explicit {
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
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()) };
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::{AudioBuffer, Region, VideoFrame};
|
||||
use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
|
|
@ -104,48 +104,62 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
|
|||
})
|
||||
}
|
||||
|
||||
/// The clean aperture (`clap`, ISO/IEC 14496-12 / QuickTime) of a `w`×`h` decoded picture as a
|
||||
/// pixel rectangle: its size, centred on the picture and moved by its offsets, rounded to whole
|
||||
/// samples. `None` (the whole picture is shown) when it is the whole picture or makes no sense: a
|
||||
/// zero denominator, an empty or larger-than-the-picture size, a rectangle outside the picture.
|
||||
/// [`VideoFrame::cropped`] moves an odd corner onto the chroma grid.
|
||||
fn clean_aperture(c: CleanAperture, (w, h): (u32, u32)) -> Option<Region> {
|
||||
let q = |(n, d): (u32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d));
|
||||
let qs = |(n, d): (i32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d));
|
||||
let (cw, ch, dx, dy) = (q(c.width)?.round(), q(c.height)?.round(), qs(c.horiz_offset)?, qs(c.vert_offset)?);
|
||||
let (fw, fh) = (f64::from(w), f64::from(h));
|
||||
if !(cw >= 1.0 && ch >= 1.0 && cw <= fw && ch <= fh) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (((fw - cw) / 2.0 + dx).round(), ((fh - ch) / 2.0 + dy).round());
|
||||
// also false for NaN
|
||||
if !(x >= 0.0 && y >= 0.0 && x + cw <= fw && y + ch <= fh) {
|
||||
return None;
|
||||
}
|
||||
let r = Region { x: x as usize, y: y as usize, w: cw as usize, h: ch as usize };
|
||||
(!r.is_full(w as usize, h as usize)).then_some(r)
|
||||
}
|
||||
|
||||
/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves
|
||||
/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default),
|
||||
/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC`
|
||||
/// and the sequence header in `av1C`. The rest defaults by frame size.
|
||||
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 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
|
||||
let vpc = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: v.colour_primaries as u16,
|
||||
transfer: v.transfer_characteristics as u16,
|
||||
matrix: v.matrix_coefficients as u16,
|
||||
full_range: v.full_range,
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: a.color_primaries as u16,
|
||||
transfer: a.transfer_characteristics as u16,
|
||||
matrix: a.matrix_coefficients as u16,
|
||||
full_range: a.full_range,
|
||||
}),
|
||||
use crate::stream_color::ColorCodes;
|
||||
use filmcraft_isobmff::ColorInfo as Colr;
|
||||
let colr = match entry.video.as_ref().and_then(|v| v.color.as_ref()) {
|
||||
Some(Colr::Nclx { primaries, transfer, matrix, full_range }) => {
|
||||
Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), Some(*full_range)))
|
||||
}
|
||||
// QuickTime `nclc` has no range flag
|
||||
Some(Colr::Nclc { primaries, transfer, matrix }) => Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), None)),
|
||||
// an ICC profile, or no `colr`: no code points
|
||||
_ => None,
|
||||
};
|
||||
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
|
||||
let (p, t, m, full) = match col {
|
||||
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
|
||||
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
|
||||
_ => return c,
|
||||
};
|
||||
if let Some(m) = Matrix::from_code(m as u8) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = Transfer::from_code(t as u8) {
|
||||
c.transfer = t;
|
||||
}
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
};
|
||||
if full {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
}
|
||||
c
|
||||
let stream = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(ColorCodes {
|
||||
primaries: v.colour_primaries,
|
||||
transfer: v.transfer_characteristics,
|
||||
matrix: v.matrix_coefficients,
|
||||
full_range: Some(v.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(ColorCodes {
|
||||
primaries: a.color_primaries,
|
||||
transfer: a.transfer_characteristics,
|
||||
matrix: a.matrix_coefficients,
|
||||
full_range: Some(a.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(_) => None,
|
||||
codec => crate::stream_color::from_codec_config(codec),
|
||||
};
|
||||
let sources: Vec<ColorCodes> = [colr, stream].into_iter().flatten().collect();
|
||||
crate::stream_color::resolve(w, h, &sources)
|
||||
}
|
||||
|
||||
impl Mp4Source {
|
||||
|
|
@ -171,11 +185,21 @@ impl Mp4Source {
|
|||
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);
|
||||
// the clean aperture ('clap') cut from every frame, before the rotation
|
||||
let mut aperture = None;
|
||||
let video = vtrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let entry = &t.entries[0];
|
||||
let vp = entry.video.clone().unwrap_or_default();
|
||||
let (w, h) = (if vp.width > 0 { vp.width as u32 } else { t.width }, if vp.height > 0 { vp.height as u32 } else { t.height });
|
||||
// H.264 and HEVC say their own output size (frame cropping / conformance window), which
|
||||
// is what the decoder returns; a sample entry can give the coded size instead (#288)
|
||||
let (w, h) = match crate::hw::NalStreamInfo::from_entry(entry) {
|
||||
Some(Ok(n)) if n.crop.2 > 0 && n.crop.3 > 0 => (n.crop.2, n.crop.3),
|
||||
_ => (w, h),
|
||||
};
|
||||
aperture = vp.clean_aperture.and_then(|c| clean_aperture(c, (w, h)));
|
||||
let (w, h) = aperture.map_or((w, h), |r| (r.w as u32, r.h as u32));
|
||||
// frame rate from the median sample duration
|
||||
let mut durs: Vec<u32> = t.samples.iter().take(240).map(|s| s.duration).collect();
|
||||
durs.sort_unstable();
|
||||
|
|
@ -276,7 +300,7 @@ impl Mp4Source {
|
|||
Some(Mp4Audio { track: i, state, starts, offset, preroll })
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation), audios })
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
|
||||
}
|
||||
|
||||
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
|
||||
|
|
@ -743,6 +767,60 @@ mod tests {
|
|||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)));
|
||||
}
|
||||
|
||||
/// A one-frame 25 fps ProRes MOV (64×32, left half bright) with clean aperture `clap`.
|
||||
fn clap_mov(clap: filmcraft_isobmff::CleanAperture) -> 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 entry = SampleEntry::prores(FourCc(*b"apch"), w as u16, h as u16);
|
||||
entry.video.as_mut().expect("video").clean_aperture = Some(clap);
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(entry, 25)).expect("track");
|
||||
mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_aperture_crops_frames_and_reported_size() {
|
||||
use filmcraft_isobmff::CleanAperture;
|
||||
let clap = |w: (u32, u32), h: (u32, u32), dx: (i32, u32), dy: (i32, u32)| CleanAperture { width: w, height: h, horiz_offset: dx, vert_offset: dy };
|
||||
// 32x16 moved 8 left of centre: columns 8..40 (24 bright, then 8 dark), rows 8..24
|
||||
let s = Mp4Source::open("clap.mov", clap_mov(clap((32, 1), (16, 1), (-8, 1), (0, 1)))).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16)));
|
||||
let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame");
|
||||
assert_eq!((f.width, f.height), (32, 16));
|
||||
let l = f.luma8();
|
||||
let col = |x: usize| (0..16).map(|y| u32::from(l[y * 32 + x])).sum::<u32>() / 16;
|
||||
assert!(col(0) > col(31) + 100 && col(23) > col(24) + 100, "bright up to column 23: {} {} {} {}", col(0), col(23), col(24), col(31));
|
||||
|
||||
// rational sizes and offsets (64/2 = 32): the same rectangle
|
||||
let s = Mp4Source::open("clap2.mov", clap_mov(clap((64, 2), (48, 3), (-16, 2), (0, 7)))).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16)));
|
||||
|
||||
// a clean aperture that makes no sense is ignored (the whole picture), never a panic
|
||||
for c in [
|
||||
clap((0, 1), (16, 1), (0, 1), (0, 1)),
|
||||
clap((32, 0), (16, 1), (0, 1), (0, 1)),
|
||||
clap((32, 1), (16, 1), (0, 0), (0, 1)),
|
||||
clap((65, 1), (16, 1), (0, 1), (0, 1)),
|
||||
clap((32, 1), (33, 1), (0, 1), (0, 1)),
|
||||
clap((32, 1), (16, 1), (17, 1), (0, 1)),
|
||||
clap((32, 1), (16, 1), (0, 1), (-9, 1)),
|
||||
clap((32, 1), (16, 1), (i32::MIN, 1), (i32::MAX, 1)),
|
||||
clap((u32::MAX, 1), (u32::MAX, 1), (0, 1), (0, 1)),
|
||||
clap((1, u32::MAX), (16, 1), (0, u32::MAX), (0, 1)),
|
||||
clap((64, 1), (32, 1), (0, 1), (0, 1)),
|
||||
] {
|
||||
let s = Mp4Source::open("bad.mov", clap_mov(c)).expect("open");
|
||||
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)), "{c:?}");
|
||||
let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame");
|
||||
assert_eq!((f.width, f.height), (64, 32), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_or_scaled_matrices_are_not_mistaken_for_rotations() {
|
||||
const ONE: i32 = 0x10000;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
//! The colour description a video stream carries in its own bitstream, for containers that don't
|
||||
//! repeat it: the H.264 / HEVC sequence parameter set's VUI (from `avcC` / `hvcC`) and the AV1
|
||||
//! sequence header's `color_config` (from `av1C`), read with the decoders' own parsers.
|
||||
//!
|
||||
//! ffmpeg writes MP4 without a `colr` box unless asked (`-movflags +write_colr`), so for most
|
||||
//! files the bitstream is the only place their colour is described. [`resolve`] merges it with
|
||||
//! what the container signals, which wins.
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, HevcConfig};
|
||||
|
||||
use crate::video::primaries_from_code;
|
||||
|
||||
/// ITU-T H.273 `Unspecified`.
|
||||
const UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// Colour code points (ITU-T H.273 / ISO/IEC 23091-2) and range as one place in a file (a
|
||||
/// container colour box, a sequence header) signals them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColorCodes {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
/// `None`: this place doesn't say.
|
||||
pub full_range: Option<bool>,
|
||||
}
|
||||
|
||||
impl ColorCodes {
|
||||
/// From wider code points (a `colr` box or a Matroska `Colour` element): a value that doesn't
|
||||
/// fit a byte is no H.273 code point, so it counts as unspecified.
|
||||
pub fn from_wide(primaries: u64, transfer: u64, matrix: u64, full_range: Option<bool>) -> Self {
|
||||
let code = |v: u64| u8::try_from(v).unwrap_or(UNSPECIFIED);
|
||||
Self { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix), full_range }
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a video codec configuration's bitstream signals: the VUI of the first SPS in an
|
||||
/// `avcC` / `hvcC`, or the `color_config` of the sequence header in an `av1C`. `None` for other
|
||||
/// codecs, an SPS without VUI, and a configuration that is truncated or corrupt.
|
||||
pub fn from_codec_config(codec: &CodecConfig) -> Option<ColorCodes> {
|
||||
match codec {
|
||||
CodecConfig::Avc(c) => avc(c),
|
||||
CodecConfig::Hevc(c) => hevc(c),
|
||||
CodecConfig::Av1(c) => av1(&c.config_obus),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn avc(c: &AvcConfig) -> Option<ColorCodes> {
|
||||
// NAL header byte: nal_unit_type 7 is an SPS
|
||||
let nal = c.sps.iter().find(|n| n.first().is_some_and(|h| h & 0x1f == 7))?;
|
||||
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(1..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn hevc(c: &HevcConfig) -> Option<ColorCodes> {
|
||||
// two-byte NAL header: nal_unit_type 33 is an SPS (whatever array the record files it under)
|
||||
let nal = c.arrays.iter().flat_map(|a| &a.nalus).find(|n| n.first().is_some_and(|h| (h >> 1) & 0x3f == 33))?;
|
||||
let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(2..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn av1(config_obus: &[u8]) -> Option<ColorCodes> {
|
||||
// OBU type 1: the sequence header
|
||||
let (_, payload) = crate::hw_frame::obus(config_obus).into_iter().find(|(t, _)| *t == 1)?;
|
||||
let c = filmcraft_av1::SequenceHeader::parse(payload).ok()?.color;
|
||||
Some(ColorCodes { primaries: c.color_primaries, transfer: c.transfer_characteristics, matrix: c.matrix_coefficients, full_range: Some(c.color_range) })
|
||||
}
|
||||
|
||||
/// Colour from the code points a file signals, most authoritative first (the container's colour
|
||||
/// box, then the bitstream's): the matrix, transfer, primaries and range each come from the first
|
||||
/// source that signals a value we know. What no source signals keeps the defaults: the matrix by
|
||||
/// frame size ([`filmcraft_frame::default_matrix`]: BT.601 for SD, BT.709 otherwise), BT.709
|
||||
/// transfer and primaries, limited range.
|
||||
pub fn resolve(width: u32, height: u32, sources: &[ColorCodes]) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 };
|
||||
if let Some(m) = sources.iter().find_map(|s| Matrix::from_code(s.matrix)) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = sources.iter().find_map(|s| Transfer::from_code(s.transfer)) {
|
||||
c.transfer = t;
|
||||
}
|
||||
if let Some(p) = sources.iter().find_map(|s| primaries_from_code(s.primaries)) {
|
||||
c.primaries = p;
|
||||
}
|
||||
if sources.iter().find_map(|s| s.full_range) == Some(true) {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
//! Colour from the bitstream when the container has none (`crate::stream_color`): ffmpeg writes MP4
|
||||
//! without a `colr` box by default, and an HEVC PQ file imported as Rec. 709 SDR, a full-range
|
||||
//! H.264 file as limited range, and a BT.601 SD file with BT.709 primaries. The parameter sets
|
||||
//! are built here bit by bit; no media files.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_bitstream::{BitWriter, escape_rbsp};
|
||||
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_isobmff::{Av1Config, AvcConfig, Brand, CodecConfig, HevcConfig, HevcNalArray, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
use crate::stream_color::{ColorCodes, from_codec_config, resolve};
|
||||
|
||||
/// The video signal description of a VUI: (colour_primaries, transfer_characteristics,
|
||||
/// matrix_coefficients) when `colour_description_present_flag` is set, and the range flag.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Signal {
|
||||
codes: Option<(u8, u8, u8)>,
|
||||
full_range: bool,
|
||||
}
|
||||
|
||||
const PQ_2020: Signal = Signal { codes: Some((9, 16, 9)), full_range: false };
|
||||
const SMPTE_170M: Signal = Signal { codes: Some((6, 6, 6)), full_range: false };
|
||||
const FULL_ONLY: Signal = Signal { codes: None, full_range: true };
|
||||
|
||||
fn signal(w: &mut BitWriter, s: Signal) {
|
||||
w.write_bit(true); // video_signal_type_present_flag
|
||||
w.write_bits(5, 3); // video_format: unspecified
|
||||
w.write_bit(s.full_range);
|
||||
w.write_bit(s.codes.is_some()); // colour_description_present_flag
|
||||
if let Some((p, t, m)) = s.codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Baseline H.264 SPS NAL unit (ITU-T H.264 §7.3.2.1.1, VUI per Annex E.1.1) of
|
||||
/// `mbs_w`×`mbs_h` macroblocks, with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn h264_sps(mbs_w: u32, mbs_h: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8); // profile_idc: Baseline
|
||||
w.write_bits(0, 8); // constraint flags
|
||||
w.write_bits(30, 8); // level_idc
|
||||
w.write_ue(0); // seq_parameter_set_id
|
||||
w.write_ue(0); // log2_max_frame_num_minus4
|
||||
w.write_ue(2); // pic_order_cnt_type
|
||||
w.write_ue(1); // max_num_ref_frames
|
||||
w.write_bit(false); // gaps_in_frame_num_value_allowed_flag
|
||||
w.write_ue(mbs_w - 1);
|
||||
w.write_ue(mbs_h - 1);
|
||||
w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // timing_info_present_flag
|
||||
w.write_bit(false); // nal_hrd_parameters_present_flag
|
||||
w.write_bit(false); // vcl_hrd_parameters_present_flag
|
||||
w.write_bit(false); // pic_struct_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![0x67];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// A Main 10 HEVC SPS NAL unit (ITU-T H.265 §7.3.2.2, VUI per Annex E.2.1) of `width`×`height`
|
||||
/// (multiples of 8), with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn hevc_sps(width: u32, height: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 4); // sps_video_parameter_set_id
|
||||
w.write_bits(0, 3); // sps_max_sub_layers_minus1
|
||||
w.write_bit(true); // sps_temporal_id_nesting_flag
|
||||
// profile_tier_level: Main 10, level 4.1
|
||||
w.write_bits(0, 2);
|
||||
w.write_bit(false);
|
||||
w.write_bits(2, 5);
|
||||
w.write_bits(0x2000_0000, 32);
|
||||
w.write_bits(0b1001, 4);
|
||||
w.write_bits(0, 32);
|
||||
w.write_bits(0, 12);
|
||||
w.write_bits(123, 8);
|
||||
w.write_ue(0); // sps_seq_parameter_set_id
|
||||
w.write_ue(1); // chroma_format_idc 4:2:0
|
||||
w.write_ue(width);
|
||||
w.write_ue(height);
|
||||
w.write_bit(false); // conformance_window_flag
|
||||
w.write_ue(2); // bit_depth_luma_minus8
|
||||
w.write_ue(2); // bit_depth_chroma_minus8
|
||||
w.write_ue(4); // log2_max_pic_order_cnt_lsb_minus4
|
||||
w.write_bit(true); // sps_sub_layer_ordering_info_present_flag
|
||||
w.write_ue(4); // sps_max_dec_pic_buffering_minus1
|
||||
w.write_ue(0); // sps_max_num_reorder_pics
|
||||
w.write_ue(0); // sps_max_latency_increase_plus1
|
||||
w.write_ue(0); // log2_min_luma_coding_block_size_minus3 (8)
|
||||
w.write_ue(1); // log2_diff_max_min_luma_coding_block_size (CTB 16)
|
||||
w.write_ue(0); // log2_min_luma_transform_block_size_minus2 (4)
|
||||
w.write_ue(2); // log2_diff_max_min_luma_transform_block_size (16)
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_inter
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_intra
|
||||
w.write_bit(false); // scaling_list_enabled_flag
|
||||
w.write_bit(false); // amp_enabled_flag
|
||||
w.write_bit(false); // sample_adaptive_offset_enabled_flag
|
||||
w.write_bit(false); // pcm_enabled_flag
|
||||
w.write_ue(0); // num_short_term_ref_pic_sets
|
||||
w.write_bit(false); // long_term_ref_pics_present_flag
|
||||
w.write_bit(false); // sps_temporal_mvp_enabled_flag
|
||||
w.write_bit(false); // strong_intra_smoothing_enabled_flag
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // neutral_chroma_indication_flag
|
||||
w.write_bit(false); // field_seq_flag
|
||||
w.write_bit(false); // frame_field_info_present_flag
|
||||
w.write_bit(false); // default_display_window_flag
|
||||
w.write_bit(false); // vui_timing_info_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.write_bit(false); // sps_extension_present_flag
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![33 << 1, 1];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// An AV1 sequence header OBU (AV1 bitstream spec §5.5; reduced still-picture header, profile 0,
|
||||
/// 10-bit 4:2:0) of `width`×`height` with `color_config` signalling `codes` and `full_range`.
|
||||
fn av1_sequence_header(width: u32, height: u32, codes: Option<(u8, u8, u8)>, full_range: bool) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 3); // seq_profile
|
||||
w.write_bit(true); // still_picture
|
||||
w.write_bit(true); // reduced_still_picture_header
|
||||
w.write_bits(0, 5); // seq_level_idx[0]
|
||||
w.write_bits(15, 4); // frame_width_bits_minus_1
|
||||
w.write_bits(15, 4); // frame_height_bits_minus_1
|
||||
w.write_bits(width - 1, 16);
|
||||
w.write_bits(height - 1, 16);
|
||||
w.write_bits(0, 3); // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
|
||||
w.write_bits(0, 3); // enable_superres, enable_cdef, enable_restoration
|
||||
w.write_bit(true); // high_bitdepth
|
||||
w.write_bit(false); // mono_chrome
|
||||
w.write_bit(codes.is_some()); // color_description_present_flag
|
||||
if let Some((p, t, m)) = codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
w.write_bit(full_range); // color_range
|
||||
w.write_bits(0, 2); // chroma_sample_position
|
||||
w.write_bit(false); // separate_uv_delta_q
|
||||
w.write_bit(false); // film_grain_params_present
|
||||
w.rbsp_trailing();
|
||||
let payload = w.finish();
|
||||
// OBU header: type 1 (OBU_SEQUENCE_HEADER), obu_has_size_field; one-byte leb128 size
|
||||
let mut obu = vec![(1 << 3) | 2, u8::try_from(payload.len()).expect("short payload")];
|
||||
obu.extend(payload);
|
||||
obu
|
||||
}
|
||||
|
||||
fn avc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4))
|
||||
}
|
||||
|
||||
fn hevc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Hevc(HevcConfig {
|
||||
general_profile_idc: 2,
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma: 10,
|
||||
bit_depth_chroma: 10,
|
||||
length_size: 4,
|
||||
arrays: vec![HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps] }],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn av1(obus: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Av1(Av1Config { high_bitdepth: true, chroma_subsampling_x: true, chroma_subsampling_y: true, config_obus: obus, ..Default::default() })
|
||||
}
|
||||
|
||||
/// A one-sample MP4 with `codec` (the sample is never decoded) and, when given, a `colr` box.
|
||||
fn mp4(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> Arc<[u8]> {
|
||||
let mut entry = match codec {
|
||||
CodecConfig::Avc(c) => SampleEntry::avc(c, w, h),
|
||||
CodecConfig::Hevc(c) => SampleEntry::hevc(c, w, h),
|
||||
other => SampleEntry::video(filmcraft_isobmff::FourCc(*b"av01"), other, w, h),
|
||||
};
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = colr;
|
||||
}
|
||||
let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(entry, 30)).expect("track");
|
||||
mux.write_sample(t, WriteSample { data: &[0, 0, 0, 1, 0x65], duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
fn mp4_color(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> ColorInfo {
|
||||
let src = crate::Mp4Source::open("clip.mp4", mp4(codec, w, h, colr)).expect("open");
|
||||
src.info().video.as_ref().expect("video").color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_pq_without_colr_is_rec2100_pq() {
|
||||
let c = mp4_color(hevc(hevc_sps(1920, 1080, Some(PQ_2020))), 1920, 1080, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited });
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert!(ColorSpace::from_info(&c).is_hdr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_range_h264_without_colr_is_full_range() {
|
||||
// video_full_range_flag with no colour description: full range, the rest by size
|
||||
let c = mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, None);
|
||||
assert_eq!(c, ColorInfo { range: Range::Full, ..ColorInfo::REC709 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bt601_sd_h264_without_colr_has_bt601_primaries() {
|
||||
let c = mp4_color(avc(h264_sps(45, 30, Some(SMPTE_170M))), 720, 480, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt601, transfer: Transfer::Bt709, primaries: Primaries::Bt601_525, range: Range::Limited });
|
||||
// PAL (BT.470 BG primaries, BT.601 matrix)
|
||||
let pal = Signal { codes: Some((5, 6, 5)), full_range: false };
|
||||
let c = mp4_color(avc(h264_sps(45, 36, Some(pal))), 720, 576, None);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt601_625, Matrix::Bt601));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_or_missing_vui_keeps_the_size_defaults() {
|
||||
let unspecified = Signal { codes: Some((2, 2, 2)), full_range: false };
|
||||
for vui in [None, Some(unspecified)] {
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, vui)), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(hevc_sps(1920, 1080, vui)), 1920, 1080, None), ColorInfo::REC709);
|
||||
// SD: the BT.601 matrix, as before
|
||||
assert_eq!(mp4_color(avc(h264_sps(45, 30, vui)), 720, 480, None), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_wins_over_the_bitstream() {
|
||||
use filmcraft_isobmff::ColorInfo::{Nclc, Nclx};
|
||||
let sps = || hevc(hevc_sps(1920, 1080, Some(PQ_2020)));
|
||||
// a BT.709 nclx box beats a PQ VUI, and is what the decoder's frames are tagged with
|
||||
let colr = Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(colr.clone())), ColorInfo::REC709);
|
||||
// HLG in colr, PQ in the VUI: HLG
|
||||
let hlg = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(hlg)).transfer, Transfer::Hlg);
|
||||
// limited range in colr beats a full-range VUI
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(colr)).range, Range::Limited);
|
||||
// what colr leaves unspecified comes from the VUI
|
||||
let partial = Nclx { primaries: 2, transfer: 16, matrix: 2, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(partial)), mp4_color(sps(), 1920, 1080, None));
|
||||
// QuickTime nclc has no range flag: the VUI's range stands
|
||||
let nclc = Nclc { primaries: 1, transfer: 1, matrix: 1 };
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(nclc)).range, Range::Full);
|
||||
// an ICC profile has no code points: the bitstream decides
|
||||
let icc = filmcraft_isobmff::ColorInfo::Icc { kind: filmcraft_isobmff::FourCc(*b"prof"), profile: vec![0; 8] };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(icc)).transfer, Transfer::Pq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_sequence_header_colour() {
|
||||
let obu = av1_sequence_header(1920, 1080, Some((9, 16, 9)), false);
|
||||
assert_eq!(from_codec_config(&av1(obu.clone())), Some(ColorCodes { primaries: 9, transfer: 16, matrix: 9, full_range: Some(false) }));
|
||||
let c = mp4_color(av1(obu), 1920, 1080, None);
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
// no colour description: unspecified code points, the range flag still counts
|
||||
let obu = av1_sequence_header(640, 360, None, true);
|
||||
assert_eq!(from_codec_config(&av1(obu)), Some(ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: Some(true) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_without_colour_uses_the_bitstream() {
|
||||
let CodecConfig::Hevc(hvcc) = hevc(hevc_sps(1920, 1080, Some(PQ_2020))) else { panic!("an hvcC") };
|
||||
let mut spec = TrackSpec::new(TrackKind::Video, "V_MPEGH/ISO/HEVC");
|
||||
spec.codec_private = hvcc.to_bytes();
|
||||
spec.video_size = Some((1920, 1080));
|
||||
spec.default_duration_ns = Some(33_333_333);
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).expect("writer");
|
||||
w.write_frame(0, 0, true, &[0, 0, 0, 1, 0x26], None).expect("frame");
|
||||
let b: Arc<[u8]> = w.finish().expect("finish").into_inner().into();
|
||||
let src = crate::MkvSource::open("clip.mkv", b).expect("open");
|
||||
let c = src.info().video.as_ref().expect("video").color;
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt2020, Matrix::Bt2020Ncl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_takes_each_field_from_the_first_source_that_knows_it() {
|
||||
let colr = ColorCodes { primaries: 1, transfer: 2, matrix: 0, full_range: None };
|
||||
let vui = ColorCodes { primaries: 9, transfer: 18, matrix: 9, full_range: Some(true) };
|
||||
// matrix 0 (identity) is not one we know: the VUI's BT.2020 is used
|
||||
let c = resolve(1920, 1080, &[colr, vui]);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt709, range: Range::Full });
|
||||
assert_eq!(resolve(720, 480, &[]), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
// code points that don't fit a byte are unspecified, not truncated (0x109 is not 9)
|
||||
assert_eq!(ColorCodes::from_wide(0x109, 0x110, 0x109, None), ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: None });
|
||||
}
|
||||
|
||||
/// Every truncation and many bit flips of each configuration: never a panic, and the result is
|
||||
/// either some colour or none (the size defaults).
|
||||
#[test]
|
||||
fn hostile_parameter_sets_never_panic() {
|
||||
let configs: Vec<(Vec<u8>, fn(Vec<u8>) -> CodecConfig)> = vec![
|
||||
(h264_sps(45, 30, Some(SMPTE_170M)), avc),
|
||||
(hevc_sps(1920, 1080, Some(PQ_2020)), hevc),
|
||||
(av1_sequence_header(1920, 1080, Some((9, 16, 9)), false), av1),
|
||||
];
|
||||
for (good, make) in configs {
|
||||
assert!(from_codec_config(&make(good.clone())).is_some());
|
||||
let mut variants: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
|
||||
for bit in 0..good.len() * 8 {
|
||||
let mut b = good.clone();
|
||||
b[bit / 8] ^= 0x80 >> (bit % 8);
|
||||
variants.push(b);
|
||||
}
|
||||
variants.push(vec![0xff; 64]);
|
||||
variants.push(Vec::new());
|
||||
for v in variants {
|
||||
let r = std::panic::catch_unwind(|| from_codec_config(&make(v.clone())));
|
||||
assert!(r.is_ok(), "panicked on {v:02x?}");
|
||||
}
|
||||
}
|
||||
// configurations with no parameter sets at all
|
||||
assert_eq!(from_codec_config(&CodecConfig::Avc(AvcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Hevc(HevcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Av1(Av1Config::default())), None);
|
||||
// a garbage SPS imports with the size defaults
|
||||
assert_eq!(mp4_color(avc(vec![0x67, 0xff, 0xff]), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(vec![33 << 1, 1, 0xff]), 1920, 1080, None), ColorInfo::REC709);
|
||||
}
|
||||
|
|
@ -615,3 +615,32 @@ fn apv_mp4_and_raw_bitstream_decode() {
|
|||
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
|
||||
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
|
||||
}
|
||||
|
||||
/// ffmpeg writes MP4 without a `colr` box unless asked: the colour comes from the x265 / x264
|
||||
/// SPS VUI, and matches the same stream remuxed with `colr`.
|
||||
#[test]
|
||||
fn mp4_colour_comes_from_the_sps_without_colr() {
|
||||
use filmcraft_color::{ColorSpace, Primaries, Range};
|
||||
let src = |name: &str, args: &[&str]| fixture(name, args).map(|b| crate::open_bytes(name, b).expect("open"));
|
||||
let color = |s: &filmcraft_media::SharedSource| s.info().video.as_ref().expect("video").color;
|
||||
let lavfi = |size: &'static str| ["-f", "lavfi", "-i", size, "-t", "0.2"];
|
||||
let x265 = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:log-level=error";
|
||||
let mut pq = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
pq.extend(["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p10le", "-x265-params", x265]);
|
||||
let Some(plain) = src("hevc_pq_no_colr.mp4", &pq) else { return };
|
||||
assert_eq!(ColorSpace::from_info(&color(&plain)), ColorSpace::Rec2100Pq);
|
||||
let colr_path = fixture_path("hevc_pq_no_colr.mp4", &pq).expect("fixture");
|
||||
if let Some(with_colr) = src("hevc_pq_colr.mp4", &["-i", colr_path.to_str().expect("path"), "-c", "copy", "-movflags", "+write_colr"]) {
|
||||
assert_eq!(color(&with_colr), color(&plain));
|
||||
}
|
||||
let mut full = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
full.extend(["-c:v", "libx264", "-pix_fmt", "yuvj420p", "-color_range", "pc"]);
|
||||
if let Some(s) = src("h264_full_no_colr.mp4", &full) {
|
||||
assert_eq!(color(&s).range, Range::Full);
|
||||
}
|
||||
let mut sd = lavfi("testsrc2=s=720x480:r=30000/1001").to_vec();
|
||||
sd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", "colorprim=smpte170m:transfer=smpte170m:colormatrix=smpte170m"]);
|
||||
if let Some(s) = src("h264_sd601_no_colr.mp4", &sd) {
|
||||
assert_eq!(color(&s).primaries, Primaries::Bt601_525);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,3 +53,113 @@ fn bframe_mp4_duration_is_the_decoded_frame_count() {
|
|||
let src = filmcraft_codecs::open_bytes("bframes_x264.mp4", bytes(&f)).unwrap();
|
||||
assert_eq!(src.info().duration, filmcraft_time::FrameRate::FPS_30.tick_of(n as i64));
|
||||
}
|
||||
|
||||
/// `mp4` (moov after mdat, as ffmpeg writes it) with a clean aperture (`clap`) of `w`×`h` moved by
|
||||
/// (`dx`, `dy`) from the centre, inserted at the end of the first video sample entry.
|
||||
fn with_clap(mp4: &[u8], (w, h, dx, dy): (u32, u32, i32, i32)) -> Vec<u8> {
|
||||
let be = |b: &[u8], at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) as usize;
|
||||
// the chain moov > trak > mdia > minf > stbl > stsd > avc1 (stsd has 8 bytes before its entries)
|
||||
let mut chain = Vec::new();
|
||||
let (mut off, mut end) = (0, mp4.len());
|
||||
for (want, skip) in [(b"moov", 8), (b"trak", 8), (b"mdia", 8), (b"minf", 8), (b"stbl", 8), (b"stsd", 16), (b"avc1", 0)] {
|
||||
loop {
|
||||
assert!(off + 8 <= end, "no {}", String::from_utf8_lossy(want));
|
||||
let size = be(mp4, off);
|
||||
if &mp4[off + 4..off + 8] == want {
|
||||
chain.push(off);
|
||||
end = off + size;
|
||||
off += skip;
|
||||
break;
|
||||
}
|
||||
off += size;
|
||||
}
|
||||
}
|
||||
let entry = *chain.last().unwrap();
|
||||
assert!(mp4.windows(4).position(|x| x == b"mdat").unwrap() < chain[0], "moov must follow mdat");
|
||||
let mut clap = Vec::new();
|
||||
for v in [40u32, u32::from_be_bytes(*b"clap"), w, 1, h, 1, dx as u32, 1, dy as u32, 1] {
|
||||
clap.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut out = mp4.to_vec();
|
||||
let at = entry + be(mp4, entry);
|
||||
out.splice(at..at, clap);
|
||||
for &b in &chain {
|
||||
let size = (be(&out, b) + 40) as u32;
|
||||
out[b..b + 4].copy_from_slice(&size.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cropped pictures are the size and the samples ffmpeg decodes (#288): H.264 frame cropping (SPS)
|
||||
/// and an HEVC conformance window while the sample entry gives the uncropped size, and a clean
|
||||
/// aperture (`clap`, as iPhones write), centred, off-centre, and with a 90° display rotation
|
||||
/// applied after it.
|
||||
#[test]
|
||||
fn cropped_pictures_match_ffmpeg() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let src = ["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"];
|
||||
let Some(plain) = fixture(&ff, "crop_plain_x264.mp4", &src) else {
|
||||
eprintln!("SKIPPED: ffmpeg without libx264");
|
||||
return;
|
||||
};
|
||||
let sps =
|
||||
fixture(&ff, "crop_sps_x264.mp4", &[&src[..], &["-bsf:v", "h264_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()).unwrap();
|
||||
// the display matrix added without re-encoding (an encode would turn the pixels instead)
|
||||
let rotated = fixture(&ff, "crop_rotated_x264.mp4", &["-display_rotation", "90", "-i", plain.to_str().unwrap(), "-c", "copy"]).unwrap();
|
||||
let dir = dir();
|
||||
let mut cases = vec![("SPS crop", sps, (288, 224))];
|
||||
// HEVC: the conformance window
|
||||
let hevc = [
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libx265",
|
||||
"-x265-params",
|
||||
"log-level=error:bframes=3",
|
||||
"-tag:v",
|
||||
"hvc1",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
];
|
||||
match fixture(&ff, "crop_conformance_x265.mp4", &[&hevc[..], &["-bsf:v", "hevc_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()) {
|
||||
Some(p) => cases.push(("HEVC conformance window", p, (288, 224))),
|
||||
None => eprintln!("SKIPPED (HEVC case): ffmpeg without libx265"),
|
||||
}
|
||||
for (name, base, clap, size) in [
|
||||
("centred clap", &plain, (280, 200, 0, 0), (280, 200)),
|
||||
("off-centre clap", &plain, (200, 150, -30, 22), (200, 150)),
|
||||
("clap then rotation", &rotated, (280, 200, 0, 0), (200, 280)),
|
||||
] {
|
||||
let path = dir.join(format!("crop_{}.mp4", name.replace(' ', "_")));
|
||||
std::fs::write(&path, with_clap(&bytes(base), clap)).unwrap();
|
||||
cases.push((name, path, size));
|
||||
}
|
||||
for (name, path, (w, h)) in cases {
|
||||
let src = filmcraft_codecs::open_bytes(path.file_name().unwrap().to_str().unwrap(), bytes(&path)).unwrap();
|
||||
let v = src.info().video.clone().unwrap();
|
||||
assert_eq!((v.width, v.height), (w, h), "{name}: reported size");
|
||||
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
|
||||
// `-flags unaligned`: ffmpeg otherwise skips a left crop that would misalign its frame
|
||||
// pointers (on AVX-512 machines it needs 64-byte alignment) and returns wider frames
|
||||
let raw = ffmpeg_out(
|
||||
&ff,
|
||||
&["-flags", "unaligned", "-i", path.to_str().unwrap(), "-map", "0:v:0", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
|
||||
);
|
||||
let frame_bytes = (w * h) as usize + 2 * cw * ch;
|
||||
if name.contains("clap") && !raw.len().is_multiple_of(frame_bytes) {
|
||||
// ffmpeg before 7.1 ignores `clap` and decodes the whole picture
|
||||
eprintln!("SKIPPED ({name}): this ffmpeg does not apply the clean aperture");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(raw.len() % frame_bytes, 0, "{name}: ffmpeg's frames are {w}x{h}");
|
||||
let n = raw.len() / frame_bytes;
|
||||
assert_eq!(n, 30, "{name}");
|
||||
for (i, want) in raw.chunks(frame_bytes).enumerate() {
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest::full(v.frame_rate.tick_of(i as i64))).unwrap();
|
||||
assert_eq!((f.width, f.height), (w, h), "{name}: frame {i} size");
|
||||
assert!(planes(&f) == raw_planes(want, w as usize, h as usize, cw, ch, 1), "{name}: frame {i} differs from ffmpeg's");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub mod frame_size;
|
|||
pub mod multicam;
|
||||
pub mod through;
|
||||
pub mod transcript;
|
||||
pub mod transitions;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -535,15 +536,30 @@ 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((head(ctx, it).0 as f64 / speed).floor() as i64);
|
||||
// a reversed clip plays source_out down to source_in, so its In edge consumes media after source_out
|
||||
let handle = match (it.reverse, media) {
|
||||
(true, Some(m)) => (m - it.source_out()).max(Tick::ZERO),
|
||||
(true, None) => Tick::MAX,
|
||||
(false, _) => head(ctx, it),
|
||||
};
|
||||
let max_ext = if handle == Tick::MAX { Tick::MAX } else { Tick((handle.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);
|
||||
// a frame hold needs no source head, but a Regular trim still stops at the previous clip
|
||||
let lo = match (it.frame_hold.is_some(), mode) {
|
||||
(true, TrimMode::Regular) => -(it.start - prev_end),
|
||||
(true, _) => Tick::MIN,
|
||||
(false, _) => lo,
|
||||
};
|
||||
d = d.clamp(lo, hi);
|
||||
}
|
||||
Edge::Out => {
|
||||
let lo = -(it.duration - ctx.min_duration);
|
||||
let mut hi = if mode == TrimMode::Regular { next_start - it.end() } else { Tick::MAX };
|
||||
if let Some(m) = media {
|
||||
if it.reverse && it.frame_hold.is_none() {
|
||||
// a reversed clip's Out edge consumes media before source_in
|
||||
hi = hi.min(Tick((head(ctx, it).0 as f64 / speed).floor() as i64));
|
||||
} else if let Some(m) = media {
|
||||
let remain = Tick(((m - it.source_out()).0 as f64 / speed).floor() as i64);
|
||||
hi = hi.min(remain);
|
||||
}
|
||||
|
|
@ -751,10 +767,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
|||
let mut d = delta;
|
||||
// left out-point limits
|
||||
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
|
||||
if let Some(m) = media_len(ctx, l) {
|
||||
// a reversed clip consumes media from the opposite end, so its media limit is the other one
|
||||
if l.reverse {
|
||||
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
} else 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((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
if r.reverse {
|
||||
if let Some(m) = media_len(ctx, r) {
|
||||
d = d.max(-Tick(((m - r.source_out()).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
if d == Tick::ZERO {
|
||||
return Ok(d);
|
||||
}
|
||||
|
|
@ -762,15 +787,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
|||
let before = seq.clone();
|
||||
{
|
||||
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
|
||||
if l.reverse {
|
||||
l.source_in -= src_of(d, ls);
|
||||
}
|
||||
l.duration += d;
|
||||
}
|
||||
{
|
||||
let (_, r) = seq.find_item_mut(right).ok_or(EditError::NoItem(right))?;
|
||||
r.start += d;
|
||||
r.duration -= d;
|
||||
r.source_in += src_of(d, rs);
|
||||
if !r.reverse {
|
||||
r.source_in += src_of(d, rs);
|
||||
}
|
||||
}
|
||||
let _ = ls;
|
||||
transitions_follow_cuts(&before, seq);
|
||||
Ok(d)
|
||||
}
|
||||
|
|
@ -799,7 +828,10 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
let mut d = delta;
|
||||
if let Some(p) = &prev {
|
||||
d = d.max(-(p.duration - ctx.min_duration));
|
||||
if let Some(m) = media_len(ctx, p) {
|
||||
if p.reverse {
|
||||
// a reversed neighbour grows by pulling earlier media in front of its source In
|
||||
d = d.min(Tick((head(ctx, p).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
} else if let Some(m) = media_len(ctx, p) {
|
||||
d = d.min(Tick(((m - p.source_out()).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
|
|
@ -807,7 +839,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((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
if n.reverse {
|
||||
// a reversed neighbour grows leftwards by pulling later media behind its source Out
|
||||
if let Some(m) = media_len(ctx, n) {
|
||||
d = d.max(-Tick(((m - n.source_out()).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
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());
|
||||
}
|
||||
|
|
@ -819,12 +858,17 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
if let Some(p) = prev {
|
||||
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
|
||||
pi.duration += d;
|
||||
if p.reverse {
|
||||
pi.source_in -= src_of(d, p.speed);
|
||||
}
|
||||
}
|
||||
if let Some(n) = next {
|
||||
let ni = t.item_mut(n.id).ok_or(EditError::NoItem(n.id))?;
|
||||
ni.start += d;
|
||||
ni.duration -= d;
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
if !n.reverse {
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
}
|
||||
}
|
||||
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
me.start += d;
|
||||
|
|
|
|||
|
|
@ -234,6 +234,27 @@ fn close_gap_works() {
|
|||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_trim_uses_the_handle_its_playback_direction_consumes() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
// source frames [5, 15) played backwards; the media is 1000 frames long
|
||||
let a = fx.put(v1, 30, 10, 5);
|
||||
fx.seq.find_item_mut(a).unwrap().1.reverse = true;
|
||||
let mut n = fx.next;
|
||||
// out extends into the 5 frames before source_in, not into the 985 after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, f(5));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(15)));
|
||||
// in extends into the media after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(10));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(25)));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_trim_respects_neighbours_and_handles() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -253,6 +274,24 @@ fn regular_trim_respects_neighbours_and_handles() {
|
|||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_regular_in_trim_stops_at_previous_clip() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 15, 10, 5);
|
||||
fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5));
|
||||
let mut n = fx.next;
|
||||
// 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(5));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]);
|
||||
// no space left: nothing to do, and no error
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, Tick::ZERO);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_trim_shifts_following() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -359,6 +398,25 @@ fn roll_slip_slide() {
|
|||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_keeps_reversed_neighbours_content() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 30, 20);
|
||||
let b = fx.put(v1, 30, 30, 50);
|
||||
let c = fx.put(v1, 60, 30, 70);
|
||||
for id in [a, c] {
|
||||
fx.seq.find_item_mut(id).unwrap().1.reverse = true;
|
||||
}
|
||||
let mut n = fx.next;
|
||||
slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]);
|
||||
assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10));
|
||||
assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50));
|
||||
assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_stay_on_their_cuts() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -673,3 +731,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() {
|
|||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_reversed_clips_keeps_media_mapping() {
|
||||
let mut fx = Fx::new();
|
||||
let v = fx.v(0);
|
||||
let mut a = fx.item(0, 30, 20);
|
||||
a.reverse = true;
|
||||
let mut b = fx.item(30, 30, 50);
|
||||
b.reverse = true;
|
||||
let (ia, ib) = (a.id, b.id);
|
||||
fx.seq.track_mut(v).unwrap().items.extend([a, b]);
|
||||
let mut n = 0;
|
||||
roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
let (_, l) = fx.seq.find_item(ia).unwrap();
|
||||
assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10)));
|
||||
let (_, r) = fx.seq.find_item(ib).unwrap();
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec<ThroughEdit> {
|
|||
/// piece is listed. The left piece absorbs the right one (keeping its own effects and attributes);
|
||||
/// a transition sitting on the cut is removed. Returns the number of edits joined.
|
||||
pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
||||
join_through_edits_where(seq, |a, b| only.is_empty() || only.contains(&a) || only.contains(&b))
|
||||
}
|
||||
|
||||
/// Join the through edits on unlocked tracks whose (left, right) pieces `wanted` accepts.
|
||||
pub fn join_through_edits_where(seq: &mut Sequence, wanted: impl Fn(ClipId, ClipId) -> bool) -> usize {
|
||||
let mut n = 0;
|
||||
for tr in seq.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
|
|
@ -67,8 +72,7 @@ pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
|||
let mut i = 0;
|
||||
while i + 1 < tr.items.len() {
|
||||
let (a, b) = (&tr.items[i], &tr.items[i + 1]);
|
||||
let wanted = only.is_empty() || only.contains(&a.id) || only.contains(&b.id);
|
||||
if !(wanted && is_through_edit(a, b)) {
|
||||
if !(wanted(a.id, b.id) && is_through_edit(a, b)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,401 @@
|
|||
//! Applied transitions: where one may sit over its cut, its alignment, and moving / resizing /
|
||||
//! removing one (Effect Controls' Duration and Alignment, dragging its edges or its middle in the
|
||||
//! Timeline, Delete).
|
||||
//!
|
||||
//! Premiere (Align transitions; Change transition duration): a transition between two clips stays
|
||||
//! over their cut, "Center at Cut", "Start at Cut", "End at Cut" or, once dragged off-centre,
|
||||
//! "Custom Start". Changing its duration moves both ends equally for Center at Cut / Custom Start,
|
||||
//! only the end for Start at Cut and only the beginning for End at Cut. A one-sided transition (a
|
||||
//! fade from or to nothing) starts or ends at its clip's edge.
|
||||
//!
|
||||
//! Premiere limits a transition by the media its clips have beyond the cut (handles). FilmCraft
|
||||
//! does not check handles when applying one, so here a transition may cover at most the clips it
|
||||
//! joins: from the outgoing clip's start to the incoming clip's end.
|
||||
|
||||
use filmcraft_project::{Sequence, Track, Transition, TransitionAlign, TransitionId};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
use crate::{Edge, EditError, Result};
|
||||
|
||||
/// How a transition sits on its cut, as Effect Controls' Alignment menu shows it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Alignment {
|
||||
Center,
|
||||
Start,
|
||||
End,
|
||||
/// Premiere's "Custom Start": dragged to a place over the cut that is none of the others.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Alignment {
|
||||
pub const MENU: [Alignment; 3] = [Alignment::Center, Alignment::Start, Alignment::End];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Alignment::Center => "center",
|
||||
Alignment::Start => "start",
|
||||
Alignment::End => "end",
|
||||
Alignment::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Alignment::Center => "Center at Cut",
|
||||
Alignment::Start => "Start at Cut",
|
||||
Alignment::End => "End at Cut",
|
||||
Alignment::Custom => "Custom Start",
|
||||
}
|
||||
}
|
||||
|
||||
/// `center`, `start` or `end` (Custom Start is reached by moving a transition, not chosen).
|
||||
pub fn parse(s: &str) -> Option<Alignment> {
|
||||
Alignment::MENU.into_iter().find(|a| a.id().eq_ignore_ascii_case(s) || a.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a transition may be: `(earliest start, cut, latest end)`. Between two clips that is the
|
||||
/// outgoing clip's start, the cut and the incoming clip's end; for a fade to or from nothing the cut
|
||||
/// is its clip's edge. `None` when its clips are gone.
|
||||
pub fn bounds(track: &Track, tr: &Transition) -> Option<(Tick, Tick, Tick)> {
|
||||
let from = tr.from.and_then(|c| track.item(c));
|
||||
let to = tr.to.and_then(|c| track.item(c));
|
||||
match (from, to) {
|
||||
(Some(f), Some(t)) => Some((f.start, t.start, t.end())),
|
||||
(None, Some(t)) => Some((t.start, t.start, t.end())),
|
||||
(Some(f), None) => Some((f.start, f.end(), f.end())),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A transition's alignment on `cut`. Centred means within half a `frame` of the cut, as
|
||||
/// centring an odd number of frames lands on a frame boundary.
|
||||
pub fn alignment(tr: &Transition, cut: Tick, frame: Tick) -> Alignment {
|
||||
match (tr.from, tr.to) {
|
||||
(None, Some(_)) => return Alignment::Start,
|
||||
(Some(_), None) => return Alignment::End,
|
||||
_ => {}
|
||||
}
|
||||
if tr.start == cut {
|
||||
Alignment::Start
|
||||
} else if tr.end() == cut {
|
||||
Alignment::End
|
||||
} else if (tr.start.0.saturating_mul(2).saturating_add(tr.duration.0) - cut.0.saturating_mul(2)).abs() <= frame.0.max(0) {
|
||||
Alignment::Center
|
||||
} else {
|
||||
Alignment::Custom
|
||||
}
|
||||
}
|
||||
|
||||
fn find(seq: &Sequence, id: TransitionId) -> Option<(&Track, &Transition)> {
|
||||
seq.all_tracks().find_map(|t| t.transitions.iter().find(|x| x.id == id).map(|x| (t, x)))
|
||||
}
|
||||
|
||||
/// Where a transition would start for `duration` and `align`, keeping its cut (and, for Center
|
||||
/// at Cut / Custom Start, its centre; `snap` puts a centred start on a frame).
|
||||
pub fn start_for(seq: &Sequence, id: TransitionId, duration: Tick, align: Alignment, frame: Tick, snap: impl Fn(Tick) -> Tick) -> Option<Tick> {
|
||||
let (track, tr) = find(seq, id)?;
|
||||
let (_, cut, _) = bounds(track, tr)?;
|
||||
let one_sided = alignment(tr, cut, frame);
|
||||
let align = match one_sided {
|
||||
Alignment::Start | Alignment::End if tr.from.is_none() || tr.to.is_none() => one_sided,
|
||||
_ => align,
|
||||
};
|
||||
Some(match align {
|
||||
Alignment::Start => cut,
|
||||
Alignment::End => cut - duration,
|
||||
Alignment::Center => snap(cut - duration.mul_ratio(1, 2)),
|
||||
// Custom Start: the ends move equally, around where it is now
|
||||
Alignment::Custom => snap(tr.start + tr.duration.mul_ratio(1, 2) - duration.mul_ratio(1, 2)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Move / resize a transition to cover `start .. start + duration`. Refused (leaving the sequence
|
||||
/// as it was) when its track is locked, it would be shorter than `min`, it would leave its clips,
|
||||
/// or it would no longer touch its cut (a one-sided one must keep its edge).
|
||||
pub fn set_span(seq: &mut Sequence, id: TransitionId, start: Tick, duration: Tick, min: Tick, frame: Tick) -> Result<()> {
|
||||
let (track, tr) = find(seq, id).ok_or_else(|| EditError::Other(format!("no transition {}", id.0)))?;
|
||||
if track.locked {
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
let (lo, cut, hi) = bounds(track, tr).ok_or_else(|| EditError::Other("the transition's clips are gone".into()))?;
|
||||
if duration < min.max(Tick(1)) {
|
||||
return Err(EditError::Other("a transition lasts at least one frame".into()));
|
||||
}
|
||||
let end = start.0.checked_add(duration.0).map(Tick).ok_or_else(|| EditError::Other("transition out of range".into()))?;
|
||||
if start < lo || end > hi {
|
||||
return Err(EditError::Other("a transition can't extend past the clips it joins".into()));
|
||||
}
|
||||
let on_cut = match (tr.from, tr.to) {
|
||||
(None, Some(_)) => start == cut,
|
||||
(Some(_), None) => end == cut,
|
||||
_ => start <= cut && cut <= end,
|
||||
};
|
||||
if !on_cut {
|
||||
return Err(EditError::Other(if tr.from.is_some() && tr.to.is_some() {
|
||||
"a transition between two clips must stay over their cut".into()
|
||||
} else {
|
||||
"a one-sided transition stays on its clip's edge".into()
|
||||
}));
|
||||
}
|
||||
let tid = track.id;
|
||||
let Some(x) = seq.track_mut(tid).and_then(|t| t.transitions.iter_mut().find(|x| x.id == id)) else {
|
||||
return Err(EditError::Other(format!("no transition {}", id.0)));
|
||||
};
|
||||
x.start = start;
|
||||
x.duration = duration;
|
||||
// the stored alignment (interchange): Custom Start has no variant, its span says it all
|
||||
x.align = match alignment(x, cut, frame) {
|
||||
Alignment::Start => TransitionAlign::StartAtCut,
|
||||
Alignment::End => TransitionAlign::EndAtCut,
|
||||
Alignment::Center | Alignment::Custom => TransitionAlign::CenterAtCut,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where dragging a transition by `delta` in the Timeline puts it, as `(start, duration)`. Its
|
||||
/// middle (`edge` None) slides it over the cut. An end trims that end only (Premiere shows the
|
||||
/// Trim-In / Trim-Out icon there): the other end stays, whatever the alignment, so a Center at
|
||||
/// Cut transition becomes Custom Start. (A typed duration, in Effect Controls or Set Transition
|
||||
/// Duration, follows the alignment instead: see [`start_for`].) A fade to or from nothing keeps
|
||||
/// its clip-edge end, and does not slide. Always clamped to where it may be ([`bounds`], over the
|
||||
/// cut, at least one `frame`). `None` when its clips are gone.
|
||||
pub fn drag_span(track: &Track, tr: &Transition, edge: Option<Edge>, delta: Tick, frame: Tick) -> Option<(Tick, Tick)> {
|
||||
let (lo, cut, hi) = bounds(track, tr)?;
|
||||
let frame = frame.max(Tick(1));
|
||||
let (s0, e0, d0) = (tr.start, tr.end(), tr.duration);
|
||||
let one_sided = tr.from.is_none() || tr.to.is_none();
|
||||
let unchanged = Some((s0, d0));
|
||||
match edge {
|
||||
None if one_sided => unchanged,
|
||||
None => {
|
||||
let (min_s, max_s) = (lo.max(cut - d0), (hi - d0).min(cut));
|
||||
if min_s > max_s { unchanged } else { Some(((s0 + delta).clamp(min_s, max_s), d0)) }
|
||||
}
|
||||
Some(Edge::In) if tr.from.is_none() => unchanged,
|
||||
Some(Edge::In) => {
|
||||
let max_s = cut.min(e0 - frame).max(lo);
|
||||
let st = (s0 + delta).clamp(lo, max_s);
|
||||
Some((st, e0 - st))
|
||||
}
|
||||
Some(Edge::Out) if tr.to.is_none() => unchanged,
|
||||
Some(Edge::Out) => {
|
||||
let min_e = cut.max(s0 + frame).min(hi);
|
||||
let en = (e0 + delta).clamp(min_e, hi);
|
||||
Some((s0, en - s0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove transitions (on unlocked tracks). Returns how many went.
|
||||
pub fn remove(seq: &mut Sequence, ids: &[TransitionId]) -> Result<usize> {
|
||||
if seq.all_tracks().any(|t| t.locked && t.transitions.iter().any(|x| ids.contains(&x.id))) {
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in seq.all_tracks_mut() {
|
||||
let before = t.transitions.len();
|
||||
t.transitions.retain(|x| !ids.contains(&x.id));
|
||||
n += before - t.transitions.len();
|
||||
}
|
||||
if n == 0 { Err(EditError::Nothing) } else { Ok(n) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{ClipId, ItemId, Label, Project, SequenceSettings, TrackItem, find_effect};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
const R: FrameRate = FrameRate::FPS_24;
|
||||
|
||||
fn f(n: i64) -> Tick {
|
||||
R.tick_of(n)
|
||||
}
|
||||
|
||||
fn clip(id: u64, start: i64, dur: i64) -> TrackItem {
|
||||
TrackItem {
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
label: Label::Iris,
|
||||
start: f(start),
|
||||
duration: f(dur),
|
||||
source_in: Tick::ZERO,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: vec![],
|
||||
markers: vec![],
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(id: u64, start: i64, dur: i64, from: Option<u64>, to: Option<u64>) -> Transition {
|
||||
// any effect: these tests are about where the transition sits
|
||||
let effect = find_effect("opacity").unwrap().instance();
|
||||
Transition {
|
||||
id: TransitionId(id),
|
||||
effect,
|
||||
start: f(start),
|
||||
duration: f(dur),
|
||||
from: from.map(ClipId),
|
||||
to: to.map(ClipId),
|
||||
align: TransitionAlign::CenterAtCut,
|
||||
reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// V1: c1 0..48, c2 48..96, c3 96..144; a centred 24-frame transition on 48 (id 1), a fade in
|
||||
/// at 0 (id 2) and a fade out at 144 (id 3).
|
||||
fn seq() -> Sequence {
|
||||
let mut p = Project::new("t");
|
||||
let s = p.new_sequence("s", SequenceSettings { frame_rate: R, ..Default::default() }, 1, 0, None);
|
||||
let mut q = p.sequence(s).unwrap().clone();
|
||||
let t = &mut q.video_tracks[0];
|
||||
t.items = vec![clip(1, 0, 48), clip(2, 48, 48), clip(3, 96, 48)];
|
||||
t.transitions = vec![transition(1, 36, 24, Some(1), Some(2)), transition(2, 0, 12, None, Some(1)), transition(3, 132, 12, Some(3), None)];
|
||||
q
|
||||
}
|
||||
|
||||
fn span(q: &Sequence, id: u64) -> (Tick, Tick) {
|
||||
let x = q.video_tracks[0].transitions.iter().find(|x| x.id.0 == id).unwrap();
|
||||
(x.start, x.duration)
|
||||
}
|
||||
|
||||
fn align(q: &Sequence, id: u64) -> Alignment {
|
||||
let (t, x) = find(q, TransitionId(id)).unwrap();
|
||||
let (_, cut, _) = bounds(t, x).unwrap();
|
||||
alignment(x, cut, f(1))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_reads_the_span() {
|
||||
let mut q = seq();
|
||||
assert_eq!((align(&q, 1), align(&q, 2), align(&q, 3)), (Alignment::Center, Alignment::Start, Alignment::End));
|
||||
set_span(&mut q, TransitionId(1), f(48), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Start);
|
||||
set_span(&mut q, TransitionId(1), f(24), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::End);
|
||||
set_span(&mut q, TransitionId(1), f(40), f(24), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Custom);
|
||||
// an odd centred duration sits half a frame off the cut and is still Center at Cut
|
||||
set_span(&mut q, TransitionId(1), f(42), f(13), f(1), f(1)).unwrap();
|
||||
assert_eq!(align(&q, 1), Alignment::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_changes_follow_the_alignment() {
|
||||
let q = seq();
|
||||
let snap = |t: Tick| R.snap(t);
|
||||
// Center at Cut: both ends move
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::Center, f(1), snap), Some(f(30)));
|
||||
// Start at Cut: only the end moves; End at Cut: only the beginning
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::Start, f(1), snap), Some(f(48)));
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(36), Alignment::End, f(1), snap), Some(f(12)));
|
||||
// a one-sided transition keeps its edge whatever is asked
|
||||
assert_eq!(start_for(&q, TransitionId(2), f(20), Alignment::Center, f(1), snap), Some(f(0)));
|
||||
assert_eq!(start_for(&q, TransitionId(3), f(20), Alignment::Center, f(1), snap), Some(f(124)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_start_resizes_around_its_middle() {
|
||||
let mut q = seq();
|
||||
set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)).unwrap();
|
||||
assert_eq!(start_for(&q, TransitionId(1), f(10), Alignment::Custom, f(1), |t| R.snap(t)), Some(f(45)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_stay_on_the_cut_and_inside_the_clips() {
|
||||
let mut q = seq();
|
||||
let before = q.clone();
|
||||
for (id, start, dur) in [
|
||||
(1, -1, 24), // before the outgoing clip
|
||||
(1, 40, 60), // past the incoming clip's end
|
||||
(1, 10, 20), // entirely before the cut
|
||||
(1, 50, 10), // entirely after it
|
||||
(1, 40, 0), // empty
|
||||
(2, 1, 12), // a fade in must start at its clip's start
|
||||
(3, 120, 12), // a fade out must end at its clip's end
|
||||
(9, 0, 12), // no such transition
|
||||
] {
|
||||
assert!(set_span(&mut q, TransitionId(id), f(start), f(dur), f(1), f(1)).is_err(), "{id} {start} {dur}");
|
||||
assert_eq!(q, before, "refused changes leave the sequence as it was");
|
||||
}
|
||||
// the whole of both clips is allowed
|
||||
set_span(&mut q, TransitionId(1), f(0), f(96), f(1), f(1)).unwrap();
|
||||
assert_eq!(span(&q, 1), (f(0), f(96)));
|
||||
// Tick::MAX never overflows
|
||||
assert!(set_span(&mut q, TransitionId(1), Tick(i64::MAX - 1), Tick(i64::MAX), f(1), f(1)).is_err());
|
||||
}
|
||||
|
||||
fn drag(q: &Sequence, id: u64, edge: Option<Edge>, frames: i64) -> (i64, i64) {
|
||||
let (t, x) = find(q, TransitionId(id)).unwrap();
|
||||
let (st, d) = drag_span(t, x, edge, f(frames), f(1)).unwrap();
|
||||
(R.frame_at(st), R.frame_at(d))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_transition() {
|
||||
let mut q = seq();
|
||||
// centred (36..60 over the cut at 48): an end trims that end only, the other stays
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -4), (32, 28));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -10), (36, 14));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -100), (36, 12), "the end stops at the cut");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), 100), (36, 60), "and at the incoming clip's end");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -100), (0, 60), "the start at the outgoing clip's start");
|
||||
// the middle slides it, staying over the cut and inside the clips
|
||||
assert_eq!(drag(&q, 1, None, 5), (41, 24));
|
||||
assert_eq!(drag(&q, 1, None, 100), (48, 24));
|
||||
assert_eq!(drag(&q, 1, None, -100), (24, 24));
|
||||
// not centred: the other end stays
|
||||
set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)).unwrap();
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), -4), (36, 24));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::In), 100), (48, 12), "the start stops at the cut");
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), 6), (40, 26));
|
||||
assert_eq!(drag(&q, 1, Some(Edge::Out), -100), (40, 8), "the end stops at the cut");
|
||||
// a fade in keeps its start, a fade out its end; neither slides
|
||||
assert_eq!(drag(&q, 2, Some(Edge::In), 3), (0, 12));
|
||||
assert_eq!(drag(&q, 2, Some(Edge::Out), 3), (0, 15));
|
||||
assert_eq!(drag(&q, 2, None, 3), (0, 12));
|
||||
assert_eq!(drag(&q, 3, Some(Edge::Out), 3), (132, 12));
|
||||
assert_eq!(drag(&q, 3, Some(Edge::In), -3), (129, 15));
|
||||
// whatever comes out is a span set_span accepts
|
||||
for (id, edge, n) in [(1, Some(Edge::In), -70), (1, None, 33), (2, Some(Edge::Out), 99), (3, Some(Edge::In), -99)] {
|
||||
let (t, x) = find(&q, TransitionId(id)).unwrap();
|
||||
let (st, d) = drag_span(t, x, edge, f(n), f(1)).unwrap();
|
||||
let mut c = q.clone();
|
||||
set_span(&mut c, TransitionId(id), st, d, f(1), f(1)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_tracks_refuse() {
|
||||
let mut q = seq();
|
||||
q.video_tracks[0].locked = true;
|
||||
assert_eq!(set_span(&mut q, TransitionId(1), f(40), f(20), f(1), f(1)), Err(EditError::Locked));
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1)]), Err(EditError::Locked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_only_the_named_transitions() {
|
||||
let mut q = seq();
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1), TransitionId(3)]), Ok(2));
|
||||
let left: Vec<u64> = q.video_tracks[0].transitions.iter().map(|x| x.id.0).collect();
|
||||
assert_eq!(left, vec![2]);
|
||||
assert_eq!(q.video_tracks[0].items.len(), 3, "the clips stay");
|
||||
assert_eq!(remove(&mut q, &[TransitionId(1)]), Err(EditError::Nothing));
|
||||
}
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
|
|||
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()))?;
|
||||
let b = src.audio_stream(need.audio_stream, 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)),
|
||||
|
|
@ -230,8 +230,19 @@ fn ensure_dir(s: &Session, dir: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> {
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() });
|
||||
if needs.iter().any(|need| need.audio_stream > 0) {
|
||||
return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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>)> {
|
||||
check_linked_streams(s, seq, plan)?;
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
|
@ -255,7 +266,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
|
|||
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => 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())
|
||||
|
|
@ -380,6 +391,7 @@ pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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)?;
|
||||
check_linked_streams(s, seq, &plan)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Media"));
|
||||
let mut files = Vec::new();
|
||||
|
|
|
|||
|
|
@ -311,3 +311,192 @@ fn commands_are_registered_with_menus_and_validate_params() {
|
|||
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
// Selected-stream essence uses only PR361 schema13 placement and decoder fields.
|
||||
mod selected_streams {
|
||||
use crate::{Services, Session};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type Calls = Arc<Mutex<Vec<(usize, i64, usize, u32)>>>;
|
||||
|
||||
struct Tones {
|
||||
info: MediaInfo,
|
||||
calls: Calls,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
fn new(streams: usize) -> Self {
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(
|
||||
filmcraft_media::Generator::BarsAndTone,
|
||||
32,
|
||||
18,
|
||||
FrameRate::FPS_24,
|
||||
Tick::from_seconds_f64(2.0),
|
||||
);
|
||||
let mut info = source.info().clone();
|
||||
info.kind = MediaKind::AudioOnly;
|
||||
info.video = None;
|
||||
info.audio_streams = vec![info.audio_streams[0].clone(); streams];
|
||||
Self { info, calls: Arc::default() }
|
||||
}
|
||||
|
||||
fn sample(stream: usize, channel: usize, frame: i64, rate: u32) -> f32 {
|
||||
let frequency = (stream * 2 + channel + 1) as f64 * 240.0;
|
||||
let amplitude = 0.05 * (stream + 1) as f64;
|
||||
(amplitude * (std::f64::consts::TAU * frequency * frame as f64 / rate as f64).sin()) as f32
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for Tones {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
Err(MediaError::NoStream("video"))
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.audio_stream(0, start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
if stream >= self.info.audio_streams.len() {
|
||||
return Err(MediaError::NoStream("audio"));
|
||||
}
|
||||
self.calls.lock().unwrap().push((stream, start, frames, sample_rate));
|
||||
Ok(AudioBuffer {
|
||||
sample_rate,
|
||||
channels: (0..2).map(|channel| (0..frames).map(|i| Self::sample(stream, channel, start + i as i64, sample_rate)).collect()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryFiles {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
}
|
||||
impl Services for MemoryFiles {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
self.writes.lock().unwrap().push((path.to_owned(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
fn export_in_memory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn add_source(s: &mut Session, streams: usize) -> (ItemId, Calls) {
|
||||
let source = Arc::new(Tones::new(streams));
|
||||
let calls = source.calls.clone();
|
||||
let mut project = (*s.project).clone();
|
||||
let item = project.add_item(
|
||||
"distinct tones",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::File { path: format!("tone-source-{streams}.wav") },
|
||||
info: source.info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
s.project = Arc::new(project);
|
||||
s.media.insert(item, source);
|
||||
(item, calls)
|
||||
}
|
||||
|
||||
fn fixture() -> (Session, ItemId, ItemId, Calls, Arc<MemoryFiles>) {
|
||||
let files = Arc::new(MemoryFiles::default());
|
||||
let mut session = Session::new(files.clone());
|
||||
let mut project = (*session.project).clone();
|
||||
let sequence =
|
||||
project.new_sequence("streams", SequenceSettings { width: 32, height: 18, frame_rate: FrameRate::FPS_24, ..Default::default() }, 0, 1, None);
|
||||
session.project = Arc::new(project);
|
||||
session.state.active_sequence = Some(sequence);
|
||||
session.state.open_sequences = vec![sequence];
|
||||
let (item, calls) = add_source(&mut session, 3);
|
||||
session.state.project_selection = vec![item];
|
||||
session.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
(session, item, sequence, calls, files)
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, item: ItemId, seconds: f64) -> Vec<ClipId> {
|
||||
let result = s.execute("timeline.place", json!({"item": item.0, "track": "A1", "seconds": seconds})).unwrap();
|
||||
result["clips"].as_array().unwrap().iter().map(|id| ClipId(id.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_essence_distinguishes_streams_and_linked_refusal_precedes_writes() {
|
||||
use crate::aaf_omf::{AudioMode, AudioPlan};
|
||||
use filmcraft_interchange::essence::{EssenceData, EssenceKey, NestNeeds};
|
||||
let (mut s, item, sequence, calls, files) = fixture();
|
||||
place(&mut s, item, 0.0);
|
||||
let plan = AudioPlan {
|
||||
mode: AudioMode::Embedded,
|
||||
aiff: false,
|
||||
mxf: false,
|
||||
sample_rate: 48_000,
|
||||
bits: 16,
|
||||
trim: true,
|
||||
handles: Tick::ZERO,
|
||||
render_effects: false,
|
||||
breakout: false,
|
||||
nests: NestNeeds::Inside,
|
||||
};
|
||||
let (essences, paths) = crate::aaf_omf::prepare_audio(&s, sequence, &plan, "ignored").unwrap();
|
||||
assert!(paths.is_empty());
|
||||
assert_eq!(essences.len(), 3);
|
||||
for (stream, essence) in essences.iter().enumerate() {
|
||||
assert_eq!(essence.key, EssenceKey::media(item, stream));
|
||||
let EssenceData::Embedded(bytes) = &essence.data else { panic!("expected embedded PCM") };
|
||||
for channel in 0..2 {
|
||||
let offset = (25 * 2 + channel) * 2;
|
||||
let actual = i16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
||||
let expected = (Tones::sample(stream, channel, 25, 48_000) * 32767.0).round() as i16;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
let options = filmcraft_interchange::aaf::AafOptions {
|
||||
media: filmcraft_interchange::essence::MediaOptions { essence: essences, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let (aaf, _) = filmcraft_interchange::aaf::export(&s.project, sequence, &options).unwrap();
|
||||
let omf_options = filmcraft_interchange::omf::OmfOptions { media: options.media.clone(), ..Default::default() };
|
||||
let (omf, _) = filmcraft_interchange::omf::export(&s.project, sequence, &omf_options).unwrap();
|
||||
for (kind, document) in [("AAF", aaf), ("OMF", omf)] {
|
||||
let (imported, extracted, _) = if kind == "AAF" {
|
||||
filmcraft_interchange::aaf::import(&document, &Default::default()).unwrap()
|
||||
} else {
|
||||
filmcraft_interchange::omf::import(&document, &Default::default()).unwrap()
|
||||
};
|
||||
assert_eq!(extracted.len(), 3, "{kind}: composition memo must retain distinct selected-stream essence");
|
||||
let tracks = &imported.project.sequence(imported.sequences[0]).unwrap().audio_tracks;
|
||||
assert_eq!(tracks.len(), 3, "{kind}");
|
||||
let mut source_ids: Vec<_> = tracks.iter().map(|track| track.items[0].item).collect();
|
||||
source_ids.sort();
|
||||
source_ids.dedup();
|
||||
assert_eq!(source_ids.len(), 3, "{kind}: each stream remains a distinct imported source");
|
||||
let mut waveforms: Vec<_> = extracted.iter().map(|media| media.wav.clone()).collect();
|
||||
waveforms.sort();
|
||||
waveforms.dedup();
|
||||
assert_eq!(waveforms.len(), 3, "{kind}");
|
||||
}
|
||||
assert!(calls.lock().unwrap().iter().any(|call| call.0 == 2));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
let error =
|
||||
s.execute("file.exportAaf", json!({"path": "must-not-be-written.aaf", "sequence": sequence.0, "audio": "linked", "trimAudio": false})).unwrap_err();
|
||||
assert!(error.to_string().contains("embedded") || error.to_string().contains("separate"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,50 @@ fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() {
|
|||
assert_eq!(a.start, b.start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_peaks_measure_the_stream_the_clip_plays() {
|
||||
let mut s = Session::default();
|
||||
let mut p = (*s.project).clone();
|
||||
let rate = FrameRate::FPS_24;
|
||||
let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None);
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24));
|
||||
let mut info = source.info().clone();
|
||||
info.audio_streams.push(info.audio_streams[0].clone());
|
||||
let item = p.add_item(
|
||||
"two streams",
|
||||
filmcraft_project::Label::Iris,
|
||||
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
|
||||
media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (vdest, adest) = {
|
||||
let q = p.sequence(seq).unwrap();
|
||||
(q.video_tracks[0].id, q.audio_tracks[0].id)
|
||||
};
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.state.active_sequence = Some(seq);
|
||||
let ids =
|
||||
crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None)
|
||||
.unwrap();
|
||||
let q = s.project.sequence(seq).unwrap();
|
||||
let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id);
|
||||
assert!(ids.contains(&a) && ids.contains(&b));
|
||||
let peaks = crate::mixer::clip_peaks(&s, &[a, b]);
|
||||
let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap();
|
||||
// The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0.
|
||||
assert!(db(a) > -30.0, "{peaks:?}");
|
||||
assert!(db(b) < -100.0, "{peaks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_audio_tracks_are_cloned_from_the_destination() {
|
||||
let s = demo();
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ impl Preferences {
|
|||
self.playback.postroll_seconds = secs(self.playback.postroll_seconds);
|
||||
self.trim.large_trim_offset = self.trim.large_trim_offset.clamp(1, 1000);
|
||||
self.labels.sanitize_labels();
|
||||
self.appearance.sync_color_theme();
|
||||
self.version = settings::PREFS_VERSION;
|
||||
}
|
||||
|
||||
|
|
@ -454,6 +455,12 @@ impl Preferences {
|
|||
let _: Preferences = serde_json::from_value(v.clone()).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
settings::sanitize(&mut v, &Self::default().to_value());
|
||||
let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
// Old clients that set the single Color Theme still pick the theme they name (before
|
||||
// `clamp`, which brings `colorTheme` back in line with the mode and theme choices).
|
||||
if key == "appearance.colorTheme" {
|
||||
let theme = p.appearance.color_theme.clone();
|
||||
p.appearance.select_theme(&theme);
|
||||
}
|
||||
p.clamp();
|
||||
*self = p;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -513,7 +513,11 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
enabled: always,
|
||||
run: |s, p| {
|
||||
let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) };
|
||||
let only = p.get("track").and_then(|_| track_param(s, p));
|
||||
// a `track` that names no caption track is an error, never every track
|
||||
let only = match p.get("track") {
|
||||
Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?),
|
||||
None => None,
|
||||
};
|
||||
let tracks: Vec<Value> =
|
||||
seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect();
|
||||
Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
|
|
|
|||
|
|
@ -137,3 +137,24 @@ fn caption_commands_need_tracks_and_navigate() {
|
|||
s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_list_track_filter_never_falls_back_to_every_track() {
|
||||
let mut s = demo();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
let all = s.execute("captions.list", json!({})).unwrap();
|
||||
let ids: Vec<u64> = all["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2, "{all}");
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
for sel in [json!(id), json!(format!("C{}", i + 1))] {
|
||||
let r = s.execute("captions.list", json!({"track": sel})).unwrap();
|
||||
let got: Vec<u64> = r["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(got, vec![*id], "{sel}");
|
||||
}
|
||||
}
|
||||
let missing = ids.iter().max().unwrap() + 999;
|
||||
for sel in [json!(missing), json!("C3"), json!("C0"), json!("nope")] {
|
||||
assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let pi = s.project.item(first).ok_or_else(|| bad("file.newSequenceFromClip", "no such item"))?.clone();
|
||||
let mut settings = match &pi.kind {
|
||||
ItemKind::Sequence(q) => q.settings.clone(),
|
||||
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(&m.info)).unwrap_or_default(),
|
||||
_ => media_root(&s.project, first).map(|(_, m, _)| crate::commands::default_seq_settings_for(m)).unwrap_or_default(),
|
||||
};
|
||||
if let ItemKind::AdjustmentLayer { width, height, rate, .. } = &pi.kind {
|
||||
settings.width = *width;
|
||||
|
|
@ -949,6 +949,7 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
for it in pr.items.values_mut() {
|
||||
match &mut it.kind {
|
||||
ItemKind::Sequence(q) => {
|
||||
let q = std::sync::Arc::make_mut(q);
|
||||
for t in q.all_tracks_mut() {
|
||||
for i in &mut t.items {
|
||||
if let Some(k) = map.get(&i.item) {
|
||||
|
|
@ -1519,17 +1520,24 @@ fn fit_fill(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
|||
let clips = video_clips(s, p);
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (fw, fh) = (q.settings.width as f64, q.settings.height as f64);
|
||||
let sizes: Vec<(ClipId, (u32, u32))> =
|
||||
clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| filmcraft_render::source_size(&s.project, i.item)).map(|sz| (*c, sz))).collect();
|
||||
let frame_par = q.settings.par;
|
||||
// (clip, storage size for the anchor, size at the display aspect in sequence pixels for the scale)
|
||||
let sizes: Vec<(ClipId, (u32, u32), (f64, f64))> = clips
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let (_, i) = q.find_item(*c)?;
|
||||
Some((*c, filmcraft_render::source_size(&s.project, i.item)?, s.project.conformed_source_size(i.item, frame_par)?))
|
||||
})
|
||||
.collect();
|
||||
if sizes.is_empty() {
|
||||
return Err(bad(if fill { "clip.fillFrame" } else { "clip.fitToFrame" }, "select a video clip"));
|
||||
}
|
||||
let label = if fill { "Fill Frame" } else { "Fit to Frame" };
|
||||
let out = s.edit_sequence(label, |q, _, _| {
|
||||
let mut out = Vec::new();
|
||||
for (c, (w, h)) in &sizes {
|
||||
for (c, (w, h), (dw, dh)) in &sizes {
|
||||
let Some((_, it)) = q.find_item_mut(*c) else { continue };
|
||||
let (sx, sy) = (fw / (*w).max(1) as f64, fh / (*h).max(1) as f64);
|
||||
let (sx, sy) = (fw / dw.max(1.0), fh / dh.max(1.0));
|
||||
let pct = (if fill { sx.max(sy) } else { sx.min(sy) }) * 100.0;
|
||||
it.scale_to_frame = false;
|
||||
let Some(m) = it.effect_mut("motion") else { continue };
|
||||
|
|
|
|||
|
|
@ -901,3 +901,25 @@ fn add_edit_cuts_only_the_selected_clips() {
|
|||
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
|
||||
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newProject", json!({"name": "Slip"})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
|
||||
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
|
||||
let item = r["item"].as_u64().unwrap();
|
||||
let fr = s.sequence_rate().tick_of(1);
|
||||
let r =
|
||||
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
|
||||
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
|
||||
// lengthen only the picture so the pair has unequal tail handles
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": 30})).unwrap();
|
||||
assert_eq!(clip(&s, v).source_in, Tick(fr.0 * 20));
|
||||
assert_eq!(clip(&s, a).source_in, Tick(fr.0 * 20), "sound must not slip alone");
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
"clip.interpretFootage",
|
||||
"Interpret Footage…",
|
||||
&["Clip", "Modify"],
|
||||
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"}"#,
|
||||
r#"{"items":[id]?,"colorSpace":"auto"|"<color space id>"?,"pixelAspect":[num,den]|"file"?}"#,
|
||||
has_footage,
|
||||
interpret,
|
||||
),
|
||||
|
|
@ -292,25 +292,57 @@ pub(crate) fn footage_targets(s: &Session, p: &Value) -> Vec<ItemId> {
|
|||
v
|
||||
}
|
||||
|
||||
/// `clip.interpretFootage`: the colour space (`colorSpace`) and / or the pixel aspect ratio
|
||||
/// (`pixelAspect`) the footage is interpreted with. `"auto"` / `"file"` go back to the file's
|
||||
/// metadata; a parameter left out keeps its current interpretation.
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "clip.interpretFootage";
|
||||
let items = footage_targets(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("clip.interpretFootage", "no footage selected"));
|
||||
return Err(bad(CMD, "no footage selected"));
|
||||
}
|
||||
let cs = match str_p(p, "colorSpace") {
|
||||
None => return Err(bad("clip.interpretFootage", "need `colorSpace` (auto or a colour space id)")),
|
||||
Some("auto") | Some("") => None,
|
||||
Some(c) => Some(ColorSpace::parse(c).ok_or_else(|| bad("clip.interpretFootage", format!("unknown colour space `{c}`")))?),
|
||||
None => None,
|
||||
Some("auto") | Some("") => Some(None),
|
||||
Some(c) => Some(Some(ColorSpace::parse(c).ok_or_else(|| bad(CMD, format!("unknown colour space `{c}`")))?)),
|
||||
};
|
||||
let par =
|
||||
match p.get("pixelAspect") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if f == "file" || f == "auto" => Some(None),
|
||||
Some(v) => Some(Some(pixel_aspect_p(v).ok_or_else(|| {
|
||||
bad(CMD, format!("pixelAspect must be [num, den] between 1:{0} and {0}:1, or \"file\"", filmcraft_project::MAX_PIXEL_ASPECT))
|
||||
})?)),
|
||||
};
|
||||
if cs.is_none() && par.is_none() {
|
||||
return Err(bad(CMD, "need `colorSpace` (auto or a colour space id) or `pixelAspect`"));
|
||||
}
|
||||
s.edit("Interpret Footage", |pr, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Media(m)) = pr.item_mut(*i).map(|it| &mut it.kind) {
|
||||
m.interpret.color_space = cs;
|
||||
if let Some(cs) = cs {
|
||||
m.interpret.color_space = cs;
|
||||
}
|
||||
if let Some(par) = par {
|
||||
m.interpret.par = par;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "colorSpace": cs.map(|c| c.id()).unwrap_or("auto")}))
|
||||
let first = items.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media());
|
||||
Ok(json!({
|
||||
"items": items.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"colorSpace": first.and_then(|m| m.interpret.color_space).map(|c| c.id()).unwrap_or("auto"),
|
||||
"pixelAspect": first.map(|m| m.pixel_aspect()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `[num, den]` as a pixel aspect ratio FilmCraft can use ([`filmcraft_project::checked_par`]).
|
||||
fn pixel_aspect_p(v: &Value) -> Option<(u32, u32)> {
|
||||
let [n, d] = v.as_array()?.as_slice() else { return None };
|
||||
let (n, d) = (u32::try_from(n.as_u64()?).ok()?, u32::try_from(d.as_u64()?).ok()?);
|
||||
filmcraft_project::checked_par((n, d))
|
||||
}
|
||||
|
||||
fn spaces(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
/// Clips or captions selected (Clear / Ripple Delete work on either).
|
||||
fn has_any_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() {
|
||||
Err("nothing selected".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Clips or captions selected (Ripple Delete: a transition has no length of its own to close).
|
||||
fn has_clip_or_caption_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
|
@ -357,12 +366,17 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
|||
out
|
||||
}
|
||||
|
||||
pub(crate) fn default_seq_settings_for(info: &filmcraft_media::MediaInfo) -> SequenceSettings {
|
||||
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
|
||||
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
|
||||
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
|
||||
pub(crate) fn default_seq_settings_for(media: &MediaClip) -> SequenceSettings {
|
||||
let info = &media.info;
|
||||
let mut st = SequenceSettings::default();
|
||||
if let Some(v) = &info.video {
|
||||
st.width = v.width;
|
||||
st.height = v.height;
|
||||
st.frame_rate = v.frame_rate;
|
||||
st.par = media.pixel_aspect();
|
||||
st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
|
||||
}
|
||||
if let Some(a) = info.audio() {
|
||||
|
|
@ -455,8 +469,15 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
|
|||
let name = str_p(p, "name").unwrap_or(name).to_string();
|
||||
let src = GeneratorSource::new(g, w, h, st.frame_rate, Tick::from_seconds_f64(secs));
|
||||
let pool = s.media.clone();
|
||||
let par = filmcraft_project::sane_par(st.par);
|
||||
let id = s.edit(&format!("New {name}"), |pr, st| {
|
||||
let id = crate::demo::add_generator(pr, &pool, src, &name, label, None);
|
||||
// made for the sequence: its pixels have the sequence's aspect, as in Premiere
|
||||
if let Some(ItemKind::Media(m)) = pr.item_mut(id).map(|i| &mut i.kind)
|
||||
&& let Some(v) = m.info.video.as_mut()
|
||||
{
|
||||
v.par = par;
|
||||
}
|
||||
st.project_selection = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
|
|
@ -585,6 +606,8 @@ pub(crate) fn place_item(
|
|||
_ => None,
|
||||
}
|
||||
.unwrap_or((1920, 1080));
|
||||
// `None`: drawn in the pixels of the sequence it is placed in
|
||||
let src_par = s.project.source_par(item);
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let media = s.media.clone();
|
||||
let scaling = s.prefs.media.default_media_scaling.clone();
|
||||
|
|
@ -593,6 +616,7 @@ pub(crate) fn place_item(
|
|||
let seq = p.sequence(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
let rate = seq.settings.frame_rate;
|
||||
let frame = (seq.settings.width, seq.settings.height);
|
||||
let seq_par = seq.settings.par;
|
||||
let mut placements = Vec::new();
|
||||
let link = if has_v && has_a { Some(p.alloc_id()) } else { None };
|
||||
if let Some(vdest) = vdest.filter(|_| has_v) {
|
||||
|
|
@ -601,8 +625,10 @@ pub(crate) fn place_item(
|
|||
for e in &mut v.effects {
|
||||
resolve_auto_points(e, frame, src_size);
|
||||
}
|
||||
if is_media && src_size != frame {
|
||||
crate::settings::apply_media_scaling(&mut v, &scaling, frame, src_size);
|
||||
// the picture's size at its display aspect, in sequence pixels
|
||||
let shown = filmcraft_project::conformed_size(src_size, src_par.unwrap_or(seq_par), seq_par);
|
||||
if is_media && shown != (f64::from(frame.0), f64::from(frame.1)) {
|
||||
crate::settings::apply_media_scaling(&mut v, &scaling, frame, shown);
|
||||
}
|
||||
placements.push((vdest, v));
|
||||
}
|
||||
|
|
@ -753,7 +779,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
|s, p| {
|
||||
let mut st = SequenceSettings::default();
|
||||
if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) {
|
||||
st = default_seq_settings_for(&from.info);
|
||||
st = default_seq_settings_for(from);
|
||||
}
|
||||
if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? {
|
||||
st.width = w;
|
||||
|
|
@ -1187,6 +1213,11 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("edit.pasteInsert", "Paste Insert", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, _| paste(s, true)),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Backspace"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
|
||||
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
|
||||
// a selected transition (clicked in the Timeline) goes, as a clip would
|
||||
if !s.state.transition_selection.is_empty() {
|
||||
let ids = s.state.transition_selection.clone();
|
||||
return remove_transitions(s, &ids, "Clear");
|
||||
}
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, false);
|
||||
}
|
||||
|
|
@ -1198,7 +1229,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
|
||||
cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_clip_or_caption_selection, |s, p| {
|
||||
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, true);
|
||||
|
|
@ -1325,13 +1356,17 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
|
||||
// selected, Enable swaps them rather than first making both the same.
|
||||
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
let mut sel = with_links(s, &clips_p(s, p));
|
||||
// a clip named twice must still flip once
|
||||
sel.sort();
|
||||
sel.dedup();
|
||||
s.edit_sequence("Enable", |q, _, _| {
|
||||
let target = !sel.iter().all(|c| q.find_item(*c).is_some_and(|(_, i)| i.enabled));
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.enabled = target;
|
||||
i.enabled = !i.enabled;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1342,10 +1377,70 @@ fn build() -> Vec<CommandSpec> {
|
|||
let sel = clips_p(s, p);
|
||||
let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some()));
|
||||
s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| {
|
||||
let l = if linked { None } else { Some(ctx.alloc()) };
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.link = l;
|
||||
if linked {
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.link = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// each selected clip with its track kind, worked out once
|
||||
type Spec = (ClipId, filmcraft_project::ItemId, filmcraft_time::TimeRange);
|
||||
let (mut v_clips, mut audio_specs): (Vec<Spec>, Vec<Spec>) = (Vec::new(), Vec::new());
|
||||
for c in &sel {
|
||||
if let Some((tid, i)) = q.find_item(*c) {
|
||||
if q.video_tracks.iter().any(|t| t.id == tid) {
|
||||
v_clips.push((*c, i.item, i.range()));
|
||||
} else {
|
||||
audio_specs.push((*c, i.item, i.range()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !audio_specs.is_empty() && v_clips.len() > 1 {
|
||||
let mut linked_audios = std::collections::HashSet::new();
|
||||
for (vid, item_id, vrange) in &v_clips {
|
||||
// Pair with overlapping audio clips from the same item, or overlapping audio clips if no same-item audio
|
||||
let matching: Vec<ClipId> = audio_specs
|
||||
.iter()
|
||||
.filter(|(aid, aitem, arange)| !linked_audios.contains(aid) && aitem == item_id && arange.overlaps(vrange))
|
||||
.map(|(aid, _, _)| *aid)
|
||||
.collect();
|
||||
let to_link = if !matching.is_empty() {
|
||||
matching
|
||||
} else {
|
||||
audio_specs
|
||||
.iter()
|
||||
.filter(|(aid, _, arange)| !linked_audios.contains(aid) && arange.overlaps(vrange))
|
||||
.map(|(aid, _, _)| *aid)
|
||||
.collect()
|
||||
};
|
||||
// a video clip with no audio partner stays unlinked: a one-member link would read as "linked"
|
||||
let l = (!to_link.is_empty()).then(|| ctx.alloc());
|
||||
if let Some((_, vi)) = q.find_item_mut(*vid) {
|
||||
vi.link = l;
|
||||
}
|
||||
for aid in to_link {
|
||||
linked_audios.insert(aid);
|
||||
if let Some((_, ai)) = q.find_item_mut(aid) {
|
||||
ai.link = l;
|
||||
}
|
||||
}
|
||||
}
|
||||
// selected audio clips with no overlapping video stay unlinked
|
||||
for (aid, _, _) in audio_specs {
|
||||
if !linked_audios.contains(&aid)
|
||||
&& let Some((_, ai)) = q.find_item_mut(aid)
|
||||
{
|
||||
ai.link = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let l = ctx.alloc();
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.link = Some(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -1536,10 +1631,20 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Edit Transition Settings",
|
||||
[],
|
||||
None,
|
||||
r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?}"#,
|
||||
r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?,"duration":ticks?|"frames":n?,"align":"center"|"start"|"end"?|"start":ticks?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
set_transition
|
||||
),
|
||||
cmd!("sequence.removeTransition", "Clear Transition", [], None, r#"{"transitions":[id]?}"#, has_seq, |s, p| {
|
||||
let ids: Vec<TransitionId> = match p.get("transitions").or_else(|| p.get("transition")) {
|
||||
Some(v) => transition_ids_p(v).ok_or_else(|| bad("sequence.removeTransition", "`transitions` is a list of transition ids"))?,
|
||||
None => s.state.transition_selection.clone(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad("sequence.removeTransition", "no transition given or selected"));
|
||||
}
|
||||
remove_transitions(s, &ids, "Clear Transition")
|
||||
}),
|
||||
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| {
|
||||
let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
|
|
@ -1837,6 +1942,15 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"markers.exportCsv",
|
||||
"Export Markers as CSV…",
|
||||
["Markers"],
|
||||
None,
|
||||
r#"{"path":str} (all markers in the active sequence)"#,
|
||||
has_seq,
|
||||
crate::marker_export::export
|
||||
),
|
||||
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
|
||||
return Ok(v);
|
||||
|
|
@ -1889,8 +2003,11 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
|
||||
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
|
||||
let r = s.sequence_rate();
|
||||
let f = r.frame_at(s.playhead()) + n;
|
||||
s.set_playhead(r.tick_of(f.max(0)));
|
||||
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
|
||||
if f > r.frame_at(Tick::MAX) {
|
||||
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
|
||||
}
|
||||
s.set_playhead(r.tick_of(f));
|
||||
Ok(json!({"frame": r.frame_at(s.playhead())}))
|
||||
}),
|
||||
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
|
||||
|
|
@ -2019,13 +2136,18 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"add":bool,"toggle":bool}"#, has_seq, |s, p| {
|
||||
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| {
|
||||
if let Some(ids) = p.get("transitions") {
|
||||
return select_transitions(s, ids, bool_p(p, "add").unwrap_or(false), bool_p(p, "toggle").unwrap_or(false));
|
||||
}
|
||||
let clips: Vec<ClipId> =
|
||||
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
|
||||
let clips = with_links(s, &clips);
|
||||
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
// and a clip selection replaces a transition one
|
||||
s.state.transition_selection.clear();
|
||||
if bool_p(p, "toggle").unwrap_or(false) {
|
||||
for c in clips {
|
||||
if let Some(i) = s.state.selection.iter().position(|x| *x == c) {
|
||||
|
|
@ -2203,8 +2325,16 @@ fn build() -> Vec<CommandSpec> {
|
|||
.unwrap_or_default();
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slip", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slip them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, d, ctx)?;
|
||||
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -2391,13 +2521,14 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Set Effect Parameter",
|
||||
[],
|
||||
None,
|
||||
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path,"time":ticks?,"merge":bool?,"begin":bool?}"##,
|
||||
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("effects.setParam", "need `param`"))?.to_string();
|
||||
let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?;
|
||||
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
|
||||
let keyframe = bool_p(p, "keyframe").unwrap_or(false);
|
||||
let ph = s.playhead();
|
||||
let tl = time_p(s, p, "").unwrap_or(ph);
|
||||
let pq = p.clone();
|
||||
|
|
@ -2406,7 +2537,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
|
||||
let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
|
|
@ -2415,6 +2546,22 @@ fn build() -> Vec<CommandSpec> {
|
|||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad("effects.setParam", "no such effect on clip"))?;
|
||||
// the parameter's definition (a mask's own parameters have none)
|
||||
let def = if pq.get("mask").is_none_or(Value::is_null) { e.def().and_then(|d| d.param(&pid)) } else { None };
|
||||
// a choice takes its option name as well as its index, as transition parameters do
|
||||
let val = match (def.map(|d| &d.kind), &val) {
|
||||
(Some(filmcraft_project::ParamKind::Choice(opts)), Value::String(name)) => {
|
||||
let i = opts
|
||||
.iter()
|
||||
.position(|o| o.eq_ignore_ascii_case(name))
|
||||
.ok_or_else(|| bad("effects.setParam", format!("`{pid}` must be one of {opts:?}")))?;
|
||||
json!(i)
|
||||
}
|
||||
_ => val.clone(),
|
||||
};
|
||||
if keyframe && def.is_some_and(|d| !d.animatable) {
|
||||
return Err(bad("effects.setParam", format!("`{pid}` cannot be animated")));
|
||||
}
|
||||
if eff == json!("opacity") || eff == json!("motion") {
|
||||
e.enabled = true;
|
||||
}
|
||||
|
|
@ -2426,10 +2573,16 @@ fn build() -> Vec<CommandSpec> {
|
|||
}
|
||||
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
|
||||
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
|
||||
prm.set_at(mt, v);
|
||||
Ok(())
|
||||
if keyframe {
|
||||
// the stopwatch and the value in one step
|
||||
prm.put_keyframe(mt, v);
|
||||
} else {
|
||||
prm.set_at(mt, v);
|
||||
}
|
||||
Ok(prm.keyframes.len())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
// 0 keyframes: the value is static (`time` was not used)
|
||||
Ok(json!({"keyframes": keyframes}))
|
||||
}
|
||||
),
|
||||
cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| {
|
||||
|
|
@ -2497,7 +2650,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Move Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "move")
|
||||
),
|
||||
|
|
@ -2515,7 +2668,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Edit Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "set")
|
||||
),
|
||||
|
|
@ -2704,7 +2857,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
|||
"set" => "Edit Keyframe",
|
||||
_ => "Keyframe Interpolation",
|
||||
};
|
||||
s.edit_sequence(label, |q, _, _| {
|
||||
// a keyframe dragged on the timeline is one undo step, like a dragged value
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
|
|
@ -2730,6 +2888,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
|||
if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) {
|
||||
let mut k = prm.keyframes.remove(i);
|
||||
k.time = to;
|
||||
if let Some(v) = p.get("value") {
|
||||
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
|
||||
}
|
||||
prm.keyframes.retain(|x| x.time != to);
|
||||
let at = prm.keyframes.partition_point(|x| x.time < to);
|
||||
prm.keyframes.insert(at, k);
|
||||
|
|
@ -3151,13 +3312,109 @@ fn set_transition_params(e: &mut filmcraft_project::EffectInstance, params: &Val
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `sequence.setTransition`: edit an applied transition's settings (Effect Controls).
|
||||
/// A JSON list of transition ids (or one id).
|
||||
fn transition_ids_p(v: &Value) -> Option<Vec<TransitionId>> {
|
||||
match v {
|
||||
Value::Array(a) => a.iter().map(|x| x.as_u64().map(TransitionId)).collect(),
|
||||
x => x.as_u64().map(|i| vec![TransitionId(i)]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeline.select {"transitions": […]}`: select transitions (clicking one in the Timeline). It
|
||||
/// replaces the clip, edit point and caption selections, as a clip selection replaces it.
|
||||
fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) -> Result<Value> {
|
||||
const CMD: &str = "timeline.select";
|
||||
let ids = transition_ids_p(ids).ok_or_else(|| bad(CMD, "`transitions` is a list of transition ids"))?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(missing) = ids.iter().find(|id| !q.all_tracks().any(|t| t.transitions.iter().any(|x| x.id == **id))) {
|
||||
return Err(bad(CMD, format!("no transition {}", missing.0)));
|
||||
}
|
||||
if toggle {
|
||||
for id in ids {
|
||||
match s.state.transition_selection.iter().position(|x| *x == id) {
|
||||
Some(i) => {
|
||||
s.state.transition_selection.remove(i);
|
||||
}
|
||||
None => s.state.transition_selection.push(id),
|
||||
}
|
||||
}
|
||||
} else if add {
|
||||
for id in ids {
|
||||
if !s.state.transition_selection.contains(&id) {
|
||||
s.state.transition_selection.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.transition_selection = ids;
|
||||
}
|
||||
s.state.selection.clear();
|
||||
s.state.caption_selection.clear();
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
Ok(json!({"selection": [], "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Remove transitions in one undo step and drop them from the selection.
|
||||
fn remove_transitions(s: &mut Session, ids: &[TransitionId], label: &str) -> Result<Value> {
|
||||
let ids = ids.to_vec();
|
||||
let n = s.edit_sequence(label, |q, _, st| {
|
||||
let n = edit::transitions::remove(q, &ids)?;
|
||||
st.transition_selection.retain(|x| !ids.contains(x));
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
/// `sequence.setTransition`: edit an applied transition (Effect Controls, the Timeline): its
|
||||
/// settings, Reverse, and where it sits. `duration` / `frames` resize it the way its alignment
|
||||
/// says (Center at Cut and Custom Start: both ends; Start at Cut: the end; End at Cut: the
|
||||
/// beginning), `align` re-aligns it on its cut, `start` moves it (dragging it over the cut; with
|
||||
/// `duration`, dragging one edge). One undo step; with `merge`, a drag's steps share one.
|
||||
fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "sequence.setTransition";
|
||||
let id = p.get("transition").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "need `transition`"))?;
|
||||
let tid = TransitionId(id);
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let found = q.video_tracks.iter().chain(q.audio_tracks.iter()).find_map(|tr| tr.transitions.iter().find(|x| x.id.0 == id)).cloned();
|
||||
let mut cur = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?;
|
||||
let rate = q.settings.frame_rate.sane();
|
||||
let frame = rate.frame_duration();
|
||||
let found = q.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == tid).map(|x| (tr, x)));
|
||||
let (track, cur) = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?;
|
||||
let mut cur = cur.clone();
|
||||
let (lo, cut, hi) = edit::transitions::bounds(track, &cur).ok_or_else(|| bad(CMD, "the transition's clips are gone"))?;
|
||||
// times outside the clips it joins are refused before any frame arithmetic (hostile input)
|
||||
let room = Tick(hi.0.saturating_sub(lo.0));
|
||||
let duration = match (p.get("frames").and_then(Value::as_i64), p.get("duration").and_then(Value::as_i64)) {
|
||||
(Some(_), Some(_)) => return Err(bad(CMD, "give `duration` or `frames`, not both")),
|
||||
(Some(f), None) if (1..=10_000_000).contains(&f) && rate.tick_of(f) <= room => Some(rate.tick_of(f)),
|
||||
(None, Some(d)) if d > 0 && d <= room.0 => Some(rate.snap_nearest(Tick(d)).max(frame)),
|
||||
(Some(_), None) | (None, Some(_)) => return Err(bad(CMD, "a transition lasts at least one frame and can't extend past the clips it joins")),
|
||||
(None, None) => None,
|
||||
};
|
||||
let align = match str_p(p, "align") {
|
||||
Some(a) => Some(edit::transitions::Alignment::parse(a).ok_or_else(|| bad(CMD, format!("`align` is center, start or end, not `{a}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let start = match p.get("start").and_then(Value::as_i64) {
|
||||
Some(t) if (lo.0..=hi.0).contains(&t) => Some(rate.snap_nearest(Tick(t))),
|
||||
Some(_) => return Err(bad(CMD, "a transition can't extend past the clips it joins")),
|
||||
None => None,
|
||||
};
|
||||
if start.is_some() && align.is_some() {
|
||||
return Err(bad(CMD, "give `start` or `align`, not both"));
|
||||
}
|
||||
let span = if duration.is_some() || align.is_some() || start.is_some() {
|
||||
let d = duration.unwrap_or(cur.duration);
|
||||
let st = match start {
|
||||
Some(st) => st,
|
||||
None => {
|
||||
let al = align.unwrap_or_else(|| edit::transitions::alignment(&cur, cut, frame));
|
||||
edit::transitions::start_for(q, tid, d, al, frame, |t| rate.snap(t)).ok_or_else(|| bad(CMD, format!("no transition {id}")))?
|
||||
}
|
||||
};
|
||||
Some((st, d))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if p.get("reset").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Some(def) = cur.effect.def()
|
||||
{
|
||||
|
|
@ -3170,19 +3427,45 @@ fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
cur.reverse = r;
|
||||
}
|
||||
let name = cur.effect.def().map_or("Transition", |d| d.name).to_string();
|
||||
s.edit_sequence(&format!("Edit {name}"), |q, _, _| {
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setTransition:{id}"));
|
||||
let after = s.edit_sequence_as(&format!("Edit {name}"), merge.as_deref(), |q, _, _| {
|
||||
if let Some((st, d)) = span {
|
||||
edit::transitions::set_span(q, tid, st, d, frame, frame)?;
|
||||
}
|
||||
let mut after = None;
|
||||
for tr in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) {
|
||||
if let Some(x) = tr.transitions.iter_mut().find(|x| x.id.0 == id) {
|
||||
if let Some(x) = tr.transitions.iter_mut().find(|x| x.id == tid) {
|
||||
x.effect = cur.effect.clone();
|
||||
x.reverse = cur.reverse;
|
||||
after = Some((x.start, x.duration));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(after)
|
||||
})?;
|
||||
Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse}))
|
||||
let (st, d) = after.unwrap_or((cur.start, cur.duration));
|
||||
let al = s
|
||||
.active_sequence()
|
||||
.and_then(|q| {
|
||||
q.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == tid).and_then(|x| edit::transitions::bounds(tr, x).map(|b| (x.clone(), b.1))))
|
||||
})
|
||||
.map(|(x, cut)| edit::transitions::alignment(&x, cut, frame).id());
|
||||
Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse, "start": st.0, "duration": d.0, "align": al}))
|
||||
}
|
||||
|
||||
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
|
||||
let (track, tr) = preview_transition(s, p, kind)?;
|
||||
let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name));
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
}
|
||||
|
||||
/// Plan an applied transition without editing the project or allocating an id.
|
||||
/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application,
|
||||
/// so a drag preview shows the exact range that release will commit.
|
||||
pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> {
|
||||
let eff_id = str_p(p, "effect")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() });
|
||||
|
|
@ -3233,6 +3516,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
|||
}
|
||||
}
|
||||
let (track, from, to, cut) = found.ok_or_else(|| EngineError::Other("no edit point at the playhead on targeted tracks".into()))?;
|
||||
if q.track(track).is_some_and(|tr| tr.locked) {
|
||||
return Err(edit::EditError::Locked.into());
|
||||
}
|
||||
let start = if from.is_some() && to.is_some() {
|
||||
cut - dur.mul_ratio(1, 2)
|
||||
} else if to.is_some() {
|
||||
|
|
@ -3241,9 +3527,7 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
|||
cut - dur
|
||||
};
|
||||
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
|
||||
let label = format!("Apply {}", def.name);
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
Ok((track, tr))
|
||||
}
|
||||
|
||||
/// JSON description of the project (bins, items) for agents.
|
||||
|
|
@ -3285,7 +3569,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
|||
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
|
||||
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
"transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "align": filmcraft_edit::transitions::bounds(t, x).map(|b| filmcraft_edit::transitions::alignment(x, b.1, rate.frame_duration()).id()), "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
json!({
|
||||
|
|
@ -3302,6 +3586,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
|||
"video": q.video_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"audio": q.audio_tracks.iter().map(tr).collect::<Vec<_>>(),
|
||||
"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -211,3 +211,56 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_multiple_video_and_audio_pairs_creates_one_link_per_pair() {
|
||||
let mut s = demo();
|
||||
let v_clips = v1(&s);
|
||||
let a_clips = a1(&s);
|
||||
assert!(v_clips.len() >= 3 && a_clips.len() >= 3);
|
||||
let (v0, v1, v2) = (v_clips[0].id, v_clips[1].id, v_clips[2].id);
|
||||
let (a0, a1, a2) = (a_clips[0].id, a_clips[1].id, a_clips[2].id);
|
||||
|
||||
// First, unlink all three pairs
|
||||
let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap();
|
||||
assert_eq!(r["linked"], false);
|
||||
for cid in [v0, a0, v1, a1, v2, a2] {
|
||||
assert_eq!(clip(&s, cid).unwrap().link, None);
|
||||
}
|
||||
|
||||
// Now, run clip.link on all six clips. In Premiere parity (#507),
|
||||
// it should create 3 separate links (one per pair), not one big link for all six.
|
||||
let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0, a1.0, v2.0, a2.0]})).unwrap();
|
||||
assert_eq!(r["linked"], true);
|
||||
|
||||
let l0 = clip(&s, v0).unwrap().link;
|
||||
let la0 = clip(&s, a0).unwrap().link;
|
||||
let l1 = clip(&s, v1).unwrap().link;
|
||||
let la1 = clip(&s, a1).unwrap().link;
|
||||
let l2 = clip(&s, v2).unwrap().link;
|
||||
let la2 = clip(&s, a2).unwrap().link;
|
||||
|
||||
assert!(l0.is_some() && l1.is_some() && l2.is_some());
|
||||
assert_eq!(l0, la0, "v0 linked to a0");
|
||||
assert_eq!(l1, la1, "v1 linked to a1");
|
||||
assert_eq!(l2, la2, "v2 linked to a2");
|
||||
|
||||
assert_ne!(l0, l1, "different pairs must not share the same link id");
|
||||
assert_ne!(l1, l2, "different pairs must not share the same link id");
|
||||
assert_ne!(l0, l2, "different pairs must not share the same link id");
|
||||
}
|
||||
|
||||
/// A video clip with no overlapping selected audio, and audio with no overlapping video, stay
|
||||
/// unlinked: a one-member link would make the clip read as "linked" with no partner.
|
||||
#[test]
|
||||
fn link_leaves_clips_without_a_partner_unlinked() {
|
||||
let mut s = demo();
|
||||
let (v, a) = (v1(&s), a1(&s));
|
||||
let (v0, v1, a0, a2) = (v[0].id, v[1].id, a[0].id, a[2].id);
|
||||
s.execute("clip.link", json!({"clips": [v0.0, v1.0, a0.0, a2.0]})).unwrap();
|
||||
s.execute("clip.link", json!({"clips": [v0.0, v1.0, a0.0, a2.0]})).unwrap();
|
||||
assert!(clip(&s, v0).unwrap().link.is_some());
|
||||
assert_eq!(clip(&s, v0).unwrap().link, clip(&s, a0).unwrap().link);
|
||||
assert_eq!(clip(&s, v1).unwrap().link, None, "v1 has no overlapping audio in the selection");
|
||||
assert_eq!(clip(&s, a2).unwrap().link, None, "a2 has no overlapping video in the selection");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ fn export_integer_parameters_cannot_wrap_or_overflow() {
|
|||
assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_camel_case_settings_are_honoured() {
|
||||
let mut s = demo();
|
||||
let camel = json!({"audio":{"sampleRate":96000}, "effects":{"loudness":{"enabled":true, "targetLufs":-16}}});
|
||||
let snake = json!({"audio":{"sample_rate":96000}, "effects":{"loudness":{"enabled":true, "target_lufs":-16}}});
|
||||
let camel = s.execute("export.resolve", json!({"format":"wav", "settings":camel})).unwrap();
|
||||
let snake = s.execute("export.resolve", json!({"format":"wav", "settings":snake})).unwrap();
|
||||
assert_eq!(camel["output"]["sampleRate"], 96000);
|
||||
assert_eq!(camel["settings"]["audio"]["sample_rate"], 96000);
|
||||
assert_eq!(camel["settings"]["effects"]["loudness"]["target_lufs"], -16.0);
|
||||
assert_eq!(camel["settings"], snake["settings"]);
|
||||
}
|
||||
|
||||
fn probe(path: &str) -> Option<Value> {
|
||||
let ffprobe = filmcraft_testkit::ffprobe_or_skip("export presets")?;
|
||||
let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-show_format", "-show_streams", path]).output().unwrap();
|
||||
|
|
@ -359,6 +372,22 @@ fn queue_orders_cancels_and_retries() {
|
|||
assert!(queue(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_move_by_extreme_offsets_clamps_instead_of_overflowing() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = (0..3)
|
||||
.map(|_| {
|
||||
let r = s.execute("export.queue.add", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": "queued-output/"})).unwrap();
|
||||
r["added"][0].as_u64().unwrap()
|
||||
})
|
||||
.collect();
|
||||
let order = |s: &mut Session| -> Vec<u64> { queue(s).iter().map(|i| i["id"].as_u64().unwrap()).collect() };
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MAX})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[0], ids[2], ids[1]]);
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MIN})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[1], ids[0], ids[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_cancels_a_running_export_and_retries_a_failed_one() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result<PresetFile, String> {
|
|||
// settings
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `sampleRate` → `sample_rate`.
|
||||
fn camel_to_snake(k: &str) -> String {
|
||||
let mut out = String::with_capacity(k.len() + 2);
|
||||
for c in k.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deep-merge `patch` into `base` (objects merge, everything else replaces).
|
||||
fn merge(base: &mut Value, patch: &Value) {
|
||||
match (base, patch) {
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
merge(b.entry(k.clone()).or_insert(Value::Null), v);
|
||||
// Some nested settings structs serialize snake_case (`audio.sample_rate`); accept the documented camelCase
|
||||
// spelling for them too instead of adding a second, ignored key next to the default.
|
||||
let snake = camel_to_snake(k);
|
||||
let key = if !b.contains_key(k) && b.contains_key(&snake) { snake } else { k.clone() };
|
||||
merge(b.entry(key).or_insert(Value::Null), v);
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
|
|
@ -769,7 +787,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let n = s.export_queue.items.len() as i64;
|
||||
let to = match (p.get("to").and_then(Value::as_i64), p.get("by").and_then(Value::as_i64)) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(b)) => i as i64 + b,
|
||||
(None, Some(b)) => (i as i64).saturating_add(b),
|
||||
_ => return Err(bad(cmd, "need `to` (index) or `by` (±n)")),
|
||||
}
|
||||
.clamp(0, n - 1) as usize;
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ fn set_controls(s: &mut Session, clip: ClipId, values: &serde_json::Map<String,
|
|||
props.insert(c.param.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
set_props(s, clip, ei, &props, tl, "Change Template Property")?;
|
||||
set_props(s, clip, ei, &props, tl, "Change Template Property", None)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
|
|
@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (layers, meta) = t.instantiate((w, h));
|
||||
let before = s.history.undo.len();
|
||||
let dur = t.duration;
|
||||
let clip = place_video_clip(s, &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.template.apply", &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let src = crate::graphics::graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(dur))
|
||||
})?;
|
||||
|
|
@ -911,7 +911,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::<String>();
|
||||
let dur = c.duration;
|
||||
let q = json!({"time": c.start.0});
|
||||
let clip = place_video_clip(s, &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.upgradeCaption", &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
(crate::graphics::graphic_source(pr, w, h, rate), dur)
|
||||
})?;
|
||||
clips.push(clip.0);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer
|
|||
use filmcraft_time::{Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
|
|
@ -96,23 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32,
|
|||
|
||||
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
|
||||
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
|
||||
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
|
||||
// Here `seconds` is duration, not the generic time parser's placement alias.
|
||||
let mut placement = p.clone();
|
||||
if let Some(params) = placement.as_object_mut() {
|
||||
params.remove("seconds");
|
||||
}
|
||||
place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let src = graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
|
||||
/// `time`), above the clips there, as one undo step; selects it.
|
||||
/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked,
|
||||
/// for its errors.
|
||||
pub(crate) fn place_video_clip(
|
||||
s: &mut Session,
|
||||
cmd: &str,
|
||||
name: &str,
|
||||
p: &Value,
|
||||
label: &str,
|
||||
|
|
@ -133,7 +135,7 @@ pub(crate) fn place_video_clip(
|
|||
let src_size = pr.source_size(src).unwrap_or((w, h));
|
||||
let t = rate.snap(t);
|
||||
let dur = dur.max(rate.frame_duration());
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
|
||||
ti.name = name;
|
||||
ti.effects.extend(extra);
|
||||
for e in &mut ti.effects {
|
||||
|
|
@ -146,7 +148,7 @@ pub(crate) fn place_video_clip(
|
|||
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
|
||||
let idx = match want_track {
|
||||
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))),
|
||||
_ => {
|
||||
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
|
||||
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
|
||||
|
|
@ -190,6 +192,10 @@ fn param_id(k: &str) -> &str {
|
|||
"fontSize" => "size",
|
||||
"fontStyle" | "style" => "font_style",
|
||||
"fillColor" | "color" => "fill_color",
|
||||
"fillKind" => "fill_kind",
|
||||
"gradientStart" => "gradient_start",
|
||||
"gradientEnd" => "gradient_end",
|
||||
"gradientAngle" => "gradient_angle",
|
||||
"strokeColor" => "stroke_color",
|
||||
"strokeWidth" => "stroke_width",
|
||||
"backgroundColor" => "background_color",
|
||||
|
|
@ -212,6 +218,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
|
|||
"align" => graphic::ALIGN_OPTS,
|
||||
"caps" => graphic::CAPS_OPTS,
|
||||
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
|
||||
"fill_kind" => graphic::FILL_KIND_OPTS,
|
||||
"shape" => SHAPE_OPTS,
|
||||
_ => &[],
|
||||
};
|
||||
|
|
@ -255,10 +262,19 @@ fn apply_props(e: &mut EffectInstance, props: &serde_json::Map<String, Value>, m
|
|||
}
|
||||
|
||||
/// Set properties `props` on a layer (keyframe-aware at time `tl`). Changing the text keeps
|
||||
/// per-character styles on their characters.
|
||||
pub(crate) fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map<String, Value>, tl: Tick, label: &str) -> Result<()> {
|
||||
/// per-character styles on their characters. `merge` folds consecutive calls with the same key
|
||||
/// into one undo step (a drag is one undoable change, like `effects.setParam`).
|
||||
pub(crate) fn set_props(
|
||||
s: &mut Session,
|
||||
clip: ClipId,
|
||||
eidx: usize,
|
||||
props: &serde_json::Map<String, Value>,
|
||||
tl: Tick,
|
||||
label: &str,
|
||||
merge: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let props = props.clone();
|
||||
s.edit_sequence(label, |q, _, _| {
|
||||
s.edit_sequence_as(label, merge, |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
|
||||
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
|
||||
let e = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?;
|
||||
|
|
@ -703,7 +719,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Rectangle",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+R"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "rectangle"),
|
||||
),
|
||||
|
|
@ -712,7 +728,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Ellipse",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+E"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "ellipse"),
|
||||
),
|
||||
|
|
@ -721,7 +737,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
"Polygon",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
None,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
|
|
@ -737,7 +753,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
props.insert("sides".into(), json!(n));
|
||||
let ph = s.playhead();
|
||||
let before = s.history.undo.len();
|
||||
set_props(s, clip, ei, &props, ph, "Change Graphic Property")?;
|
||||
set_props(s, clip, ei, &props, ph, "Change Graphic Property", None)?;
|
||||
if s.history.undo.len() > before {
|
||||
s.history.undo.pop();
|
||||
}
|
||||
|
|
@ -766,7 +782,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
|||
return Err(bad("graphics.newFromFile", "the file has no picture"));
|
||||
}
|
||||
let (name, dur) = (pi.name.clone(), pi.duration());
|
||||
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
let clip = place_video_clip(s, "graphics.newFromFile", &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
s.state.graphic_layers.clear();
|
||||
Ok(json!({"clip": clip.0, "item": item.0}))
|
||||
},
|
||||
|
|
@ -902,7 +918,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => {
|
||||
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
|
||||
(new_graphic_clip(s, layer, &name, p)?, 0)
|
||||
(new_graphic_clip(s, "graphics.newText", layer, &name, p)?, 0)
|
||||
}
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
|
|
@ -913,7 +929,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
"Shape",
|
||||
&[],
|
||||
None,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]? (the shape's centre),"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id? (add the shape to this graphic),"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
|
||||
|
|
@ -936,7 +952,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
|
||||
let (clip, layer_i) = match into {
|
||||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
|
||||
None => (new_graphic_clip(s, "graphics.newShape", layer, "Shape", p)?, 0),
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
},
|
||||
|
|
@ -956,7 +972,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
let mut props = serde_json::Map::new();
|
||||
props.insert("text".into(), Value::String(text));
|
||||
let ph = s.playhead();
|
||||
set_props(s, clip, ei, &props, ph, "Edit Text")?;
|
||||
set_props(s, clip, ei, &props, ph, "Edit Text", None)?;
|
||||
if merge && s.history.undo.len() >= 2 {
|
||||
// keep the snapshot from before the typing session
|
||||
s.history.undo.pop();
|
||||
|
|
@ -969,14 +985,19 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
"Set Graphic Properties",
|
||||
&[],
|
||||
None,
|
||||
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?}"##,
|
||||
r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?,"merge":bool?,"begin":bool?}"##,
|
||||
has_graphic,
|
||||
|s, p| {
|
||||
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.set", "no graphic clip"))?;
|
||||
let (_, ei) = layer_effect_index(s, clip, p)?;
|
||||
let props = p.get("props").and_then(Value::as_object).ok_or_else(|| bad("graphics.set", "need `props`"))?.clone();
|
||||
let tl = time_p(s, p, "").unwrap_or_else(|| s.playhead());
|
||||
set_props(s, clip, ei, &props, tl, "Change Graphic Property")?;
|
||||
// `merge`: a drag of a property is one undo step; `begin` starts a new one (#201)
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("graphics.set:{}:{ei}", clip.0));
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
set_props(s, clip, ei, &props, tl, "Change Graphic Property", merge.as_deref())?;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -65,6 +65,36 @@ fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track
|
||||
/// that is not free there is an error named after the command that asked.
|
||||
#[test]
|
||||
fn graphic_placement_takes_a_track_and_a_time() {
|
||||
let commands =
|
||||
[("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))];
|
||||
for (command, mut params) in commands {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 72})).unwrap();
|
||||
params["track"] = json!(2);
|
||||
params["frame"] = json!(24);
|
||||
params["seconds"] = json!(1);
|
||||
let r = s.execute(command, params.clone()).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (track, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(track, q.video_tracks[2].id, "{command}");
|
||||
assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}");
|
||||
// the same place again is taken
|
||||
let e = s.execute(command, params).unwrap_err().to_string();
|
||||
assert!(e.contains("V3 is not free here"), "{command}: {e}");
|
||||
let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" };
|
||||
assert!(e.contains(named), "{command}: {e}");
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_makes_a_graphic_clip_above_the_footage() {
|
||||
let mut s = demo();
|
||||
|
|
@ -123,6 +153,38 @@ fn typing_coalesces_into_one_undo_step_and_props_set() {
|
|||
assert_eq!(text_of(&layers(&s, clip)[0]), "");
|
||||
}
|
||||
|
||||
/// A drag of a property is one undo step: `graphics.set` with `merge` folds consecutive calls
|
||||
/// into the step the first change of the press began (`begin`), so one undo restores the
|
||||
/// original value (the color picker in Essential Graphics was committing one step per frame).
|
||||
#[test]
|
||||
fn a_property_drag_is_one_undo_step() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newText", json!({"text": "Title"})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [0.0, 0.0, 0.0, 1.0]}})).unwrap();
|
||||
let color = |s: &Session| -> [f32; 4] {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (_, it) = q.find_item(clip).unwrap();
|
||||
let e = it.effects.iter().find(|e| e.effect == "graphic_text").unwrap();
|
||||
e.param("shadow_color").unwrap().value_at(Tick::ZERO).as_color().unwrap()
|
||||
};
|
||||
let before = s.history.undo.len();
|
||||
// the drag: the first change of the press begins the step, the rest fold into it
|
||||
for (i, c) in [[0.1, 0.0, 0.0, 1.0], [0.2, 0.0, 0.0, 1.0], [0.3, 0.0, 0.0, 1.0], [0.4, 0.0, 0.0, 1.0]].iter().enumerate() {
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": c}, "merge": true, "begin": i == 0})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), before + 1, "one undo step for the whole drag");
|
||||
assert_eq!(color(&s)[0], 0.4);
|
||||
// a second drag (a new press) begins its own step
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [1.0, 0.0, 0.0, 1.0]}, "merge": true, "begin": true})).unwrap();
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "layer": 0, "props": {"shadow_color": [0.0, 1.0, 0.0, 1.0]}, "merge": true})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), before + 2, "a new press begins a new step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(color(&s)[0], 0.4, "undo takes back only the second drag");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(color(&s)[0], 0.0, "and then the first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_and_distribute_layers() {
|
||||
let mut s = demo();
|
||||
|
|
@ -149,6 +211,24 @@ fn align_and_distribute_layers() {
|
|||
let _ = w;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_fill_sets_and_evaluates() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.execute(
|
||||
"graphics.set",
|
||||
json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill");
|
||||
assert_eq!(g.start[0], 1.0);
|
||||
assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}");
|
||||
assert_eq!(g.angle, 0.0);
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap();
|
||||
assert!(layers(&s, clip)[0].appearance.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_renders_in_the_program_and_survives_save() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod graphic_templates;
|
|||
pub mod graphics;
|
||||
pub mod interchange;
|
||||
pub mod keyboard;
|
||||
mod marker_export;
|
||||
pub mod masks;
|
||||
pub mod media_browser;
|
||||
pub mod media_pool;
|
||||
|
|
@ -260,6 +261,10 @@ pub struct EditorState {
|
|||
/// Selected captions (caption tracks / Captions panel).
|
||||
#[serde(default)]
|
||||
pub caption_selection: Vec<ClipId>,
|
||||
/// Selected transitions (clicked in the Timeline): Effect Controls shows one, Delete removes
|
||||
/// them. Selecting clips clears it and selecting transitions clears the clip selection.
|
||||
#[serde(default)]
|
||||
pub transition_selection: Vec<filmcraft_project::TransitionId>,
|
||||
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
|
||||
#[serde(default)]
|
||||
pub graphic_layers: Vec<usize>,
|
||||
|
|
@ -707,14 +712,15 @@ impl Session {
|
|||
open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(),
|
||||
active_sequence: self.state.active_sequence.filter(is_seq),
|
||||
sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(),
|
||||
playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are
|
||||
/// not sequences of this project are dropped (also a second mention of the same sequence),
|
||||
/// and numbers are brought into range. A view without any open sequence leaves the project
|
||||
/// on its first sequence: a project saved by a session that never showed one (a script, the
|
||||
/// CLI) should not open on an empty Timeline.
|
||||
/// and numbers are brought into range (a playhead also lands on a frame of its sequence). A
|
||||
/// view without any open sequence leaves the project on its first sequence: a project saved
|
||||
/// by a session that never showed one (a script, the CLI) should not open on an empty Timeline.
|
||||
pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_sequences {
|
||||
|
|
@ -728,6 +734,12 @@ impl Session {
|
|||
}
|
||||
self.state.timeline_views =
|
||||
view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect();
|
||||
for (id, t) in view.playheads {
|
||||
if let Some(seq) = self.project.sequence(id) {
|
||||
let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD);
|
||||
self.state.playheads.insert(id, seq.settings.frame_rate.snap(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every edit passes through here: one that would put a sequence inside itself (directly or
|
||||
|
|
@ -1065,6 +1077,8 @@ mod nesting_tests;
|
|||
#[cfg(test)]
|
||||
mod panels_tests;
|
||||
#[cfg(test)]
|
||||
mod par_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
#[cfg(test)]
|
||||
mod previews_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service.
|
||||
|
||||
use filmcraft_project::Label;
|
||||
use filmcraft_time::{Tick, format_timecode_frames};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::bad;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "markers.exportCsv";
|
||||
const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_MARKERS: usize = 100_000;
|
||||
|
||||
fn label_name(s: &Session, color: Label) -> &str {
|
||||
s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name())
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty() && !v.contains('\0'))
|
||||
.ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
seq.settings.validate().map_err(|e| bad(CMD, e))?;
|
||||
let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence");
|
||||
if seq.markers.len() > MAX_MARKERS {
|
||||
return Err(bad(CMD, "too many markers (maximum 100000)"));
|
||||
}
|
||||
// Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric,
|
||||
// timecode and enum fields; arbitrary text may double in size when every character is a quote.
|
||||
let mut bound = 512usize;
|
||||
for m in &seq.markers {
|
||||
let row = name
|
||||
.len()
|
||||
.checked_add(m.name.len())
|
||||
.and_then(|n| n.checked_add(m.comment.len()))
|
||||
.and_then(|n| n.checked_add(label_name(s, m.color).len()))
|
||||
.and_then(|n| n.checked_mul(2))
|
||||
.and_then(|n| n.checked_add(1024));
|
||||
bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?;
|
||||
}
|
||||
let rate = seq.settings.frame_rate;
|
||||
let mut markers: Vec<_> = seq.markers.iter().collect();
|
||||
markers.sort_by_key(|m| (m.start, m.id));
|
||||
let mut csv =
|
||||
String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n");
|
||||
for m in markers {
|
||||
if m.start.0 < 0 || m.duration.0 < 0 {
|
||||
return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0)));
|
||||
}
|
||||
let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?;
|
||||
let tc = |t: Tick| -> Result<String> {
|
||||
let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?;
|
||||
Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame))
|
||||
};
|
||||
let fields = [
|
||||
name.to_string(),
|
||||
m.id.0.to_string(),
|
||||
m.name.clone(),
|
||||
m.comment.clone(),
|
||||
format!("{:?}", m.kind),
|
||||
label_name(s, m.color).to_string(),
|
||||
tc(m.start)?,
|
||||
tc(Tick(end))?,
|
||||
rate.frame_at(m.duration).to_string(),
|
||||
m.start.0.to_string(),
|
||||
m.duration.0.to_string(),
|
||||
format!("{}/{}", rate.num, rate.den),
|
||||
];
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
csv.push(',');
|
||||
}
|
||||
csv.push('"');
|
||||
// Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text,
|
||||
// including formulas preceded by whitespace; numeric/timecode fields stay numeric.
|
||||
if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) {
|
||||
csv.push('\'');
|
||||
}
|
||||
for ch in field.chars() {
|
||||
if ch == '"' {
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push(ch);
|
||||
}
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push_str("\r\n");
|
||||
}
|
||||
s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use filmcraft_project::{Label, Marker, MarkerId, MarkerKind};
|
||||
use filmcraft_time::FrameRate;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Files {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
fail: bool,
|
||||
}
|
||||
impl Services for Files {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.fail {
|
||||
return Err(std::io::ErrorKind::PermissionDenied.into());
|
||||
}
|
||||
self.writes.lock().unwrap().push((path.into(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn session(files: Arc<Files>) -> Session {
|
||||
let mut s = Session { services: files, ..Default::default() };
|
||||
s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap();
|
||||
s
|
||||
}
|
||||
fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker {
|
||||
Marker {
|
||||
id: MarkerId(id),
|
||||
start,
|
||||
duration,
|
||||
name: name.into(),
|
||||
comment: "First line\r\nSecond, \"quoted\"".into(),
|
||||
kind: MarkerKind::Chapter,
|
||||
color: Label::Iris,
|
||||
}
|
||||
}
|
||||
fn set_markers(s: &mut Session, markers: Vec<Marker>) {
|
||||
s.edit_sequence("Review markers", |q, _, _| {
|
||||
q.markers = markers;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn written(files: &Files) -> String {
|
||||
String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_24;
|
||||
set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]);
|
||||
s.execute("markers.markIn", json!({"frame":12})).unwrap();
|
||||
s.state.hidden_marker_colors.push(Label::Iris);
|
||||
let before = s.project.clone();
|
||||
let state = serde_json::to_value(&s.state).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap();
|
||||
let csv = written(&files);
|
||||
assert_eq!(result["markers"], 2);
|
||||
assert_eq!(result["bytes"], csv.len());
|
||||
assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\""));
|
||||
assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap());
|
||||
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
s.undo();
|
||||
s.redo();
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_29_97;
|
||||
s.edit_sequence("Timing", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
q.settings.drop_frame = true;
|
||||
q.start_timecode = 107892;
|
||||
q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"df.csv"})).unwrap();
|
||||
assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\""));
|
||||
assert!(written(&files).contains("\"30000/1001\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] {
|
||||
let mut m = marker(100, Tick::ZERO, Tick::ZERO, text);
|
||||
m.comment = text.into();
|
||||
set_markers(&mut s, vec![m]);
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = text.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"safe.csv"})).unwrap();
|
||||
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3);
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into();
|
||||
s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap();
|
||||
assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim())));
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let spec = crate::find_command(CMD).unwrap();
|
||||
assert_eq!(spec.menu, ["Markers"]);
|
||||
assert!(spec.params.contains("path"));
|
||||
assert!(spec.shortcut.is_none());
|
||||
assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0);
|
||||
assert_eq!(written(&files).lines().count(), 1);
|
||||
assert!((spec.enabled)(&Session::default()).is_err());
|
||||
assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_and_damaged_timing_fail_before_writing() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] {
|
||||
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err());
|
||||
}
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] {
|
||||
Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")];
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
}
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")];
|
||||
q.settings.frame_rate.num = i64::MAX;
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.settings.frame_rate = FrameRate::FPS_24;
|
||||
q.start_timecode = i64::MAX;
|
||||
q.markers[0].start = FrameRate::FPS_24.tick_of(1);
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
q_reset(&mut s, id);
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.execute(CMD, json!({"path":"recovered.csv"})).unwrap();
|
||||
}
|
||||
fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) {
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.start_timecode = 0;
|
||||
q.markers.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reports_and_write_errors_are_actionable() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]);
|
||||
assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.services = Arc::new(Files { fail: true, ..Default::default() });
|
||||
set_markers(&mut s, vec![]);
|
||||
s.saved_revision = s.revision;
|
||||
assert!(!s.is_dirty());
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_23_976;
|
||||
s.edit_sequence("Locked review", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) {
|
||||
t.locked = true;
|
||||
}
|
||||
q.markers =
|
||||
vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into();
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Review name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.saved_revision = s.revision;
|
||||
let path = "review, \"notes\"\n日本語.csv";
|
||||
s.execute(CMD, json!({"path":path})).unwrap();
|
||||
assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path);
|
||||
let csv = written(&files);
|
||||
assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\""));
|
||||
assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\""));
|
||||
assert!(csv.contains("\"A, \"\"B\"\"\n字幕\""));
|
||||
assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap());
|
||||
assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1)));
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
|
|
@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize;
|
||||
let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?;
|
||||
edit_mask(s, p, "Add Mask Vertex", move |m, t| {
|
||||
let n = m.path_at(t).vertices.len();
|
||||
// clamp against the length before any insertion, so the index stays valid in the grown path
|
||||
let i = (after + 1).min(n);
|
||||
if m.path.is_animated() && m.path.keyframes.len() > 1 {
|
||||
if n == 0 {
|
||||
return Err(bad("masks.addVertex", "the animated mask path has no vertices to insert after"));
|
||||
}
|
||||
// keep every keyframe interpolable: insert the vertex into all of them
|
||||
for k in &mut m.path.keyframes {
|
||||
if let ParamValue::Path(path) = &mut k.value {
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
let a = path.vertices[after.min(path.vertices.len() - 1)].p;
|
||||
let b = path.vertices[i % path.vertices.len()].p;
|
||||
path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
let len = path.vertices.len();
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let at_i = i.min(len);
|
||||
let a = path.vertices[after.min(len - 1)].p;
|
||||
let b = path.vertices[at_i % len].p;
|
||||
path.vertices.insert(at_i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut path = m.path_at(t);
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
if m.path.keyframes.len() > 1 {
|
||||
path.vertices[i] = MaskVertex::corner(at);
|
||||
if let Some(v) = path.vertices.get_mut(i) {
|
||||
*v = MaskVertex::corner(at);
|
||||
}
|
||||
} else {
|
||||
path.vertices.insert(i, MaskVertex::corner(at));
|
||||
path.vertices.insert(i.min(path.vertices.len()), MaskVertex::corner(at));
|
||||
}
|
||||
set_path_at(m, t, path);
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -106,6 +106,39 @@ fn keyframed_mask_path_interpolates() {
|
|||
assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_vertex_on_animated_path_clamps_oversized_index_and_rejects_empty_path() {
|
||||
let (mut s, clip) = demo();
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
let rate = s.active_sequence().unwrap().settings.frame_rate;
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"delta": [5, 0]})).unwrap();
|
||||
// an index past the last vertex appends instead of panicking
|
||||
s.execute("masks.addVertex", json!({"after": 100, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5);
|
||||
|
||||
// an empty animated path is a parameter error and leaves the mask alone
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 1, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"mask": 1, "delta": [5, 0]})).unwrap();
|
||||
let e = s.execute("masks.addVertex", json!({"mask": 1, "after": 0, "at": [1, 1]})).unwrap_err().to_string();
|
||||
assert!(e.contains("no vertices"), "{e}");
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][1]["path"]["vertices"].as_array().unwrap().len(), 0);
|
||||
|
||||
// a static empty path still takes its first vertex
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("masks.addVertex", json!({"mask": 2, "after": 0, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][2]["path"]["vertices"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_drag_is_one_undo_step_and_project_round_trips() {
|
||||
let (mut s, _) = demo();
|
||||
|
|
|
|||
|
|
@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// ------------------------------------------------------------------------------------- audio gain
|
||||
|
||||
/// Peak (dBFS, before clip gain) of each selected audio clip's source range.
|
||||
fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
pub(crate) fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
||||
let sr = seq.settings.sample_rate.max(1);
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
|
|
@ -1065,7 +1065,7 @@ fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraf
|
|||
let mut pos = s0;
|
||||
while pos < s0 + len {
|
||||
let n = (s0 + len - pos).min(sr as i64) as usize;
|
||||
if let Ok(b) = src.audio(pos, n, sr) {
|
||||
if let Ok(b) = src.audio_stream(it.audio_stream, pos, n, sr) {
|
||||
peak = b.peaks().into_iter().fold(peak, f32::max);
|
||||
}
|
||||
pos += n as i64;
|
||||
|
|
|
|||
|
|
@ -475,12 +475,12 @@ fn sync_items(s: &Session, items: &[ItemId], cmd: &str) -> Result<Vec<SyncClip>>
|
|||
}
|
||||
|
||||
fn settings_for(s: &Session, item: ItemId) -> SequenceSettings {
|
||||
let info = match s.project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => Some(m.info.clone()),
|
||||
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()).map(|m| m.info.clone()),
|
||||
let media = match s.project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => Some(m),
|
||||
Some(ItemKind::Subclip { parent, .. }) => s.project.item(*parent).and_then(|i| i.as_media()),
|
||||
_ => None,
|
||||
};
|
||||
let mut st = info.as_ref().map(crate::commands::default_seq_settings_for).unwrap_or_default();
|
||||
let mut st = media.map(crate::commands::default_seq_settings_for).unwrap_or_default();
|
||||
if let Some(m) = s.project.item(item).and_then(|i| i.as_media())
|
||||
&& let Some(r) = m.interpret.frame_rate
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
//! Anamorphic media: the pixel aspect ratio reaches New Sequence From Clip, the placement of clips
|
||||
//! (default scaling, Fit / Fill) and the rendered frame, Interpret Footage overrides it, and
|
||||
//! hostile ratios are square. Synthetic media (a Color Matte given 4:3 pixels) runs everywhere;
|
||||
//! ffmpeg-made 1440 x 1080-style files (at a tenth of the size) check the MP4 and Matroska
|
||||
//! demuxers end to end when ffmpeg is installed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use filmcraft_project::{ItemId, ItemKind, ParamValue};
|
||||
|
||||
/// A 144 x 108 Color Matte (a 1440 x 1080 stand-in) whose pixels are `par`.
|
||||
fn matte(s: &mut Session, par: (u32, u32)) -> ItemId {
|
||||
let r = s.execute("file.newColorMatte", json!({"color": "#ff0000", "width": 144, "height": 108, "seconds": 2.0})).unwrap();
|
||||
let id = ItemId(r["item"].as_u64().unwrap());
|
||||
if let Some(ItemKind::Media(m)) = Arc::make_mut(&mut s.project).item_mut(id).map(|i| &mut i.kind) {
|
||||
m.info.video.as_mut().unwrap().par = par;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// The alpha of the middle row of the program frame at columns `xs`.
|
||||
fn coverage(s: &Session, xs: &[usize]) -> Vec<f32> {
|
||||
let img = s.render_program(1.0).unwrap();
|
||||
xs.iter().map(|&x| img.get(x, img.h / 2)[3]).collect()
|
||||
}
|
||||
|
||||
/// A 192 x 108 square-pixel sequence (a 1920 x 1080 stand-in) with `item` overwritten at 0.
|
||||
fn overwrite_into_square_hd(s: &mut Session, item: ItemId) -> filmcraft_project::ClipId {
|
||||
s.execute("file.newSequence", json!({"name": "HD", "width": 192, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
|
||||
let r = s.execute("source.overwrite", json!({})).unwrap();
|
||||
filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn motion_scale(s: &Session, clip: filmcraft_project::ClipId) -> f64 {
|
||||
let (_, it) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
match it.effect("motion").unwrap().params["scale"].value {
|
||||
ParamValue::Float(v) => v,
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sequence_from_clip_carries_the_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let r = s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
let st = s.project.sequence(ItemId(r["sequence"].as_u64().unwrap())).unwrap().settings.clone();
|
||||
assert_eq!((st.width, st.height, st.par), (144, 108, (4, 3)));
|
||||
assert!(st.validate().is_ok());
|
||||
// the clip maps pixel for pixel onto its own sequence: the whole frame
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[0, 1, 72, 142, 143]).iter().all(|a| *a > 0.99));
|
||||
// New Sequence from the item too
|
||||
s.execute("file.newSequence", json!({"name": "From item", "fromItem": item.0})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
// a ratio written in other terms is reduced
|
||||
let other = matte(&mut s, (16, 12));
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [other.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anamorphic_clip_fills_a_square_pixel_sequence() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
// displayed 192 wide at 100 %: the whole frame (it was pillarboxed at 144 wide)
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[1, 10, 96, 181, 190]).iter().all(|a| *a > 0.99), "{:?}", coverage(&s, &[1, 10, 96, 181, 190]));
|
||||
// Fit / Fill fit the display size: already the frame size
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
s.execute("clip.fillFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
|
||||
// a square-pixel matte of the same size is pillarboxed, and Fit to Frame leaves it so
|
||||
let square = matte(&mut s, (1, 1));
|
||||
let clip = overwrite_into_square_hd(&mut s, square);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[1, 20, 30, 96, 165, 175, 190]);
|
||||
assert!(c[0] < 0.01 && c[1] < 0.01 && c[2] > 0.99 && c[3] > 0.99 && c[4] > 0.99 && c[5] < 0.01 && c[6] < 0.01, "{c:?}");
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_media_scaling_uses_the_display_size() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
// Set to Frame Size: 1920 x 1080 display in 1920 x 1080 needs no scaling
|
||||
s.prefs.media.default_media_scaling = "setToFrameSize".into();
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
// in a 96 x 108 frame it is halved (display 192 wide), not scaled to 96 / 144
|
||||
s.execute("file.newSequence", json!({"name": "Narrow", "width": 96, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
let r = s.execute("source.overwrite", json!({})).unwrap();
|
||||
let clip = filmcraft_project::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(motion_scale(&s, clip), 50.0);
|
||||
// Scale to Frame Size: the renderer fits the display size
|
||||
s.prefs.media.default_media_scaling = "scaleToFrameSize".into();
|
||||
s.execute("file.newSequence", json!({"name": "Wide", "width": 384, "height": 108, "fps": 24})).unwrap();
|
||||
s.execute("source.open", json!({"item": item.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.0})).unwrap();
|
||||
s.execute("source.overwrite", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[90, 100, 192, 283, 293]);
|
||||
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] > 0.99 && c[3] > 0.99 && c[4] < 0.01, "192 wide, centred: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_footage_pixel_aspect_wins_over_the_file_and_undoes() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
let r = s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": [2, 1]})).unwrap();
|
||||
assert_eq!(r["pixelAspect"], json!([2, 1]));
|
||||
let media = |s: &Session| s.project.item(item).unwrap().as_media().unwrap().clone();
|
||||
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (Some((2, 1)), (2, 1)));
|
||||
// the colour interpretation is left as it was
|
||||
assert_eq!(media(&s).interpret.color_space, None);
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (2, 1));
|
||||
// a 2:1 picture 144 wide shows 288 wide: wider than the 192-wide square frame at 100 %
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[1, 190]).iter().all(|a| *a > 0.99));
|
||||
s.execute("clip.fitToFrame", json!({"clips": [clip.0]})).unwrap();
|
||||
assert!((motion_scale(&s, clip) - 100.0 * 192.0 / 288.0).abs() < 1e-9);
|
||||
// "file" goes back to the file's ratio; undo restores the override
|
||||
s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": "file"})).unwrap();
|
||||
assert_eq!((media(&s).interpret.par, media(&s).pixel_aspect()), (None, (4, 3)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(media(&s).interpret.par, Some((2, 1)));
|
||||
// hostile values are refused and change nothing
|
||||
let rev = s.revision;
|
||||
for bad in [
|
||||
json!([0, 1]),
|
||||
json!([1, 0]),
|
||||
json!([9, 1]),
|
||||
json!([1, 9]),
|
||||
json!([4]),
|
||||
json!([4, 3, 1]),
|
||||
json!([-4, 3]),
|
||||
json!([4.5, 3]),
|
||||
json!([u64::MAX, 1]),
|
||||
json!("wide"),
|
||||
json!({"n": 4}),
|
||||
] {
|
||||
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0], "pixelAspect": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("clip.interpretFootage", json!({"items": [item.0]})).is_err(), "nothing to change");
|
||||
assert_eq!((s.revision, media(&s).interpret.par), (rev, Some((2, 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_file_pixel_aspects_make_square_sequences() {
|
||||
for bad in [(0, 0), (0, 3), (4, 0), (u32::MAX, 1), (1, u32::MAX), (9, 1)] {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, bad);
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
let st = s.active_sequence().unwrap().settings.clone();
|
||||
assert_eq!(st.par, (1, 1), "{bad:?}");
|
||||
assert!(st.validate().is_ok());
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
assert!(coverage(&s, &[0, 143]).iter().all(|a| *a > 0.99), "{bad:?}");
|
||||
let clip = overwrite_into_square_hd(&mut s, item);
|
||||
assert_eq!(motion_scale(&s, clip), 100.0);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
let c = coverage(&s, &[1, 96, 190]);
|
||||
assert!(c[0] < 0.01 && c[1] > 0.99 && c[2] < 0.01, "{bad:?}: square, pillarboxed: {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generators_made_for_a_non_square_sequence_have_its_pixels() {
|
||||
let mut s = Session::default();
|
||||
let item = matte(&mut s, (4, 3));
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
// a Color Matte made now is 144 x 108 with the sequence's 4:3 pixels: it fills the frame
|
||||
let r = s.execute("file.newColorMatte", json!({"color": "#00ff00"})).unwrap();
|
||||
let matte2 = ItemId(r["item"].as_u64().unwrap());
|
||||
let m = s.project.item(matte2).unwrap().as_media().unwrap();
|
||||
assert_eq!((m.info.video.as_ref().unwrap().width, m.pixel_aspect()), (144, (4, 3)));
|
||||
}
|
||||
|
||||
/// ffmpeg-made 144 x 108 H.264 with 4:3 samples (`setsar=4/3`): MP4 (`pasp`) and Matroska
|
||||
/// (DisplayWidth / DisplayHeight).
|
||||
fn anamorphic_fixtures() -> Option<Vec<PathBuf>> {
|
||||
let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("par")?;
|
||||
let dir = filmcraft_testkit::fixtures_dir("engine/par");
|
||||
let mut out = Vec::new();
|
||||
for ext in ["mp4", "mkv"] {
|
||||
let path = dir.join(format!("anamorphic_144x108_sar43.{ext}"));
|
||||
out.push(filmcraft_testkit::fixtures::generate(&path, |tmp| {
|
||||
std::process::Command::new(&ffmpeg)
|
||||
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=144x108:rate=24:duration=1"])
|
||||
.args(["-vf", "setsar=4/3", "-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv420p"])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
})?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_anamorphic_files_fill_a_square_frame_and_make_anamorphic_sequences() {
|
||||
let Some(files) = anamorphic_fixtures() else { return };
|
||||
for file in files {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.import", json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let v = s.project.item(item).unwrap().as_media().unwrap().info.video.clone().unwrap();
|
||||
assert_eq!((v.width, v.height, v.par), (144, 108, (4, 3)), "{}", file.display());
|
||||
s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.par, (4, 3));
|
||||
overwrite_into_square_hd(&mut s, item);
|
||||
s.execute("playhead.set", json!({"seconds": 0.5})).unwrap();
|
||||
// testsrc2 is opaque everywhere: covered edge to edge
|
||||
let c = coverage(&s, &[1, 10, 181, 190]);
|
||||
assert!(c.iter().all(|a| *a > 0.99), "{}: {c:?}", file.display());
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap<ItemId, (Tick, Tic
|
|||
Some(ItemKind::Subclip { parent, .. }) => {
|
||||
used.insert(ti.item);
|
||||
target = *parent;
|
||||
// Loaded projects can nest subclips; keep every link up to the media (bounded like `resolve_media`).
|
||||
let mut up = *parent;
|
||||
for _ in 0..16 {
|
||||
used.insert(up);
|
||||
match p.item(up).map(|i| &i.kind) {
|
||||
Some(ItemKind::Subclip { parent, .. }) => up = *parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => continue,
|
||||
|
|
|
|||
|
|
@ -101,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() {
|
|||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subclip_keeps_its_whole_parent_chain_used() {
|
||||
use filmcraft_project::{ItemKind, Label};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
let root = tmp_dir("pm-nested-subclip");
|
||||
let (s, items, _) = project(&root);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let mut p = (*s.project).clone();
|
||||
let range = TimeRange::new(Tick::ZERO, Tick(1000));
|
||||
let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None);
|
||||
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
for x in t.items.iter_mut().filter(|i| i.item == items[0]) {
|
||||
x.item = outer;
|
||||
}
|
||||
}
|
||||
let (_, used) = super::project_manager::used_ranges(&p, &[seq]);
|
||||
assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
|
||||
use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeDisplay, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -699,14 +699,30 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// The selected items plus everything they need: sequence contents (recursively), subclip
|
||||
/// parents, multi-camera / merged-clip sources and graphic canvases.
|
||||
///
|
||||
/// Bins and items share one id space, so a named bin contributes every item it holds (and
|
||||
/// everything those items need), as `project.delete` does. The project's own top bin does not,
|
||||
/// since no command acts on it (#244), and only ids that name a real item end up in the result.
|
||||
pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
|
||||
let mut keep: BTreeSet<ItemId> = BTreeSet::new();
|
||||
let mut todo: Vec<ItemId> = roots.to_vec();
|
||||
let mut seen_bins: BTreeSet<u64> = BTreeSet::new();
|
||||
while let Some(id) = todo.pop() {
|
||||
if !keep.insert(id) {
|
||||
let bin = BinId(id.0);
|
||||
if bin != p.root.id && p.root.find_bin(bin).is_some() {
|
||||
if seen_bins.insert(id.0)
|
||||
&& let Some(b) = p.root.find_bin(bin)
|
||||
{
|
||||
let mut items = Vec::new();
|
||||
b.all_items(&mut items);
|
||||
todo.extend(items);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(it) = p.item(id) else { continue };
|
||||
if !keep.insert(id) {
|
||||
continue;
|
||||
}
|
||||
match &it.kind {
|
||||
ItemKind::Subclip { parent, .. } => todo.push(*parent),
|
||||
ItemKind::Sequence(q) => {
|
||||
|
|
@ -754,6 +770,9 @@ fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("file.exportSelectionProject", "select items in the Project panel"));
|
||||
}
|
||||
let keep = closure(&s.project, &roots);
|
||||
if keep.is_empty() {
|
||||
return Err(bad("file.exportSelectionProject", "the selection has no items to export"));
|
||||
}
|
||||
let mut sub = project_subset(&s.project, &keep);
|
||||
let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone());
|
||||
sub.name = name.clone();
|
||||
|
|
|
|||
|
|
@ -220,6 +220,59 @@ fn export_selection_project_and_ale() {
|
|||
assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_selection_project_of_a_bin_exports_its_contents() {
|
||||
let mut s = demo();
|
||||
let (footage_bin, bin_items) = s
|
||||
.project
|
||||
.root
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => {
|
||||
let mut items = Vec::new();
|
||||
b.all_items(&mut items);
|
||||
Some((b.id.0, items))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("Footage bin");
|
||||
assert_eq!(bin_items.len(), 6);
|
||||
|
||||
// Selecting the bin (its id, not its items) must export the bin's contents, not an empty project.
|
||||
s.execute("project.select", json!({"items": [footage_bin]})).unwrap();
|
||||
let dir = tmp_dir("export-bin");
|
||||
let path = dir.join("Footage.fcproj").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap();
|
||||
let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project;
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project");
|
||||
let reported: std::collections::BTreeSet<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items");
|
||||
for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] {
|
||||
assert!(p.items.values().any(|i| i.name == n), "{n}");
|
||||
}
|
||||
assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind");
|
||||
let mut listed = Vec::new();
|
||||
p.root.all_items(&mut listed);
|
||||
assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. })));
|
||||
|
||||
// A mixed bin + item request must not report the bin id, which names no item in the output.
|
||||
let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string();
|
||||
let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap();
|
||||
let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project;
|
||||
for v in r["items"].as_array().unwrap() {
|
||||
let id = ItemId(v.as_u64().unwrap());
|
||||
assert!(p.item(id).is_some(), "reported item {id:?} is in the output");
|
||||
}
|
||||
|
||||
// An empty bin has nothing to export: an actionable error, no misleading empty file.
|
||||
let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
||||
s.execute("project.select", json!({"items": [empty_bin]})).unwrap();
|
||||
let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string();
|
||||
assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err());
|
||||
assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_properties_settings_and_scratch_disks() {
|
||||
let dir = tmp_dir("props");
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ pub fn transcode(
|
|||
if let Some(v) = &info.video {
|
||||
st.width = v.width;
|
||||
st.height = v.height;
|
||||
// pixels in = pixels out: the sequence has the clip's pixel aspect, so nothing is stretched
|
||||
st.par = clip.pixel_aspect();
|
||||
}
|
||||
st.frame_rate = rate;
|
||||
st.sample_rate = info.audio().map_or(48_000, |a| a.sample_rate.max(8000));
|
||||
|
|
|
|||
|
|
@ -407,12 +407,18 @@ pub fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(MediaRef::File { path }) => path,
|
||||
_ => return Err(bad("media.relink", format!("item {} is not file media", item.0))),
|
||||
};
|
||||
let c = check_candidate(s, item, &path, &o)?;
|
||||
let mut c = check_candidate(s, item, &path, &o)?;
|
||||
if !c.ok && !force {
|
||||
return Err(EngineError::Other(format!("{} doesn't match {}: {}", file_name(&path), file_name(&old), c.problems.join("; "))));
|
||||
}
|
||||
if c.source.is_none() && s.media.open_file(&path, &*s.services).is_err() {
|
||||
return Err(EngineError::Other(format!("{path} can't be opened")));
|
||||
if c.source.is_none() {
|
||||
// Forced past a failed check: the file was never opened for its properties, so do it now
|
||||
// and let the item take the new file's own info instead of keeping the old file's.
|
||||
let Ok(src) = s.media.open_file(&path, &*s.services) else {
|
||||
return Err(EngineError::Other(format!("{path} can't be opened")));
|
||||
};
|
||||
c.info = Some(src.info().clone());
|
||||
c.source = Some(src);
|
||||
}
|
||||
let mut plans = vec![Plan { item, cand: c }];
|
||||
if o.relink_others {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() {
|
|||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_relink_takes_the_new_files_properties() {
|
||||
let root = tmp_dir("relink-forced-info");
|
||||
let (path, items) = saved_project(&root);
|
||||
std::fs::remove_dir_all(root.join("Media")).unwrap();
|
||||
// same name, different pictures and a different size
|
||||
let other = root.join("Other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12);
|
||||
let mut s = open(&path);
|
||||
s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap();
|
||||
let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width));
|
||||
assert_eq!(width, Some(W * 2), "the item describes the replacement file");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_offline_shows_the_slate_until_relinked() {
|
||||
let root = tmp_dir("relink-offline");
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let report = simplify_sequence(pr, &mut q, &o);
|
||||
q.check().map_err(EngineError::Other)?;
|
||||
let bin = pr.root.parent_of(seq_id).filter(|b| b.0 != 0);
|
||||
let id = pr.add_item(&name, src.label, ItemKind::Sequence(Box::new(q)), bin);
|
||||
let id = pr.add_item(&name, src.label, ItemKind::Sequence(std::sync::Arc::new(q)), bin);
|
||||
st.active_sequence = Some(id);
|
||||
if !st.open_sequences.contains(&id) {
|
||||
st.open_sequences.push(id);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of
|
||||
//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track
|
||||
//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2.
|
||||
//! them (the open tabs in order, the active one, each sequence's zoom, scroll and track heights,
|
||||
//! and each sequence's playhead). Premiere's tab behaviour was observed in Premiere Pro 26.5.2.
|
||||
|
||||
use super::*;
|
||||
use filmcraft_project::SequenceView;
|
||||
use filmcraft_time::TICKS_PER_SECOND;
|
||||
use serde_json::json;
|
||||
|
||||
/// The demo project with two more sequences open: tabs [main, b, c], `c` active.
|
||||
|
|
@ -106,6 +107,52 @@ fn a_saved_project_reopens_with_its_tabs_and_their_views() {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Each sequence reopens with its playhead where it was left: the project file keeps one playhead
|
||||
/// per sequence beside the tabs and views.
|
||||
#[test]
|
||||
fn each_sequence_reopens_with_its_playhead_where_it_was_left() {
|
||||
let (mut s, [main, b, c]) = three_tabs();
|
||||
let at = |s: &Session, seq: ItemId, secs: i64| s.project.sequence(seq).unwrap().settings.frame_rate.snap(Tick(secs * TICKS_PER_SECOND));
|
||||
s.execute("sequence.open", json!({"item": main.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 7})).unwrap();
|
||||
s.execute("sequence.open", json!({"item": b.0})).unwrap();
|
||||
s.execute("playhead.set", json!({"seconds": 2})).unwrap();
|
||||
let (main_at, b_at) = (at(&s, main, 7), at(&s, b, 2));
|
||||
assert_eq!(s.playhead(), b_at);
|
||||
assert_eq!(s.project_view().playheads.get(&c), None, "a playhead that never moved is not written");
|
||||
let dir = temp_dir("playheads");
|
||||
let path = dir.join("tabs.fcproj").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
||||
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(t.state.active_sequence, Some(b));
|
||||
assert_eq!(t.playhead(), b_at, "the sequence that was shown is back where it was");
|
||||
t.execute("sequence.open", json!({"item": main.0})).unwrap();
|
||||
assert_eq!(t.playhead(), main_at, "and so is every other sequence");
|
||||
t.execute("sequence.open", json!({"item": c.0})).unwrap();
|
||||
assert_eq!(t.playhead(), Tick::ZERO);
|
||||
assert!(!t.is_dirty(), "a restored playhead is not an edit");
|
||||
|
||||
// with the preference off, nothing of the view comes back, playheads included
|
||||
let mut u = Session::default();
|
||||
u.prefs.timeline.restore_open_sequences = false;
|
||||
u.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(u.playhead(), Tick::ZERO);
|
||||
|
||||
// a file written before playheads were kept opens as it did then: every playhead at zero
|
||||
let mut doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(doc["view"]["playheads"].is_object());
|
||||
doc["view"].as_object_mut().unwrap().remove("playheads");
|
||||
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let mut w = Session::default();
|
||||
w.execute("file.open", json!({"path": path})).unwrap();
|
||||
assert_eq!(w.state.active_sequence, Some(b));
|
||||
assert_eq!(w.playhead(), Tick::ZERO);
|
||||
assert!(w.state.playheads.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The view in a project file is not trusted: ids of things that are not sequences, repeats and
|
||||
/// numbers out of range are dropped or brought into range, and a view that cannot be read at all
|
||||
/// does not stop the project from opening.
|
||||
|
|
@ -134,6 +181,13 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
|
|||
"999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0},
|
||||
},
|
||||
"playheads": {
|
||||
c.0.to_string(): -5,
|
||||
b.0.to_string(): i64::MAX,
|
||||
main.0.to_string(): TICKS_PER_SECOND + 1,
|
||||
"999999": TICKS_PER_SECOND,
|
||||
footage.0.to_string(): TICKS_PER_SECOND,
|
||||
},
|
||||
}));
|
||||
assert_eq!(t.state.open_sequences, [c, b]);
|
||||
assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab");
|
||||
|
|
@ -141,6 +195,16 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() {
|
|||
let v = t.state.timeline_views[&b];
|
||||
assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0);
|
||||
assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0);
|
||||
// playheads: only sequences keep one, never before zero or past the bound, always on a frame
|
||||
let rate = |seq: ItemId| t.project.sequence(seq).unwrap().settings.frame_rate;
|
||||
assert_eq!(t.state.playheads.keys().copied().collect::<Vec<_>>(), {
|
||||
let mut ids = vec![main, b, c];
|
||||
ids.sort();
|
||||
ids
|
||||
});
|
||||
assert_eq!(t.state.playheads[&c], Tick::ZERO);
|
||||
assert_eq!(t.state.playheads[&b], rate(b).snap(filmcraft_project::ProjectView::MAX_PLAYHEAD));
|
||||
assert_eq!(t.state.playheads[&main], rate(main).snap(Tick(TICKS_PER_SECOND + 1)));
|
||||
|
||||
// not a view at all: the project opens as it does without one
|
||||
for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] {
|
||||
|
|
@ -159,6 +223,7 @@ fn the_view_of_a_deleted_sequence_is_forgotten() {
|
|||
s.execute("project.delete", json!({"items": [b.0]})).unwrap();
|
||||
assert_eq!(s.state.timeline_views.keys().copied().collect::<Vec<_>>(), [main, c]);
|
||||
assert_eq!(s.state.open_sequences, [main, c]);
|
||||
// the saved view never names it either
|
||||
// the saved view never names it either, nor keeps its playhead
|
||||
assert_eq!(s.project_view().open_sequences, [main, c]);
|
||||
assert!(!s.project_view().playheads.contains_key(&b));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
//! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | |
|
||||
//! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | |
|
||||
//! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | |
|
||||
//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | |
|
||||
//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | |
|
||||
//! | `sequence.throughEdits` | query: the through edits of the active sequence | |
|
||||
//! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U |
|
||||
//! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | |
|
||||
|
|
@ -103,7 +103,15 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
|||
s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits);
|
||||
Ok(json!({"showThroughEdits": s.state.show_through_edits}))
|
||||
}),
|
||||
spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits),
|
||||
spec(
|
||||
"sequence.joinThroughEdits",
|
||||
"Join Through Edits",
|
||||
&[],
|
||||
None,
|
||||
r#"{"clips":[id]?,"cut":[id,id]?,"all":bool?}"#,
|
||||
has_seq,
|
||||
join_through_edits,
|
||||
),
|
||||
query("sequence.throughEdits", "List Through Edits", "{}", |s, _| {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
Ok(Value::Array(
|
||||
|
|
@ -326,6 +334,9 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<V
|
|||
// ---------- through edits ----------
|
||||
|
||||
fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(cut) = p.get("cut") {
|
||||
return join_through_edit_at(s, cut);
|
||||
}
|
||||
let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty());
|
||||
let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) };
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
|
@ -343,6 +354,26 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"joined": n}))
|
||||
}
|
||||
|
||||
/// `sequence.joinThroughEdits {cut: [left, right]}`: join just that one cut (the edit point menu,
|
||||
/// #219), with the cuts between the same pieces' linked partners when linked selection is on.
|
||||
fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result<Value> {
|
||||
let ids: Vec<ClipId> = cut.as_array().map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
|
||||
let [left, right] = ids[..] else {
|
||||
return Err(EngineError::Other("sequence.joinThroughEdits: `cut` needs two clip ids, [left, right]".into()));
|
||||
};
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if !edit::through::through_edits(q).iter().any(|e| e.left == left && e.right == right) {
|
||||
return Err(EngineError::Other("that cut is not a through edit".into()));
|
||||
}
|
||||
let (lefts, rights) = (with_links(s, &[left]), with_links(s, &[right]));
|
||||
let n = s.edit_sequence("Join Through Edits", |q, _, st| {
|
||||
let n = edit::through::join_through_edits_where(q, |a, b| lefts.contains(&a) && rights.contains(&b));
|
||||
st.selection.retain(|c| q.find_item(*c).is_some());
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"joined": n}))
|
||||
}
|
||||
|
||||
// ---------- subsequence ----------
|
||||
|
||||
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the
|
||||
|
|
|
|||
|
|
@ -142,6 +142,41 @@ fn through_edits_show_and_join() {
|
|||
assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2);
|
||||
}
|
||||
|
||||
/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its
|
||||
/// linked partner, even when one of its pieces is also part of another through edit.
|
||||
#[test]
|
||||
fn join_one_through_edit_by_its_cut() {
|
||||
let mut s = demo();
|
||||
let v1 = s.active_sequence().unwrap().video_tracks[0].id.0;
|
||||
for frame in [30, 40] {
|
||||
s.execute("playhead.set", json!({"frame": frame})).unwrap();
|
||||
s.execute("sequence.addEditAllTracks", json!({})).unwrap();
|
||||
}
|
||||
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
|
||||
let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect();
|
||||
assert_eq!(on_v1.len(), 2);
|
||||
// the second cut: its left piece is also the right piece of the first one
|
||||
let (second, first) = (on_v1[1], on_v1[0]);
|
||||
assert_eq!(second["left"], first["right"]);
|
||||
let n = te.as_array().unwrap().len();
|
||||
let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap();
|
||||
assert_eq!(r["joined"], json!(2), "V1 + linked A1");
|
||||
let te = s.execute("sequence.throughEdits", json!({})).unwrap();
|
||||
assert_eq!(te.as_array().unwrap().len(), n - 2);
|
||||
assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_by_cut_rejects_hostile_parameters() {
|
||||
let mut s = demo();
|
||||
let items = s.active_sequence().unwrap().video_tracks[0].items.clone();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] {
|
||||
assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}");
|
||||
}
|
||||
assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_subsequence_from_in_out_and_from_selection() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -88,8 +88,18 @@ impl Default for GeneralPrefs {
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct AppearancePrefs {
|
||||
/// "Color Theme": `darkest` (default) | `dark` | `light`.
|
||||
/// Legacy single "Color Theme": `darkest` | `dark` | `light`. Setting it (old settings files,
|
||||
/// automation, View ▸ Appearance) selects that theme and fixes the mode to its family. It is
|
||||
/// kept in line with the fields below ([`AppearancePrefs::sync_color_theme`]): the light or
|
||||
/// dark theme of a fixed mode, and the dark theme in Auto (whose shown theme depends on the
|
||||
/// system; read `appearanceMode` for that).
|
||||
pub color_theme: String,
|
||||
/// "Appearance Mode": `auto` (follow the operating system) | `dark` (default) | `light`.
|
||||
pub appearance_mode: String,
|
||||
/// The theme shown in dark mode: `darkest` (default) | `dark`.
|
||||
pub dark_theme: String,
|
||||
/// The theme shown in light mode: `light`.
|
||||
pub light_theme: String,
|
||||
/// "Accessible color contrast": brighter secondary text and borders.
|
||||
pub accessible_contrast: bool,
|
||||
/// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`).
|
||||
|
|
@ -98,7 +108,55 @@ pub struct AppearancePrefs {
|
|||
|
||||
impl Default for AppearancePrefs {
|
||||
fn default() -> Self {
|
||||
Self { color_theme: "darkest".into(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into() }
|
||||
Self {
|
||||
color_theme: "darkest".into(),
|
||||
appearance_mode: "dark".into(),
|
||||
dark_theme: "darkest".into(),
|
||||
light_theme: "light".into(),
|
||||
accessible_contrast: false,
|
||||
highlight_color: DEFAULT_HIGHLIGHT.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearancePrefs {
|
||||
/// The theme to show (`darkest` | `dark` | `light`): the light or dark theme of the mode. Auto
|
||||
/// follows `system_light` (the operating system's appearance) and is dark without an answer.
|
||||
pub fn shown_theme(&self, system_light: Option<bool>) -> &str {
|
||||
let light = match self.appearance_mode.as_str() {
|
||||
"light" => true,
|
||||
"auto" => system_light == Some(true),
|
||||
_ => false,
|
||||
};
|
||||
if light { &self.light_theme } else { &self.dark_theme }
|
||||
}
|
||||
|
||||
/// Select `theme` (a Color Theme value) as the theme of its family and fix the mode to that
|
||||
/// family. Unknown names are ignored.
|
||||
pub fn select_theme(&mut self, theme: &str) {
|
||||
if LIGHT_THEMES.iter().any(|(v, _)| *v == theme) {
|
||||
self.light_theme = theme.into();
|
||||
self.appearance_mode = "light".into();
|
||||
} else if DARK_THEMES.iter().any(|(v, _)| *v == theme) {
|
||||
self.dark_theme = theme.into();
|
||||
self.appearance_mode = "dark".into();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the legacy `color_theme` in line with the mode and theme choices, so clients that
|
||||
/// still read it get the theme a fixed mode shows (the dark theme in Auto).
|
||||
pub fn sync_color_theme(&mut self) {
|
||||
let theme = self.shown_theme(None).to_string();
|
||||
self.color_theme = theme;
|
||||
}
|
||||
|
||||
/// The mode after this one in the header button's cycle: Auto, Light, Dark, Auto…
|
||||
pub fn next_mode(&self) -> &'static str {
|
||||
match self.appearance_mode.as_str() {
|
||||
"auto" => "light",
|
||||
"light" => "dark",
|
||||
_ => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +486,8 @@ pub struct TimelinePrefs {
|
|||
pub match_frame_sets_in: bool,
|
||||
pub restore_open_sequences: bool,
|
||||
pub add_tracks_automatically: bool,
|
||||
/// Files dropped onto the Timeline are placed directly on it.
|
||||
pub drop_import_to_timeline: bool,
|
||||
}
|
||||
|
||||
impl Default for TimelinePrefs {
|
||||
|
|
@ -454,6 +514,7 @@ impl Default for TimelinePrefs {
|
|||
match_frame_sets_in: false,
|
||||
restore_open_sequences: true,
|
||||
add_tracks_automatically: true,
|
||||
drop_import_to_timeline: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -607,6 +668,9 @@ const OPENING: &[(&str, &str)] = &[("showOpenDialog", "Show Open Dialog"), ("sho
|
|||
const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
|
||||
const PROJECT_OPEN: &[(&str, &str)] = &[("openNewTab", "Open new tab"), ("openNewWindow", "Open in new window")];
|
||||
const THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark"), ("light", "Light")];
|
||||
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("auto", "Sync with system"), ("dark", "Dark"), ("light", "Light")];
|
||||
pub const DARK_THEMES: &[(&str, &str)] = &[("darkest", "Darkest"), ("dark", "Dark")];
|
||||
pub const LIGHT_THEMES: &[(&str, &str)] = &[("light", "Light")];
|
||||
const MIXDOWN: &[(&str, &str)] = &[("front", "Front Only"), ("frontRear", "Front + Rear"), ("frontLfe", "Front + LFE"), ("frontRearLfe", "Front + Rear + LFE")];
|
||||
const AUDITION: &[(&str, &str)] = &[("scratch", "Scratch disk location for Captured Audio"), ("nextToMedia", "Next to original media files")];
|
||||
const BUFFERS: &[(&str, &str)] = &[("64", "64"), ("128", "128"), ("256", "256"), ("512", "512"), ("1024", "1024"), ("2048", "2048"), ("4096", "4096")];
|
||||
|
|
@ -680,7 +744,15 @@ static CATEGORIES: &[Category] = &[
|
|||
f(
|
||||
"general.interfaceLanguage",
|
||||
"Interface Language",
|
||||
Kind::Choice(&[("system", "System Language"), ("en", "English"), ("ja", "日本語"), ("es", "Español"), ("pt-br", "Português (Brasil)")]),
|
||||
Kind::Choice(&[
|
||||
("system", "System Language"),
|
||||
("en", "English"),
|
||||
("ja", "日本語"),
|
||||
("es", "Español"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
("uk", "Українська"),
|
||||
("zh-cn", "简体中文"),
|
||||
]),
|
||||
true,
|
||||
),
|
||||
f("general.atStartup", "At Startup", Kind::Choice(STARTUP), true),
|
||||
|
|
@ -714,6 +786,12 @@ static CATEGORIES: &[Category] = &[
|
|||
id: "appearance",
|
||||
title: "Appearance",
|
||||
rows: &[
|
||||
// Drawn by the dialog as the Appearance Mode selector above the light and dark theme
|
||||
// cards; the fields below keep their keys, validation and `prefs.schema` entries.
|
||||
Row::Custom("appearanceModes"),
|
||||
f("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true),
|
||||
f("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
f("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true),
|
||||
b("appearance.accessibleContrast", "Accessible color contrast", true),
|
||||
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
|
||||
|
|
@ -966,7 +1044,7 @@ static CATEGORIES: &[Category] = &[
|
|||
b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true),
|
||||
f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true),
|
||||
Row::Note(
|
||||
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
|
||||
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
|
||||
),
|
||||
],
|
||||
},
|
||||
|
|
@ -1005,6 +1083,7 @@ static CATEGORIES: &[Category] = &[
|
|||
b("timeline.matchFrameSetsIn", "Match frame sets in point", false),
|
||||
b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", false),
|
||||
b("timeline.addTracksAutomatically", "Add tracks automatically when editing source clips onto the Timeline", false),
|
||||
b("timeline.dropImportToTimeline", "Place files dropped onto the Timeline directly on the Timeline", true),
|
||||
],
|
||||
},
|
||||
Category {
|
||||
|
|
@ -1133,6 +1212,18 @@ pub fn migrate(v: &mut Value) {
|
|||
// pre/postroll; v2 stores them as reals, which serde reads either way.
|
||||
m.insert("version".into(), json!(2));
|
||||
}
|
||||
// Files from before appearance modes had one Color Theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to another theme or to Auto.
|
||||
if let Some(a) = m.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
&& !a.contains_key("appearanceMode")
|
||||
&& let Some(theme) = a.get("colorTheme").and_then(Value::as_str).map(str::to_string)
|
||||
{
|
||||
let mut p = AppearancePrefs::default();
|
||||
p.select_theme(&theme);
|
||||
a.insert("appearanceMode".into(), json!(p.appearance_mode));
|
||||
a.insert("darkTheme".into(), json!(p.dark_theme));
|
||||
a.insert("lightTheme".into(), json!(p.light_theme));
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::autosave::Preferences {
|
||||
|
|
@ -1211,17 +1302,18 @@ pub fn map_output(left: &[f32], right: &[f32], out: &mut [f32], ch: usize, map:
|
|||
}
|
||||
}
|
||||
|
||||
/// Settings ▸ Media ▸ Default Media Scaling for a clip of `src` size placed in a `frame`-sized
|
||||
/// sequence: `scaleToFrameSize` turns on Scale to Frame Size (rasterised at frame size),
|
||||
/// `setToFrameSize` sets Motion ▸ Scale so the picture fits the frame.
|
||||
pub fn apply_media_scaling(ti: &mut filmcraft_project::TrackItem, scaling: &str, frame: (u32, u32), src: (u32, u32)) {
|
||||
if src.0 == 0 || src.1 == 0 {
|
||||
/// Settings ▸ Media ▸ Default Media Scaling for a clip placed in a `frame`-sized sequence. `src`
|
||||
/// is the clip's size at its display aspect in sequence pixels
|
||||
/// ([`filmcraft_project::conformed_size`]): `scaleToFrameSize` turns on Scale to Frame Size
|
||||
/// (rasterised at frame size), `setToFrameSize` sets Motion ▸ Scale so the picture fits the frame.
|
||||
pub fn apply_media_scaling(ti: &mut filmcraft_project::TrackItem, scaling: &str, frame: (u32, u32), src: (f64, f64)) {
|
||||
if !(src.0 >= 1e-9 && src.1 >= 1e-9 && src.0.is_finite() && src.1.is_finite()) {
|
||||
return;
|
||||
}
|
||||
match scaling {
|
||||
"scaleToFrameSize" => ti.scale_to_frame = true,
|
||||
"setToFrameSize" => {
|
||||
let fit = (frame.0 as f64 / src.0 as f64).min(frame.1 as f64 / src.1 as f64);
|
||||
let fit = (frame.0 as f64 / src.0).min(frame.1 as f64 / src.1);
|
||||
if let Some(m) = ti.effects.iter_mut().find(|e| e.effect == "motion") {
|
||||
m.params.insert("scale".into(), filmcraft_project::Param::new(filmcraft_project::ParamValue::Float((fit * 1000.0).round() / 10.0)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,3 +551,111 @@ fn hardware_decoding_setting_drives_the_decoder_switch() {
|
|||
assert!(filmcraft_codecs::hw::hardware_decoding());
|
||||
assert!(settings::field("playback.hardwareDecoding").is_some_and(|f| f.wired && matches!(f.kind, Kind::Choice(_))));
|
||||
}
|
||||
|
||||
/// Settings ▸ Timeline ▸ "Place files dropped onto the Timeline directly on the Timeline": on by default.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_defaults_to_on() {
|
||||
assert!(settings::TimelinePrefs::default().drop_import_to_timeline);
|
||||
assert!(Preferences::default().timeline.drop_import_to_timeline);
|
||||
}
|
||||
|
||||
/// The key is saved under `timeline.dropImportToTimeline`, read back by `prefs.get`, and persisted.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_round_trips_through_prefs() {
|
||||
let dir = tmp_dir("drop-import-to-timeline");
|
||||
let path = dir.join("preferences.json");
|
||||
let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() };
|
||||
set(&mut s, "timeline.dropImportToTimeline", json!(false));
|
||||
assert!(!s.prefs.timeline.drop_import_to_timeline);
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "timeline.dropImportToTimeline"})).unwrap(), json!(false));
|
||||
assert!(!Preferences::load(&path).timeline.drop_import_to_timeline);
|
||||
// a non-boolean is rejected and the stored value is kept
|
||||
assert!(s.execute("prefs.set", json!({"key": "timeline.dropImportToTimeline", "value": "no"})).is_err());
|
||||
assert!(!s.prefs.timeline.drop_import_to_timeline);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Preferences files written before the key existed load it as on.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_defaults_to_on_for_older_prefs() {
|
||||
assert!(serde_json::from_value::<settings::TimelinePrefs>(json!({})).unwrap().drop_import_to_timeline);
|
||||
let old = Preferences::from_value(json!({"version": 2, "timeline": {"stillImageDuration": 3}}));
|
||||
assert!(old.timeline.drop_import_to_timeline);
|
||||
assert_eq!(old.timeline.still_image_duration, 3.0);
|
||||
}
|
||||
|
||||
/// The key is a Boolean field of the Timeline category in the settings registry.
|
||||
#[test]
|
||||
fn drop_import_to_timeline_is_in_the_settings_registry() {
|
||||
let (cat, f) = settings::fields().into_iter().find(|(_, f)| f.key == "timeline.dropImportToTimeline").expect("registry field");
|
||||
assert_eq!(cat, "timeline");
|
||||
assert!(matches!(f.kind, Kind::Bool));
|
||||
assert_eq!(settings::field("timeline.dropImportToTimeline").map(|f| f.key), Some("timeline.dropImportToTimeline"));
|
||||
}
|
||||
|
||||
/// New users keep the dark Darkest theme (following the system is opt-in); a file saved before
|
||||
/// appearance modes keeps showing its Color Theme as a fixed Dark or Light mode.
|
||||
#[test]
|
||||
fn appearance_defaults_and_legacy_theme_migrate() {
|
||||
let a = Preferences::default().appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), ("dark", "darkest", "light"));
|
||||
assert_eq!(a.shown_theme(Some(true)), "darkest", "Dark mode ignores a light system");
|
||||
let dir = tmp_dir("prefs-appearance-migrate");
|
||||
let path = dir.join("preferences.json");
|
||||
for (theme, mode, dark, light) in [("darkest", "dark", "darkest", "light"), ("dark", "dark", "dark", "light"), ("light", "light", "darkest", "light")] {
|
||||
std::fs::write(&path, json!({"version": 2, "appearance": {"colorTheme": theme}}).to_string()).unwrap();
|
||||
let a = Preferences::load(&path).appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.dark_theme.as_str(), a.light_theme.as_str()), (mode, dark, light), "{theme}");
|
||||
assert_eq!(a.shown_theme(None), theme);
|
||||
}
|
||||
// a v1 file without an appearance section, and a hostile theme, get the defaults
|
||||
std::fs::write(&path, r#"{"autoSave":{"enabled":true}}"#).unwrap();
|
||||
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
|
||||
std::fs::write(&path, r#"{"appearance":{"colorTheme":"neon","appearanceMode":7,"darkTheme":"light"}}"#).unwrap();
|
||||
assert_eq!(Preferences::load(&path).appearance, settings::AppearancePrefs::default());
|
||||
// a file with a mode keeps it
|
||||
std::fs::write(&path, r#"{"appearance":{"colorTheme":"darkest","appearanceMode":"auto","lightTheme":"light","darkTheme":"dark"}}"#).unwrap();
|
||||
let a = Preferences::load(&path).appearance;
|
||||
assert_eq!((a.appearance_mode.as_str(), a.shown_theme(Some(true)), a.shown_theme(Some(false)), a.shown_theme(None)), ("auto", "light", "dark", "dark"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_choices_validate_and_legacy_theme_still_selects() {
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "dark"}})).unwrap();
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("auto", "dark"));
|
||||
for (key, value) in [
|
||||
("appearance.darkTheme", json!("light")),
|
||||
("appearance.lightTheme", json!("darkest")),
|
||||
("appearance.appearanceMode", json!("system")),
|
||||
("appearance.appearanceMode", json!(1)),
|
||||
] {
|
||||
assert!(s.execute("prefs.set", json!({"key": key, "value": value})).is_err(), "{key} {value}");
|
||||
}
|
||||
// the single Color Theme of older clients selects that theme and fixes the mode to its family
|
||||
set(&mut s, "appearance.colorTheme", json!("light"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.light_theme.as_str()), ("light", "light"));
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "dark", "the dark choice is kept");
|
||||
set(&mut s, "appearance.colorTheme", json!("darkest"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.dark_theme.as_str()), ("dark", "darkest"));
|
||||
// the legacy Color Theme follows the mode and theme choices, so clients reading it aren't stale
|
||||
assert_eq!(s.prefs.appearance.color_theme, "darkest");
|
||||
set(&mut s, "appearance.appearanceMode", json!("light"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "light");
|
||||
set(&mut s, "appearance.appearanceMode", json!("dark"));
|
||||
set(&mut s, "appearance.darkTheme", json!("dark"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "dark");
|
||||
set(&mut s, "appearance.appearanceMode", json!("auto"));
|
||||
assert_eq!(s.prefs.appearance.color_theme, "dark", "Auto: the dark theme");
|
||||
set(&mut s, "appearance.colorTheme", json!("light"));
|
||||
assert_eq!((s.prefs.appearance.appearance_mode.as_str(), s.prefs.appearance.color_theme.as_str()), ("light", "light"));
|
||||
// the header button's cycle
|
||||
let mut a = settings::AppearancePrefs::default();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
a.appearance_mode = a.next_mode().into();
|
||||
seen.push(a.appearance_mode.clone());
|
||||
}
|
||||
assert_eq!(seen, ["auto", "light", "dark", "auto"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec<Marker>> {
|
|||
let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?;
|
||||
match &mut it.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => Ok(&mut m.markers),
|
||||
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut q.markers),
|
||||
filmcraft_project::ItemKind::Sequence(q) => Ok(&mut std::sync::Arc::make_mut(q).markers),
|
||||
_ => Err(EngineError::Other("Source marker editing requires media or a sequence".into())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() {
|
|||
assert!(img.px.chunks(4).any(|p| p[3] > 0.9));
|
||||
}
|
||||
|
||||
/// Ordinary and configured multi-frame offsets remain valid.
|
||||
#[test]
|
||||
fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() {
|
||||
for fps in [31.0, 23.98, 24.999] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": fps})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
s.execute("playhead.set", json!({"frame": 10})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
for frame in 11..=42 {
|
||||
s.execute("playhead.stepForward", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
for frame in (10..42).rev() {
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), frame);
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap();
|
||||
s.execute("playhead.stepForward5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 22);
|
||||
s.execute("playhead.stepBack5", json!({})).unwrap();
|
||||
assert_eq!(rate.frame_at(s.playhead()), 10);
|
||||
s.execute("playhead.step", json!({"frames": -20})).unwrap();
|
||||
s.execute("playhead.stepBack", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"fps": 31.0})).unwrap();
|
||||
for start in [10, 0] {
|
||||
s.execute("playhead.set", json!({"frame": start})).unwrap();
|
||||
let before = s.playhead();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX}))));
|
||||
assert!(result.is_ok(), "an extreme frame offset must not panic");
|
||||
assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. })));
|
||||
assert_eq!(s.playhead(), before);
|
||||
}
|
||||
s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap();
|
||||
assert_eq!(s.playhead(), Tick::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_edit_undo_redo() {
|
||||
let mut s = demo();
|
||||
|
|
@ -282,6 +326,137 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() {
|
|||
assert_eq!(scale(&s), (0, 100.0));
|
||||
}
|
||||
|
||||
/// `effects.setParam` writes a keyframe only on an animated parameter. An agent that sends a value
|
||||
/// with a `time` to a parameter that is not animated gets a static value (the result says so:
|
||||
/// 0 keyframes); `"keyframe": true` is the stopwatch and the value in one step.
|
||||
#[test]
|
||||
fn set_param_writes_a_keyframe_when_asked_or_animated() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
// (keyframes, static value, value at 0.5 s, value at 2.5 s)
|
||||
let scale = |s: &Session| {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
let p = &it.effect("motion").unwrap().params["scale"];
|
||||
let at = |seconds: f64| p.f64_at(it.source_time_at(Tick::from_seconds_f64(seconds)));
|
||||
(p.keyframes.len(), p.value.as_f64().unwrap(), at(0.5), at(2.5))
|
||||
};
|
||||
let set = |value: f64, seconds: f64| json!({"clip": c, "effect": "motion", "param": "scale", "value": value, "seconds": seconds});
|
||||
// not animated: the time is not used and the value is static
|
||||
let r = s.execute("effects.setParam", set(80.0, 0.5)).unwrap();
|
||||
assert_eq!(r["keyframes"], 0, "{r}");
|
||||
assert_eq!(scale(&s), (0, 80.0, 80.0, 80.0));
|
||||
// `keyframe`: the parameter becomes animated at that time
|
||||
let mut first = set(60.0, 0.5);
|
||||
first["keyframe"] = json!(true);
|
||||
let r = s.execute("effects.setParam", first).unwrap();
|
||||
assert_eq!(r["keyframes"], 1, "{r}");
|
||||
assert_eq!(scale(&s).0, 1);
|
||||
// animated now, so a plain set at another time is a second keyframe
|
||||
let r = s.execute("effects.setParam", set(120.0, 2.5)).unwrap();
|
||||
assert_eq!(r["keyframes"], 2, "{r}");
|
||||
let (n, _, a, b) = scale(&s);
|
||||
assert_eq!((n, a, b), (2, 60.0, 120.0));
|
||||
// a keyframe that is already there is replaced, not doubled
|
||||
let mut again = set(70.0, 0.5);
|
||||
again["keyframe"] = json!(true);
|
||||
assert_eq!(s.execute("effects.setParam", again).unwrap()["keyframes"], 2);
|
||||
assert_eq!(scale(&s).2, 70.0);
|
||||
s.undo();
|
||||
assert_eq!(scale(&s).2, 60.0);
|
||||
s.undo();
|
||||
assert_eq!(scale(&s).0, 1);
|
||||
s.redo();
|
||||
s.redo();
|
||||
let (n, _, a, b) = scale(&s);
|
||||
assert_eq!((n, a, b), (2, 70.0, 120.0));
|
||||
// a parameter that cannot be animated refuses the keyframe and keeps its value
|
||||
let blend = json!({"clip": c, "effect": "opacity", "param": "blend", "value": 8, "keyframe": true});
|
||||
let e = s.execute("effects.setParam", blend).unwrap_err().to_string();
|
||||
assert!(e.contains("cannot be animated"), "{e}");
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
assert_eq!(it.effect("opacity").unwrap().params["blend"].value, filmcraft_project::ParamValue::Choice(0));
|
||||
// `keyframe` that is not a bool means no keyframe request
|
||||
let mut odd = set(70.0, 0.5);
|
||||
odd["keyframe"] = json!("yes");
|
||||
assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2);
|
||||
}
|
||||
|
||||
/// A choice parameter takes its option name as well as its index, as transition parameters do
|
||||
/// (`effects.list {"detail": true}` lists the names).
|
||||
#[test]
|
||||
fn set_param_takes_a_choice_by_name() {
|
||||
use filmcraft_project::ParamValue;
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
let blend =
|
||||
|s: &Session| s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1.effect("opacity").unwrap().params["blend"].value.clone();
|
||||
let set = |value: serde_json::Value| json!({"clip": c, "effect": "opacity", "param": "blend", "value": value});
|
||||
s.execute("effects.setParam", set(json!("Screen"))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(8));
|
||||
s.execute("effects.setParam", set(json!("linear dodge (add)"))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(10), "names are matched without case");
|
||||
s.execute("effects.setParam", set(json!(12))).unwrap();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12), "an index works as before");
|
||||
s.undo();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(10));
|
||||
s.redo();
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12));
|
||||
// a name that is not an option is an error that lists the options and changes nothing
|
||||
let e = s.execute("effects.setParam", set(json!("Glow"))).unwrap_err().to_string();
|
||||
assert!(e.contains("Screen") && e.contains("Overlay"), "{e}");
|
||||
for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("effects.setParam", set(bad.clone())).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(blend(&s), ParamValue::Choice(12));
|
||||
// a name is still the wrong type for a parameter that is not a choice
|
||||
assert!(s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": "Screen"})).is_err());
|
||||
}
|
||||
|
||||
/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit
|
||||
/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one.
|
||||
#[test]
|
||||
fn keyframe_drags_are_one_undo_step() {
|
||||
let mut s = demo();
|
||||
let c = s.active_sequence().unwrap().audio_tracks[0].items[0].id.0;
|
||||
let level = |s: &Session| -> Vec<(i64, f64)> {
|
||||
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1;
|
||||
it.effect("volume").unwrap().params["level"].keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()
|
||||
};
|
||||
let key = json!({"clip": c, "effect": "volume", "param": "level"});
|
||||
for sec in [0.5, 1.5] {
|
||||
s.execute("playhead.set", json!({"seconds": sec})).unwrap();
|
||||
s.execute("effects.addKeyframe", key.clone()).unwrap();
|
||||
}
|
||||
let before = level(&s);
|
||||
let [(t0, _), (t1, _)] = before[..] else { panic!("two keyframes: {before:?}") };
|
||||
|
||||
// the line between them: both keyframes move together, frame after frame
|
||||
for (i, v) in [-1.0, -2.0, -3.0].into_iter().enumerate() {
|
||||
for t in [t0, t1] {
|
||||
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t, "value": v, "merge": true, "begin": i == 0 && t == t0});
|
||||
s.execute("effects.setKeyframe", p).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(level(&s), [(t0, -3.0), (t1, -3.0)]);
|
||||
s.undo();
|
||||
assert_eq!(level(&s), before);
|
||||
|
||||
// one keyframe: time and value in one command
|
||||
let mut at = t1;
|
||||
for (i, d) in [1000, 2000, 3000].into_iter().enumerate() {
|
||||
let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": at, "to": t1 + d, "value": -6.0, "merge": true, "begin": i == 0});
|
||||
s.execute("effects.moveKeyframe", p).unwrap();
|
||||
at = t1 + d;
|
||||
}
|
||||
assert_eq!(level(&s), [before[0], (t1 + 3000, -6.0)]);
|
||||
s.undo();
|
||||
assert_eq!(level(&s), before);
|
||||
|
||||
let bad = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t0, "to": t0 + 1, "value": "loud"});
|
||||
assert!(s.execute("effects.moveKeyframe", bad).is_err());
|
||||
assert_eq!(level(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_and_markers() {
|
||||
let mut s = demo();
|
||||
|
|
@ -641,3 +816,145 @@ fn dragging_an_effect_parameter_is_one_undo_step() {
|
|||
s.undo();
|
||||
assert_eq!(opacity(&s), start, "and then the whole first one");
|
||||
}
|
||||
|
||||
/// The demo's first video transition between two clips: (id, start, duration, cut).
|
||||
fn demo_crossing(s: &Session) -> (u64, Tick, Tick, Tick) {
|
||||
let q = s.active_sequence().unwrap();
|
||||
q.video_tracks
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
t.transitions.iter().find(|x| x.from.is_some() && x.to.is_some()).map(|x| (x.id.0, x.start, x.duration, t.item(x.to.unwrap()).unwrap().start))
|
||||
})
|
||||
.expect("the demo has a transition between two clips")
|
||||
}
|
||||
|
||||
fn transition_ids(s: &Session) -> Vec<u64> {
|
||||
s.active_sequence().unwrap().all_tracks().flat_map(|t| t.transitions.iter().map(|x| x.id.0)).collect()
|
||||
}
|
||||
|
||||
/// #430: a transition can be selected (it replaces the clip selection and back), Delete removes
|
||||
/// it and leaves its clips, undo brings it back; Ripple Delete is not for a transition alone.
|
||||
#[test]
|
||||
fn transitions_select_and_delete() {
|
||||
let mut s = demo();
|
||||
let (tid, ..) = demo_crossing(&s);
|
||||
let clips = s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>();
|
||||
let some_clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
|
||||
s.execute("timeline.select", json!({"clips": [some_clip]})).unwrap();
|
||||
let r = s.execute("timeline.select", json!({"transitions": [tid]})).unwrap();
|
||||
assert_eq!(r["transitionSelection"], json!([tid]));
|
||||
assert!(s.state.selection.is_empty(), "selecting a transition replaces the clip selection");
|
||||
assert!(s.execute("timeline.select", json!({"transitions": [987_654]})).is_err(), "unknown ids are refused");
|
||||
assert!(s.execute("edit.rippleDelete", json!({})).is_err(), "Ripple Delete needs clips");
|
||||
let before = (*s.project).clone();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert!(!transition_ids(&s).contains(&tid), "Delete removes the selected transition");
|
||||
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), clips, "and leaves the clips");
|
||||
assert!(s.state.transition_selection.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, before, "one undo step");
|
||||
// selecting clips clears a transition selection
|
||||
s.execute("timeline.select", json!({"transitions": [tid]})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [some_clip]})).unwrap();
|
||||
assert!(s.state.transition_selection.is_empty());
|
||||
// by id, without a selection
|
||||
s.execute("sequence.removeTransition", json!({"transitions": [tid]})).unwrap();
|
||||
assert!(!transition_ids(&s).contains(&tid));
|
||||
assert!(s.execute("sequence.removeTransition", json!({"transitions": [tid]})).is_err(), "nothing left to remove");
|
||||
assert!(s.execute("sequence.removeTransition", json!({})).is_err(), "nothing given or selected");
|
||||
}
|
||||
|
||||
/// #224 / Effect Controls: Duration follows the alignment, Alignment re-aligns on the cut, Start
|
||||
/// moves it (Custom Start), all within the clips it joins; a drag is one undo step.
|
||||
#[test]
|
||||
fn transition_duration_alignment_and_position() {
|
||||
let mut s = demo();
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let (tid, _, _, cut) = demo_crossing(&s);
|
||||
let set = |s: &mut Session, p: Value| {
|
||||
let mut p = p;
|
||||
p["transition"] = json!(tid);
|
||||
s.execute("sequence.setTransition", p)
|
||||
};
|
||||
let r = set(&mut s, json!({"align": "start"})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["align"].as_str()), (Some(cut.0), Some("start")));
|
||||
// Start at Cut: only the end moves
|
||||
let r = set(&mut s, json!({"frames": 10})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some(cut.0), Some(f(10).0)));
|
||||
// End at Cut: only the beginning moves
|
||||
let r = set(&mut s, json!({"align": "end", "frames": 8})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64(), r["align"].as_str()), (Some((cut - f(8)).0), Some(f(8).0), Some("end")));
|
||||
// Center at Cut: both ends
|
||||
let r = set(&mut s, json!({"align": "center", "frames": 12})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["align"].as_str()), (Some((cut - f(6)).0), Some("center")));
|
||||
// dragging it over the cut: Custom Start; then a new duration keeps its middle
|
||||
let r = set(&mut s, json!({"start": (cut - f(3)).0})).unwrap();
|
||||
assert_eq!(r["align"], json!("custom"));
|
||||
let r = set(&mut s, json!({"frames": 6})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some(cut.0), Some(f(6).0)), "centre at cut + 3 frames, 6 frames long");
|
||||
// dragging one edge: start and duration together
|
||||
let r = set(&mut s, json!({"start": (cut - f(4)).0, "duration": f(10).0})).unwrap();
|
||||
assert_eq!((r["start"].as_i64(), r["duration"].as_i64()), (Some((cut - f(4)).0), Some(f(10).0)));
|
||||
// refused, and nothing changes
|
||||
let before = (*s.project).clone();
|
||||
let history = s.history.undo.len();
|
||||
for p in [
|
||||
json!({"frames": 0}),
|
||||
json!({"frames": 100_000_000}),
|
||||
json!({"duration": -5}),
|
||||
json!({"duration": i64::MAX}),
|
||||
json!({"frames": 10, "duration": f(10).0}),
|
||||
json!({"align": "middle"}),
|
||||
json!({"align": "start", "start": cut.0}),
|
||||
json!({"start": i64::MIN}),
|
||||
json!({"start": (cut + f(1)).0, "duration": f(1).0}),
|
||||
] {
|
||||
assert!(set(&mut s, p.clone()).is_err(), "{p}");
|
||||
assert_eq!(*s.project, before, "{p}");
|
||||
assert_eq!(s.history.undo.len(), history, "{p}");
|
||||
}
|
||||
assert!(s.execute("sequence.setTransition", json!({"transition": 987_654, "frames": 5})).is_err());
|
||||
// a drag (merge) is one undo step
|
||||
let history = s.history.undo.len();
|
||||
for (i, n) in [11, 12, 13, 14].into_iter().enumerate() {
|
||||
set(&mut s, json!({"frames": n, "merge": true, "begin": i == 0})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), history + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, before);
|
||||
}
|
||||
|
||||
/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one
|
||||
/// disabled clip selected it swaps them, instead of first making both the same.
|
||||
#[test]
|
||||
fn enable_flips_each_selected_clip() {
|
||||
let mut s = demo();
|
||||
let enabled = |s: &Session, c: u64| s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.enabled;
|
||||
let partner = |s: &Session, c: u64| {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let link = q.find_item(ClipId(c)).unwrap().1.link?;
|
||||
q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != ClipId(c)).map(|i| i.id.0)
|
||||
};
|
||||
let (a, b) = (v1(&s)[0].0, v1(&s)[1].0);
|
||||
s.execute("clip.enable", json!({"clips": [a]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// mixed selection: each flips
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, false));
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// a clip named twice still flips once, and one press is one undo step
|
||||
s.execute("clip.enable", json!({"clips": [a, a]})).unwrap();
|
||||
assert!(enabled(&s, a));
|
||||
s.undo();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true));
|
||||
// with linked selection on, a clip's linked audio flips with it
|
||||
if let Some(au) = partner(&s, a) {
|
||||
assert_eq!(enabled(&s, au), enabled(&s, a));
|
||||
}
|
||||
// a uniform selection still toggles as before
|
||||
s.execute("clip.enable", json!({"clips": [b]})).unwrap();
|
||||
s.execute("clip.enable", json!({"clips": [a, b]})).unwrap();
|
||||
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ pub fn toggle_type(s: &mut Session) -> Result<Value> {
|
|||
|
||||
/// Trim every selected edit point by `delta` (edit point movement; positive = later).
|
||||
fn trim_all(s: &mut Session, delta: Tick) -> Result<Value> {
|
||||
let applied = trim_all_raw(s, delta)?;
|
||||
let applied = trim_all_raw(s, &|_, _| Some(delta))?;
|
||||
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
|
||||
add_shift(s, first);
|
||||
Ok(json!({"applied": applied}))
|
||||
|
|
@ -193,7 +193,8 @@ fn reset_shift(s: &mut Session) {
|
|||
s.state.trim_shift = TrimShift::default();
|
||||
}
|
||||
|
||||
fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
|
||||
/// Trim every selected edit point; `delta_for` gives each point's own movement, read from the sequence as it is when that point is reached.
|
||||
fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Option<Tick>) -> Result<Vec<Value>> {
|
||||
if s.state.edit_points.is_empty() {
|
||||
return Err(EngineError::Other("no edit points selected".into()));
|
||||
}
|
||||
|
|
@ -201,6 +202,7 @@ fn trim_all_raw(s: &mut Session, delta: Tick) -> Result<Vec<Value>> {
|
|||
let mut applied = Vec::new();
|
||||
for ep in &pts {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let Some(delta) = delta_for(seq, ep) else { continue };
|
||||
let r = match ep.kind {
|
||||
TrimKind::Roll => {
|
||||
let Some((l, r)) = roll_pair(seq, ep) else { continue };
|
||||
|
|
@ -226,9 +228,10 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// `trim.extendToPlayhead`: move each selected edit point to the playhead.
|
||||
pub fn extend_to_playhead(s: &mut Session) -> Result<Value> {
|
||||
let ph = s.playhead();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let first = s.state.edit_points.first().and_then(|e| edit_time(seq, e)).ok_or_else(|| EngineError::Other("no edit points selected".into()))?;
|
||||
trim_all(s, ph - first)
|
||||
let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?;
|
||||
let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default();
|
||||
add_shift(s, first);
|
||||
Ok(json!({"applied": applied}))
|
||||
}
|
||||
|
||||
/// `trim.toPlayhead {side: "previous"|"next", ripple}` (Q/W, ⌥Q/⌥W): remove the material between
|
||||
|
|
@ -502,14 +505,14 @@ fn apply_dynamic(s: &mut Session, offset: Tick) -> Result<Tick> {
|
|||
let redo = std::mem::take(&mut s.history.redo);
|
||||
s.history.limit = usize::MAX;
|
||||
s.project = d.before.clone();
|
||||
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) };
|
||||
let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, &|_, _| Some(offset)) };
|
||||
let applied = match r {
|
||||
Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(),
|
||||
Err(_) => {
|
||||
// refused outright (e.g. it would break sync): stay where we were
|
||||
s.project = d.before.clone();
|
||||
if d.offset != Tick::ZERO {
|
||||
let _ = trim_all_raw(s, d.offset);
|
||||
let _ = trim_all_raw(s, &|_, _| Some(d.offset));
|
||||
}
|
||||
d.offset
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,3 +210,29 @@ fn trim_monitor_sides_and_shift_counters() {
|
|||
let seq = s.execute("sequence.inspect", json!({})).unwrap();
|
||||
assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let q = s.active_sequence().unwrap();
|
||||
// a clip on V1 and the overlay on V2 that overlap in time but end at different frames
|
||||
let mut found = None;
|
||||
for a in &q.video_tracks[0].items {
|
||||
for b in &q.video_tracks[1].items {
|
||||
let (lo, hi) = (a.start.max(b.start), a.end().min(b.end()));
|
||||
if found.is_none() && a.end() != b.end() && hi - lo > rate.tick_of(2) {
|
||||
found = Some((a.id, b.id, lo + rate.tick_of(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (a, b, ph) = found.expect("demo has overlapping clips on V1 and V2 with different ends");
|
||||
s.execute("playhead.set", json!({"time": ph.0})).unwrap();
|
||||
s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap();
|
||||
s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap();
|
||||
s.execute("trim.extendToPlayhead", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.find_item(a).unwrap().1.end(), ph);
|
||||
assert_eq!(q.find_item(b).unwrap().1.end(), ph);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,38 @@ fn demo() -> Session {
|
|||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_preview_is_read_only_and_matches_apply_and_undo() {
|
||||
use crate::commands::preview_transition;
|
||||
use filmcraft_project::TrackKind;
|
||||
for (kind, effect) in [(TrackKind::Video, "cross_dissolve"), (TrackKind::Audio, "constant_power")] {
|
||||
let mut s = demo();
|
||||
let clips: Vec<_> = s.active_sequence().unwrap().tracks(kind)[0].items.iter().map(|c| c.id).collect();
|
||||
for clip in clips {
|
||||
for edge in ["in", "out"] {
|
||||
let params = json!({"effect": effect, "clip": clip.0, "edge": edge, "frames": 7});
|
||||
let before = s.project.to_json();
|
||||
let revision = s.revision;
|
||||
let (track, planned) = preview_transition(&s, ¶ms, kind).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.revision, revision);
|
||||
let result = s.execute("effects.apply", params).unwrap();
|
||||
let applied =
|
||||
s.active_sequence().unwrap().track(track).unwrap().transitions.iter().find(|t| Some(t.id.0) == result["transition"].as_u64()).unwrap();
|
||||
assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(preview_transition(&s, &json!({"effect": effect, "clip": u64::MAX}), kind).is_err());
|
||||
let track = s.active_sequence().unwrap().tracks(kind)[0].id;
|
||||
std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true;
|
||||
assert!(preview_transition(&s, &json!({"effect": effect, "clip": s.active_sequence().unwrap().track(track).unwrap().items[0].id.0}), kind).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn first_v1_clip(s: &Session) -> u64 {
|
||||
s.active_sequence().unwrap().video_tracks[0].items[0].id.0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@ fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
|
|||
m.info.audio_streams.truncate(max);
|
||||
}
|
||||
filmcraft_project::ItemKind::Sequence(sequence) => {
|
||||
let sequence = std::sync::Arc::make_mut(sequence);
|
||||
for clip in sequence.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) {
|
||||
if clip.audio_stream >= max {
|
||||
log::warn!("sequence `{name}`: clip `{}` uses audio stream {}, reset to 0", clip.name, clip.audio_stream);
|
||||
|
|
@ -418,6 +419,7 @@ mod tests {
|
|||
open_sequences: vec![seq],
|
||||
active_sequence: Some(seq),
|
||||
sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(),
|
||||
playheads: [(seq, filmcraft_time::Tick(3 * filmcraft_time::TICKS_PER_SECOND))].into(),
|
||||
};
|
||||
for pretty in [true, false] {
|
||||
let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap();
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ pub fn big_project(n: usize) -> Project {
|
|||
let seq_id = ItemId(21);
|
||||
let mut next = 1_000_000u64;
|
||||
let seq = match &mut p.items.get_mut(&seq_id).unwrap().kind {
|
||||
ItemKind::Sequence(s) => s,
|
||||
ItemKind::Sequence(s) => std::sync::Arc::make_mut(s),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let v0 = seq.video_tracks[0].items[0].clone();
|
||||
|
|
|
|||
|
|
@ -179,6 +179,61 @@ impl VideoFrame {
|
|||
Some(VideoFrame { width: ow as u32, height: oh as u32, data, color: self.color, par: self.par, pts: self.pts })
|
||||
}
|
||||
|
||||
/// The part of the frame inside `region` (a container's clean aperture). The region is clipped
|
||||
/// to the frame; on Y'CbCr frames its corner is moved up / left onto the chroma grid (at most
|
||||
/// one sample, keeping its size where the frame allows), so chroma is cut where luma is. An empty
|
||||
/// region, or one covering the whole frame, leaves the frame as it is.
|
||||
pub fn cropped(&self, region: Region) -> VideoFrame {
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let (sx, sy) = match &self.data {
|
||||
PixelData::Yuv8 { chroma, .. } | PixelData::Yuv16 { chroma, .. } => chroma.shifts(),
|
||||
PixelData::Rgba8(_) | PixelData::RgbaF32(_) => (0, 0),
|
||||
};
|
||||
let (mx, my) = ((1usize << sx) - 1, (1usize << sy) - 1);
|
||||
let (x, y) = (region.x.min(w) & !mx, region.y.min(h) & !my);
|
||||
let r = Region { x, y, w: region.w, h: region.h }.clip(w, h);
|
||||
if r.w == 0 || r.h == 0 || (r.w == w && r.h == h) {
|
||||
return self.clone();
|
||||
}
|
||||
let chroma_rect = |cw: usize, ch: usize| {
|
||||
(r.x >> sx, r.y >> sy, r.w.div_ceil(1 << sx).min(cw - (r.x >> sx).min(cw)), r.h.div_ceil(1 << sy).min(ch - (r.y >> sy).min(ch)))
|
||||
};
|
||||
let data = match &self.data {
|
||||
PixelData::Rgba8(d) => PixelData::Rgba8(Arc::new(crop_plane(d, w, h, 4, r))),
|
||||
PixelData::RgbaF32(d) => PixelData::RgbaF32(Arc::new(crop_plane(d, w, h, 4, r))),
|
||||
PixelData::Yuv8 { planes, chroma, alpha } => {
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch);
|
||||
let c = Region { x: cx, y: cy, w: cr_w, h: cr_h };
|
||||
PixelData::Yuv8 {
|
||||
planes: [
|
||||
Arc::new(crop_plane(&planes[0], w, h, 1, r)),
|
||||
Arc::new(crop_plane(&planes[1], cw, ch, 1, c)),
|
||||
Arc::new(crop_plane(&planes[2], cw, ch, 1, c)),
|
||||
],
|
||||
chroma: *chroma,
|
||||
alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))),
|
||||
}
|
||||
}
|
||||
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch);
|
||||
let c = Region { x: cx, y: cy, w: cr_w, h: cr_h };
|
||||
PixelData::Yuv16 {
|
||||
planes: [
|
||||
Arc::new(crop_plane(&planes[0], w, h, 1, r)),
|
||||
Arc::new(crop_plane(&planes[1], cw, ch, 1, c)),
|
||||
Arc::new(crop_plane(&planes[2], cw, ch, 1, c)),
|
||||
],
|
||||
chroma: *chroma,
|
||||
bits: *bits,
|
||||
alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))),
|
||||
}
|
||||
}
|
||||
};
|
||||
VideoFrame { width: r.w as u32, height: r.h as u32, data, color: self.color, par: self.par, pts: self.pts }
|
||||
}
|
||||
|
||||
/// The frame turned clockwise by `quarter_turns` × 90° (a container's display rotation).
|
||||
/// Planes rotate at their own resolution; 4:2:2 chroma is widened to 4:4:4 first for a quarter
|
||||
/// or three-quarter turn (its half-width chroma would become half-height, which has no
|
||||
|
|
@ -595,6 +650,20 @@ fn box_plane<T: Copy + Into<u32> + TryFrom<u32> + Send + Sync + Default>(src: &[
|
|||
out
|
||||
}
|
||||
|
||||
/// The `r` part of a `w`×`h` plane of `n`-element pixels (`r` lies inside the plane). A plane too
|
||||
/// short for its size gives a blank result, as in [`rotate_plane`].
|
||||
fn crop_plane<T: Copy + Default>(src: &[T], w: usize, h: usize, n: usize, r: Region) -> Vec<T> {
|
||||
let mut out = vec![T::default(); r.w * r.h * n];
|
||||
if src.len() < w * h * n || r.x + r.w > w || r.y + r.h > h {
|
||||
return out;
|
||||
}
|
||||
for (row, o) in out.chunks_exact_mut((r.w * n).max(1)).enumerate().take(r.h) {
|
||||
let s = ((r.y + row) * w + r.x) * n;
|
||||
o.copy_from_slice(&src[s..s + r.w * n]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `w`×`h` plane of `n`-element pixels turned clockwise by `q` (1–3) quarter turns.
|
||||
fn rotate_plane<T: Copy + Default + Send + Sync>(src: &[T], w: usize, h: usize, n: usize, q: u8) -> Vec<T> {
|
||||
let mut out = vec![T::default(); w * h * n];
|
||||
|
|
@ -643,6 +712,65 @@ fn rotate_chroma<T: Copy + Default + Send + Sync>(planes: &[Arc<Vec<T>>; 3], chr
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn cropping_cuts_every_plane_at_the_same_place() {
|
||||
// 6x4 RGBA8, red = index
|
||||
let px: Vec<u8> = (0..24u8).flat_map(|i| [i, 0, 0, 255]).collect();
|
||||
let f = VideoFrame { par: (4, 3), ..VideoFrame::rgba8(6, 4, px) };
|
||||
let c = f.cropped(Region { x: 1, y: 1, w: 3, h: 2 });
|
||||
assert_eq!((c.width, c.height, c.par), (3, 2, (4, 3)));
|
||||
assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::<Vec<_>>(), [7, 8, 9, 13, 14, 15]);
|
||||
// a region reaching past the frame is clipped to it; an empty or full one changes nothing
|
||||
let c = f.cropped(Region { x: 4, y: 3, w: 100, h: 100 });
|
||||
assert_eq!((c.width, c.height), (2, 1));
|
||||
assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::<Vec<_>>(), [22, 23]);
|
||||
for r in [
|
||||
Region { x: 0, y: 0, w: 0, h: 2 },
|
||||
Region { x: 9, y: 9, w: 2, h: 2 },
|
||||
Region { x: 0, y: 0, w: 6, h: 4 },
|
||||
Region { x: 0, y: 0, w: usize::MAX, h: usize::MAX },
|
||||
] {
|
||||
let c = f.cropped(r);
|
||||
assert_eq!((c.width, c.height), (6, 4), "{r:?}");
|
||||
}
|
||||
|
||||
// 4:2:0, odd sizes (7x5: chroma 4x3), with alpha: luma = index, chroma = 100 + index
|
||||
let luma: Vec<u8> = (0..35u8).collect();
|
||||
let cb: Vec<u8> = (100..112u8).collect();
|
||||
let yuv = |chroma| VideoFrame {
|
||||
width: 7,
|
||||
height: 5,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new(luma.clone()), Arc::new(cb.clone()), Arc::new(cb.clone())], chroma, alpha: Some(Arc::new(luma.clone())) },
|
||||
color: Default::default(),
|
||||
par: (1, 1),
|
||||
pts: Default::default(),
|
||||
};
|
||||
// an odd corner moves onto the chroma grid (1,1 -> 0,0), keeping the size
|
||||
let c = yuv(Chroma::C420).cropped(Region { x: 1, y: 1, w: 3, h: 3 });
|
||||
assert_eq!((c.width, c.height), (3, 3));
|
||||
let PixelData::Yuv8 { planes, alpha, .. } = &c.data else { panic!() };
|
||||
assert_eq!(*planes[0], [0, 1, 2, 7, 8, 9, 14, 15, 16]);
|
||||
assert_eq!(**alpha.as_ref().unwrap(), *planes[0]);
|
||||
assert_eq!(*planes[1], [100, 101, 104, 105], "chroma 2x2 from (0,0)");
|
||||
// even corner: chroma starts at (1,1); the last chroma column of an odd width is kept
|
||||
let c = yuv(Chroma::C420).cropped(Region { x: 2, y: 2, w: 5, h: 3 });
|
||||
let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() };
|
||||
assert_eq!((c.width, c.height), (5, 3));
|
||||
assert_eq!(*planes[1], [105, 106, 107, 109, 110, 111], "chroma 3x2 from (1,1)");
|
||||
// 4:4:4 needs no alignment
|
||||
let c = yuv(Chroma::C444).cropped(Region { x: 1, y: 1, w: 2, h: 1 });
|
||||
let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() };
|
||||
assert_eq!(*planes[0], [8, 9]);
|
||||
// a plane shorter than its size gives a blank picture, never a panic
|
||||
let mut short = yuv(Chroma::C420);
|
||||
if let PixelData::Yuv8 { planes, .. } = &mut short.data {
|
||||
planes[0] = Arc::new(vec![1; 3]);
|
||||
}
|
||||
let c = short.cropped(Region { x: 2, y: 2, w: 2, h: 2 });
|
||||
let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() };
|
||||
assert_eq!(*planes[0], [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_turns_every_plane_clockwise() {
|
||||
// 4x2 RGBA8 with distinct pixels 0..8
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ pub(crate) mod pid {
|
|||
// Root
|
||||
pub const ROOT_META_DICTIONARY: u16 = 0x0001;
|
||||
pub const ROOT_HEADER: u16 = 0x0002;
|
||||
// MetaDictionary and MetaDefinition
|
||||
pub const META_CLASS_DEFINITIONS: u16 = 0x0003;
|
||||
pub const META_TYPE_DEFINITIONS: u16 = 0x0004;
|
||||
pub const META_IDENTIFICATION: u16 = 0x0005;
|
||||
// Header
|
||||
pub const BYTE_ORDER: u16 = 0x3B01;
|
||||
pub const LAST_MODIFIED: u16 = 0x3B02;
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ fn prop_name(pid: u16) -> &'static str {
|
|||
match pid {
|
||||
0x0001 => "MetaDictionary",
|
||||
0x0002 => "Header",
|
||||
0x0003 => "ClassDefinitions",
|
||||
0x0004 => "TypeDefinitions",
|
||||
0x3B03 => "Content",
|
||||
0x3B04 => "Dictionary",
|
||||
0x3B06 => "IdentificationList",
|
||||
|
|
@ -200,6 +202,16 @@ fn prop_name(pid: u16) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it
|
||||
/// from: AUIDs (definitions) are 16 bytes, MobIDs 32.
|
||||
fn set_key_size(key_pid: u16) -> usize {
|
||||
match key_pid {
|
||||
0x0005 | 0x1B01 => 16,
|
||||
0x4401 | 0x2701 => 32,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_name(pid: u16) -> String {
|
||||
let suffix = format!("-{pid:x}");
|
||||
let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect();
|
||||
|
|
@ -269,7 +281,7 @@ impl W {
|
|||
index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key
|
||||
index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key
|
||||
let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None };
|
||||
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0);
|
||||
let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0);
|
||||
if let Some(k) = set_key {
|
||||
index.extend_from_slice(&k.to_le_bytes());
|
||||
index.push(key_size as u8);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,32 @@ fn keys(dict: &Obj, p: u16) -> HashSet<Vec<u8>> {
|
|||
dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect()
|
||||
}
|
||||
|
||||
/// #323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and
|
||||
/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types
|
||||
/// those readers already know.
|
||||
#[test]
|
||||
fn the_meta_dictionary_has_its_class_and_type_definition_sets() {
|
||||
let (p, s) = project();
|
||||
for small in [false, true] {
|
||||
let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap();
|
||||
let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap();
|
||||
assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY);
|
||||
for name in ["ClassDefinitions-3", "TypeDefinitions-4"] {
|
||||
// u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size
|
||||
let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(index.len(), 15, "{name}: an empty set");
|
||||
assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries");
|
||||
assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID");
|
||||
assert_eq!(index[14], 16, "{name}: 16-byte keys");
|
||||
}
|
||||
let root = store::read(&bytes).unwrap();
|
||||
let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap();
|
||||
for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] {
|
||||
assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_files_have_the_required_structure() {
|
||||
let (p, s) = project();
|
||||
|
|
|
|||
|
|
@ -349,9 +349,13 @@ pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mu
|
|||
.with(pid::CONTENT, Value::Strong(Box::new(content)))
|
||||
.with(pid::DICTIONARY, Value::Strong(Box::new(dict)))
|
||||
.with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident]));
|
||||
let root = Obj::new(ROOT)
|
||||
.with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY))))
|
||||
.with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
|
||||
// The meta-dictionary holds only extensions to the baseline object model, and everything written
|
||||
// here is baseline, so its class and type definition sets are empty; readers that load them
|
||||
// (pyaaf2) still need the sets to be there (#323).
|
||||
let meta = Obj::new(META_DICTIONARY)
|
||||
.with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION))
|
||||
.with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION));
|
||||
let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header)));
|
||||
store::write(&root, version).map_err(Error::Other)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,15 @@ pub fn file_url_to_path(url: &str) -> String {
|
|||
/// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML).
|
||||
pub fn path_to_file_url(path: &str, localhost: bool) -> String {
|
||||
let p = path.replace('\\', "/");
|
||||
// Windows verbatim paths (`\\?\C:\x`, `\\?\UNC\server\share`, as `canonicalize` returns them)
|
||||
// are not network shares: drop the prefix so `?` doesn't become the URL's host.
|
||||
let p = if let Some(unc) = p.strip_prefix("//?/UNC/") {
|
||||
format!("//{unc}")
|
||||
} else if let Some(local) = p.strip_prefix("//?/") {
|
||||
local.to_string()
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let host = if localhost { "localhost" } else { "" };
|
||||
if is_windows_abs(&p) {
|
||||
format!("file://{host}/{}", percent_encode_path(&p))
|
||||
|
|
@ -378,7 +387,7 @@ impl Builder {
|
|||
/// Add an empty sequence item (filled later with [`Builder::put_sequence`]).
|
||||
pub fn reserve_sequence(&mut self, name: &str, settings: SequenceSettings, bin: Option<BinId>) -> ItemId {
|
||||
let seq = empty_sequence(settings);
|
||||
self.p.add_item(name, Label::Forest, ItemKind::Sequence(Box::new(seq)), bin)
|
||||
self.p.add_item(name, Label::Forest, ItemKind::Sequence(std::sync::Arc::new(seq)), bin)
|
||||
}
|
||||
|
||||
pub fn put_sequence(&mut self, id: ItemId, mut seq: Sequence) {
|
||||
|
|
@ -675,6 +684,8 @@ mod tests {
|
|||
assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov");
|
||||
assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov");
|
||||
assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf");
|
||||
assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4");
|
||||
assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4");
|
||||
for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] {
|
||||
assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p);
|
||||
assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p);
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ struct Exporter<'a> {
|
|||
p: &'a Project,
|
||||
media: &'a MediaOptions,
|
||||
doc: Document,
|
||||
memo: HashMap<(String, CKind, Option<u32>), usize>,
|
||||
memo: HashMap<(String, CKind, usize, Option<u32>), usize>,
|
||||
report: &'a mut Report,
|
||||
seq_rate: FrameRate,
|
||||
nests: Nests,
|
||||
|
|
@ -307,7 +307,7 @@ impl Exporter<'_> {
|
|||
if ckind == CKind::Sound
|
||||
&& let Some(e) = self.media.essence_for(clip_key, channel).cloned()
|
||||
{
|
||||
let memo_key = (format!("clip:{}", c.id.0), ckind, channel);
|
||||
let memo_key = (format!("clip:{}", c.id.0), ckind, 0, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
|
|
@ -346,7 +346,7 @@ impl Exporter<'_> {
|
|||
self.report.warn(format!("the sound of the nested sequence \"{name}\" was not rendered (left as a gap)"));
|
||||
return None;
|
||||
}
|
||||
let memo_key = (format!("seq:{}", item.0), ckind, channel);
|
||||
let memo_key = (format!("seq:{}", item.0), ckind, 0, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
|
|
@ -429,21 +429,29 @@ impl Exporter<'_> {
|
|||
// the source channel this slot plays when broken out to mono
|
||||
let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s));
|
||||
let clip_key = EssenceKey::Clip(c.id);
|
||||
let media_key = EssenceKey::Media(item);
|
||||
let media_key = EssenceKey::media(item, c.audio_stream);
|
||||
let ess = (ckind == CKind::Sound)
|
||||
.then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel)))
|
||||
.flatten()
|
||||
.cloned();
|
||||
let key = match &ess {
|
||||
Some(e) if e.key == clip_key => format!("clip:{}", c.id.0),
|
||||
_ if ckind == CKind::Sound && c.audio_stream > 0 => format!("item:{}:stream:{}", item.0, c.audio_stream),
|
||||
_ => format!("item:{}", item.0),
|
||||
};
|
||||
let memo_key = (key.clone(), ckind, channel);
|
||||
// Original linked file locators cannot name a container stream. Leave an explicit
|
||||
// unsupported-feature report rather than reference its first audio stream.
|
||||
if ckind == CKind::Sound && c.audio_stream > 0 && ess.is_none() {
|
||||
self.report.warn("selected container audio streams require separate or embedded audio essence (left as gaps)");
|
||||
return None;
|
||||
}
|
||||
let stream = if ckind == CKind::Sound { c.audio_stream } else { 0 };
|
||||
let memo_key = (key.clone(), ckind, stream, channel);
|
||||
if let Some(&i) = self.memo.get(&memo_key) {
|
||||
return Some(i);
|
||||
}
|
||||
let video = m.info.video.as_ref();
|
||||
let audio = m.info.audio();
|
||||
let audio = m.info.audio_streams.get(c.audio_stream);
|
||||
let still = m.info.kind == MediaKind::Still;
|
||||
match ckind {
|
||||
CKind::Picture if video.is_none() && !still => return None,
|
||||
|
|
|
|||
|
|
@ -19,15 +19,25 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EssenceKey {
|
||||
Media(ItemId),
|
||||
MediaStream { item: ItemId, stream: usize },
|
||||
Clip(ClipId),
|
||||
}
|
||||
|
||||
impl EssenceKey {
|
||||
pub fn media(item: ItemId, stream: usize) -> Self {
|
||||
if stream == 0 { Self::Media(item) } else { Self::MediaStream { item, stream } }
|
||||
}
|
||||
}
|
||||
|
||||
/// One range of source audio an export references.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AudioNeed {
|
||||
pub key: EssenceKey,
|
||||
/// The media item (for a clip key: the clip's media).
|
||||
pub item: ItemId,
|
||||
/// Selected container stream; zero for legacy and sequence sources.
|
||||
#[serde(default)]
|
||||
pub audio_stream: usize,
|
||||
/// The media file, when the item is file media.
|
||||
pub path: Option<String>,
|
||||
/// Media time range to supply (handles included, clamped to the media).
|
||||
|
|
@ -120,7 +130,8 @@ pub struct NeedOptions {
|
|||
/// Extra media before and after every used range.
|
||||
pub handles: Tick,
|
||||
/// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range
|
||||
/// per media item covering all its uses ([`EssenceKey::Media`]).
|
||||
/// per media item and container stream covering all its uses. Stream zero keeps
|
||||
/// the legacy [`EssenceKey::Media`] identity.
|
||||
pub per_clip: bool,
|
||||
/// Whole media instead of the used range (embedding / copying without trimming).
|
||||
pub whole_media: bool,
|
||||
|
|
@ -163,7 +174,7 @@ fn collect_needs(
|
|||
let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles);
|
||||
match project.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Media(m)) => {
|
||||
let Some(a) = m.info.audio() else { continue };
|
||||
let Some(a) = m.info.audio_streams.get(c.audio_stream) else { continue };
|
||||
let path = match &m.media {
|
||||
MediaRef::File { path } => Some(path.clone()),
|
||||
MediaRef::Generator(_) => None,
|
||||
|
|
@ -176,8 +187,17 @@ fn collect_needs(
|
|||
start = Tick::ZERO;
|
||||
end = dur.max(end);
|
||||
}
|
||||
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) };
|
||||
let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate });
|
||||
let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::media(item, c.audio_stream) };
|
||||
let e = by_item.entry(key).or_insert_with(|| AudioNeed {
|
||||
key,
|
||||
item,
|
||||
audio_stream: c.audio_stream,
|
||||
path,
|
||||
start,
|
||||
end,
|
||||
channels: a.channels,
|
||||
sample_rate: a.sample_rate,
|
||||
});
|
||||
e.start = e.start.min(start);
|
||||
e.end = e.end.max(end);
|
||||
}
|
||||
|
|
@ -196,7 +216,10 @@ fn collect_needs(
|
|||
_ => 2,
|
||||
};
|
||||
let key = EssenceKey::Clip(c.id);
|
||||
by_item.insert(key, AudioNeed { key, item, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) });
|
||||
by_item.insert(
|
||||
key,
|
||||
AudioNeed { key, item, audio_stream: 0, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) },
|
||||
);
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,24 @@ struct Imp<'a, 'i, 'r> {
|
|||
in_progress: HashSet<String>,
|
||||
links: Vec<PendingLink>,
|
||||
xml_clip_ids: HashMap<(ItemId, String), ClipId>,
|
||||
/// `(sequence key, file key)` pairs where some audio clip item of that file uses `sourcetrack/trackindex` > 1,
|
||||
/// i.e. the file's channels are split onto mono tracks in that sequence (#463).
|
||||
split_files: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// The `(sequence key, file key)` an audio clip item belongs to, if it references a file.
|
||||
fn clip_seq_file(c: Node) -> Option<(String, String)> {
|
||||
let seq = c.ancestors().skip(1).find(|a| a.has_tag_name("sequence"))?;
|
||||
Some((node_key(seq), node_key(child(c, "file")?)))
|
||||
}
|
||||
|
||||
/// The 1-based `sourcetrack/trackindex` of an audio clip item.
|
||||
fn audio_track_index(c: Node) -> Option<i64> {
|
||||
let st = child(c, "sourcetrack")?;
|
||||
if child_text(st, "mediatype").is_some_and(|m| m != "audio") {
|
||||
return None;
|
||||
}
|
||||
child_i64(st, "trackindex")
|
||||
}
|
||||
|
||||
fn node_key(n: Node) -> String {
|
||||
|
|
@ -136,9 +154,15 @@ pub(crate) fn import(text_in: &str, opts: &ImportOptions, report: &mut Report) -
|
|||
in_progress: HashSet::new(),
|
||||
links: Vec::new(),
|
||||
xml_clip_ids: HashMap::new(),
|
||||
split_files: HashSet::new(),
|
||||
};
|
||||
for n in root.descendants().filter(|n| n.is_element()) {
|
||||
match n.tag_name().name() {
|
||||
"clipitem" if audio_track_index(n).is_some_and(|i| i > 1) => {
|
||||
if let Some(key) = clip_seq_file(n) {
|
||||
imp.split_files.insert(key);
|
||||
}
|
||||
}
|
||||
"file" if elements(n).next().is_some() => {
|
||||
imp.files.entry(node_key(n)).or_insert(n);
|
||||
}
|
||||
|
|
@ -482,6 +506,17 @@ impl<'a, 'i> Imp<'a, 'i, '_> {
|
|||
if let Some(l) = path_text(c, &["labels", "label2"]).and_then(label_from) {
|
||||
ti.label = l;
|
||||
}
|
||||
// `sourcetrack/trackindex` (1-based) picks the source channel of a mono track split off a multichannel file (#463).
|
||||
// Index 1 is also what a stereo clip on a stereo track carries, so it means channel 0 only when another clip
|
||||
// item of the same file in this sequence uses a higher index (the file is split); otherwise it keeps the
|
||||
// default (empty) channel mapping.
|
||||
if kind == TrackKind::Audio
|
||||
&& let Some(idx) = audio_track_index(c)
|
||||
&& (idx > 1 || (idx == 1 && clip_seq_file(c).is_some_and(|k| self.split_files.contains(&k))))
|
||||
&& let Ok(ch) = u16::try_from(idx - 1)
|
||||
{
|
||||
ti.source_channels = vec![ch];
|
||||
}
|
||||
if let Some(mode) = child_text(c, "compositemode") {
|
||||
match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) {
|
||||
Some((_, v)) if *v != 0 => set_param(&mut ti, "opacity", "blend", Param::new(ParamValue::Choice(*v))),
|
||||
|
|
@ -962,7 +997,7 @@ impl Exp<'_, '_> {
|
|||
if kind == TrackKind::Audio {
|
||||
self.w.open("sourcetrack", &[]);
|
||||
self.w.text("mediatype", "audio");
|
||||
self.w.text("trackindex", 1);
|
||||
self.w.text("trackindex", c.source_channels.first().map_or(1, |ch| u32::from(*ch).saturating_add(1)));
|
||||
self.w.close();
|
||||
}
|
||||
if let Some(blend) = param(c, "opacity", "blend").and_then(|p| p.value.as_f64()).map(|v| v as u32).filter(|v| *v != 0) {
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option<BinId>)
|
|||
it.created = it.id.0;
|
||||
match &mut it.kind {
|
||||
ItemKind::Sequence(seq) => {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for m in &mut seq.markers {
|
||||
m.id = MarkerId(target.alloc_id());
|
||||
}
|
||||
|
|
@ -510,6 +511,7 @@ pub fn rebase_source_timecode(project: &mut Project, item: ItemId, start_tc_fram
|
|||
}
|
||||
for it in project.items.values_mut() {
|
||||
if let ItemKind::Sequence(seq) = &mut it.kind {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for t in seq.all_tracks_mut() {
|
||||
for ti in t.items.iter_mut().filter(|ti| ti.item == item) {
|
||||
ti.source_in -= off;
|
||||
|
|
|
|||
|
|
@ -527,3 +527,76 @@ fn sub_frame_clips_keep_equal_source_and_record_spans() {
|
|||
assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #463: a stereo file split onto two mono tracks (A1 = channel 1, A2 = channel 2) lost the right channel,
|
||||
/// because every audio clip item was written with `sourcetrack/trackindex` 1 and the importer ignored it.
|
||||
#[test]
|
||||
fn split_mono_tracks_keep_their_source_channel() {
|
||||
let r = FrameRate::FPS_23_976;
|
||||
let mut p = Project::new("P");
|
||||
let a = media(&mut p, "/m/a.wav", false, true, r);
|
||||
let s = sequence(&mut p, "t", r, false);
|
||||
let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 24, 0);
|
||||
let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0, 24, 0);
|
||||
for (id, ch) in [(left, 0u16), (right, 1u16)] {
|
||||
let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap();
|
||||
it.source_channels = vec![ch];
|
||||
}
|
||||
let (imp, xml, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default());
|
||||
assert!(xml.contains("<trackindex>2</trackindex>"), "{xml}");
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let channels = |t: usize| seq.tracks(TrackKind::Audio)[t].items.iter().map(|i| i.source_channels.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(channels(0), vec![vec![0u16]], "{xml}");
|
||||
assert_eq!(channels(1), vec![vec![1u16]], "{xml}");
|
||||
let (bytes, _) = export(&imp.project, only_seq(&imp), Format::Fcp7Xml, &ExportOptions::default()).expect("export");
|
||||
assert!(String::from_utf8(bytes).unwrap().contains("<trackindex>2</trackindex>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sourcetrack_trackindex_2_imports_as_source_channel_1() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio><track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track></audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[0].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
||||
/// #463: Premiere writes a stereo file split onto two mono tracks as clip items with `trackindex` 1 and 2;
|
||||
/// a file used only with `trackindex` 1 is a stereo clip and keeps the default (all channels) mapping.
|
||||
#[test]
|
||||
fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE xmeml>
|
||||
<xmeml version="4"><sequence id="sequence-1"><name>S</name><duration>48</duration>{RATE}
|
||||
<media><audio>
|
||||
<track><clipitem id="clipitem-1"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"><name>take.wav</name><pathurl>file://localhost/media/take.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem>
|
||||
<clipitem id="clipitem-3"><name>other</name>{RATE}<start>48</start><end>96</end><in>0</in><out>48</out>
|
||||
<file id="file-2"><name>other.wav</name><pathurl>file://localhost/media/other.wav</pathurl>{RATE}<duration>48</duration><media><audio><channelcount>2</channelcount></audio></media></file>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>1</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
<track><clipitem id="clipitem-2"><name>take</name>{RATE}<start>0</start><end>48</end><in>0</in><out>48</out>
|
||||
<file id="file-1"/>
|
||||
<sourcetrack><mediatype>audio</mediatype><trackindex>2</trackindex></sourcetrack>
|
||||
</clipitem></track>
|
||||
</audio></media>
|
||||
</sequence></xmeml>"#
|
||||
);
|
||||
let (imp, _) = import(doc.as_bytes(), Format::Fcp7Xml, None).unwrap();
|
||||
let seq = imp.project.sequence(only_seq(&imp)).unwrap();
|
||||
let a1 = &seq.tracks(TrackKind::Audio)[0].items;
|
||||
assert_eq!(a1[0].source_channels, vec![0u16]);
|
||||
assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels);
|
||||
assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,8 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor
|
|||
| anything else | `Unknown { fourcc, raw }` |
|
||||
|
||||
Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into
|
||||
`VideoParams`. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`.
|
||||
`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture
|
||||
before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`.
|
||||
|
||||
### Robustness
|
||||
The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "filmcraft-platform"
|
||||
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders"
|
||||
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API and NVDEC on Linux), with transparent fallback to the pure-Rust decoders"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -50,6 +50,7 @@ windows = { version = "0.62", features = [
|
|||
"Win32_Media_MediaFoundation",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# Linux: libva (VA-API) is loaded at run time with libloading (ISC; already in the lockfile through
|
||||
|
|
|
|||
|
|
@ -65,15 +65,31 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
|
|||
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
|
||||
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
|
||||
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
|
||||
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
|
||||
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
|
||||
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go
|
||||
in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709
|
||||
limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver
|
||||
that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on
|
||||
the encode thread, where it competed with the render of the next frames for the thread pool and
|
||||
held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
|
||||
for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe
|
||||
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
|
||||
It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems
|
||||
without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export
|
||||
ends it with an error, since a hardware stream cannot be finished in software. The same session,
|
||||
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
|
||||
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
|
||||
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
|
||||
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
|
||||
run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the
|
||||
stream as Annex B access units, so there is no host-side DPB: `cuvid::Session` creates the decoder
|
||||
in the parser's sequence callback, decodes in its picture callback, and reads each displayed
|
||||
picture back (NV12 / P016) through the CUDA driver into the same planes as the other backends.
|
||||
The factory goes in front of VA-API's: what NVDEC declines still reaches VA-API (another GPU),
|
||||
then the software decoders. With both registered, `perf.stats` names NVDEC as the hardware
|
||||
backend, also for the streams VA-API decodes. The callback state is a `Box::into_raw` allocation
|
||||
the parser and `Session` reach through the same pointer, freed in `Drop`. After a start at a CRA picture its RASL pictures are not fed (no
|
||||
decoder outputs them, and the parser would otherwise show the CRA picture with the last one's
|
||||
timestamp). A seek recreates the parser and keeps the decoder.
|
||||
- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained
|
||||
Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2`
|
||||
are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens
|
||||
|
|
@ -281,6 +297,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
|
|||
| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
|
||||
| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames |
|
||||
| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC |
|
||||
| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors |
|
||||
| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) |
|
||||
| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe |
|
||||
| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight |
|
||||
|
|
@ -288,6 +305,8 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
|
|||
| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
|
||||
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
|
||||
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
|
||||
| `tests/nvdec.rs` (Linux, NVIDIA driver) | H.264 High, HEVC Main (open GOP: CRA + RASL) and Main 10 at 640x360, H.264 and HEVC Main 10 at 1080p: bit-exact with the software decoders over the whole stream, after `reset` + reseek to every sync sample and again after the resets; a forced mid-stream failure continues in software with identical output; interlaced and 10-bit H.264 and 4:2:2 HEVC are declined, as is everything while Hardware decoding is Off; damaged samples (bit flips, truncation, corrupt lengths) never crash or hang |
|
||||
| `src/nvdec/abi_tests.rs` | FFI structs' sizes, alignments, field offsets and constants against a C compiler's view of NVIDIA's `cuviddec.h` / `nvcuvid.h` (nv-codec-headers) |
|
||||
| `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads |
|
||||
| `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors |
|
||||
| `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
//! The mouse position in screen pixels, for when the windowing layer has none.
|
||||
//!
|
||||
//! While files are dragged in from the OS file manager, winit on Windows delivers no cursor events
|
||||
//! (it only reports hovered and dropped files), so the UI cannot learn where a drop landed.
|
||||
//!
|
||||
//! This is the one non-media OS call in this crate: `crates/platform` is the only crate allowed
|
||||
//! `unsafe` (AGENTS.md §0.3, ADR 0001), so the single `GetCursorPos` call lives here rather than
|
||||
//! in the UI. It is read-only, has a safe `Option`-returning API, and every other platform (and a
|
||||
//! failed call) falls back to the last pointer position egui saw. Drop it once winit reports the
|
||||
//! pointer position during an OS drag-and-drop.
|
||||
|
||||
/// The cursor's position on the virtual screen in physical pixels, or `None` where the OS cannot
|
||||
/// say or this platform's windowing layer already reports it during a drag.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn cursor_screen_position() -> Option<(i32, i32)> {
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||
let mut p = POINT { x: 0, y: 0 };
|
||||
// SAFETY: `p` is a valid, writable POINT for the duration of the call.
|
||||
unsafe { GetCursorPos(&mut p) }.ok()?;
|
||||
Some((p.x, p.y))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn cursor_screen_position() -> Option<(i32, i32)> {
|
||||
None
|
||||
}
|
||||
|
|
@ -129,7 +129,10 @@ impl HybridDecoder {
|
|||
}
|
||||
|
||||
/// Switch to the software decoder after `err`, replaying the log (which ends with the sample
|
||||
/// that failed).
|
||||
/// that failed). The replay goes on past samples the software decoder rejects, as decoding in
|
||||
/// software from the restart point would: one that the hardware had already decoded is logged
|
||||
/// (its call returned long ago), and if the failed sample itself is rejected its error is this
|
||||
/// call's, while the pictures replayed before it are kept for the next output.
|
||||
fn fall_back(&mut self, err: CodecError) -> Result<Vec<DecodedFrame>> {
|
||||
let Some(mut hw) = self.hw.take() else { return Err(err) };
|
||||
log::warn!("{} failed ({err}); continuing with the software decoder", hw.name());
|
||||
|
|
@ -149,12 +152,28 @@ impl HybridDecoder {
|
|||
return Err(CodecError::Decode(format!("{err} (continuing in software after the next seek)")));
|
||||
};
|
||||
let mut frames = Vec::new();
|
||||
for (s, p) in &log {
|
||||
let mut rejected = None;
|
||||
let last = log.len().saturating_sub(1);
|
||||
for (i, (s, p)) in log.iter().enumerate() {
|
||||
let Some(sw) = self.sw.as_mut() else { break };
|
||||
frames.extend(sw.decode(s, *p)?);
|
||||
match sw.decode(s, *p) {
|
||||
Ok(out) => frames.extend(out),
|
||||
Err(e) if i == last => rejected = Some(e),
|
||||
Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"),
|
||||
}
|
||||
}
|
||||
self.log = Some(log);
|
||||
Ok(self.merge(frames))
|
||||
match rejected {
|
||||
None => Ok(self.merge(frames)),
|
||||
Some(e) => {
|
||||
for f in frames {
|
||||
if !self.emitted.contains(&f.pts) {
|
||||
self.carry.insert(f.pts, f);
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
//! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders
|
||||
//! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264
|
||||
//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; Media Foundation / DXVA on
|
||||
//! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API
|
||||
//! driver) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
|
||||
//! Windows; NVDEC ([`nvdec`], NVIDIA's driver) and VA-API ([`vaapi`]) on Linux for H.264 and HEVC; on other systems (and on
|
||||
//! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
|
||||
//! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an
|
||||
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on
|
||||
//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows
|
||||
|
|
@ -34,11 +34,14 @@
|
|||
mod annexb;
|
||||
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
|
||||
mod biplanar;
|
||||
pub mod cursor;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod hardware_encode;
|
||||
pub mod hybrid;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod media_foundation;
|
||||
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
|
||||
pub mod nvdec;
|
||||
#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
|
||||
pub mod nvenc;
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
|
@ -101,9 +104,9 @@ pub fn register() -> Availability {
|
|||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
match vaapi::va::probe() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
let mut available = match vaapi::va::probe() {
|
||||
Ok(driver) => {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}"));
|
||||
filmcraft_codecs::register_video_decoder(vaapi_factory);
|
||||
filmcraft_codecs::hw::set_hw_backend("VA-API");
|
||||
|
|
@ -111,9 +114,23 @@ pub fn register() -> Availability {
|
|||
}
|
||||
Err(why) => {
|
||||
log::info!("no VA-API hardware decoding: {why}");
|
||||
Availability::Unavailable("no VA-API driver (libva and a DRM render node)")
|
||||
Availability::Unavailable("no VA-API driver (libva and a DRM render node) and no NVIDIA driver (NVDEC)")
|
||||
}
|
||||
};
|
||||
// NVIDIA's proprietary driver has no VA-API of its own: NVDEC goes in front, and what it
|
||||
// declines still reaches VA-API (a second GPU) and then the software decoders
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
match nvdec::cuvid::probe() {
|
||||
Ok(driver) => {
|
||||
static NVDEC: std::sync::Once = std::sync::Once::new();
|
||||
NVDEC.call_once(|| log::info!("hardware decoding through NVDEC: {driver}"));
|
||||
filmcraft_codecs::register_video_decoder(nvdec_factory);
|
||||
filmcraft_codecs::hw::set_hw_backend("NVDEC");
|
||||
available = Availability::Available("NVDEC");
|
||||
}
|
||||
Err(why) => log::info!("no NVDEC hardware decoding: {why}"),
|
||||
}
|
||||
available
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
|
|
@ -133,6 +150,10 @@ pub fn registered() -> bool {
|
|||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
if filmcraft_codecs::video_decoder_registered(nvdec_factory) {
|
||||
return true;
|
||||
}
|
||||
filmcraft_codecs::video_decoder_registered(vaapi_factory)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
|
|
@ -154,7 +175,13 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
|
|||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok())
|
||||
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| {
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
if nvdec::NvDecoder::new(info.clone()).is_ok() {
|
||||
return true;
|
||||
}
|
||||
vaapi::VaDecoder::new(info).is_ok()
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
|
|
@ -218,3 +245,24 @@ pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The NVDEC factory: a [`HybridDecoder`] around [`nvdec::NvDecoder`] for H.264 and HEVC streams
|
||||
/// this system's NVIDIA GPU decodes, `None` otherwise (VA-API and the software decoders keep those).
|
||||
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
|
||||
pub fn nvdec_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft_codecs::Result<Box<dyn filmcraft_codecs::VideoDecoder>>> {
|
||||
if !filmcraft_codecs::hw::hardware_decoding() {
|
||||
return None;
|
||||
}
|
||||
let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?;
|
||||
match nvdec::NvDecoder::new(info.clone()) {
|
||||
Ok(nv) => Some(Ok(Box::new(HybridDecoder::new(Box::new(nv), entry.clone(), info)))),
|
||||
Err(why) => {
|
||||
log::info!("NVDEC declined {} video: {why}", entry.codec.name());
|
||||
// with VA-API behind this factory, that one counts the decline (or takes the stream)
|
||||
if !filmcraft_codecs::video_decoder_registered(vaapi_factory) {
|
||||
filmcraft_codecs::hw::note_hw_declined();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
//! Linux LP64 layouts generated with GCC from the installed MIT-licensed NVIDIA
|
||||
//! `dynlink_cuda.h`, `dynlink_cuviddec.h` and `dynlink_nvcuvid.h` (NVDEC API 13.1).
|
||||
//! Regenerate with a C program printing sizeof, _Alignof, offsetof for every field
|
||||
//! below, constant values, and the video-signal byte with each bit-field set alone.
|
||||
use super::ffi::*;
|
||||
use std::mem::{align_of, offset_of, size_of};
|
||||
#[test]
|
||||
fn layouts_match_the_header() {
|
||||
assert_eq!(offset_of!(CUVIDPICPARAMS, nBitstreamDataLen), 24, "CUVIDPICPARAMS.nBitstreamDataLen");
|
||||
assert_eq!(offset_of!(CUVIDPICPARAMS, pBitstreamData), 32, "CUVIDPICPARAMS.pBitstreamData");
|
||||
assert_eq!(offset_of!(CUVIDPICPARAMS, nNumSlices), 40, "CUVIDPICPARAMS.nNumSlices");
|
||||
assert_eq!(offset_of!(CUVIDPICPARAMS, pSliceDataOffsets), 48, "CUVIDPICPARAMS.pSliceDataOffsets");
|
||||
assert_eq!((size_of::<CUVIDEOFORMAT>(), align_of::<CUVIDEOFORMAT>()), (64, 4));
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, codec), 0);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, frame_rate), 4);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, progressive_sequence), 12);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_luma_minus8), 13);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_chroma_minus8), 14);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, min_num_decode_surfaces), 15);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, coded_width), 16);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, coded_height), 20);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, display_area), 24);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, chroma_format), 40);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, bitrate), 44);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, display_aspect_ratio), 48);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, video_signal_description), 56);
|
||||
assert_eq!(offset_of!(CUVIDEOFORMAT, seqhdr_data_length), 60);
|
||||
assert_eq!((size_of::<CUVIDDECODECAPS>(), align_of::<CUVIDDECODECAPS>()), (88, 4));
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, eCodecType), 0);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, eChromaFormat), 4);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nBitDepthMinus8), 8);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved1), 12);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsSupported), 24);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nNumNVDECs), 25);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nOutputFormatMask), 26);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxWidth), 28);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHeight), 32);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxMBCount), 36);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMinWidth), 40);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMinHeight), 42);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsHistogramSupported), 44);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nCounterBitDepth), 45);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHistogramBins), 46);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsDecodeStatsSupported), 48);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved4), 49);
|
||||
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved3), 52);
|
||||
assert_eq!((size_of::<CUVIDDECODECREATEINFO>(), align_of::<CUVIDDECODECREATEINFO>()), (176, 8));
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulWidth), 0);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulHeight), 8);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumDecodeSurfaces), 16);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, CodecType), 24);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ChromaFormat), 28);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulCreationFlags), 32);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, bitDepthMinus8), 40);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulIntraDecodeOnly), 48);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxWidth), 56);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxHeight), 64);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved1), 72);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, display_area), 80);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, OutputFormat), 88);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, DeinterlaceMode), 92);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetWidth), 96);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetHeight), 104);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumOutputSurfaces), 112);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, vidLock), 120);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, target_rect), 128);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableHistogram), 136);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableDecodeFeatures), 144);
|
||||
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved2), 152);
|
||||
assert_eq!((size_of::<CUVIDPARSERPARAMS>(), align_of::<CUVIDPARSERPARAMS>()), (136, 8));
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, CodecType), 0);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxNumDecodeSurfaces), 4);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulClockRate), 8);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulErrorThreshold), 12);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxDisplayDelay), 16);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, flags), 20);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, uReserved1), 24);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pUserData), 40);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnSequenceCallback), 48);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDecodePicture), 56);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDisplayPicture), 64);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetOperatingPoint), 72);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetSEIMsg), 80);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pvReserved2), 88);
|
||||
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pExtVideoInfo), 128);
|
||||
assert_eq!((size_of::<CUVIDSOURCEDATAPACKET>(), align_of::<CUVIDSOURCEDATAPACKET>()), (32, 8));
|
||||
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, flags), 0);
|
||||
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload_size), 8);
|
||||
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload), 16);
|
||||
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, timestamp), 24);
|
||||
assert_eq!((size_of::<CUVIDPARSERDISPINFO>(), align_of::<CUVIDPARSERDISPINFO>()), (24, 8));
|
||||
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, picture_index), 0);
|
||||
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, progressive_frame), 4);
|
||||
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, top_field_first), 8);
|
||||
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, repeat_first_field), 12);
|
||||
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, timestamp), 16);
|
||||
assert_eq!((size_of::<CUVIDPROCPARAMS>(), align_of::<CUVIDPROCPARAMS>()), (264, 8));
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, progressive_frame), 0);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, second_field), 4);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, top_field_first), 8);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, unpaired_field), 12);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_flags), 16);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_zero), 20);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_dptr), 24);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_pitch), 32);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_format), 36);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_dptr), 40);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_pitch), 48);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved1), 52);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, output_stream), 56);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved), 64);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, histogram_dptr), 248);
|
||||
assert_eq!(offset_of!(CUVIDPROCPARAMS, pCuvidProcExt), 256);
|
||||
assert_eq!((size_of::<FrameRate>(), align_of::<FrameRate>()), (8, 4));
|
||||
assert_eq!((size_of::<Rect>(), align_of::<Rect>()), (16, 4));
|
||||
assert_eq!((size_of::<ShortRect>(), align_of::<ShortRect>()), (8, 2));
|
||||
assert_eq!((size_of::<AspectRatio>(), align_of::<AspectRatio>()), (8, 4));
|
||||
assert_eq!((size_of::<VideoSignal>(), align_of::<VideoSignal>()), (4, 1));
|
||||
}
|
||||
#[test]
|
||||
fn constants_match_the_header() {
|
||||
assert_eq!(cudaVideoCodec_H264 as i64, 4);
|
||||
assert_eq!(cudaVideoCodec_HEVC as i64, 8);
|
||||
assert_eq!(cudaVideoChromaFormat_420 as i64, 1);
|
||||
assert_eq!(cudaVideoSurfaceFormat_NV12 as i64, 0);
|
||||
assert_eq!(cudaVideoSurfaceFormat_P016 as i64, 1);
|
||||
assert_eq!(cudaVideoDeinterlaceMode_Weave as i64, 0);
|
||||
assert_eq!(cudaVideoCreate_PreferCUVID as i64, 4);
|
||||
assert_eq!(CUVID_PKT_ENDOFSTREAM as i64, 1);
|
||||
assert_eq!(CUVID_PKT_TIMESTAMP as i64, 2);
|
||||
assert_eq!(CUVID_PKT_ENDOFPICTURE as i64, 8);
|
||||
assert_eq!(7u8, 7);
|
||||
assert_eq!(1u8 << 3, 8);
|
||||
}
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
//! Synchronous NVDEC parser callbacks and CUDA readback. All driver handles stay here.
|
||||
use std::cell::Cell;
|
||||
use std::ffi::c_void;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::ptr;
|
||||
|
||||
use filmcraft_codecs::hw::NalCodec;
|
||||
use filmcraft_frame::VideoFrame;
|
||||
|
||||
use super::ffi::*;
|
||||
use crate::biplanar::{Biplanar, Geometry, to_frame};
|
||||
use crate::nvenc::device::Device;
|
||||
|
||||
const MAX_SURFACES: u32 = 64;
|
||||
const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;
|
||||
const MAX_PACKET_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_READY_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Api {
|
||||
caps: GetDecoderCaps,
|
||||
create_decoder: CreateDecoder,
|
||||
destroy_decoder: DestroyDecoder,
|
||||
decode: DecodePicture,
|
||||
map: MapVideoFrame,
|
||||
unmap: UnmapVideoFrame,
|
||||
create_parser: CreateVideoParser,
|
||||
parse: ParseVideoData,
|
||||
destroy_parser: DestroyVideoParser,
|
||||
}
|
||||
impl Api {
|
||||
fn load() -> Result<(Self, libloading::Library), String> {
|
||||
// SAFETY: symbols have the signatures in NVIDIA's public headers. The caller
|
||||
// retains the returned library until all handles using these pointers are destroyed.
|
||||
unsafe {
|
||||
let library = libloading::Library::new("libnvcuvid.so.1").map_err(|e| format!("no NVIDIA NVDEC library: {e}"))?;
|
||||
let api = Self {
|
||||
caps: *library.get(b"cuvidGetDecoderCaps\0").map_err(|e| e.to_string())?,
|
||||
create_decoder: *library.get(b"cuvidCreateDecoder\0").map_err(|e| e.to_string())?,
|
||||
destroy_decoder: *library.get(b"cuvidDestroyDecoder\0").map_err(|e| e.to_string())?,
|
||||
decode: *library.get(b"cuvidDecodePicture\0").map_err(|e| e.to_string())?,
|
||||
map: *library.get(b"cuvidMapVideoFrame64\0").map_err(|e| e.to_string())?,
|
||||
unmap: *library.get(b"cuvidUnmapVideoFrame64\0").map_err(|e| e.to_string())?,
|
||||
create_parser: *library.get(b"cuvidCreateVideoParser\0").map_err(|e| e.to_string())?,
|
||||
parse: *library.get(b"cuvidParseVideoData\0").map_err(|e| e.to_string())?,
|
||||
destroy_parser: *library.get(b"cuvidDestroyVideoParser\0").map_err(|e| e.to_string())?,
|
||||
};
|
||||
Ok((api, library))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn status(code: i32, operation: &str) -> Result<(), String> {
|
||||
if code == 0 { Ok(()) } else { Err(format!("{operation} failed ({code})")) }
|
||||
}
|
||||
|
||||
// Only POD structs from ffi.rs are zeroed: no Rust references or invalid enum discriminants.
|
||||
fn caps(codec: i32, bits: u32) -> CUVIDDECODECAPS {
|
||||
// SAFETY: this C struct contains only integers; zero is a valid value for every field.
|
||||
let mut caps: CUVIDDECODECAPS = unsafe { std::mem::zeroed() };
|
||||
caps.eCodecType = codec;
|
||||
caps.eChromaFormat = cudaVideoChromaFormat_420;
|
||||
caps.nBitDepthMinus8 = bits.saturating_sub(8);
|
||||
caps
|
||||
}
|
||||
fn output_format(bits: u32) -> i32 {
|
||||
if bits == 10 { cudaVideoSurfaceFormat_P016 } else { cudaVideoSurfaceFormat_NV12 }
|
||||
}
|
||||
fn validate(coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result<u32, String> {
|
||||
let (w, h) = coded;
|
||||
let (x, y, cw, ch) = geometry.crop;
|
||||
if w == 0 || h == 0 || w > 8192 || h > 8192 || !w.is_multiple_of(2) || !h.is_multiple_of(2) {
|
||||
return Err(format!("NVDEC coded size {w}x{h} is not supported"));
|
||||
}
|
||||
if !matches!(geometry.bits, 8 | 10) || cw == 0 || ch == 0 || x.checked_add(cw).is_none_or(|end| end > w) || y.checked_add(ch).is_none_or(|end| end > h) {
|
||||
return Err("NVDEC picture depth or crop is invalid".into());
|
||||
}
|
||||
let delay = u32::try_from(reorder).map_err(|_| "NVDEC reorder count overflows")?;
|
||||
if delay >= MAX_SURFACES {
|
||||
return Err("NVDEC reorder count is too large".into());
|
||||
}
|
||||
Ok(delay)
|
||||
}
|
||||
fn check_caps(caps: &CUVIDDECODECAPS, coded: (u32, u32), bits: u32) -> Result<(), String> {
|
||||
let (w, h) = coded;
|
||||
let blocks = w.div_ceil(16).checked_mul(h.div_ceil(16)).ok_or("NVDEC macroblock count overflows")?;
|
||||
let mask = 1u16.checked_shl(output_format(bits) as u32).ok_or("NVDEC output format overflows")?;
|
||||
if caps.bIsSupported == 0
|
||||
|| caps.nOutputFormatMask & mask == 0
|
||||
|| w < u32::from(caps.nMinWidth)
|
||||
|| h < u32::from(caps.nMinHeight)
|
||||
|| w > caps.nMaxWidth
|
||||
|| h > caps.nMaxHeight
|
||||
|| blocks > caps.nMaxMBCount
|
||||
{
|
||||
return Err(format!("GPU does not support NVDEC codec {} at {w}x{h}, {bits}-bit 4:2:0", caps.eCodecType));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slices per picture the driver's offset list is read for.
|
||||
const MAX_SLICES: u32 = 8192;
|
||||
|
||||
struct State {
|
||||
api: Api,
|
||||
// Points to Session's separately boxed, immutable Device (stable across moves).
|
||||
device: *const Device,
|
||||
decoder: *mut c_void,
|
||||
codec: i32,
|
||||
coded: (u32, u32),
|
||||
geometry: Geometry,
|
||||
delay: u32,
|
||||
surfaces: u32,
|
||||
mapped: Cell<Option<u64>>,
|
||||
pending: Vec<CUVIDPARSERDISPINFO>,
|
||||
ready: Vec<(i64, VideoFrame)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
impl State {
|
||||
fn sequence(&mut self, format: &CUVIDEOFORMAT) -> Result<i32, String> {
|
||||
// The driver rounds the coded size up to its own block size (HEVC 640x360 comes back as
|
||||
// 640x368): take its size, which is the mapped surface's, when it holds the stream's.
|
||||
let driver = (format.coded_width, format.coded_height);
|
||||
let fits = |d: u32, c: u32| d >= c && d - c < 64;
|
||||
if !(fits(driver.0, self.coded.0) && fits(driver.1, self.coded.1)) || (!self.decoder.is_null() && driver != self.coded) {
|
||||
return Err(format!("NVDEC sequence is {}x{}, the stream {}x{}", driver.0, driver.1, self.coded.0, self.coded.1));
|
||||
}
|
||||
self.coded = driver;
|
||||
if format.codec != self.codec
|
||||
|| format.chroma_format != cudaVideoChromaFormat_420
|
||||
|| format.progressive_sequence != 1
|
||||
|| u32::from(format.bit_depth_luma_minus8).checked_add(8) != Some(self.geometry.bits)
|
||||
|| format.bit_depth_chroma_minus8 != format.bit_depth_luma_minus8
|
||||
{
|
||||
return Err("NVDEC sequence differs from the stream configuration".into());
|
||||
}
|
||||
let surfaces = u32::from(format.min_num_decode_surfaces).max(self.delay.checked_add(1).ok_or("NVDEC surface count overflows")?).max(2);
|
||||
if format.min_num_decode_surfaces == 0 || surfaces > MAX_SURFACES {
|
||||
return Err("NVDEC reported an invalid decode surface count".into());
|
||||
}
|
||||
if !self.decoder.is_null() {
|
||||
if surfaces > self.surfaces {
|
||||
return Err("NVDEC sequence needs more decode surfaces".into());
|
||||
}
|
||||
return i32::try_from(self.surfaces).map_err(|_| "NVDEC surface count overflows".into());
|
||||
}
|
||||
// SAFETY: plain C data (integers and pointers) permits an all-zero initialization.
|
||||
let mut info: CUVIDDECODECREATEINFO = unsafe { std::mem::zeroed() };
|
||||
info.ulWidth = u64::from(self.coded.0);
|
||||
info.ulHeight = u64::from(self.coded.1);
|
||||
info.ulNumDecodeSurfaces = u64::from(surfaces);
|
||||
info.CodecType = self.codec;
|
||||
info.ChromaFormat = cudaVideoChromaFormat_420;
|
||||
info.ulCreationFlags = cudaVideoCreate_PreferCUVID;
|
||||
info.bitDepthMinus8 = u64::from(self.geometry.bits.saturating_sub(8));
|
||||
info.ulMaxWidth = info.ulWidth;
|
||||
info.ulMaxHeight = info.ulHeight;
|
||||
let right = i16::try_from(self.coded.0).map_err(|_| "NVDEC width overflows")?;
|
||||
let bottom = i16::try_from(self.coded.1).map_err(|_| "NVDEC height overflows")?;
|
||||
// Explicit full coded rectangles prevent the parser's display crop being applied twice.
|
||||
info.display_area = ShortRect { left: 0, top: 0, right, bottom };
|
||||
info.target_rect = info.display_area;
|
||||
info.OutputFormat = output_format(self.geometry.bits);
|
||||
info.DeinterlaceMode = cudaVideoDeinterlaceMode_Weave;
|
||||
info.ulTargetWidth = info.ulWidth;
|
||||
info.ulTargetHeight = info.ulHeight;
|
||||
info.ulNumOutputSurfaces = 1;
|
||||
// SAFETY: the context is current throughout the parser call; info and the out-pointer
|
||||
// are live. The decoder is owned by Session until explicitly destroyed.
|
||||
status(unsafe { (self.api.create_decoder)(&mut self.decoder, &mut info) }, "cuvidCreateDecoder")?;
|
||||
if self.decoder.is_null() {
|
||||
return Err("NVDEC returned a null decoder".into());
|
||||
}
|
||||
self.surfaces = surfaces;
|
||||
i32::try_from(surfaces).map_err(|_| "NVDEC surface count overflows".into())
|
||||
}
|
||||
fn display(&mut self, info: &CUVIDPARSERDISPINFO) -> Result<i32, String> {
|
||||
if self.decoder.is_null()
|
||||
|| info.picture_index < 0
|
||||
|| u32::try_from(info.picture_index).is_ok_and(|i| i >= self.surfaces)
|
||||
|| info.progressive_frame != 1
|
||||
|| info.repeat_first_field != 0
|
||||
{
|
||||
return Err("NVDEC reported an invalid or non-progressive display picture".into());
|
||||
}
|
||||
if self.pending.len() >= MAX_SURFACES as usize {
|
||||
return Err("NVDEC display queue exceeds its surface limit".into());
|
||||
}
|
||||
self.pending.try_reserve(1).map_err(|e| format!("NVDEC display allocation failed: {e}"))?;
|
||||
self.pending.push(*info);
|
||||
Ok(1)
|
||||
}
|
||||
fn drain(&mut self) -> Result<(), String> {
|
||||
// Copy queued displays before another decode can recycle their picture indices.
|
||||
for info in std::mem::take(&mut self.pending) {
|
||||
self.map_picture(&info)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn map_picture(&mut self, info: &CUVIDPARSERDISPINFO) -> Result<(), String> {
|
||||
// SAFETY: this C struct contains integers and nullable pointers only.
|
||||
let mut proc: CUVIDPROCPARAMS = unsafe { std::mem::zeroed() };
|
||||
proc.progressive_frame = 1;
|
||||
proc.top_field_first = info.top_field_first;
|
||||
let (mut address, mut pitch) = (0u64, 0u32);
|
||||
// SAFETY: the validated picture index belongs to the live decoder. Both output
|
||||
// pointers and proc are writable, and Session has pushed the context on this thread.
|
||||
status(unsafe { (self.api.map)(self.decoder, info.picture_index, &mut address, &mut pitch, &mut proc) }, "cuvidMapVideoFrame64")?;
|
||||
self.mapped.set(Some(address));
|
||||
let mapped = Mapped { api: self.api, decoder: self.decoder, address, outstanding: &self.mapped, active: true };
|
||||
let frame = self.readback(address, pitch);
|
||||
mapped.unmap()?;
|
||||
let frame = frame?;
|
||||
self.ready.try_reserve(1).map_err(|e| format!("NVDEC output allocation failed: {e}"))?;
|
||||
self.ready.push((info.timestamp, frame));
|
||||
Ok(())
|
||||
}
|
||||
fn readback(&self, address: u64, pitch: u32) -> Result<VideoFrame, String> {
|
||||
if address == 0 {
|
||||
return Err("NVDEC returned a null mapped address".into());
|
||||
}
|
||||
let (luma_size, total) = mapped_layout(self.coded, self.geometry.bits, pitch)?;
|
||||
let picture_bytes = (self.coded.0 as usize)
|
||||
.checked_mul(self.coded.1 as usize)
|
||||
.and_then(|n| n.checked_mul(if self.geometry.bits == 10 { 3 } else { 2 }))
|
||||
.ok_or("NVDEC output size overflows")?;
|
||||
if self.ready.len() >= MAX_SURFACES as usize
|
||||
|| self.ready.len().checked_add(1).and_then(|n| n.checked_mul(picture_bytes)).is_none_or(|n| n > MAX_READY_BYTES)
|
||||
{
|
||||
return Err("NVDEC output queue exceeds its memory limit".into());
|
||||
}
|
||||
address.checked_add(u64::try_from(total).map_err(|_| "NVDEC mapped size overflows")?).ok_or("NVDEC mapped address overflows")?;
|
||||
let mut host = Vec::new();
|
||||
host.try_reserve_exact(total).map_err(|e| format!("NVDEC readback allocation failed: {e}"))?;
|
||||
host.resize(total, 0);
|
||||
// SAFETY: Session's separately boxed device outlives State and every synchronous callback;
|
||||
// it is immutable, and the CUDA context is current. No mutable Device references exist.
|
||||
let device = unsafe { self.device.as_ref() }.ok_or("NVDEC CUDA device is missing")?;
|
||||
device.copy_to_host(address, &mut host)?;
|
||||
let luma = host.get(..luma_size).ok_or("NVDEC luma range is invalid")?;
|
||||
let chroma = host.get(luma_size..).ok_or("NVDEC chroma range is invalid")?;
|
||||
to_frame(&Biplanar { luma, chroma, stride: pitch as usize, rows: self.coded.1 as usize }, &self.geometry)
|
||||
}
|
||||
}
|
||||
fn mapped_layout(coded: (u32, u32), bits: u32, pitch: u32) -> Result<(usize, usize), String> {
|
||||
let row_bytes = coded.0.checked_mul(if bits == 10 { 2 } else { 1 }).ok_or("NVDEC row size overflows")?;
|
||||
if pitch < row_bytes || pitch == 0 || coded.1 == 0 || !coded.1.is_multiple_of(2) || !matches!(bits, 8 | 10) {
|
||||
return Err("NVDEC reported an invalid mapped pitch or size".into());
|
||||
}
|
||||
let luma = (pitch as usize).checked_mul(coded.1 as usize).ok_or("NVDEC luma size overflows")?;
|
||||
let total = luma.checked_add(luma / 2).ok_or("NVDEC mapped size overflows")?;
|
||||
if total > MAX_FRAME_BYTES {
|
||||
return Err("NVDEC mapped frame exceeds its memory limit".into());
|
||||
}
|
||||
Ok((luma, total))
|
||||
}
|
||||
struct Mapped<'a> {
|
||||
api: Api,
|
||||
decoder: *mut c_void,
|
||||
address: u64,
|
||||
outstanding: &'a Cell<Option<u64>>,
|
||||
active: bool,
|
||||
}
|
||||
impl Mapped<'_> {
|
||||
fn unmap(mut self) -> Result<(), String> {
|
||||
self.active = false;
|
||||
// SAFETY: this guard owns one successful mapping on this decoder, and the context is
|
||||
// still current. No GPU-memory references escape the synchronous host copy.
|
||||
status(unsafe { (self.api.unmap)(self.decoder, self.address) }, "cuvidUnmapVideoFrame64")?;
|
||||
self.outstanding.set(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for Mapped<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
// SAFETY: balances a successful map on every early return or caught panic;
|
||||
// the enclosing parser operation retains the current context and driver library.
|
||||
let st = unsafe { (self.api.unmap)(self.decoder, self.address) };
|
||||
match status(st, "cuvidUnmapVideoFrame64") {
|
||||
Ok(()) => self.outstanding.set(None),
|
||||
Err(e) => log::warn!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback state is exclusively accessed by synchronous callbacks while parse is running.
|
||||
unsafe fn callback(data: *mut c_void, f: impl FnOnce(&mut State) -> Result<i32, String>) -> i32 {
|
||||
// SAFETY: cuvid receives the stable, leaked State pointer at parser creation; callbacks run
|
||||
// synchronously, without a live Rust reference to State in the enclosing parse call.
|
||||
let Some(state) = (unsafe { data.cast::<State>().as_mut() }) else { return 0 };
|
||||
if state.error.is_some() {
|
||||
return 0;
|
||||
}
|
||||
match catch_unwind(AssertUnwindSafe(|| f(state))) {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
state.error = Some(error);
|
||||
0
|
||||
}
|
||||
Err(_) => {
|
||||
state.error = Some("NVDEC callback panicked".into());
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe extern "C" fn sequence(data: *mut c_void, format: *mut CUVIDEOFORMAT) -> i32 {
|
||||
// SAFETY: cuvid invokes this callback with its synchronous format pointer and our boxed state.
|
||||
unsafe { callback(data, |state| state.sequence(format.as_ref().ok_or("NVDEC sequence pointer is null")?)) }
|
||||
}
|
||||
unsafe extern "C" fn decode(data: *mut c_void, picture: *mut CUVIDPICPARAMS) -> i32 {
|
||||
// SAFETY: the picture parameters belong to cuvid for the callback's duration: their shared
|
||||
// head is read (the slice offset list for at most `MAX_SLICES` entries, as the parser sized
|
||||
// it), and they are passed through to the live decoder with the context current.
|
||||
unsafe {
|
||||
callback(data, |state| {
|
||||
if state.decoder.is_null() || picture.is_null() {
|
||||
return Err("NVDEC decode pointer or decoder is null".into());
|
||||
}
|
||||
// A picture without bitstream data, or with slices past the end of it, is an error
|
||||
// here and never reaches the driver.
|
||||
let p = &*picture;
|
||||
let offsets = if p.pSliceDataOffsets.is_null() || p.nNumSlices > MAX_SLICES {
|
||||
&[][..]
|
||||
} else {
|
||||
std::slice::from_raw_parts(p.pSliceDataOffsets, p.nNumSlices as usize)
|
||||
};
|
||||
if p.pBitstreamData.is_null() || p.nBitstreamDataLen == 0 || offsets.is_empty() || offsets.iter().any(|&o| o > p.nBitstreamDataLen) {
|
||||
return Err("NVDEC parser returned a picture without valid bitstream data".into());
|
||||
}
|
||||
state.drain()?;
|
||||
status((state.api.decode)(state.decoder, picture), "cuvidDecodePicture")?;
|
||||
Ok(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
unsafe extern "C" fn display(data: *mut c_void, picture: *mut CUVIDPARSERDISPINFO) -> i32 {
|
||||
// SAFETY: cuvid supplies display metadata valid during this synchronous callback;
|
||||
// null is only an EOS notification, which we did not request.
|
||||
unsafe { callback(data, |state| state.display(picture.as_ref().ok_or("NVDEC display pointer is null")?)) }
|
||||
}
|
||||
|
||||
/// A movable session. Each operation pushes its CUDA context on the calling thread.
|
||||
pub struct Session {
|
||||
// From `Box::into_raw` and freed in `Drop`: the parser's callbacks get this same pointer as
|
||||
// their user data, so Rust code reaches the state only through it (never through a `Box`
|
||||
// whose later use would invalidate the pointer the driver holds).
|
||||
state: ptr::NonNull<State>,
|
||||
parser: *mut c_void,
|
||||
device: Box<Device>,
|
||||
// Dropped after device/context release, and after all cuvid handles have been destroyed.
|
||||
_library: libloading::Library,
|
||||
}
|
||||
// SAFETY: no callbacks or GPU-memory references outlive an operation. The state allocation and
|
||||
// boxed device retain stable addresses across moves; every driver call pushes the context on its
|
||||
// calling thread.
|
||||
unsafe impl Send for Session {}
|
||||
impl Session {
|
||||
pub fn new(codec: NalCodec, coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result<Self, String> {
|
||||
let delay = validate(coded, geometry, reorder)?;
|
||||
if matches!(codec, NalCodec::H264) && geometry.bits != 8 {
|
||||
return Err("NVDEC only takes 8-bit H.264".into());
|
||||
}
|
||||
let codec = match codec {
|
||||
NalCodec::H264 => cudaVideoCodec_H264,
|
||||
NalCodec::Hevc => cudaVideoCodec_HEVC,
|
||||
};
|
||||
let (api, library) = Api::load()?;
|
||||
let device = Box::new(Device::new()?);
|
||||
{
|
||||
let _current = device.push_current()?;
|
||||
let mut capabilities = caps(codec, geometry.bits);
|
||||
// SAFETY: capabilities is a live writable C struct, and the CUDA context is current.
|
||||
status(unsafe { (api.caps)(&mut capabilities) }, "cuvidGetDecoderCaps")?;
|
||||
check_caps(&capabilities, coded, geometry.bits)?;
|
||||
}
|
||||
let state = ptr::NonNull::new(Box::into_raw(Box::new(State {
|
||||
api,
|
||||
device: ptr::from_ref(device.as_ref()),
|
||||
decoder: ptr::null_mut(),
|
||||
codec,
|
||||
coded,
|
||||
geometry,
|
||||
delay,
|
||||
surfaces: 0,
|
||||
mapped: Cell::new(None),
|
||||
pending: Vec::new(),
|
||||
ready: Vec::new(),
|
||||
error: None,
|
||||
})))
|
||||
.ok_or("NVDEC state allocation is null")?;
|
||||
// From here on `Drop` frees the state, also when creating the parser fails.
|
||||
let mut session = Self { state, parser: ptr::null_mut(), device, _library: library };
|
||||
session.create_parser()?;
|
||||
Ok(session)
|
||||
}
|
||||
/// The callback state. Never hold the reference across a driver call that can run the
|
||||
/// parser's callbacks (`cuvidParseVideoData`): they reach the state through the same pointer.
|
||||
fn state(&mut self) -> &mut State {
|
||||
// SAFETY: the pointer comes from `Box::into_raw` and only `Drop` frees it, so it is valid and
|
||||
// aligned. `&mut self` makes this the only Rust access; callbacks run only inside
|
||||
// `parse`'s driver call, during which no reference from here is live.
|
||||
unsafe { self.state.as_mut() }
|
||||
}
|
||||
fn create_parser(&mut self) -> Result<(), String> {
|
||||
let (api, codec, delay) = {
|
||||
let state = self.state();
|
||||
(state.api, state.codec, state.delay)
|
||||
};
|
||||
let _current = self.device.push_current()?;
|
||||
// SAFETY: plain C data, nullable callback pointers and opaque pointers allow zeros.
|
||||
let mut params: CUVIDPARSERPARAMS = unsafe { std::mem::zeroed() };
|
||||
params.CodecType = codec;
|
||||
params.ulMaxNumDecodeSurfaces = 1; // sequence callback supplies the real DPB size
|
||||
params.ulClockRate = 0;
|
||||
params.ulMaxDisplayDelay = delay;
|
||||
params.pUserData = self.state.as_ptr().cast();
|
||||
params.pfnSequenceCallback = Some(sequence);
|
||||
params.pfnDecodePicture = Some(decode);
|
||||
params.pfnDisplayPicture = Some(display);
|
||||
// SAFETY: params and output pointer are valid; the state allocation remains alive until
|
||||
// after parser destruction (Drop), and the library is retained by Session.
|
||||
status(unsafe { (api.create_parser)(&mut self.parser, &mut params) }, "cuvidCreateVideoParser")?;
|
||||
if self.parser.is_null() {
|
||||
return Err("NVDEC returned a null parser".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn parse(&mut self, payload: &[u8], pts: i64, flags: u64) -> Result<Vec<(i64, VideoFrame)>, String> {
|
||||
if let Some(error) = &self.state().error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
if self.parser.is_null() {
|
||||
return Err("NVDEC parser is not available".into());
|
||||
}
|
||||
let api = self.state().api;
|
||||
let _current = self.device.push_current()?;
|
||||
let mut packet = CUVIDSOURCEDATAPACKET {
|
||||
flags,
|
||||
payload_size: u64::try_from(payload.len()).map_err(|_| "NVDEC packet length overflows")?,
|
||||
payload: if payload.is_empty() { ptr::null() } else { payload.as_ptr() },
|
||||
timestamp: pts,
|
||||
};
|
||||
// SAFETY: parser is live, payload and packet outlive this synchronous call. No reference
|
||||
// to State is live across it, so callbacks have exclusive access through their pointer.
|
||||
let st = unsafe { (api.parse)(self.parser, &mut packet) };
|
||||
// SAFETY: as in `state()`; the parse call has returned, so no callback runs while this
|
||||
// reference lives (the readback below calls no parser function).
|
||||
let state = unsafe { self.state.as_mut() };
|
||||
if let Some(error) = &state.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
status(st, "cuvidParseVideoData")?;
|
||||
match catch_unwind(AssertUnwindSafe(|| state.drain())) {
|
||||
Ok(result) => result?,
|
||||
Err(_) => return Err("NVDEC readback panicked".into()),
|
||||
}
|
||||
Ok(std::mem::take(&mut state.ready))
|
||||
}
|
||||
pub fn feed(&mut self, annex_b: &[u8], pts: i64) -> Result<Vec<(i64, VideoFrame)>, String> {
|
||||
if annex_b.is_empty() || annex_b.len() > MAX_PACKET_BYTES {
|
||||
return Err("NVDEC access unit is empty or too large".into());
|
||||
}
|
||||
let result = self.parse(annex_b, pts, CUVID_PKT_TIMESTAMP | CUVID_PKT_ENDOFPICTURE);
|
||||
if let Err(error) = &result {
|
||||
self.state().error.get_or_insert_with(|| error.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn flush(&mut self) -> Result<Vec<(i64, VideoFrame)>, String> {
|
||||
let result = self.parse(&[], 0, CUVID_PKT_ENDOFSTREAM);
|
||||
self.restart()?;
|
||||
result
|
||||
}
|
||||
/// Destroy the parser and, unless `keep_decoder`, the decoder (a seek keeps it: creating one
|
||||
/// costs tens of milliseconds, and the next parser's sequence callback takes it as it is).
|
||||
fn destroy(&mut self, keep_decoder: bool) -> Result<(), String> {
|
||||
let _current = self.device.push_current()?;
|
||||
let parser = self.parser;
|
||||
// SAFETY: as in `state()`; while this reference lives only the unmap and destroy calls
|
||||
// run, and they invoke no parser callbacks.
|
||||
let state = unsafe { self.state.as_mut() };
|
||||
let mut error = None;
|
||||
if let Some(address) = state.mapped.get() {
|
||||
// SAFETY: retry an outstanding map before releasing its decoder; context is current.
|
||||
match status(unsafe { (state.api.unmap)(state.decoder, address) }, "cuvidUnmapVideoFrame64") {
|
||||
Ok(()) => state.mapped.set(None),
|
||||
Err(e) => {
|
||||
error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !keep_decoder && !state.decoder.is_null() {
|
||||
// SAFETY: owned decoder, no host views or callbacks remain; unmapping was attempted
|
||||
// first, and context and library remain live while the driver releases its resources.
|
||||
match status(unsafe { (state.api.destroy_decoder)(state.decoder) }, "cuvidDestroyDecoder") {
|
||||
Ok(()) => {
|
||||
state.decoder = ptr::null_mut();
|
||||
state.surfaces = 0;
|
||||
state.mapped.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !parser.is_null() {
|
||||
// SAFETY: owned parser, no parse call running; user data and library are still live.
|
||||
match status(unsafe { (state.api.destroy_parser)(parser) }, "cuvidDestroyVideoParser") {
|
||||
Ok(()) => self.parser = ptr::null_mut(),
|
||||
Err(e) => {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
match error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
fn restart(&mut self) -> Result<(), String> {
|
||||
let state = self.state();
|
||||
state.ready.clear();
|
||||
state.pending.clear();
|
||||
state.error = None;
|
||||
let result = self.destroy(true).and_then(|()| self.create_parser());
|
||||
if let Err(error) = &result {
|
||||
self.state().error = Some(error.clone());
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
if let Err(e) = self.restart() {
|
||||
log::warn!("NVDEC reset failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.destroy(false) {
|
||||
log::warn!("NVDEC cleanup failed: {e}");
|
||||
}
|
||||
// A parser that failed to be destroyed is leaked rather than left pointing at freed state.
|
||||
if self.parser.is_null() {
|
||||
// SAFETY: the pointer came from `Box::into_raw` in `new`, is freed only here, and no parser
|
||||
// holds it any more (destroyed above), so nothing can reach it afterwards.
|
||||
drop(unsafe { Box::from_raw(self.state.as_ptr()) });
|
||||
} else {
|
||||
log::warn!("NVDEC parser could not be destroyed: leaking its state");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Load both drivers and retain a primary context; does not create a decoder or parse video.
|
||||
pub fn probe() -> Result<String, String> {
|
||||
let (_api, _library) = Api::load()?;
|
||||
let device = Device::new()?;
|
||||
let _current = device.push_current()?;
|
||||
Ok("NVDEC (libnvcuvid, first CUDA device)".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn geometry() -> Geometry {
|
||||
Geometry { crop: (0, 0, 640, 360), bits: 8, color: filmcraft_color::ColorInfo::REC709, par: (1, 1) }
|
||||
}
|
||||
#[test]
|
||||
fn hostile_geometry_and_driver_sizes_are_errors() {
|
||||
for size in [(0, 0), (u32::MAX, 368), (640, u32::MAX), (641, 368)] {
|
||||
assert!(catch_unwind(|| validate(size, geometry(), 2)).unwrap().is_err());
|
||||
}
|
||||
let mut g = geometry();
|
||||
g.crop = (u32::MAX, 0, 640, 360);
|
||||
assert!(validate((640, 368), g, 2).is_err());
|
||||
assert!(validate((640, 368), geometry(), usize::MAX).is_err());
|
||||
assert_eq!(validate((640, 368), geometry(), 2).unwrap(), 2);
|
||||
for (size, bits, pitch) in [((640, 368), 8, 639), ((640, 368), 10, 1279), ((640, 368), 8, u32::MAX), ((640, u32::MAX), 10, 1280)] {
|
||||
assert!(catch_unwind(|| mapped_layout(size, bits, pitch)).unwrap().is_err());
|
||||
}
|
||||
assert_eq!(mapped_layout((640, 368), 8, 640).unwrap(), (235520, 353280));
|
||||
assert_eq!(mapped_layout((640, 368), 10, 1280).unwrap(), (471040, 706560));
|
||||
}
|
||||
#[test]
|
||||
fn capabilities_decline_unsupported_streams() {
|
||||
let mut c = caps(cudaVideoCodec_HEVC, 10);
|
||||
assert!(check_caps(&c, (640, 368), 10).is_err());
|
||||
c.bIsSupported = 1;
|
||||
c.nOutputFormatMask = 2;
|
||||
c.nMaxWidth = 8192;
|
||||
c.nMaxHeight = 8192;
|
||||
c.nMaxMBCount = 65536;
|
||||
assert!(check_caps(&c, (640, 368), 10).is_ok());
|
||||
assert!(check_caps(&c, (640, 368), 8).is_err());
|
||||
c.nMaxMBCount = 1;
|
||||
assert!(check_caps(&c, (640, 368), 10).is_err());
|
||||
c.nMinWidth = 1000;
|
||||
assert!(check_caps(&c, (640, 368), 10).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn session_is_send() {
|
||||
fn is_send<T: Send>() {}
|
||||
is_send::<Session>();
|
||||
}
|
||||
|
||||
// These stubs must never be called: the callback tests stop before driver access.
|
||||
unsafe extern "C" fn test_caps(_: *mut CUVIDDECODECAPS) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_create_decoder(_: *mut *mut c_void, _: *mut CUVIDDECODECREATEINFO) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_destroy(_: *mut c_void) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_decode(_: *mut c_void, _: *mut CUVIDPICPARAMS) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_map(_: *mut c_void, _: i32, _: *mut u64, _: *mut u32, _: *mut CUVIDPROCPARAMS) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_unmap(_: *mut c_void, _: u64) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_create_parser(_: *mut *mut c_void, _: *mut CUVIDPARSERPARAMS) -> i32 {
|
||||
999
|
||||
}
|
||||
unsafe extern "C" fn test_parse(_: *mut c_void, _: *mut CUVIDSOURCEDATAPACKET) -> i32 {
|
||||
999
|
||||
}
|
||||
fn test_state() -> State {
|
||||
State {
|
||||
api: Api {
|
||||
caps: test_caps,
|
||||
create_decoder: test_create_decoder,
|
||||
destroy_decoder: test_destroy,
|
||||
decode: test_decode,
|
||||
map: test_map,
|
||||
unmap: test_unmap,
|
||||
create_parser: test_create_parser,
|
||||
parse: test_parse,
|
||||
destroy_parser: test_destroy,
|
||||
},
|
||||
device: ptr::null(),
|
||||
decoder: ptr::null_mut(),
|
||||
codec: cudaVideoCodec_H264,
|
||||
coded: (640, 368),
|
||||
geometry: geometry(),
|
||||
delay: 2,
|
||||
surfaces: 0,
|
||||
mapped: Cell::new(None),
|
||||
pending: Vec::new(),
|
||||
ready: Vec::new(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn callback_panics_and_hostile_metadata_do_not_escape() {
|
||||
let mut state = test_state();
|
||||
let data = ptr::from_mut(&mut state).cast();
|
||||
// SAFETY: data points to exclusive live test state; the closure touches no driver.
|
||||
assert_eq!(unsafe { callback(data, |_| panic!("synthetic callback panic")) }, 0);
|
||||
assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked"));
|
||||
// SAFETY: same live state. A recorded failure prevents the second closure running.
|
||||
assert_eq!(unsafe { callback(data, |_| Err("later error".into())) }, 0);
|
||||
assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked"));
|
||||
state.error = None;
|
||||
let data = ptr::from_mut(&mut state).cast();
|
||||
// SAFETY: valid test user data; null metadata is deliberately tested and never dereferenced.
|
||||
assert_eq!(unsafe { sequence(data, ptr::null_mut()) }, 0);
|
||||
assert_eq!(state.error.as_deref(), Some("NVDEC sequence pointer is null"));
|
||||
// SAFETY: C may report null user data; our callback rejects it before dereferencing.
|
||||
assert_eq!(unsafe { display(ptr::null_mut(), ptr::null_mut()) }, 0);
|
||||
// SAFETY: POD C metadata can be initialized to zeros for these hostile-data tests.
|
||||
let mut format: CUVIDEOFORMAT = unsafe { std::mem::zeroed() };
|
||||
format.codec = state.codec;
|
||||
format.coded_width = 640;
|
||||
format.coded_height = 368;
|
||||
format.chroma_format = cudaVideoChromaFormat_420;
|
||||
format.progressive_sequence = 1;
|
||||
for count in [0, 65, u8::MAX] {
|
||||
format.min_num_decode_surfaces = count;
|
||||
assert!(state.sequence(&format).is_err());
|
||||
}
|
||||
// SAFETY: POD display metadata; the null decoder and hostile index fail before mapping.
|
||||
let mut picture: CUVIDPARSERDISPINFO = unsafe { std::mem::zeroed() };
|
||||
state.decoder = ptr::dangling_mut::<c_void>();
|
||||
state.surfaces = 4;
|
||||
picture.progressive_frame = 1;
|
||||
for index in [-1, 4, i32::MAX] {
|
||||
picture.picture_index = index;
|
||||
assert!(state.display(&picture).is_err());
|
||||
}
|
||||
picture.picture_index = 0;
|
||||
picture.timestamp = 17;
|
||||
assert_eq!(state.display(&picture).unwrap(), 1);
|
||||
picture.picture_index = 1;
|
||||
picture.timestamp = -9;
|
||||
assert_eq!(state.display(&picture).unwrap(), 1);
|
||||
assert_eq!(state.pending.iter().map(|p| (p.picture_index, p.timestamp)).collect::<Vec<_>>(), vec![(0, 17), (1, -9)]);
|
||||
}
|
||||
unsafe extern "C" fn test_unmap_ok(_: *mut c_void, _: u64) -> i32 {
|
||||
0
|
||||
}
|
||||
#[test]
|
||||
fn mapping_guard_tracks_cleanup_on_success_failure_and_unwind() {
|
||||
let outstanding = Cell::new(Some(123));
|
||||
let mut api = test_state().api;
|
||||
api.unmap = test_unmap_ok;
|
||||
drop(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true });
|
||||
assert_eq!(outstanding.get(), None);
|
||||
outstanding.set(Some(123));
|
||||
assert!(
|
||||
catch_unwind(AssertUnwindSafe(|| {
|
||||
let _mapped = Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true };
|
||||
panic!("synthetic readback panic");
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(outstanding.get(), None);
|
||||
outstanding.set(Some(123));
|
||||
api.unmap = test_unmap;
|
||||
assert!(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true }.unmap().is_err());
|
||||
assert_eq!(outstanding.get(), Some(123));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
//
|
||||
// Copyright (c) 2010-2026 NVIDIA Corporation
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person
|
||||
// obtaining a copy of this software and associated documentation
|
||||
// files (the "Software"), to deal in the Software without
|
||||
// restriction, including without limitation the rights to use,
|
||||
// copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the software, and to permit persons to whom the
|
||||
// software is furnished to do so, subject to the following
|
||||
// conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be
|
||||
// included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
// OTHER DEALINGS IN THE SOFTWARE.
|
||||
//
|
||||
|
||||
//! NVDEC declarations transcribed from NVIDIA's MIT headers (NVDEC API 13.1).
|
||||
//! Linux LP64 only; C `tcu_ulong` is an unsigned long, not a u32.
|
||||
#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)]
|
||||
use std::ffi::{c_int, c_uint, c_ulong, c_void};
|
||||
pub const cudaVideoCodec_H264: c_int = 4;
|
||||
pub const cudaVideoCodec_HEVC: c_int = 8;
|
||||
pub const cudaVideoChromaFormat_420: c_int = 1;
|
||||
pub const cudaVideoSurfaceFormat_NV12: c_int = 0;
|
||||
pub const cudaVideoSurfaceFormat_P016: c_int = 1;
|
||||
pub const cudaVideoDeinterlaceMode_Weave: c_int = 0;
|
||||
pub const cudaVideoCreate_PreferCUVID: c_ulong = 4;
|
||||
pub const CUVID_PKT_ENDOFSTREAM: c_ulong = 1;
|
||||
pub const CUVID_PKT_TIMESTAMP: c_ulong = 2;
|
||||
pub const CUVID_PKT_ENDOFPICTURE: c_ulong = 8;
|
||||
/// The start of `CUVIDPICPARAMS`: the fields every codec shares and this backend checks. The rest
|
||||
/// (reserved words and the codec-specific union) is only ever passed through by pointer.
|
||||
#[repr(C)]
|
||||
pub struct CUVIDPICPARAMS {
|
||||
pub PicWidthInMbs: c_int,
|
||||
pub FrameHeightInMbs: c_int,
|
||||
pub CurrPicIdx: c_int,
|
||||
pub field_pic_flag: c_int,
|
||||
pub bottom_field_flag: c_int,
|
||||
pub second_field: c_int,
|
||||
pub nBitstreamDataLen: c_uint,
|
||||
pub pBitstreamData: *const u8,
|
||||
pub nNumSlices: c_uint,
|
||||
pub pSliceDataOffsets: *const c_uint,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct FrameRate {
|
||||
pub numerator: u32,
|
||||
pub denominator: u32,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub left: i32,
|
||||
pub top: i32,
|
||||
pub right: i32,
|
||||
pub bottom: i32,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ShortRect {
|
||||
pub left: i16,
|
||||
pub top: i16,
|
||||
pub right: i16,
|
||||
pub bottom: i16,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AspectRatio {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct VideoSignal {
|
||||
pub flags: u8,
|
||||
pub color_primaries: u8,
|
||||
pub transfer_characteristics: u8,
|
||||
pub matrix_coefficients: u8,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDEOFORMAT {
|
||||
pub codec: c_int,
|
||||
pub frame_rate: FrameRate,
|
||||
pub progressive_sequence: u8,
|
||||
pub bit_depth_luma_minus8: u8,
|
||||
pub bit_depth_chroma_minus8: u8,
|
||||
pub min_num_decode_surfaces: u8,
|
||||
pub coded_width: u32,
|
||||
pub coded_height: u32,
|
||||
pub display_area: Rect,
|
||||
pub chroma_format: c_int,
|
||||
pub bitrate: u32,
|
||||
pub display_aspect_ratio: AspectRatio,
|
||||
pub video_signal_description: VideoSignal,
|
||||
pub seqhdr_data_length: u32,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDDECODECAPS {
|
||||
pub eCodecType: c_int,
|
||||
pub eChromaFormat: c_int,
|
||||
pub nBitDepthMinus8: u32,
|
||||
pub reserved1: [u32; 3],
|
||||
pub bIsSupported: u8,
|
||||
pub nNumNVDECs: u8,
|
||||
pub nOutputFormatMask: u16,
|
||||
pub nMaxWidth: u32,
|
||||
pub nMaxHeight: u32,
|
||||
pub nMaxMBCount: u32,
|
||||
pub nMinWidth: u16,
|
||||
pub nMinHeight: u16,
|
||||
pub bIsHistogramSupported: u8,
|
||||
pub nCounterBitDepth: u8,
|
||||
pub nMaxHistogramBins: u16,
|
||||
pub bIsDecodeStatsSupported: u8,
|
||||
pub reserved4: [u8; 3],
|
||||
pub reserved3: [u32; 9],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDDECODECREATEINFO {
|
||||
pub ulWidth: c_ulong,
|
||||
pub ulHeight: c_ulong,
|
||||
pub ulNumDecodeSurfaces: c_ulong,
|
||||
pub CodecType: c_int,
|
||||
pub ChromaFormat: c_int,
|
||||
pub ulCreationFlags: c_ulong,
|
||||
pub bitDepthMinus8: c_ulong,
|
||||
pub ulIntraDecodeOnly: c_ulong,
|
||||
pub ulMaxWidth: c_ulong,
|
||||
pub ulMaxHeight: c_ulong,
|
||||
pub Reserved1: c_ulong,
|
||||
pub display_area: ShortRect,
|
||||
pub OutputFormat: c_int,
|
||||
pub DeinterlaceMode: c_int,
|
||||
pub ulTargetWidth: c_ulong,
|
||||
pub ulTargetHeight: c_ulong,
|
||||
pub ulNumOutputSurfaces: c_ulong,
|
||||
pub vidLock: *mut c_void,
|
||||
pub target_rect: ShortRect,
|
||||
pub enableHistogram: c_ulong,
|
||||
pub enableDecodeFeatures: c_ulong,
|
||||
pub Reserved2: [c_ulong; 3],
|
||||
}
|
||||
pub type SequenceCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDEOFORMAT) -> c_int;
|
||||
pub type DecodeCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int;
|
||||
pub type DisplayCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPARSERDISPINFO) -> c_int;
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDPARSERPARAMS {
|
||||
pub CodecType: c_int,
|
||||
pub ulMaxNumDecodeSurfaces: u32,
|
||||
pub ulClockRate: u32,
|
||||
pub ulErrorThreshold: u32,
|
||||
pub ulMaxDisplayDelay: u32,
|
||||
pub flags: u32,
|
||||
pub uReserved1: [u32; 4],
|
||||
pub pUserData: *mut c_void,
|
||||
pub pfnSequenceCallback: Option<SequenceCallback>,
|
||||
pub pfnDecodePicture: Option<DecodeCallback>,
|
||||
pub pfnDisplayPicture: Option<DisplayCallback>,
|
||||
pub pfnGetOperatingPoint: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int>,
|
||||
pub pfnGetSEIMsg: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int>,
|
||||
pub pvReserved2: [*mut c_void; 5],
|
||||
pub pExtVideoInfo: *mut c_void,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDSOURCEDATAPACKET {
|
||||
pub flags: c_ulong,
|
||||
pub payload_size: c_ulong,
|
||||
pub payload: *const u8,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDPARSERDISPINFO {
|
||||
pub picture_index: c_int,
|
||||
pub progressive_frame: c_int,
|
||||
pub top_field_first: c_int,
|
||||
pub repeat_first_field: c_int,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CUVIDPROCPARAMS {
|
||||
pub progressive_frame: c_int,
|
||||
pub second_field: c_int,
|
||||
pub top_field_first: c_int,
|
||||
pub unpaired_field: c_int,
|
||||
pub reserved_flags: u32,
|
||||
pub reserved_zero: u32,
|
||||
pub raw_input_dptr: u64,
|
||||
pub raw_input_pitch: u32,
|
||||
pub raw_input_format: u32,
|
||||
pub raw_output_dptr: u64,
|
||||
pub raw_output_pitch: u32,
|
||||
pub Reserved1: u32,
|
||||
pub output_stream: *mut c_void,
|
||||
pub Reserved: [u32; 46],
|
||||
pub histogram_dptr: *mut u64,
|
||||
pub pCuvidProcExt: *mut c_void,
|
||||
}
|
||||
pub type GetDecoderCaps = unsafe extern "C" fn(*mut CUVIDDECODECAPS) -> c_int;
|
||||
pub type CreateDecoder = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDDECODECREATEINFO) -> c_int;
|
||||
pub type DestroyDecoder = unsafe extern "C" fn(*mut c_void) -> c_int;
|
||||
pub type DecodePicture = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int;
|
||||
pub type MapVideoFrame = unsafe extern "C" fn(*mut c_void, c_int, *mut u64, *mut u32, *mut CUVIDPROCPARAMS) -> c_int;
|
||||
pub type UnmapVideoFrame = unsafe extern "C" fn(*mut c_void, u64) -> c_int;
|
||||
pub type CreateVideoParser = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDPARSERPARAMS) -> c_int;
|
||||
pub type ParseVideoData = unsafe extern "C" fn(*mut c_void, *mut CUVIDSOURCEDATAPACKET) -> c_int;
|
||||
pub type DestroyVideoParser = unsafe extern "C" fn(*mut c_void) -> c_int;
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! Hardware decoding through NVDEC (Linux, NVIDIA's proprietary driver).
|
||||
//!
|
||||
//! Unlike VA-API, NVDEC brings its own stream parser (`libnvcuvid`): [`cuvid::Session`] is fed
|
||||
//! Annex B access units and returns pictures in presentation order, read back from the GPU through
|
||||
//! the CUDA driver. Both libraries are loaded at run time; without them there is no NVDEC decoder,
|
||||
//! never a failure to start. H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive; everything else
|
||||
//! stays with VA-API or in software.
|
||||
//!
|
||||
//! The FFI is in [`ffi`] (declarations) and [`cuvid`] (calls); the rest of this module is safe code.
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
pub mod cuvid;
|
||||
pub mod ffi;
|
||||
|
||||
#[cfg(test)]
|
||||
mod abi_tests;
|
||||
|
||||
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
|
||||
use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder};
|
||||
|
||||
use crate::annexb::to_annex_b;
|
||||
use crate::biplanar::Geometry;
|
||||
|
||||
/// An NVDEC decoder for one H.264 or HEVC stream.
|
||||
pub struct NvDecoder {
|
||||
session: cuvid::Session,
|
||||
info: NalStreamInfo,
|
||||
name: &'static str,
|
||||
/// Whether the parser has the stream's parameter sets (sent with the first sample after a reset).
|
||||
primed: bool,
|
||||
/// HEVC, after starting at a CRA / BLA picture: its RASL pictures (they refer to pictures
|
||||
/// before it) are not fed until the next random access point.
|
||||
skip_rasl: bool,
|
||||
fed: u64,
|
||||
fail_after: Option<u64>,
|
||||
}
|
||||
|
||||
impl NvDecoder {
|
||||
/// A decoder for the stream `info` describes, or why NVDEC does not take it on this system.
|
||||
pub fn new(info: NalStreamInfo) -> std::result::Result<Self, String> {
|
||||
let bits = info.bit_depth_luma;
|
||||
let taken = match info.codec {
|
||||
NalCodec::H264 => bits == 8,
|
||||
NalCodec::Hevc => matches!(bits, 8 | 10),
|
||||
};
|
||||
if !taken || info.chroma_format_idc != 1 || info.bit_depth_chroma != bits || info.interlaced {
|
||||
return Err(format!(
|
||||
"{} chroma format {} at {bits}/{}-bit is not taken",
|
||||
if info.interlaced { "interlaced" } else { "progressive" },
|
||||
info.chroma_format_idc,
|
||||
info.bit_depth_chroma
|
||||
));
|
||||
}
|
||||
let geometry = Geometry { crop: info.crop, bits, color: info.color, par: info.par };
|
||||
let session = cuvid::Session::new(info.codec, info.coded, geometry, info.reorder)?;
|
||||
let name = match info.codec {
|
||||
NalCodec::H264 => "NVDEC H.264",
|
||||
NalCodec::Hevc => "NVDEC HEVC",
|
||||
};
|
||||
Ok(Self { session, info, name, primed: false, skip_rasl: false, fed: 0, fail_after: None })
|
||||
}
|
||||
|
||||
/// Test hook: fail every decode call after the first `n` (a GPU lost mid-stream).
|
||||
pub fn fail_after(&mut self, n: u64) {
|
||||
self.fail_after = Some(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(out: Vec<(i64, filmcraft_frame::VideoFrame)>) -> Vec<DecodedFrame> {
|
||||
out.into_iter().map(|(pts, frame)| DecodedFrame { pts, frame, draft: false }).collect()
|
||||
}
|
||||
|
||||
impl VideoDecoder for NvDecoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
self.fed += 1;
|
||||
if self.fail_after.is_some_and(|n| self.fed > n) {
|
||||
return Err(CodecError::Decode("NVDEC decoder failed (test hook)".into()));
|
||||
}
|
||||
if self.info.codec == NalCodec::Hevc {
|
||||
let types = self.info.nal_types(sample);
|
||||
if self.info.is_irap(sample) {
|
||||
// IDR pictures (19, 20) have no RASL pictures
|
||||
self.skip_rasl = !self.primed && !types.iter().any(|t| matches!(t, 19 | 20));
|
||||
} else if self.skip_rasl && types.iter().any(|t| matches!(t, 8 | 9)) {
|
||||
// Not output by any decoder (the software one drops them too); the parser drops
|
||||
// them as well, but then shows the CRA picture with the last one's timestamp.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
let data = to_annex_b(&self.info, sample, !self.primed).map_err(CodecError::Decode)?;
|
||||
self.primed = true;
|
||||
self.session.feed(&data, pts).map(frames).map_err(CodecError::Decode)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
// the parser starts over after an end of stream
|
||||
self.primed = false;
|
||||
match self.session.flush() {
|
||||
Ok(out) => frames(out),
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e} while flushing", self.name);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.primed = false;
|
||||
self.session.reset();
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
self.info.is_random_access(sample)
|
||||
}
|
||||
|
||||
fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
self.info.is_disposable(sample)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue