UI.6: refresh shared UI integration against current main
This commit is contained in:
commit
1996723225
|
|
@ -4,9 +4,9 @@
|
|||
# The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release`
|
||||
# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
|
||||
# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
|
||||
# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no
|
||||
# secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are refused,
|
||||
# so the draft-release job (which needs them) is skipped and nothing is published.
|
||||
# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment
|
||||
# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are
|
||||
# refused, so the draft-release job (which needs them) is skipped and nothing is published.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
|
|
@ -231,6 +231,80 @@ jobs:
|
|||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the
|
||||
# hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they
|
||||
# need no riscv64 libraries and fall back to the pure-Rust codecs when absent.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- 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@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$FILMCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
|
|
@ -365,7 +439,7 @@ jobs:
|
|||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ as PhotoCraft does with `cargo xtask corpus`.
|
|||
- [docs/agents.md](docs/agents.md): driving FilmCraft over MCP / the control channel, and the agent work loop
|
||||
- [docs/control-protocol.md](docs/control-protocol.md): control-channel method reference
|
||||
- [ATTRIBUTION.md](ATTRIBUTION.md): asset index
|
||||
- [ROADMAP.md](ROADMAP.md), [docs/target-app-parity.md](docs/target-app-parity.md), [docs/gaps.md](docs/gaps.md): stage, parity with Premiere Pro and the ranked work list (craftrules `standards/progress-docs.md`)
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
|||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
|
||||
| `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`) |
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
|
|||
|
||||
## Start every session here
|
||||
`plan/` is maintainer-local (gitignored, not in the repo). Each step names the public equivalent to use if you don't have it.
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md).
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers). Public: [`ROADMAP.md`](ROADMAP.md), then [`docs/gaps.md`](docs/gaps.md) (ranked shortfalls) and [`docs/roadmap.md`](docs/roadmap.md) (current focus).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README/docs of the crate you touch. Public: [`docs/architecture.md`](docs/architecture.md), [`docs/contributing.md`](docs/contributing.md), [`docs/testing.md`](docs/testing.md). Visual/behaviour reference: `plan/premiere/` (local only, never committed).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7; public summary: [`docs/agents.md`](docs/agents.md) §5–7). Don't stop to ask unless it lists the decision as the user's.
|
||||
|
||||
|
|
|
|||
|
|
@ -1674,8 +1674,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"eframe",
|
||||
|
|
@ -1694,6 +1695,7 @@ dependencies = [
|
|||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
|
|
@ -1704,7 +1706,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1714,7 +1716,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -1722,7 +1724,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1732,14 +1734,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
|
|
@ -1755,7 +1757,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1766,14 +1768,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1786,11 +1788,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
|
|
@ -1811,7 +1813,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
|
|
@ -1845,7 +1847,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1853,7 +1855,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -1863,7 +1865,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
|
|
@ -1876,7 +1878,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -1908,7 +1910,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
|
|
@ -1938,7 +1940,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
|
|
@ -1951,7 +1953,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
|
|
@ -1961,7 +1963,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1969,7 +1971,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -1986,7 +1988,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
|
|
@ -2005,7 +2007,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2015,7 +2017,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2025,7 +2027,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2035,7 +2037,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
|
|
@ -2052,7 +2054,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2063,7 +2065,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
@ -2071,7 +2073,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
|
|
@ -2084,7 +2086,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
|
|
@ -2093,7 +2095,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -2101,28 +2103,28 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
|
|
@ -2148,7 +2150,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
|
|
@ -2162,7 +2164,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
|
|
@ -2172,7 +2174,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
|
|
@ -2192,7 +2194,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
|
|
@ -2201,7 +2203,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -2219,14 +2221,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
|
|
@ -2238,7 +2240,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -2246,7 +2248,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-tts"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
|
|
@ -2260,7 +2262,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-tts-text"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -2268,7 +2270,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"craft-ui",
|
||||
|
|
@ -2306,7 +2308,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
|
|
@ -2315,7 +2317,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -3241,6 +3243,25 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
|
|
@ -4166,6 +4187,16 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
|
|
@ -7311,7 +7342,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -274,20 +274,23 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
|
|||
| [docs/control-protocol.md](docs/control-protocol.md) | Control-channel and MCP reference |
|
||||
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
|
||||
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
|
||||
| [ROADMAP.md](ROADMAP.md) | Honest assessment, what's missing, milestones and estimates |
|
||||
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
|
||||
|
||||
## Status
|
||||
|
||||
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
|
||||
|
||||
We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
|
||||
|
||||
- **Feature checklist: ~87%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft.
|
||||
- **Ready for real work: ~50–60%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS 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)).
|
||||
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **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.
|
||||
|
|
@ -359,6 +362,9 @@ and web backend defaults are unchanged.
|
|||
| Fedora/RHEL/openSUSE | `filmcraft-<ver>-linux-x86_64.rpm` | `filmcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
|
||||
|
||||
RISC-V (riscv64): `filmcraft-<ver>-linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04
|
||||
or Debian 13).
|
||||
|
||||
The tarball installer places FilmCraft, its command-line tool and desktop integration in
|
||||
`~/.local`, without administrator permissions. For all users, run
|
||||
`sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation.
|
||||
|
|
|
|||
247
ROADMAP.md
247
ROADMAP.md
|
|
@ -3,165 +3,113 @@
|
|||
- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths.
|
||||
- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained.
|
||||
|
||||
Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land.
|
||||
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
**Last updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
|
||||
Two numbers, because they answer different questions. The **feature checklist** counts whether
|
||||
Premiere's menu items, effects, panels and formats exist in FilmCraft. **Ready for real work** is
|
||||
our honest estimate of how close FilmCraft is to replacing Premiere for an editor on real
|
||||
projects: depth, correctness, robustness, speed and the pro ecosystem. The gap between the two
|
||||
is the work that matters most now. Agents: read [Where we are lacking](#where-we-are-lacking)
|
||||
before choosing work.
|
||||
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
|
||||
[docs/gaps.md](docs/gaps.md) (the ranked work list: **agents, read it before choosing work**) and
|
||||
[docs/roadmap.md](docs/roadmap.md) (milestones and current focus).
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
## Headline numbers
|
||||
|
||||
### How the checklist number is made, and what it doesn't tell you
|
||||
|
||||
- **Partly measured.** The menu figure comes from diffing a dump of Premiere 26.5's menus against
|
||||
`filmcraft-cli commands`. The effects, transitions and audio-effects counts are checked by unit
|
||||
tests against Premiere's Effects panel tree (`premiere_26_video_effects_catalogue`,
|
||||
`premiere_audio_set_is_complete_and_foldered`, `premiere_26_transition_tree`).
|
||||
- **Partly agent-reported.** The other areas' percentages are written by the agents doing the work.
|
||||
No automated parity check runs in CI (PhotoCraft has `cargo xtask parity`; we don't yet), so
|
||||
nothing stops a number drifting upward.
|
||||
- **Presence, not quality.** An item counts once it exists. An effect described as an
|
||||
"approximation" counts the same as an exact one. Nothing compares our rendering with
|
||||
Premiere's.
|
||||
- **Weights understate what users hit first.** Performance carries 5%. Plugins and AI features
|
||||
aren't in the scorecard at all.
|
||||
|
||||
### By dimension
|
||||
|
||||
| Dimension | Checklist says | Honest estimate | Evidence |
|
||||
|---|---|---|---|
|
||||
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264, plus H.265 through a hardware encoder only (NVENC on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: Main 8-bit SDR); ProRes, DNxHR and image sequences cover mastering. No AV1 export, no 10-bit / HDR HEVC on macOS. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30), and for H.264 and HEVC on Linux (VA-API: 4K H.264 at 11 ms of CPU per frame instead of 417 ms, 4K HEVC at 10 ms instead of 203 ms). The GPU does compositing, blend modes (#32) and 33 common effects (GPU2, GPU4, GPU5): colour adjustments, crop/flip/transform, blurs, sharpen, vignette, video limiter. Lumetri, keys, masks and the remaining effects are CPU, and so is export rendering unless Export ▸ GPU Rendering is turned on (opt-in); H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows and Linux with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists on macOS and Windows only, through the same hardware encoders. Linux decodes H.264 and HEVC in hardware (VP9 and AV1 there are software); its only hardware encoder is NVENC H.264. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 33 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. |
|
||||
| **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. |
|
||||
| **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. |
|
||||
| **Platforms** | not tracked | **macOS solid; Windows and Linux thin** | Development and testing happen on macOS. Windows and Linux are built for releases but barely exercised at runtime. The web build is a compile target; the browser app shell isn't finished. |
|
||||
| **UI fidelity** | ~85% | **~80%** | Agents compare with Premiere reference screenshots; this hasn't been checked independently. |
|
||||
|
||||
### Where we are lacking
|
||||
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API): H.264 and HEVC are in; VP9 and AV1 remain;
|
||||
- zero-copy decoded frames into wgpu;
|
||||
- the remaining effects on the GPU: Lumetri, keys, masks (33 common effects are done);
|
||||
- GPU export, then hardware encode: H.264 and H.265 on macOS are in (H.264 opt-in; H.265 has no other encoder), H.264 and H.265 (Main 8-bit, and Main 10 HDR PQ / HLG) on NVENC (Windows); Main 10 / HDR HEVC on VideoToolbox, Linux and other Windows vendors remain.
|
||||
2. **A measured parity number.** Add `cargo xtask parity`, run in CI. It should cover menus,
|
||||
commands, effects, transitions, panels, preferences and formats against the Premiere snapshot in
|
||||
`plan/premiere/`. It should report presence and fidelity separately (exact, approximate, stub).
|
||||
3. **Real-world media corpus.** Phone, camera and screen recordings, variable frame rate, damaged
|
||||
files, long GOPs, odd containers. Each gets an import → edit → export round-trip test with an
|
||||
ffprobe / ffmpeg oracle.
|
||||
4. **Workflow acceptance tests.** Scripted MCP sessions that do real jobs end to end: assemble and
|
||||
trim a scene, colour it, mix and loudness-normalise it, caption it, deliver it, round-trip
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder) and on Windows (NVENC, 8-bit and Main 10 HDR); still to do: HEVC elsewhere, 10-bit / HDR HEVC on macOS, and AV1 (`rav1e`, BSD-2).
|
||||
7. **Plugin hosting** (VST3 / Audio Units, then OpenFX). Needs FFI, so it falls under the isolated
|
||||
`unsafe` crate rule (AGENTS.md §0.3). **Owner decision needed** before starting.
|
||||
8. **AI features** with openly licensed local models: transcription on by default, text-based
|
||||
editing, speech enhancement, reframing.
|
||||
9. **The web app shell**: file access, WebCodecs, audio.
|
||||
|
||||
### Where we're going
|
||||
|
||||
**Next:** close items 1–5. Hardware acceleration fixes the gap users notice first (CPU-bound 4K playback and export). The
|
||||
parity tool, media corpus and workflow tests make the progress number trustworthy. Windows and
|
||||
Linux CI makes "runs everywhere" true.
|
||||
|
||||
**After that:** delivery codecs, plugin hosting (pending the owner's decision) and AI features,
|
||||
measured with the same tools.
|
||||
|
||||
**Goal:** "ready for real work" at 85%+, measured rather than estimated, on all three desktop
|
||||
platforms.
|
||||
|
||||
## Feature checklist scorecard
|
||||
|
||||
Measured against Premiere Pro 26.5 on this machine. Menu items: the native menu bar dump, minus
|
||||
Adobe-cloud-only items (Team Projects, Productions, Firefly, Stock, Dynamic Link, account/help pages),
|
||||
matched against `filmcraft-cli commands` and the UI command table. Effects: the Effects panel tree.
|
||||
|
||||
| Area | Weight | Measured | Coverage |
|
||||
|---|---|---|---|
|
||||
| Editing, timeline, trimming, multicam | 20% | core edit algebra, trim modes, sync, multicam, menu long tail, Scene Edit Detection, Automate to Sequence, Premiere default keyboard (143 new commands; 39 cloud/absent-panel keys skipped) | ~86% |
|
||||
| Menus / commands | (cross-check) | ~325 of 344 in-scope menu items | ~95% |
|
||||
| Effects and transitions | 12% | video effects 93/93 (+Legacy, Obsolete), audio effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3; some approximations (Warp Stabilizer 2-D, Morph Cut, Auto Reframe) | ~92% |
|
||||
| Media I/O and codecs | 12% | H.264, HEVC, VP9, AV1, ProRes, DNx, MJPEG, MPEG-2/MPEG-1 (4:2:2 incl. IMX/XDCAM), Opus, AAC, AC-3, MP2; MP4/MOV/MKV/WebM, MXF (incl. MPEG-2), MPEG TS/PS (AVCHD, broadcast, DVD), Ogg, image sequences, BWF; no E-AC-3, camera raw, HW decode | ~90% |
|
||||
| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Project panel List/Icon/Freeform with view presets and hover scrub; Media Browser with Favorites/navigation/ingest | ~85% |
|
||||
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
|
||||
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; H.264 and HEVC decode in hardware on Linux (VA-API), and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| **Weighted total** | | | **~87%** |
|
||||
|
||||
## Estimate to parity
|
||||
|
||||
This estimate is for the **feature checklist**. Closing the honest-assessment gaps (above) is the
|
||||
"Robust on real-world material" row, plus plugins and AI features, which it doesn't include.
|
||||
|
||||
Measured throughput in the last work block (2026-10-01 night → 10-02): five Opus 5.5 agents in
|
||||
parallel for ~4.5 wall-clock hours (~18 agent-hours including integration) moved parity by ~4 points
|
||||
(≈4–5 agent-hours per point), on a machine that was heavily overloaded (load 150–300 on 14 cores) and
|
||||
once ran out of disk. The remaining points are a long tail (each effect, dialog and preference page is
|
||||
small, but there are many), so the estimate applies a 1.3–1.5× tail factor.
|
||||
|
||||
| | Opus 5.5 agent-hours | Wall-clock (4–5 parallel agents + integrator) |
|
||||
| | Value | How |
|
||||
|---|---|---|
|
||||
| Feature parity by checklist (~19 points left) | ~90–125 | **~20–32 h** |
|
||||
| Robust on real-world material (codec edge cases, 4K/8K performance, pro workflows) | +120–200 | **+30–50 h** |
|
||||
| **Total to "100% and better"** | **~210–325** | **~50–82 h (≈2–3.5 days, 24/7)** |
|
||||
| **Feature breadth** | **~86%** | Does each Premiere feature exist? Menus 92% (measured: label diff against Premiere 26.5.2's menu dump), effects / transitions / audio effects 100% (measured by tests), other areas estimated |
|
||||
| **Ready for real work** | **~56%** (51–61%) | How close FilmCraft is to replacing Premiere for an editor on real projects: depth, correctness, file compatibility, speed, stability, ecosystem. Estimated depth per area, combined by a [written formula](docs/target-app-parity.md#three-readiness-numbers) |
|
||||
| **Mainstream practitioner** | **~55%** | A typical professional editor's weekly work only, discounted ×0.90 interaction, ×0.92 stability, ×0.88 file exchange with Premiere users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| **Essentials user** | **~60%** | Import, cut, title, music, export at default settings, discounted ×0.88 launch / stability, ×0.90 discoverability, ×0.95 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| **To beta** | **~400–700 Opus 5.5 agent-hours** | Estimated, calibrated from this repo's PR history |
|
||||
| **To full parity** | **~1,550–2,750 agent-hours** | ~70% parallelizes; plugins, AI models, native-speaker review and some hardware need a human |
|
||||
| Code (measured) | 45 crates, ~343k lines of Rust, 2,520 tests, 268 merged PRs, 69 authors | `git`, `gh`, `grep` |
|
||||
|
||||
The 2026-10-02 block moved parity ~62% → ~72% with six agents in ~6 wall-clock hours, despite the
|
||||
disk filling twice; the remaining work is mostly panels (scopes, Metadata, Media Browser), export
|
||||
presets/queue, codecs (MXF, image sequences, hardware decode), performance and graphics templates.
|
||||
Two numbers, because they answer different questions. The gap between them is the work that
|
||||
matters most now: breadth is nearly done, depth is not.
|
||||
|
||||
Limits on speed: CPU and disk on one machine (more than ~5 agents slows everyone down: each worktree
|
||||
build is 6–7 GB and a full `cargo xtask ci` takes 20–60 min under load), and a single integrator
|
||||
merging, resolving conflicts (e.g. two agents bumping the project schema) and re-running CI. With one
|
||||
agent and no parallelism, multiply wall-clock by ~3–4.
|
||||
### Readiness by audience
|
||||
|
||||
Not reachable clean-room and locally: **Generative Extend** (needs a large video-generation model).
|
||||
**Enhance Speech** and **Auto Reframe** are feasible only with openly licensed models we can ship.
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~56% | ~1,450–2,600 (~70% parallelizes) | Plugin hosting, AI models, localization incl. RTL / Devanagari, camera RAW and delivery codecs, `.prproj`, hardware acceleration, UI fidelity |
|
||||
| Mainstream practitioner | ~55% | ~500–850 (~65% parallelizes) | `.prproj` import (60–120), UI fidelity (120–200), stability and PR CI (40–80), 10-bit / 4:2:2 camera decode (30–50), playback / hardware (60–100), import and export gaps (60–100) |
|
||||
| Essentials user | ~60% | ~180–350 (~60% parallelizes) | Launch crashes on Windows / Intel / older macOS (40–80), discoverability and timeline friction (60–110), playback lag (30–60), common import gaps (20–40), audio devices (15–30) |
|
||||
|
||||
## Milestones
|
||||
Method and calibration: [docs/target-app-parity.md](docs/target-app-parity.md#three-readiness-numbers).
|
||||
|
||||
Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining agent-hours.
|
||||
## By dimension
|
||||
|
||||
| # | Milestone | Status | Done | Remaining | Est. |
|
||||
|---|---|---|---|---|---|
|
||||
| M0 | Skeleton + visual shell | ✅ | Workspace, 20 crates, dock/workspaces, Premiere 26 look, native menus, control channel, MCP, xtask gates (layers, wasm) | — | — |
|
||||
| M1 | Media I/O | ✅ | MP4/MOV demux+mux, WAV, stills, MJPEG, symphonia audio (MP3/FLAC/ALAC/Vorbis), GOP seek + frame cache, import | Media Browser polish | 2 |
|
||||
| M2 | H.264 decoder | ✅ | Own decoder, bit-exact on 37+ streams, 500–600 fps 1080p | — | — |
|
||||
| M3 | Editing core | 🟡 | Edit algebra (insert/overwrite/razor/lift/extract/ripple/roll/slip/slide/rate-stretch/nest/paste), tools, markers, trim mode + Trim Monitor + dynamic J/K/L trimming, Keyboard Shortcuts editor with FilmCraft/Premiere/FCP/Avid presets; Edit/Clip/File menu commands (Label colours and Select Label Group, Paste/Remove Attributes, Select All Matching, Remove Unused, Consolidate Duplicates, Sequence From Clip, Bin From Selection, Offline File, Close Project, Make/Edit Subclip, Modify Audio Channels/Timecode, Frame Hold Options/Add Frame Hold/Insert Frame Hold Segment, Time Interpolation with frame blending, Fit/Fill frame, Breakout to Mono, Extract Audio, Replace With Clip; M3.11: Scene Edit Detection (pure-Rust cut detection, background job), Normalize Mix Track, Simplify Sequence, Transcribe Sequence, Find/Find Next and search bins, Automate to Sequence, Edit Original, Edit Offline, Source Settings, Update Metadata (XMP), Generate Audio Waveform, Project Settings General/Scratch Disks, Get Media File Properties, Save as Template, Selection as FilmCraft Project, Avid Log Exchange export, Flash Cue markers, Dynamic Audio Waveforms, Reveal Log Files, System Compatibility Report); M3.12 keyboard parity: Premiere's default keyboard (~115 keyboard-only commands: edit-point navigation on targeted/any track, select clip at playhead/next/previous, extend edit to playhead, nudge/slip/slide selection, target and source-patch toggles, clip volume ±1 dB/many, frame maximize/full screen/panel cycling, monitor zoom, track heights, Project and Text panel keyboard navigation, text size/leading/alignment, Export Frame, poster frames; see docs/keyboard.md); multicam (Create Multi-Camera Source Sequence, Multi-Camera view with live switching on 1–9, angle switching, Enable/Flatten, Edit Cameras) and sync (Synchronize, Merge Clips; In/Out/timecode/marker/audio — GCC-PHAT, sample-accurate) | Multicam paging >16 angles and grid thumbnails, optical flow (renders as frame blending), 39 Premiere default shortcuts skipped with reasons in docs/keyboard.md (Productions, AI/cloud tools, work area bar, Production/Search panels) | 6–10 |
|
||||
| M4 | Playback | 🟡 | Audio-clock master, prefetch with cancellation, J/K/L, correct dropped-frame stats, playback resolution, render bar + content-hashed render previews, App Nap opt-out; 1080p H.264 and 3 stacked 1080p streams play with 0 dropped frames | 4K under load, 8K, frame-threaded AV1 decode, reduced-resolution decode for multicam grids | 6–10 |
|
||||
| M5 | Effects, keyframes, GPU | 🟡 | All 93 Premiere 26 video effects and 84 transitions (CPU), blend modes and 33 common effects on the GPU (#32, GPU2, GPU4, GPU5), keyframes + value/velocity graphs, wgpu compositor, effect + opacity masks (ellipse/polygon/pen, feather, expansion, modes; CPU/WGSL parity), mask tracking (Lucas–Kanade + RANSAC), adjustment layers, effect presets (built-in + user, JSON import/export) | Effects on the GPU (in progress, #30), GPU masks in the live path, GPU export, exact Warp Stabilizer (3-D) and Morph Cut | 20–30 |
|
||||
| M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 or QuickTime + own AAC; ProRes, DNxHR, MJPEG, PNG/TIFF/BMP sequences (Premiere-style numbering), GIF, WAV, AIFF; background jobs; M6.5: Export mode parity (frame size / rate / scaling / pixel aspect, profile / level, CBR / VBR 1- and 2-pass, keyframe distance, audio codec / rate / channels / bitrate / sample size, multiplexer, burn-in or sidecar captions, image / name / timecode overlays, video limiter, loudness normalization with true-peak limiter, metadata, ranges, estimated size, summary), 24 built-in presets + user presets with favourites and import/export (Preset Manager), export queue (reorder, cancel, retry, several sequences / ranges), Quick Export; `export.*` commands and `filmcraft-cli export --preset` | Interlaced encoding, 5.1 audio, smart render, publishing destinations | 6–8 |
|
||||
| M7 | Audio | 🟡 | Mixer graph (tracks → submixes → Mix, pre/post-fader inserts and sends, latency-compensated, sample-accurate, ~6× realtime for 24 tracks × 3 effects on one core), Audio Track Mixer + Audio Clip Mixer panels, track automation (Off/Read/Latch/Touch/Write, recorded live while playing, thinned to keyframes, timeline lanes with pen editing), solo/solo-safe, channel mapping basics, peak + BS.1770 loudness meters (match ffmpeg), DSP crate with 16 clip/track effects, Audio Gain (set/adjust/normalize), Constant Power / Constant Gain / Exponential Fade, Essential Sound (types, Loudness auto-match, Repair incl. DeEss/DeReverb, Clarity, Creative, Ducking keyframes, presets; full Dialogue chain 22× realtime) | Music duration remix, ML speech enhancement, 5.1 panner and multichannel buses, voice-over record, effect editor windows (EQ curve), remaining effects (multiband, convolution reverb), clip-mixer automation recording | 6–9 |
|
||||
| M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette, section bypass; Input LUT / Look LUT (.cube 1D/3D/shaper, .3dl; tetrahedral CPU + WGSL; project LUT library; built-in camera conversions); Colour Match (Oklab tonal-range statistics, skin protection, solved in Lumetri wheels); colour management: Rec. 709 / Rec. 2100 PQ / HLG working spaces + wide gamut, Interpret Footage colour space (S-Log3, V-Log, Canon Log 2/3, LogC3/4, Apple Log, D-Log from published specs), metadata auto-detect, BT.2390 tone mapping, gamut mapping, HDR export signalling (VUI/colr/mdcv/clli/SEI, ffprobe-verified); Lumetri Scopes: Vectorscope YUV (75/100 % targets, skin-tone line) and HLS, Histogram, Parade (RGB/YUV/RGB-White), Waveform (RGB/Luma/YC/YC no Chroma), presets, Rec. 601/709/2100, 8-bit/float/HDR (cd/m²) scales, multi-scope grid, `scopes.read` | HDR-aware Lumetri maths, macOS EDR monitors, mastering metadata → tone-map peak, D-Log M (no published formula), HSL Secondary refine | 3–5 |
|
||||
| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4), DNxHD/DNxHR decoder (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2/4:4:4, interlaced, alpha; within IDCT precision of ffmpeg on 22 fixtures) + DNxHR LB/SQ/HQ/HQX/444 encoder and MOV `AVdh` export, AV1 decoder (Main profile, 8/10-bit, all intra/inter tools, loop filters, superres, film grain, intra BC, spatial layers; bit-exact vs libdav1d on 19 SVT fixtures + 22 libaom vectors; MP4 `av01` / WebM `V_AV1` import), MXF import (OP1a/OP-Atom; AVC with POC-ordered B pictures bit-exact, DNxHD/DNxHR, ProRes, PCM/AES3; MPEG-2 reported unsupported; index-table seeking, material package timecode), Ogg Opus/Vorbis (granule seeking, pre-skip, end trimming), image sequences, Broadcast WAV timecode | VP9 frame threading, AV1 threading/SIMD (~4 fps 1080p today), AV1 High/Professional profiles, MPEG-2 decoder, hardware decode | 12–20 |
|
||||
| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute; graphics templates (own `.fcgt` format, 8 original built-ins, Browse tab with engine-rendered thumbnails, export / install / apply / editable properties; never reads .mogrt), rolls/crawls with ease and pre/postroll, responsive pins and intro/outro, per-character styles, Upgrade Caption to Graphic, source graphics, Replace Fonts; MCC (608+708), EBU STL, TTML/IMSC1, DFXP (frame-exact, property-tested) | 608/708 embedding in video streams, speech-to-text | 12–18 |
|
||||
| M11 | Interchange & project management | ✅ | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO; offline media (own slate) + Link Media (fingerprint-checked relink, folder remap, search, Align Timecode, Make Offline); proxies (ProRes Proxy/LT, H.264 ¼/½ background jobs, attach/detach/reconnect, monitor toggle, export full-res) + ingest (copy/transcode/proxies); Project Manager (collect, consolidate + transcode with handles, size estimate) | Rename media to clip names, image-sequence conversion, Media Browser-driven relink, smart (cross-drive) path tracking | 1–2 |
|
||||
| M12–M16 | Multicam, web (WASM), platform, long tail | 🟡 | Multicam: audio sync (GCC-PHAT, sub-sample), Merge Clips, multi-camera source sequences, Multi-Camera view with live switching (1–9); L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), scene detection, auto reframe, ~800 remaining commands and dialogs, performance hardening | 35–55 |
|
||||
| Dimension | Ready | Hours | Doc |
|
||||
|---|---|---|---|
|
||||
| Features (editing, effects, colour, audio, graphics, export settings) | ~66% | 350–620 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) |
|
||||
| UI / UX fidelity | ~60% | 120–200 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats (containers, `.prproj`, interchange, captions, stills) | ~60% | 140–240 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Codecs | ~60% | 150–250 | [codec-parity.md](docs/codec-parity.md) |
|
||||
| Hardware acceleration | ~45% | 120–200 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ~35% | 150–230 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~55% | 60–120 | [performance.md](docs/performance.md) |
|
||||
| Stability | ~45% | 60–120 | [gaps.md G4](docs/gaps.md#g4-crashes-at-start-up-and-no-test-ci-on-pull-requests) |
|
||||
| Platforms (macOS, Windows, Linux, FreeBSD, web) | ~60% | 50–90 | [gaps.md G9](docs/gaps.md#g9-windows-and-linux-at-run-time) |
|
||||
| Plugins and ecosystem | ~5% | 200–400 | [gaps.md G11](docs/gaps.md#g11-no-plugin-hosting) |
|
||||
| AI features | ~25% | 150–300 | [gaps.md G12](docs/gaps.md#g12-ai-features) |
|
||||
|
||||
## Running now
|
||||
## Features
|
||||
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 33 common effects on the GPU (GPU2, GPU4, GPU5). Windows: Media Foundation / Direct3D 11 H.264 + HEVC decode (HW2) and VP9 + AV1 (HW3), NVENC H.264 export (HW4, opt-in) and H.265 export. Linux: VA-API H.264 + HEVC decode (HW5). Next: VA-API VP9 / AV1, zero-copy upload, Lumetri on the GPU.
|
||||
- **Next:** [Where we are lacking](#where-we-are-lacking) items 1–5. GPU export waits for the export-crate work (#19 and the never-crash follow-up) to land.
|
||||
| Area | Breadth | Ready | Hours |
|
||||
|---|---|---|---|
|
||||
| Editing, timeline, trimming, multicam | ~90% | ~70% | 80–140 |
|
||||
| Effects and transitions | 100% (measured) | ~60% | 80–140 |
|
||||
| Media import and codecs | ~75% | ~60% | 150–250 |
|
||||
| Audio | ~88% | ~65% | 50–90 |
|
||||
| Panels and UI fidelity | ~88% | ~60% | 120–200 |
|
||||
| Colour | ~90% | ~70% | 40–70 |
|
||||
| Graphics, titles, captions | ~85% | ~65% | 50–90 |
|
||||
| Export and delivery | ~80% | ~65% | 60–110 |
|
||||
| Project files and interchange | ~70% | ~45% | 90–160 |
|
||||
| Preferences and project management | ~80% | ~65% | 20–40 |
|
||||
|
||||
## Log
|
||||
Evidence for every row: [docs/target-app-parity.md](docs/target-app-parity.md#by-feature-area).
|
||||
|
||||
## Languages
|
||||
|
||||
Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings):
|
||||
|
||||
| Language | Status | Strings |
|
||||
|---|---|---|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial (whole interface; engine errors English) | 99.6% |
|
||||
| Spanish | partial (whole interface) | 99.9% |
|
||||
| Hindi | none (no Devanagari shaping in the interface) | 0% |
|
||||
| Arabic | none (no RTL interface) | 0% |
|
||||
| French | partial (whole interface; native review welcome) | ~100% |
|
||||
| Portuguese (Brazil) | menus only | 9.9% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | partial (whole interface) | 99.7% |
|
||||
| German | none | 0% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
1. **No launch crashes, and CI on pull requests** (macOS 12, Intel UHD, Windows 11 reports; no workflow runs the tests today). 40–80 h.
|
||||
2. **Open Premiere's `.prproj`**, the beta blocker. 60–120 h.
|
||||
3. **10-bit / 4:2:2 camera media without hardware** (H.264 High 10 / 4:2:2, HEVC 4:2:2; #626). 30–50 h.
|
||||
4. **Measure fidelity against Premiere** and add `cargo xtask parity`. 50–90 h.
|
||||
5. **Real-world media corpus and workflow acceptance tests.** 40–70 h.
|
||||
6. **Hardware acceleration, continued** (#30): zero-copy upload, more effects on the GPU, Quick Sync / AMD encoders, VideoToolbox Main 10. 120–200 h.
|
||||
7. **UI fidelity:** Program monitor transform handles, Effect Controls, docking. 120–200 h.
|
||||
8. **Plugin hosting** (VST3 / AU, then OpenFX). Owner decision needed. 200–400 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
|
||||
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
|
||||
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
|
||||
- **2026-10-09:** release 0.5.0. NVENC H.265 export on Windows, Main and Main 10 HDR (#328, #360); NVENC H.264 export (#386); VA-API H.264 / HEVC decode (#358); Source monitor playback (#423); multi-stream container audio, schema v13 (#361); New Sequence and Sequence Settings dialogs (#269, #355); Spanish for the panels (#238); background decoders and memory stress tests (#385, #381).
|
||||
- **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping.
|
||||
- **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed.
|
||||
- **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged.
|
||||
|
|
@ -212,6 +160,13 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
|||
- **2026-09-30 (late morning):** keyframe value/velocity graphs; xtask gates; H.264 MP4 export; Lumetri curves, wheels, looks, HSL secondary.
|
||||
- **2026-09-30 (early morning):** H.264 decoder, ProRes, AAC; GPU compositor; MCP; Premiere 26 visual fidelity pass.
|
||||
|
||||
### Interface localization
|
||||
## Revision history
|
||||
|
||||
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.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points |
|
||||
| 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) |
|
||||
| 2026-10-10 | major | Restructured to the craftrules progress-docs shape: stage banner, headline numbers, dimension / feature / language tables, upcoming; re-measured against Premiere 26.5.2 (breadth ~87 → ~86%, ready ~50–60% → ~55%); honest assessment and scorecard moved to docs/target-app-parity.md, "Where we are lacking" to docs/gaps.md, milestones to docs/roadmap.md, interface localization to docs/localization-parity.md |
|
||||
| 2026-10-08 | minor | Log entries for HEVC export, Source playback and range dragging |
|
||||
| 2026-10-05 | major | Honest assessment added: checklist ~87% vs ready for real work ~50–60% |
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const VALUED: &[&str] = &[
|
|||
"--end",
|
||||
"--data-dir",
|
||||
"--settings",
|
||||
"--gpu-rendering",
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
@ -60,6 +61,17 @@ impl Args {
|
|||
self.opts.iter().any(|(n, _)| n == k)
|
||||
}
|
||||
|
||||
/// A version query: `--version` anywhere, or `-V` in the subcommand position, so global
|
||||
/// options may come first (`--demo --version`, `--compact -V`).
|
||||
pub fn wants_version(&self) -> bool {
|
||||
self.flag("--version") || self.pos(0) == Some("-V")
|
||||
}
|
||||
|
||||
/// The first option that is not in `allowed`, if any.
|
||||
pub fn unknown_opt(&self, allowed: &[&str]) -> Option<&str> {
|
||||
self.opts.iter().map(|(n, _)| n.as_str()).find(|n| !allowed.contains(n))
|
||||
}
|
||||
|
||||
/// Command params from positionals `from..`: one JSON object, or `key=value` pairs
|
||||
/// (dotted keys nest: `color.r=1`).
|
||||
pub fn params_from(&self, from: usize) -> Result<Value, String> {
|
||||
|
|
@ -135,9 +147,37 @@ mod tests {
|
|||
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_after_global_options() {
|
||||
for s in ["--version", "-V", "--demo --version", "--compact -V", "--data-dir d --version", "export --version"] {
|
||||
assert!(args(s).wants_version(), "{s}");
|
||||
}
|
||||
for s in ["version", "--data-dir -V", "exec x -V", "-- --version"] {
|
||||
assert!(!args(s).wants_version(), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_opt_reports_first_unlisted_option() {
|
||||
let allowed = ["--bridge", "--project"];
|
||||
assert_eq!(args("mcp --automation-read-root X").unknown_opt(&allowed), Some("--automation-read-root"));
|
||||
assert_eq!(args("mcp --bridge=a --typo=1").unknown_opt(&allowed), Some("--typo"));
|
||||
assert_eq!(args("mcp --project p --bridge a").unknown_opt(&allowed), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dash_ends_options() {
|
||||
let a = args("import -- --weird-name.mov a.mov");
|
||||
assert_eq!(a.positionals, ["import", "--weird-name.mov", "a.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_rendering_takes_a_space_separated_value() {
|
||||
let a = args("export --gpu-rendering off out.wav --start 0 --end 0.1");
|
||||
assert_eq!(a.positionals, ["export", "out.wav"]);
|
||||
assert_eq!(a.opt("--gpu-rendering"), Some("off"));
|
||||
let b = args("export out.wav --gpu-rendering auto");
|
||||
assert_eq!(b.positionals, ["export", "out.wav"]);
|
||||
assert_eq!(b.opt("--gpu-rendering"), Some("auto"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ SUBCOMMANDS
|
|||
[--scale f] [--quality 0-100] [--no-audio] [--queue]
|
||||
export the active sequence and wait for it to finish: with an
|
||||
export preset (`export --list-presets`; built-in or the user's), or
|
||||
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed
|
||||
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
|
||||
from the extension); --range entire|inOut|workArea, or a custom
|
||||
range in seconds; --settings is ExportSettings JSON merged over the
|
||||
preset; --scale renders at a fraction of the frame size (0.5 =
|
||||
|
|
@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> {
|
|||
"tif" | "tiff" => "tiff",
|
||||
"bmp" => "bmp",
|
||||
"aif" | "aiff" => "aiff",
|
||||
"m4a" | "aac" => "aac",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -252,12 +253,6 @@ async fn main() {
|
|||
}
|
||||
|
||||
async fn cli() {
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
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.
|
||||
|
|
@ -265,6 +260,13 @@ async fn cli() {
|
|||
out!("{HELP}");
|
||||
return;
|
||||
}
|
||||
// `--version` anywhere and `-V` as the subcommand, also after global options (`--demo --version`, #710).
|
||||
if a.wants_version() {
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "-h" => out!("{HELP}"),
|
||||
|
|
@ -462,6 +464,9 @@ async fn cli() {
|
|||
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
|
||||
}
|
||||
"mcp" => {
|
||||
if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) {
|
||||
usage(format!("unknown mcp option `{opt}`"));
|
||||
}
|
||||
let server = match a.opt("--bridge") {
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
|
||||
None => match Backend::open(&a) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ fn help_and_usage_errors() {
|
|||
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_after_global_options() {
|
||||
let want = format!("filmcraft-cli {}\n", env!("CARGO_PKG_VERSION"));
|
||||
for args in [&["--version"][..], &["-V"], &["version"], &["--demo", "--version"], &["--compact", "-V"], &["--demo", "version"]] {
|
||||
let o = cli(args);
|
||||
assert!(o.status.success(), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout), want, "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_help_matches_help_command() {
|
||||
let reference = cli(&["help"]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
|
||||
//! anchor clicks live in [`crate::fs::download`]).
|
||||
//!
|
||||
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
|
||||
//! (one file per frame) delivered only about half of its frames (#378). Each download is given a
|
||||
//! slot [`GAP_MS`] after the previous one.
|
||||
|
||||
/// Milliseconds between two download clicks.
|
||||
pub const GAP_MS: f64 = 125.0;
|
||||
|
||||
/// Hands out click times.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Pacer {
|
||||
next_free: f64,
|
||||
}
|
||||
|
||||
impl Pacer {
|
||||
/// Reserve the next slot at time `now` (ms); returns how long to wait before clicking.
|
||||
pub fn reserve(&mut self, now: f64) -> f64 {
|
||||
let start = now.max(self.next_free);
|
||||
self.next_free = start + GAP_MS;
|
||||
start - now
|
||||
}
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ struct Fs {
|
|||
|
||||
thread_local! {
|
||||
static FS: RefCell<Fs> = RefCell::new(Fs::default());
|
||||
static DOWNLOADS: RefCell<crate::download_pacing::Pacer> = RefCell::new(crate::download_pacing::Pacer::default());
|
||||
}
|
||||
|
||||
/// Wake-up hook for arriving data.
|
||||
|
|
@ -405,7 +406,8 @@ impl Services for WebServices {
|
|||
}
|
||||
}
|
||||
|
||||
/// Offer bytes as a browser download named `name`.
|
||||
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
|
||||
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
|
||||
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let blob = bytes_to_blob(data, "application/octet-stream")?;
|
||||
|
|
@ -414,10 +416,20 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
|||
a.set_href(&url);
|
||||
a.set_download(file_name(name));
|
||||
a.style().set_property("display", "none")?;
|
||||
doc.body().ok_or("no body")?.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
|
||||
let name = name.to_owned();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if delay > 0.0 {
|
||||
sleep(delay as i32).await;
|
||||
}
|
||||
let clicked = match doc.body() {
|
||||
Some(body) => body.append_child(&a).map(|_| a.click()),
|
||||
None => Err("no body".into()),
|
||||
};
|
||||
a.remove();
|
||||
if let Err(e) = clicked {
|
||||
log::warn!("download {name}: {e:?}");
|
||||
}
|
||||
sleep(60_000).await;
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
pub mod api;
|
||||
pub mod audio;
|
||||
pub mod download_pacing;
|
||||
pub mod fs;
|
||||
pub mod import;
|
||||
pub mod opfs;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
|
||||
//! wasm32-only.
|
||||
|
||||
#[path = "../src/download_pacing.rs"]
|
||||
mod download_pacing;
|
||||
|
||||
use download_pacing::{GAP_MS, Pacer};
|
||||
|
||||
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
|
||||
#[test]
|
||||
fn burst_of_downloads_is_spaced_out() {
|
||||
let mut p = Pacer::default();
|
||||
let delays: Vec<f64> = (0..20).map(|_| p.reserve(1000.0)).collect();
|
||||
assert_eq!(delays[0], 0.0);
|
||||
for (i, d) in delays.iter().enumerate() {
|
||||
assert_eq!(*d, i as f64 * GAP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// A download after a quiet period is not delayed.
|
||||
#[test]
|
||||
fn download_after_a_pause_is_immediate() {
|
||||
let mut p = Pacer::default();
|
||||
assert_eq!(p.reserve(0.0), 0.0);
|
||||
assert_eq!(p.reserve(10_000.0), 0.0);
|
||||
}
|
||||
|
|
@ -32,8 +32,12 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
|
|
|
|||
|
|
@ -237,6 +237,11 @@ fn main() -> eframe::Result {
|
|||
app.hooks.pick_folder_at =
|
||||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Help menu, About and Discord links (#642): eframe is built without its `links`
|
||||
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
|
||||
// that waits for the browser never freezes the UI.
|
||||
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
|
||||
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
|
||||
// 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()));
|
||||
|
|
@ -283,6 +288,18 @@ fn main() -> eframe::Result {
|
|||
started
|
||||
}
|
||||
|
||||
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
|
||||
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
|
||||
let mut clipboard = arboard::Clipboard::new().ok()?;
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(filmcraft_ui_egui::ClipboardMedia::Files(files.iter().map(|p| p.to_string_lossy().into_owned()).collect()));
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
Some(filmcraft_ui_egui::ClipboardMedia::Image { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
|
||||
}
|
||||
|
||||
/// Open a file in its default application, or reveal it in the file manager (Edit Original,
|
||||
/// Reveal Log Files).
|
||||
fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
|||
filmcraft_ui_egui::i18n::Language::Uk
|
||||
} else if checked("app.language.chinese") {
|
||||
filmcraft_ui_egui::i18n::Language::ZhCn
|
||||
} else if checked("app.language.german") {
|
||||
filmcraft_ui_egui::i18n::Language::De
|
||||
} else if checked("app.language.russian") {
|
||||
filmcraft_ui_egui::i18n::Language::Ru
|
||||
} else if checked("app.language.french") {
|
||||
filmcraft_ui_egui::i18n::Language::Fr
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,23 +2,23 @@
|
|||
"schema": 1,
|
||||
"repo": "storytold/filmcraft",
|
||||
"branch": "main",
|
||||
"head": "7b6c134472287db4367fca80bcb498240ba36567",
|
||||
"head_date": "2026-10-09T15:02:38Z",
|
||||
"commits": 539,
|
||||
"head": "9efe8f3112194b58a30cf0449b569306077c67d4",
|
||||
"head_date": "2026-10-10T22:34:28Z",
|
||||
"commits": 812,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 48,
|
||||
"commits": 337,
|
||||
"lines_added": 311167,
|
||||
"lines_deleted": 13200,
|
||||
"prs": 49,
|
||||
"commits": 366,
|
||||
"lines_added": 313219,
|
||||
"lines_deleted": 14128,
|
||||
"binary_added": 74,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-09-30T09:22:00Z",
|
||||
"last_commit": "2026-10-09T15:02:38Z"
|
||||
"last_commit": "2026-10-10T20:08:58Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
|
|
@ -154,9 +154,9 @@
|
|||
"login": "carlitosteso3",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 15,
|
||||
"commits": 15,
|
||||
"lines_added": 1740,
|
||||
"prs": 16,
|
||||
"commits": 16,
|
||||
"lines_added": 2009,
|
||||
"lines_deleted": 207,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
|
|
@ -232,27 +232,53 @@
|
|||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 209,
|
||||
"lines_deleted": 0,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 441,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:57:38Z",
|
||||
"last_commit": "2026-10-08T23:52:30Z"
|
||||
"last_commit": "2026-10-09T22:01:08Z"
|
||||
},
|
||||
{
|
||||
"login": "txiki739",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 8,
|
||||
"lines_added": 7976,
|
||||
"lines_deleted": 3333,
|
||||
"prs": 8,
|
||||
"commits": 11,
|
||||
"lines_added": 8160,
|
||||
"lines_deleted": 3347,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:41:48Z",
|
||||
"last_commit": "2026-10-09T09:28:24Z"
|
||||
"last_commit": "2026-10-09T14:06:49Z"
|
||||
},
|
||||
{
|
||||
"login": "mutombopr",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 19,
|
||||
"lines_added": 10421,
|
||||
"lines_deleted": 337,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T03:57:26Z",
|
||||
"last_commit": "2026-10-10T17:46:20Z"
|
||||
},
|
||||
{
|
||||
"login": "ahmedhosnypro",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 5,
|
||||
"lines_added": 473,
|
||||
"lines_deleted": 80,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T05:32:08Z",
|
||||
"last_commit": "2026-10-09T22:00:30Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
|
|
@ -284,14 +310,14 @@
|
|||
"login": "MrCloudy2",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 132,
|
||||
"lines_deleted": 4,
|
||||
"prs": 6,
|
||||
"commits": 7,
|
||||
"lines_added": 1293,
|
||||
"lines_deleted": 46,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T08:53:54Z",
|
||||
"last_commit": "2026-10-08T08:53:54Z"
|
||||
"last_commit": "2026-10-10T15:14:34Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
|
|
@ -336,27 +362,27 @@
|
|||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 74,
|
||||
"lines_deleted": 6,
|
||||
"prs": 29,
|
||||
"commits": 29,
|
||||
"lines_added": 1048,
|
||||
"lines_deleted": 137,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:56Z",
|
||||
"last_commit": "2026-10-09T08:45:28Z"
|
||||
"last_commit": "2026-10-10T21:22:52Z"
|
||||
},
|
||||
{
|
||||
"login": "Tom1tk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 47,
|
||||
"lines_deleted": 26,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 70,
|
||||
"lines_deleted": 30,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:47:40Z",
|
||||
"last_commit": "2026-10-08T14:56:39Z"
|
||||
"last_commit": "2026-10-09T14:19:08Z"
|
||||
},
|
||||
{
|
||||
"login": "Pathos0925",
|
||||
|
|
@ -375,14 +401,14 @@
|
|||
"login": "mjohnson7cd",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 100,
|
||||
"lines_deleted": 2,
|
||||
"prs": 3,
|
||||
"commits": 2,
|
||||
"lines_added": 121,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:36:28Z",
|
||||
"last_commit": "2026-10-08T20:36:28Z"
|
||||
"last_commit": "2026-10-10T00:47:33Z"
|
||||
},
|
||||
{
|
||||
"login": "Oldrich333",
|
||||
|
|
@ -401,14 +427,14 @@
|
|||
"login": "cshapiro",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 5,
|
||||
"lines_added": 3128,
|
||||
"lines_deleted": 111,
|
||||
"prs": 4,
|
||||
"commits": 6,
|
||||
"lines_added": 3165,
|
||||
"lines_deleted": 116,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T23:28:32Z",
|
||||
"last_commit": "2026-10-09T02:57:30Z"
|
||||
"last_commit": "2026-10-09T15:59:56Z"
|
||||
},
|
||||
{
|
||||
"login": "rossaai",
|
||||
|
|
@ -440,14 +466,14 @@
|
|||
"login": "JordyUSA",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 4158,
|
||||
"lines_deleted": 283,
|
||||
"prs": 6,
|
||||
"commits": 9,
|
||||
"lines_added": 4948,
|
||||
"lines_deleted": 405,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:35:57Z",
|
||||
"last_commit": "2026-10-09T02:53:28Z"
|
||||
"last_commit": "2026-10-10T15:06:45Z"
|
||||
},
|
||||
{
|
||||
"login": "jerrykimo",
|
||||
|
|
@ -466,14 +492,27 @@
|
|||
"login": "swillunderscore",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 1008,
|
||||
"lines_deleted": 13,
|
||||
"prs": 2,
|
||||
"commits": 6,
|
||||
"lines_added": 2407,
|
||||
"lines_deleted": 63,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:58:37Z",
|
||||
"last_commit": "2026-10-09T02:19:21Z"
|
||||
"last_commit": "2026-10-10T00:20:25Z"
|
||||
},
|
||||
{
|
||||
"login": "jnohclee-rgb",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 19,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T02:14:06Z",
|
||||
"last_commit": "2026-10-09T02:14:06Z"
|
||||
},
|
||||
{
|
||||
"login": "IppSec",
|
||||
|
|
@ -492,14 +531,14 @@
|
|||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 323,
|
||||
"lines_deleted": 25,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 1169,
|
||||
"lines_deleted": 93,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:06:37Z",
|
||||
"last_commit": "2026-10-09T05:44:39Z"
|
||||
"last_commit": "2026-10-09T23:00:34Z"
|
||||
},
|
||||
{
|
||||
"login": "ItzBloxy",
|
||||
|
|
@ -513,6 +552,435 @@
|
|||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:27:48Z",
|
||||
"last_commit": "2026-10-09T10:17:08Z"
|
||||
},
|
||||
{
|
||||
"login": "dmatviichuk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 464,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T12:18:42Z",
|
||||
"last_commit": "2026-10-09T12:18:42Z"
|
||||
},
|
||||
{
|
||||
"login": "David-T-Campos",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 7,
|
||||
"lines_added": 61,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:25:06Z",
|
||||
"last_commit": "2026-10-09T15:27:55Z"
|
||||
},
|
||||
{
|
||||
"login": "lloyd-nich",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 3,
|
||||
"lines_added": 1477,
|
||||
"lines_deleted": 121,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:58:49Z",
|
||||
"last_commit": "2026-10-10T07:02:57Z"
|
||||
},
|
||||
{
|
||||
"login": "haranishi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 3700,
|
||||
"lines_deleted": 92,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:14:19Z",
|
||||
"last_commit": "2026-10-10T08:34:16Z"
|
||||
},
|
||||
{
|
||||
"login": "vbasky",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 11,
|
||||
"lines_added": 1314,
|
||||
"lines_deleted": 215,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T17:37:44Z",
|
||||
"last_commit": "2026-10-10T16:41:59Z"
|
||||
},
|
||||
{
|
||||
"login": "ossini",
|
||||
"people_entry": true,
|
||||
"github_name": "Oskar Sobania",
|
||||
"prs": 2,
|
||||
"commits": 12,
|
||||
"lines_added": 1381,
|
||||
"lines_deleted": 149,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T18:43:22Z",
|
||||
"last_commit": "2026-10-10T17:17:47Z"
|
||||
},
|
||||
{
|
||||
"login": "liangforstudy",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 575,
|
||||
"lines_deleted": 20,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:22:05Z",
|
||||
"last_commit": "2026-10-10T09:40:58Z"
|
||||
},
|
||||
{
|
||||
"login": "ReReddo",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 12,
|
||||
"commits": 14,
|
||||
"lines_added": 1918,
|
||||
"lines_deleted": 126,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:30:29Z",
|
||||
"last_commit": "2026-10-10T19:45:20Z"
|
||||
},
|
||||
{
|
||||
"login": "MasakatsuFunaki",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 261,
|
||||
"lines_deleted": 13,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T21:49:24Z",
|
||||
"last_commit": "2026-10-09T21:49:24Z"
|
||||
},
|
||||
{
|
||||
"login": "maccagnit",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 221,
|
||||
"lines_deleted": 30,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T23:00:05Z",
|
||||
"last_commit": "2026-10-10T15:22:56Z"
|
||||
},
|
||||
{
|
||||
"login": "digitaldecaycreations-lgtm",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 11,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T23:20:58Z",
|
||||
"last_commit": "2026-10-09T23:20:58Z"
|
||||
},
|
||||
{
|
||||
"login": "dylanmtaylor",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 37,
|
||||
"lines_deleted": 42,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:04:17Z",
|
||||
"last_commit": "2026-10-10T00:04:17Z"
|
||||
},
|
||||
{
|
||||
"login": "sawizzle",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 23,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:06:15Z",
|
||||
"last_commit": "2026-10-10T04:50:49Z"
|
||||
},
|
||||
{
|
||||
"login": "luniiya",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 697,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T00:14:55Z",
|
||||
"last_commit": "2026-10-10T00:14:55Z"
|
||||
},
|
||||
{
|
||||
"login": "chuygarcia92",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 38,
|
||||
"lines_deleted": 33,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T02:20:17Z",
|
||||
"last_commit": "2026-10-10T02:20:17Z"
|
||||
},
|
||||
{
|
||||
"login": "Hathor875",
|
||||
"people_entry": true,
|
||||
"github_name": "Krzysztof Cieślik",
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 109,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T02:21:06Z",
|
||||
"last_commit": "2026-10-10T02:21:06Z"
|
||||
},
|
||||
{
|
||||
"login": "Guilhermeb-Ferrarezi",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 161,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T02:43:20Z",
|
||||
"last_commit": "2026-10-10T02:48:16Z"
|
||||
},
|
||||
{
|
||||
"login": "mitchmckinstry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 399,
|
||||
"lines_deleted": 11,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T03:14:19Z",
|
||||
"last_commit": "2026-10-10T03:25:58Z"
|
||||
},
|
||||
{
|
||||
"login": "gyroninja",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 178,
|
||||
"lines_deleted": 12,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T04:32:42Z",
|
||||
"last_commit": "2026-10-10T10:14:18Z"
|
||||
},
|
||||
{
|
||||
"login": "hadmark",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 4,
|
||||
"lines_added": 3608,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T06:08:41Z",
|
||||
"last_commit": "2026-10-10T06:32:49Z"
|
||||
},
|
||||
{
|
||||
"login": "SilentYeti",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 1562,
|
||||
"lines_deleted": 72,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T06:17:11Z",
|
||||
"last_commit": "2026-10-10T09:17:16Z"
|
||||
},
|
||||
{
|
||||
"login": "adurr21",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 287,
|
||||
"lines_deleted": 68,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T07:03:03Z",
|
||||
"last_commit": "2026-10-10T07:03:03Z"
|
||||
},
|
||||
{
|
||||
"login": "maxm11",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 6,
|
||||
"lines_added": 487,
|
||||
"lines_deleted": 38,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T07:19:38Z",
|
||||
"last_commit": "2026-10-10T14:18:27Z"
|
||||
},
|
||||
{
|
||||
"login": "Ni-zav",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 10,
|
||||
"commits": 13,
|
||||
"lines_added": 498,
|
||||
"lines_deleted": 65,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T08:47:24Z",
|
||||
"last_commit": "2026-10-10T21:55:59Z"
|
||||
},
|
||||
{
|
||||
"login": "ivandrozdov1505-coder",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 404,
|
||||
"lines_deleted": 5,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T10:40:22Z",
|
||||
"last_commit": "2026-10-10T10:40:22Z"
|
||||
},
|
||||
{
|
||||
"login": "KattKnight",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 335,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T10:56:07Z",
|
||||
"last_commit": "2026-10-10T13:00:21Z"
|
||||
},
|
||||
{
|
||||
"login": "keller-lukas",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 480,
|
||||
"lines_deleted": 15,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T11:06:02Z",
|
||||
"last_commit": "2026-10-10T11:06:02Z"
|
||||
},
|
||||
{
|
||||
"login": "4papuce",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 39,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T12:43:28Z",
|
||||
"last_commit": "2026-10-10T12:43:28Z"
|
||||
},
|
||||
{
|
||||
"login": "vangel76",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 49,
|
||||
"commits": 53,
|
||||
"lines_added": 11727,
|
||||
"lines_deleted": 752,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T13:47:11Z",
|
||||
"last_commit": "2026-10-10T22:34:28Z"
|
||||
},
|
||||
{
|
||||
"login": "abdelkabirouadoukou",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 30,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T15:54:00Z",
|
||||
"last_commit": "2026-10-10T15:54:00Z"
|
||||
},
|
||||
{
|
||||
"login": "impostazioni",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 596,
|
||||
"lines_deleted": 34,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T17:29:09Z",
|
||||
"last_commit": "2026-10-10T20:52:28Z"
|
||||
},
|
||||
{
|
||||
"login": "alex-brazh",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 159,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-10T19:55:52Z",
|
||||
"last_commit": "2026-10-10T19:55:52Z"
|
||||
},
|
||||
{
|
||||
"login": "LloydNicholson",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 0,
|
||||
"lines_added": 0,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": null,
|
||||
"last_commit": null
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
|
|
@ -521,27 +989,27 @@
|
|||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 474,
|
||||
"lines_added": 346706,
|
||||
"lines_deleted": 19057
|
||||
"commits": 602,
|
||||
"lines_added": 374868,
|
||||
"lines_deleted": 20972
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 18,
|
||||
"lines_added": 13935,
|
||||
"lines_deleted": 854
|
||||
"commits": 25,
|
||||
"lines_added": 18034,
|
||||
"lines_deleted": 957
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 11,
|
||||
"lines_added": 2057,
|
||||
"lines_deleted": 361
|
||||
"commits": 16,
|
||||
"lines_added": 3654,
|
||||
"lines_deleted": 503
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
|
|
@ -557,9 +1025,9 @@
|
|||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 235,
|
||||
"lines_deleted": 22
|
||||
"commits": 2,
|
||||
"lines_added": 496,
|
||||
"lines_deleted": 35
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
|
|
|
|||
|
|
@ -56,3 +56,21 @@ sync_github_name = false
|
|||
|
||||
[people.liangforstudy]
|
||||
sync_github_name = true
|
||||
|
||||
[people.keller-lukas]
|
||||
real_name = "Lukas Keller"
|
||||
display_name = "Lukas Keller"
|
||||
sync_github_name = false
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ossini]
|
||||
sync_github_name = true
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "filmcraft-ac3"
|
||||
description = "Clean-room AC-3 (Dolby Digital, ATSC A/52) audio decoder"
|
||||
description = "Clean-room AC-3 and E-AC-3 (Dolby Digital / Dolby Digital Plus, ATSC A/52) audio decoder"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# filmcraft-ac3
|
||||
|
||||
Clean-room AC-3 (Dolby Digital) audio decoder in pure Rust. Layer L0: depends only on
|
||||
Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder in pure Rust. Layer L0: depends only on
|
||||
`thiserror`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg,
|
||||
liba52) was consulted; FFmpeg is used only as an external fixture generator and test oracle.
|
||||
|
||||
|
|
@ -11,14 +11,17 @@ publicly available standard: §5 bit stream syntax and semantics, §6 decoding o
|
|||
exponents, §7.2 parametric bit allocation (Tables 7.6-7.16 extracted mechanically from the
|
||||
standard's text), §7.3 mantissas and dither, §7.4 coupling, §7.5 rematrixing, §7.7.1 dynamic
|
||||
range, §7.9 transforms and block switching. The window is computed as the Kaiser-Bessel derived
|
||||
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶.
|
||||
window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. E-AC-3: Annex E §E2 bit stream syntax
|
||||
(syncinfo, bsi, audfrm, audblk; Tables E2.1-E2.13, frame exponent strategies from Table E2.10),
|
||||
§E3.3 modified parameters, §E3.4.2 AHT helper variables (to detect AHT), §E3.6 spectral extension
|
||||
(attenuation Table E3.14, which is 2^(-(code + 1)(bin + 1) / 15), checked against the table).
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
let mut dec = filmcraft_ac3::Decoder::new();
|
||||
let h = filmcraft_ac3::parse_header(frame)?; // rate, channels, frame size
|
||||
let out = dec.decode(frame)?; // 1536 samples per channel, WAV channel order
|
||||
let out = dec.decode(frame)?; // h.samples() per channel, WAV channel order
|
||||
dec.reset(); // before decoding from another position
|
||||
```
|
||||
|
||||
|
|
@ -26,7 +29,7 @@ dec.reset(); // before decoding from another
|
|||
|
||||
- Every audio coding mode (1+1, 1/0, 2/0, 3/0, 2/1, 3/1, 2/2, 3/2) with or without LFE; 32 /
|
||||
44.1 / 48 kHz and every frame size; bsid ≤ 8 (9 and 10, the half / quarter-rate variants, are
|
||||
accepted). E-AC-3 (bsid 11-16) is refused with `Error::Unsupported`.
|
||||
accepted).
|
||||
- D15 / D25 / D45 exponents with reuse across blocks; the parametric bit allocation in the
|
||||
standard's fixed-point steps, delta bit allocation, the all-zero SNR offset special case;
|
||||
grouped 3-, 5- and 11-level mantissas (groups shared across exponent sets), 7- and 15-level
|
||||
|
|
@ -43,6 +46,26 @@ dec.reset(); // before decoding from another
|
|||
- Errors (bad exponents, reserved codes, blocks running past the frame) return `Err`; garbage
|
||||
never panics.
|
||||
|
||||
## E-AC-3 (bsid 11-16)
|
||||
|
||||
- Independent substream 0 is decoded (stream types 0 and 2); dependent substreams (the channels
|
||||
beyond 5.1 of a 7.1 program) and further independent programs decode to no channels, so a 7.1
|
||||
stream plays as its 5.1 core. `Header::is_primary` tells them apart.
|
||||
- 1, 2, 3 or 6 blocks per syncframe (`Header::samples`), 32 / 44.1 / 48 kHz and the reduced rates
|
||||
16 / 22.05 / 24 kHz, every acmod with or without LFE.
|
||||
- The whole bsi (mixing and informational metadata, custom channel maps, addbsi) is parsed and
|
||||
skipped; audfrm: per-block or frame-based exponent strategies (exponents may be reused across
|
||||
syncframes of fewer than six blocks; the decoder keeps them until `reset`), the syntax-enable
|
||||
flags and their defaults, SNR offset strategies 1-3, converter fields, block start information.
|
||||
- Audio blocks share the AC-3 path: coupling with the default or transmitted band structure
|
||||
(`firstcplcos` / `firstcplleak` states), rematrixing with the Annex E band count, end mantissas
|
||||
derived from spectral extension.
|
||||
- Spectral extension: band structure, coordinates, blending factors, translation with wrap,
|
||||
banded RMS, the border / wrap-point notch filter, noise blending and scaling (§E3.6.4).
|
||||
- Refused with `Error::Unsupported`: the adaptive hybrid transform (when a channel uses it; `ahte`
|
||||
alone is fine) and enhanced coupling. Transient pre-noise processing data is read and not
|
||||
applied.
|
||||
|
||||
## Accuracy (vs FFmpeg as an external oracle)
|
||||
|
||||
AC-3 output is not bit-exact across decoders: zero-bit mantissas are filled with
|
||||
|
|
@ -56,10 +79,17 @@ FFmpeg's decode (worst channel SNR):
|
|||
| 1/0 32 kHz 96 kb/s | 94.1 dB | 95.7 dB |
|
||||
| 3/2 + LFE 48 kHz 448 kb/s | 81.4 dB | 90.2 dB |
|
||||
| 2/0 pink noise 128 kb/s (many zero-bit mantissas) | 27.7 dB | 27.9 dB |
|
||||
| E-AC-3 2/0 48 kHz 192 kb/s (tones, chirp) | 64.8 dB | 74.7 dB |
|
||||
| E-AC-3 2/0 44.1 kHz 64 kb/s with coupling and rematrixing | 65.5 dB | 66.1 dB |
|
||||
| E-AC-3 1/0 32 kHz 64 kb/s | 91.0 dB | 90.1 dB |
|
||||
| E-AC-3 3/2 + LFE 48 kHz 384 kb/s with mixing / informational metadata | 81.6 dB | 85.3 dB |
|
||||
| E-AC-3 2/0 pink noise 96 kb/s | 48.6 dB | 47.9 dB |
|
||||
|
||||
The noise row shows the difference is dither: two of our own decodes with different dither
|
||||
sequences differ as much as we differ from FFmpeg. In MPEG-2 TS (AVCHD-style `.mts`) and VOB the
|
||||
maximum sample difference to FFmpeg is 2.3·10⁻³.
|
||||
maximum sample difference to FFmpeg is 2.3·10⁻³. E-AC-3 through `filmcraft-codecs`
|
||||
(`tests/eac3_oracle.rs`): 5.1 in MP4 and Matroska 91.3 dB, stereo in QuickTime 87.0 dB and
|
||||
MPEG-TS 79.5 dB against FFmpeg's decode.
|
||||
|
||||
## Gaps
|
||||
|
||||
|
|
@ -67,4 +97,10 @@ maximum sample difference to FFmpeg is 2.3·10⁻³.
|
|||
§7.9.4.2 but not checked against an oracle.
|
||||
- No downmixing, no heavy compression (`compr`) or dialogue normalisation (both are metadata
|
||||
for playback systems), no CRC check (corrupt frames are caught by consistency checks).
|
||||
- E-AC-3 is not decoded.
|
||||
- FFmpeg's E-AC-3 encoder writes six-block syncframes with frame-based exponent strategies,
|
||||
coupling with the default band structure and none of the optional per-block syntax. Spectral
|
||||
extension (no free encoder writes it), fewer blocks per syncframe, the reduced rates and the
|
||||
per-block syntax (shared with AC-3) are implemented from the standard and covered by unit tests
|
||||
on synthetic data, not by an oracle.
|
||||
- E-AC-3: no adaptive hybrid transform, enhanced coupling, transient pre-noise processing, or
|
||||
channels from dependent substreams (7.1 plays as 5.1).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,634 @@
|
|||
//! E-AC-3 (ATSC A/52:2012 Annex E): the syncframe header and bit stream information (§E2.2.2),
|
||||
//! the audio frame (§E2.2.3) and spectral extension (§E2.2.4, §E3.6). The audio blocks share the
|
||||
//! AC-3 decoder (`Decoder::audio_block`), which reads the E-AC-3 differences from [`FrameInfo`].
|
||||
|
||||
use crate::{Bits, Error, Header, NFCHANS, Result};
|
||||
|
||||
/// Sample rates by fscod, and by fscod2 when fscod is 3 (Tables E2.2, E2.3).
|
||||
const RATES: [u32; 3] = [48_000, 44_100, 32_000];
|
||||
const REDUCED_RATES: [u32; 3] = [24_000, 22_050, 16_000];
|
||||
/// Audio blocks per syncframe by numblkscod (Table E2.4).
|
||||
const BLOCKS: [u8; 4] = [1, 2, 3, 6];
|
||||
|
||||
/// Exponent strategies of the six blocks for each frame-based strategy code (Table E2.10):
|
||||
/// 0 reuse, 1 D15, 2 D25, 3 D45.
|
||||
const FRAME_EXPSTR: [[u8; 6]; 32] = [
|
||||
[1, 0, 0, 0, 0, 0],
|
||||
[1, 0, 0, 0, 0, 3],
|
||||
[1, 0, 0, 0, 2, 0],
|
||||
[1, 0, 0, 0, 3, 3],
|
||||
[2, 0, 0, 2, 0, 0],
|
||||
[2, 0, 0, 2, 0, 3],
|
||||
[2, 0, 0, 3, 2, 0],
|
||||
[2, 0, 0, 3, 3, 3],
|
||||
[2, 0, 1, 0, 0, 0],
|
||||
[2, 0, 2, 0, 0, 3],
|
||||
[2, 0, 2, 0, 2, 0],
|
||||
[2, 0, 2, 0, 3, 3],
|
||||
[2, 0, 3, 2, 0, 0],
|
||||
[2, 0, 3, 2, 0, 3],
|
||||
[2, 0, 3, 3, 2, 0],
|
||||
[2, 0, 3, 3, 3, 3],
|
||||
[3, 1, 0, 0, 0, 0],
|
||||
[3, 1, 0, 0, 0, 3],
|
||||
[3, 2, 0, 0, 2, 0],
|
||||
[3, 2, 0, 0, 3, 3],
|
||||
[3, 2, 0, 2, 0, 0],
|
||||
[3, 2, 0, 2, 0, 3],
|
||||
[3, 2, 0, 3, 2, 0],
|
||||
[3, 2, 0, 3, 3, 3],
|
||||
[3, 3, 1, 0, 0, 0],
|
||||
[3, 3, 2, 0, 0, 3],
|
||||
[3, 3, 2, 0, 2, 0],
|
||||
[3, 3, 2, 0, 3, 3],
|
||||
[3, 3, 3, 2, 0, 0],
|
||||
[3, 3, 3, 2, 0, 3],
|
||||
[3, 3, 3, 3, 2, 0],
|
||||
[3, 3, 3, 3, 3, 3],
|
||||
];
|
||||
|
||||
/// Default coupling band structure by coupling sub-band (Table E2.12, "Default Coupling Banding
|
||||
/// Structure"; sub-band 0 never starts a combined band).
|
||||
const DEFAULT_CPLBNDSTRC: [bool; 18] = {
|
||||
let mut t = [false; 18];
|
||||
let ones = [8, 10, 11, 13, 14, 15, 16, 17];
|
||||
let mut i = 0;
|
||||
while i < ones.len() {
|
||||
t[ones[i]] = true;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// Default spectral extension band structure by sub-band (Table E2.11).
|
||||
const DEFAULT_SPXBNDSTRC: [bool; 17] = {
|
||||
let mut t = [false; 17];
|
||||
let mut i = 8;
|
||||
while i < 17 {
|
||||
t[i] = true;
|
||||
i += 2;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
/// First transform coefficient of spectral extension sub-band `k` (Table E3.13, k ≤ 17).
|
||||
const fn spx_band_bin(k: usize) -> usize {
|
||||
25 + 12 * k
|
||||
}
|
||||
|
||||
/// Spectral extension attenuation (Table E3.14): spxattentab[code][binindex] = 2^(-(code + 1) ·
|
||||
/// (binindex + 1) / 15), which the table lists to nine decimals.
|
||||
fn spx_atten(code: usize, binindex: usize) -> f32 {
|
||||
let e = (code as f64 + 1.0) * (binindex as f64 + 1.0) / 15.0;
|
||||
(-e).exp2() as f32
|
||||
}
|
||||
|
||||
/// Parse the header of an E-AC-3 syncframe (bsid 11-16) at the start of `b` (at least 7 bytes).
|
||||
pub(crate) fn parse_header(b: &[u8], bsid: u8) -> Result<Header> {
|
||||
let mut r = Bits::new(b.get(2..).unwrap_or_default());
|
||||
let strmtyp = r.read(2) as u8;
|
||||
let substreamid = r.read(3) as u8;
|
||||
let words = r.read(11) as usize + 1;
|
||||
let fscod = r.read(2) as usize;
|
||||
let (sample_rate, blocks) = match RATES.get(fscod) {
|
||||
Some(&rate) => (rate, BLOCKS[r.read(2) as usize & 3]),
|
||||
None => (*REDUCED_RATES.get(r.read(2) as usize).ok_or(Error::Invalid("reserved fscod2"))?, 6),
|
||||
};
|
||||
let acmod = r.read(3) as u8;
|
||||
let lfeon = r.bit();
|
||||
if strmtyp == 3 {
|
||||
return Err(Error::Unsupported("reserved E-AC-3 stream type 3".into()));
|
||||
}
|
||||
let frame_bytes = words * 2;
|
||||
if frame_bytes < 8 {
|
||||
return Err(Error::Invalid("E-AC-3 syncframe too short"));
|
||||
}
|
||||
// frame bits per second of audio
|
||||
let bits = frame_bytes as u64 * 8 * u64::from(sample_rate) / (256 * u64::from(blocks));
|
||||
let bitrate_kbps = u32::try_from(bits / 1000).unwrap_or(u32::MAX);
|
||||
Ok(Header { sample_rate, bitrate_kbps, frame_bytes, bsid, acmod, lfeon, blocks, strmtyp, substreamid })
|
||||
}
|
||||
|
||||
/// What the audio frame and blocks need from the bit stream information.
|
||||
pub(crate) struct Bsi {
|
||||
pub strmtyp: u8,
|
||||
/// Sample rate code for the bit allocation's hearing threshold: fscod, or fscod2 at the
|
||||
/// reduced rates.
|
||||
pub fscod: usize,
|
||||
pub numblkscod: usize,
|
||||
}
|
||||
|
||||
/// Read syncinfo and bsi (§E2.2.1-2), leaving `r` at the audio frame.
|
||||
pub(crate) fn read_bsi(r: &mut Bits) -> Result<Bsi> {
|
||||
r.skip(16); // syncword
|
||||
let strmtyp = r.read(2) as u8;
|
||||
r.skip(3 + 11); // substreamid, frmsiz
|
||||
let fscod = r.read(2) as usize;
|
||||
let (rate_code, numblkscod) = if fscod == 3 {
|
||||
let fscod2 = r.read(2) as usize;
|
||||
if fscod2 == 3 {
|
||||
return Err(Error::Invalid("reserved fscod2"));
|
||||
}
|
||||
(fscod2, 3)
|
||||
} else {
|
||||
(fscod, r.read(2) as usize)
|
||||
};
|
||||
let nblocks = BLOCKS[numblkscod & 3] as usize;
|
||||
let acmod = r.read(3) as usize;
|
||||
let lfeon = r.bit();
|
||||
r.skip(5 + 5); // bsid, dialnorm
|
||||
if r.bit() {
|
||||
r.skip(8); // compr
|
||||
}
|
||||
if acmod == 0 {
|
||||
r.skip(5); // dialnorm2
|
||||
if r.bit() {
|
||||
r.skip(8); // compr2
|
||||
}
|
||||
}
|
||||
if strmtyp == 1 && r.bit() {
|
||||
r.skip(16); // chanmap
|
||||
}
|
||||
if r.bit() {
|
||||
// mixing metadata
|
||||
if acmod > 2 {
|
||||
r.skip(2); // dmixmod
|
||||
}
|
||||
if acmod & 1 != 0 && acmod > 2 {
|
||||
r.skip(6); // ltrtcmixlev, lorocmixlev
|
||||
}
|
||||
if acmod & 4 != 0 {
|
||||
r.skip(6); // ltrtsurmixlev, lorosurmixlev
|
||||
}
|
||||
if lfeon && r.bit() {
|
||||
r.skip(5); // lfemixlevcod
|
||||
}
|
||||
if strmtyp == 0 {
|
||||
if r.bit() {
|
||||
r.skip(6); // pgmscl
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(6); // pgmscl2
|
||||
}
|
||||
if r.bit() {
|
||||
r.skip(6); // extpgmscl
|
||||
}
|
||||
match r.read(2) {
|
||||
1 => r.skip(5), // premixcmpsel, drcsrc, premixcmpscl
|
||||
2 => r.skip(12),
|
||||
// mixdeflen: the mixdata field (through mixdatafill) is mixdeflen + 2 bytes
|
||||
3 => {
|
||||
let len = r.read(5) as usize;
|
||||
r.skip(8 * (len + 2));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if acmod < 2 {
|
||||
if r.bit() {
|
||||
r.skip(14); // panmean, paninfo
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(14); // panmean2, paninfo2
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
// frame mixing configuration information
|
||||
if numblkscod == 0 {
|
||||
r.skip(5);
|
||||
} else {
|
||||
for _ in 0..nblocks {
|
||||
if r.bit() {
|
||||
r.skip(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
// informational metadata
|
||||
r.skip(5); // bsmod, copyrightb, origbs
|
||||
if acmod == 2 {
|
||||
r.skip(4); // dsurmod, dheadphonmod
|
||||
}
|
||||
if acmod >= 6 {
|
||||
r.skip(2); // dsurexmod
|
||||
}
|
||||
if r.bit() {
|
||||
r.skip(8); // mixlevel, roomtyp, adconvtyp
|
||||
}
|
||||
if acmod == 0 && r.bit() {
|
||||
r.skip(8); // mixlevel2, roomtyp2, adconvtyp2
|
||||
}
|
||||
if fscod < 3 {
|
||||
r.skip(1); // sourcefscod
|
||||
}
|
||||
}
|
||||
if strmtyp == 0 && numblkscod != 3 {
|
||||
r.skip(1); // convsync
|
||||
}
|
||||
if strmtyp == 2 && (numblkscod == 3 || r.bit()) {
|
||||
r.skip(6); // blkid → frmsizecod
|
||||
}
|
||||
if r.bit() {
|
||||
let l = r.read(6) as usize;
|
||||
r.skip((l + 1) * 8); // addbsi
|
||||
}
|
||||
Ok(Bsi { strmtyp, fscod: rate_code, numblkscod })
|
||||
}
|
||||
|
||||
/// The audio frame (§E2.2.3) and the state that runs from block to block within a syncframe.
|
||||
pub(crate) struct FrameInfo {
|
||||
pub strmtyp: u8,
|
||||
pub blkswe: bool,
|
||||
pub dithflage: bool,
|
||||
pub bamode: bool,
|
||||
pub frmfgaincode: bool,
|
||||
pub dbaflde: bool,
|
||||
pub skipflde: bool,
|
||||
pub snroffststr: u8,
|
||||
pub frmcsnroffst: i32,
|
||||
pub frmfsnroffst: i32,
|
||||
pub cplstre: [bool; 6],
|
||||
pub cplinu: [bool; 6],
|
||||
pub cplexpstr: [u8; 6],
|
||||
pub chexpstr: [[u8; 5]; 6],
|
||||
pub lfeexpstr: [u8; 6],
|
||||
/// Spectral extension attenuation code per channel (spxattencod), when in use.
|
||||
pub spxatten: [Option<usize>; 5],
|
||||
pub firstcplcos: [bool; 5],
|
||||
pub firstcplleak: bool,
|
||||
/// Coupling band structure by absolute coupling sub-band.
|
||||
pub cplbndstrc: [bool; 18],
|
||||
pub spx: Spx,
|
||||
}
|
||||
|
||||
impl FrameInfo {
|
||||
/// Read the audio frame. `words` is the syncframe size in 16-bit words.
|
||||
pub(crate) fn read(r: &mut Bits, bsi: &Bsi, acmod: usize, lfeon: bool, nblocks: usize, words: usize) -> Result<FrameInfo> {
|
||||
let nf = NFCHANS[acmod & 7];
|
||||
let six = bsi.numblkscod == 3;
|
||||
let (expstre, ahte) = if six { (r.bit(), r.bit()) } else { (true, false) };
|
||||
let snroffststr = r.read(2) as u8;
|
||||
if snroffststr == 3 {
|
||||
return Err(Error::Invalid("reserved SNR offset strategy"));
|
||||
}
|
||||
let transproce = r.bit();
|
||||
let mut e = FrameInfo {
|
||||
strmtyp: bsi.strmtyp,
|
||||
blkswe: r.bit(),
|
||||
dithflage: r.bit(),
|
||||
bamode: r.bit(),
|
||||
frmfgaincode: r.bit(),
|
||||
dbaflde: r.bit(),
|
||||
skipflde: r.bit(),
|
||||
snroffststr,
|
||||
frmcsnroffst: 0,
|
||||
frmfsnroffst: 0,
|
||||
cplstre: [false; 6],
|
||||
cplinu: [false; 6],
|
||||
cplexpstr: [0; 6],
|
||||
chexpstr: [[0; 5]; 6],
|
||||
lfeexpstr: [0; 6],
|
||||
spxatten: [None; 5],
|
||||
firstcplcos: [true; 5],
|
||||
firstcplleak: true,
|
||||
cplbndstrc: DEFAULT_CPLBNDSTRC,
|
||||
spx: Spx::new(),
|
||||
};
|
||||
let spxattene = r.bit();
|
||||
if acmod > 1 {
|
||||
e.cplstre[0] = true;
|
||||
e.cplinu[0] = r.bit();
|
||||
for blk in 1..nblocks {
|
||||
e.cplstre[blk] = r.bit();
|
||||
e.cplinu[blk] = if e.cplstre[blk] { r.bit() } else { e.cplinu[blk - 1] };
|
||||
}
|
||||
}
|
||||
if expstre {
|
||||
for blk in 0..nblocks {
|
||||
if e.cplinu[blk] {
|
||||
e.cplexpstr[blk] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
e.chexpstr[blk][ch] = r.read(2) as u8;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// frame-based strategies (six blocks only)
|
||||
if acmod > 1 && e.cplinu.iter().any(|&c| c) {
|
||||
e.cplexpstr = FRAME_EXPSTR[r.read(5) as usize & 31];
|
||||
}
|
||||
for ch in 0..nf {
|
||||
let s = FRAME_EXPSTR[r.read(5) as usize & 31];
|
||||
for (blk, &v) in s.iter().enumerate() {
|
||||
e.chexpstr[blk][ch] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lfeon {
|
||||
for blk in 0..nblocks {
|
||||
e.lfeexpstr[blk] = r.read(1) as u8;
|
||||
}
|
||||
}
|
||||
if bsi.strmtyp == 0 && (six || r.bit()) {
|
||||
r.skip(5 * nf); // convexpstr
|
||||
}
|
||||
if ahte {
|
||||
// AHT flags exist for channels whose exponents are sent once in the frame (§E3.4.2)
|
||||
let unsupported = || Err(Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
|
||||
let ncplblks = e.cplinu.iter().filter(|&&c| c).count();
|
||||
let ncplregs = (0..6).filter(|&b| e.cplstre[b] || e.cplexpstr[b] != 0).count();
|
||||
if ncplblks == 6 && ncplregs == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if (0..6).filter(|&b| e.chexpstr[b][ch] != 0).count() == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
}
|
||||
if lfeon && e.lfeexpstr.iter().filter(|&&s| s != 0).count() == 1 && r.bit() {
|
||||
return unsupported();
|
||||
}
|
||||
}
|
||||
if snroffststr == 0 {
|
||||
e.frmcsnroffst = r.read(6) as i32;
|
||||
e.frmfsnroffst = r.read(4) as i32;
|
||||
}
|
||||
if transproce {
|
||||
// transient pre-noise processing (§E3.7) is not applied
|
||||
for _ in 0..nf {
|
||||
if r.bit() {
|
||||
r.skip(10 + 8); // transprocloc, transproclen
|
||||
}
|
||||
}
|
||||
}
|
||||
if spxattene {
|
||||
for ch in 0..nf {
|
||||
if r.bit() {
|
||||
e.spxatten[ch] = Some(r.read(5) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
if bsi.numblkscod != 0 && r.bit() {
|
||||
// blkstrtinfo: (blocks - 1) × (4 + ceil(log2(words per frame))) bits (§E2.3.2.27)
|
||||
let log2 = (usize::BITS - words.saturating_sub(1).leading_zeros()) as usize;
|
||||
r.skip((nblocks - 1) * (4 + log2));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spectral extension parameters (§E2.2.4, §E3.6), carried from block to block of a syncframe.
|
||||
pub(crate) struct Spx {
|
||||
pub inu: bool,
|
||||
pub chinspx: [bool; 5],
|
||||
pub begf: usize,
|
||||
strtf: usize,
|
||||
/// First sub-band and one past the last (spx_begin_subbnd, spx_end_subbnd).
|
||||
begin: usize,
|
||||
end: usize,
|
||||
bndstrc: [bool; 17],
|
||||
/// Band sizes in coefficients (spxbndsztab) and their count (nspxbnds).
|
||||
sizes: [usize; 17],
|
||||
nbands: usize,
|
||||
first: [bool; 5],
|
||||
co: [[f32; 17]; 5],
|
||||
nblend: [[f32; 17]; 5],
|
||||
sblend: [[f32; 17]; 5],
|
||||
}
|
||||
|
||||
impl Spx {
|
||||
fn new() -> Spx {
|
||||
Spx {
|
||||
inu: false,
|
||||
chinspx: [false; 5],
|
||||
begf: 0,
|
||||
strtf: 0,
|
||||
begin: 0,
|
||||
end: 0,
|
||||
bndstrc: DEFAULT_SPXBNDSTRC,
|
||||
sizes: [0; 17],
|
||||
nbands: 0,
|
||||
first: [true; 5],
|
||||
co: [[0.0; 17]; 5],
|
||||
nblend: [[0.0; 17]; 5],
|
||||
sblend: [[1.0; 17]; 5],
|
||||
}
|
||||
}
|
||||
|
||||
/// First coefficient of the extension region.
|
||||
pub fn begin_bin(&self) -> usize {
|
||||
spx_band_bin(self.begin)
|
||||
}
|
||||
|
||||
/// Read the strategy and coordinates of block `blk`.
|
||||
pub(crate) fn read(&mut self, r: &mut Bits, blk: usize, acmod: usize, nf: usize) -> Result<()> {
|
||||
if blk == 0 || r.bit() {
|
||||
self.inu = r.bit();
|
||||
if self.inu {
|
||||
if acmod == 1 {
|
||||
self.chinspx = [true, false, false, false, false];
|
||||
} else {
|
||||
for ch in 0..nf {
|
||||
self.chinspx[ch] = r.bit();
|
||||
}
|
||||
}
|
||||
self.strtf = r.read(2) as usize;
|
||||
self.begf = r.read(3) as usize;
|
||||
let endf = r.read(3) as usize;
|
||||
self.begin = if self.begf < 6 { self.begf + 2 } else { self.begf * 2 - 3 };
|
||||
self.end = if endf < 3 { endf + 5 } else { endf * 2 + 3 };
|
||||
if r.bit() {
|
||||
for b in self.begin + 1..self.end {
|
||||
self.bndstrc[b] = r.bit();
|
||||
}
|
||||
}
|
||||
if self.begin >= self.end {
|
||||
return Err(Error::Invalid("spectral extension ends before it begins"));
|
||||
}
|
||||
if self.strtf >= self.begin {
|
||||
return Err(Error::Invalid("spectral extension copies from above its start"));
|
||||
}
|
||||
self.nbands = 1;
|
||||
self.sizes = [0; 17];
|
||||
self.sizes[0] = 12;
|
||||
for b in self.begin + 1..self.end {
|
||||
if self.bndstrc[b] {
|
||||
self.sizes[self.nbands - 1] += 12;
|
||||
} else {
|
||||
self.sizes[self.nbands] = 12;
|
||||
self.nbands += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.chinspx = [false; 5];
|
||||
self.first = [true; 5];
|
||||
}
|
||||
}
|
||||
if !self.inu {
|
||||
return Ok(());
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if !self.chinspx[ch] {
|
||||
self.first[ch] = true;
|
||||
continue;
|
||||
}
|
||||
let coe = std::mem::replace(&mut self.first[ch], false) || r.bit();
|
||||
if !coe {
|
||||
continue;
|
||||
}
|
||||
let blend = r.read(5) as f32 / 32.0;
|
||||
let mstr = r.read(2) as i32;
|
||||
// coordinates (§E3.6.3) and blending factors (§E3.6.4.2.1)
|
||||
let mut mant = self.begin_bin() as f32;
|
||||
let end = spx_band_bin(self.end) as f32;
|
||||
for b in 0..self.nbands {
|
||||
let e = r.read(4) as i32;
|
||||
let m = r.read(2) as f32;
|
||||
let v = if e == 15 { m / 4.0 } else { (m + 4.0) / 8.0 };
|
||||
self.co[ch][b] = v * 2f32.powi(-(e + 3 * mstr));
|
||||
let size = self.sizes[b] as f32;
|
||||
let nratio = ((mant + 0.5 * size) / end - blend).clamp(0.0, 1.0);
|
||||
self.nblend[ch][b] = nratio.sqrt();
|
||||
self.sblend[ch][b] = (1.0 - nratio).sqrt();
|
||||
mant += size;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synthesize the extension region of one channel's coefficients (§E3.6.4): translate the
|
||||
/// low band up, notch-filter the borders when attenuation is on, blend with `noise` (zero
|
||||
/// mean, unit variance) scaled to the band's energy, and scale by the coordinates.
|
||||
pub(crate) fn synthesize(&self, ch: usize, atten: Option<usize>, tc: &mut [f32; 256], mut noise: impl FnMut() -> f32) {
|
||||
let copystart = spx_band_bin(self.strtf);
|
||||
let copyend = self.begin_bin();
|
||||
let sizes = &self.sizes[..self.nbands];
|
||||
// translation
|
||||
let mut wrap = [false; 17];
|
||||
let (mut copy, mut insert) = (copystart, copyend);
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
if copy + size > copyend {
|
||||
copy = copystart;
|
||||
wrap[b] = true;
|
||||
}
|
||||
for _ in 0..size {
|
||||
if copy == copyend {
|
||||
copy = copystart;
|
||||
}
|
||||
if let (Some(&v), true) = (tc.get(copy), insert < tc.len()) {
|
||||
tc[insert] = v;
|
||||
}
|
||||
insert += 1;
|
||||
copy += 1;
|
||||
}
|
||||
}
|
||||
// banded RMS energy of the translated coefficients
|
||||
let mut rms = [0f32; 17];
|
||||
let mut at = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
let band = tc.get(at..at + size).unwrap_or_default();
|
||||
rms[b] = (band.iter().map(|v| v * v).sum::<f32>() / size as f32).sqrt();
|
||||
at += size;
|
||||
}
|
||||
// notch filter around the baseband / extension border and each wrap point
|
||||
if let Some(code) = atten {
|
||||
let g = [spx_atten(code, 0), spx_atten(code, 1), spx_atten(code, 2)];
|
||||
let mut notch = |center: usize| {
|
||||
for (k, gain) in [g[0], g[1], g[2], g[1], g[0]].into_iter().enumerate() {
|
||||
if let Some(v) = (center + k).checked_sub(2).and_then(|i| tc.get_mut(i)) {
|
||||
*v *= gain;
|
||||
}
|
||||
}
|
||||
};
|
||||
notch(copyend);
|
||||
let mut start = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
if b > 0 && wrap[b] {
|
||||
notch(start);
|
||||
}
|
||||
start += size;
|
||||
}
|
||||
}
|
||||
// noise blending and scaling
|
||||
let mut at = copyend;
|
||||
for (b, &size) in sizes.iter().enumerate() {
|
||||
let nscale = rms[b] * self.nblend[ch][b];
|
||||
let sscale = self.sblend[ch][b];
|
||||
let co = self.co[ch][b] * 32.0;
|
||||
for _ in 0..size {
|
||||
let n = noise();
|
||||
if let Some(v) = tc.get_mut(at) {
|
||||
*v = (*v * sscale + n * nscale) * co;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attenuation_matches_table_e3_14() {
|
||||
// rows 0, 4, 14 and 31 as printed
|
||||
let rows: [(usize, [f64; 3]); 4] = [
|
||||
(0, [0.954841604, 0.911722489, 0.870550563]),
|
||||
(4, [0.793700526, 0.629960525, 0.500000000]),
|
||||
(14, [0.500000000, 0.250000000, 0.125000000]),
|
||||
(31, [0.227930622, 0.051952369, 0.011841536]),
|
||||
];
|
||||
for (code, want) in rows {
|
||||
for (i, w) in want.into_iter().enumerate() {
|
||||
assert!((f64::from(spx_atten(code, i)) - w).abs() < 1e-7, "{code} {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_band_structures() {
|
||||
assert_eq!(DEFAULT_CPLBNDSTRC.iter().filter(|&&b| b).count(), 8);
|
||||
assert!(DEFAULT_CPLBNDSTRC[8] && !DEFAULT_CPLBNDSTRC[9] && !DEFAULT_CPLBNDSTRC[12] && DEFAULT_CPLBNDSTRC[17]);
|
||||
assert_eq!(DEFAULT_SPXBNDSTRC.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect::<Vec<_>>(), vec![8, 10, 12, 14, 16]);
|
||||
// every frame-based strategy sends new exponents in block 0
|
||||
assert!(FRAME_EXPSTR.iter().all(|s| s[0] != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectral_extension_translation_and_scaling() {
|
||||
// begin at sub-band 2 (bin 49), end at 5 (bin 85), copy from sub-band 0 (bin 25): three
|
||||
// 12-bin bands, the third wraps back to the copy start
|
||||
let mut s = Spx::new();
|
||||
s.inu = true;
|
||||
(s.strtf, s.begin, s.end, s.nbands) = (0, 2, 5, 3);
|
||||
s.sizes[..3].copy_from_slice(&[12, 12, 12]);
|
||||
s.co[0][..3].copy_from_slice(&[1.0 / 32.0, 0.5 / 32.0, 0.25 / 32.0]);
|
||||
s.nblend[0] = [0.0; 17];
|
||||
s.sblend[0] = [1.0; 17];
|
||||
let mut tc = [0f32; 256];
|
||||
for (i, v) in tc[25..49].iter_mut().enumerate() {
|
||||
*v = i as f32 + 1.0;
|
||||
}
|
||||
s.synthesize(0, None, &mut tc, || 0.0);
|
||||
assert_eq!(tc[49], 1.0);
|
||||
assert_eq!(tc[61], 13.0 * 0.5);
|
||||
assert_eq!(tc[73], 1.0 * 0.25);
|
||||
assert_eq!(tc[84], 12.0 * 0.25);
|
||||
assert_eq!(tc[85], 0.0);
|
||||
// pure noise: the band gets the energy of its translated coefficients
|
||||
s.nblend[0] = [1.0; 17];
|
||||
s.sblend[0] = [0.0; 17];
|
||||
s.co[0] = [1.0 / 32.0; 17];
|
||||
let mut tc2 = [0f32; 256];
|
||||
tc2[25..49].fill(2.0);
|
||||
let mut k = 0;
|
||||
s.synthesize(0, None, &mut tc2, || {
|
||||
k += 1;
|
||||
if k % 2 == 0 { 1.0 } else { -1.0 }
|
||||
});
|
||||
assert!(tc2[49..85].iter().all(|v| v.abs() == 2.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Clean-room AC-3 (Dolby Digital) audio decoder, written from ATSC A/52:2012 ("Digital Audio
|
||||
//! Compression Standard", §5 bit stream syntax, §6-7 decoding).
|
||||
//! Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder, written from
|
||||
//! ATSC A/52:2012 ("Digital Audio Compression Standard", §5 bit stream syntax, §6-7 decoding,
|
||||
//! Annex E for E-AC-3).
|
||||
//!
|
||||
//! - All audio coding modes (1+1, 1/0 … 3/2) with or without LFE, 32 / 44.1 / 48 kHz, every
|
||||
//! frame size (Table 5.18).
|
||||
|
|
@ -11,10 +12,15 @@
|
|||
//! - Output: planar f32 at full scale ±1, channels in WAV / SMPTE order (L R C LFE Ls Rs); no
|
||||
//! downmix.
|
||||
//!
|
||||
//! E-AC-3 (bsid 11-16) is recognised and refused.
|
||||
//! E-AC-3 (bsid 11-16, Annex E): independent substream 0 (dependent substreams and further
|
||||
//! programs are skipped), 1 / 2 / 3 / 6 blocks per syncframe, the reduced sample rates, frame-based
|
||||
//! exponent strategies and the other frame-level syntax, and spectral extension (§E3.6). The
|
||||
//! adaptive hybrid transform and enhanced coupling are refused with [`Error::Unsupported`];
|
||||
//! transient pre-noise processing data is read and not applied.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod eac3;
|
||||
mod tables;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
|
@ -47,12 +53,35 @@ pub struct Header {
|
|||
pub bsid: u8,
|
||||
pub acmod: u8,
|
||||
pub lfeon: bool,
|
||||
/// Audio blocks (of 256 samples per channel) in the syncframe: 6 for AC-3; 1, 2, 3 or 6 for
|
||||
/// E-AC-3.
|
||||
pub blocks: u8,
|
||||
/// E-AC-3 stream type (Table E2.1): 0 independent, 1 dependent, 2 independent converted from
|
||||
/// AC-3. 0 for AC-3.
|
||||
pub strmtyp: u8,
|
||||
/// E-AC-3 substream id (0 for AC-3).
|
||||
pub substreamid: u8,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
/// Output channels (full bandwidth + LFE).
|
||||
pub fn channels(&self) -> usize {
|
||||
NFCHANS[self.acmod as usize] + self.lfeon as usize
|
||||
NFCHANS[self.acmod as usize & 7] + self.lfeon as usize
|
||||
}
|
||||
/// Samples per channel the syncframe decodes to.
|
||||
pub fn samples(&self) -> usize {
|
||||
256 * self.blocks as usize
|
||||
}
|
||||
/// Whether this is an E-AC-3 syncframe (bsid 11-16).
|
||||
pub fn is_eac3(&self) -> bool {
|
||||
self.bsid > 10
|
||||
}
|
||||
/// Whether the syncframe carries the program [`Decoder::decode`] outputs: AC-3, or E-AC-3
|
||||
/// independent substream 0 (§E2.3.1.2). [`Decoder::decode`] returns no channels for the
|
||||
/// others: dependent substreams (the channels beyond 5.1 of a 7.1 program) and further
|
||||
/// independent programs.
|
||||
pub fn is_primary(&self) -> bool {
|
||||
self.strmtyp != 1 && self.substreamid == 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,8 +93,11 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
|
|||
let fscod = b[4] >> 6;
|
||||
let frmsizecod = b[4] & 0x3F;
|
||||
let bsid = b[5] >> 3;
|
||||
if bsid > 10 {
|
||||
return Err(Error::Unsupported(format!("bsid {bsid} (E-AC-3)")));
|
||||
if (11..=16).contains(&bsid) {
|
||||
return eac3::parse_header(b, bsid);
|
||||
}
|
||||
if bsid > 16 {
|
||||
return Err(Error::Unsupported(format!("bsid {bsid}")));
|
||||
}
|
||||
if fscod == 3 || frmsizecod >= 38 {
|
||||
return Err(Error::Invalid("reserved fscod / frmsizecod"));
|
||||
|
|
@ -91,7 +123,7 @@ pub fn parse_header(b: &[u8]) -> Result<Header> {
|
|||
r.skip(2);
|
||||
}
|
||||
let lfeon = r.read(1) == 1;
|
||||
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon })
|
||||
Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon, blocks: 6, strmtyp: 0, substreamid: 0 })
|
||||
}
|
||||
|
||||
/// MSB-first bit reader; reads past the end give zeros.
|
||||
|
|
@ -469,7 +501,9 @@ struct Frame {
|
|||
chincpl: [bool; 5],
|
||||
phsflginu: bool,
|
||||
cplbegf: usize,
|
||||
cplendf: usize,
|
||||
/// cplendf + 3: one past the last coupling sub-band (with spectral extension cplendf may be
|
||||
/// negative, §E3.3.1).
|
||||
cplend: usize,
|
||||
/// Coupling band of each coupling sub-band (relative to cplbegf).
|
||||
sub_to_band: [usize; 18],
|
||||
ncplbnd: usize,
|
||||
|
|
@ -500,7 +534,7 @@ impl Frame {
|
|||
chincpl: [false; 5],
|
||||
phsflginu: false,
|
||||
cplbegf: 0,
|
||||
cplendf: 0,
|
||||
cplend: 0,
|
||||
sub_to_band: [0; 18],
|
||||
ncplbnd: 0,
|
||||
cplco: [[0.0; 18]; 5],
|
||||
|
|
@ -523,7 +557,7 @@ impl Frame {
|
|||
37 + 12 * self.cplbegf
|
||||
}
|
||||
fn cplendmant(&self) -> usize {
|
||||
37 + 12 * (self.cplendf + 3)
|
||||
37 + 12 * self.cplend
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +656,9 @@ pub struct Decoder {
|
|||
delay: [[f32; 256]; 6],
|
||||
dither: u32,
|
||||
seed: u32,
|
||||
/// E-AC-3: the block state of the last syncframe (acmod, lfeon, sample rate code), whose
|
||||
/// exponents a syncframe of fewer than six blocks may reuse.
|
||||
carry: Option<(usize, bool, usize, Box<Frame>)>,
|
||||
}
|
||||
|
||||
impl Default for Decoder {
|
||||
|
|
@ -630,7 +667,8 @@ impl Default for Decoder {
|
|||
}
|
||||
}
|
||||
|
||||
/// A decoded syncframe: 1536 samples per channel, WAV channel order.
|
||||
/// A decoded syncframe: [`Header::samples`] samples per channel (1536 for AC-3), WAV channel
|
||||
/// order. No channels for an E-AC-3 syncframe that is not [`Header::is_primary`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Decoded {
|
||||
pub header: Header,
|
||||
|
|
@ -639,7 +677,7 @@ pub struct Decoded {
|
|||
|
||||
impl Decoder {
|
||||
pub fn new() -> Decoder {
|
||||
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678 }
|
||||
Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678, carry: None }
|
||||
}
|
||||
|
||||
/// Seed the dither generator (dither is decoder-specific noise; tests compare two seeds).
|
||||
|
|
@ -650,6 +688,7 @@ impl Decoder {
|
|||
/// Forget the overlap (before decoding from another position).
|
||||
pub fn reset(&mut self) {
|
||||
self.delay = [[0.0; 256]; 6];
|
||||
self.carry = None;
|
||||
}
|
||||
|
||||
fn dither(&mut self) -> f32 {
|
||||
|
|
@ -658,12 +697,20 @@ impl Decoder {
|
|||
((self.dither >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0) * 0.707
|
||||
}
|
||||
|
||||
/// Zero-mean, unit-variance noise for spectral extension (§E3.6.4.2.4): uniform in ±√3.
|
||||
fn noise(&mut self) -> f32 {
|
||||
self.dither() * (3f32.sqrt() / 0.707)
|
||||
}
|
||||
|
||||
/// Decode one syncframe.
|
||||
pub fn decode(&mut self, frame: &[u8]) -> Result<Decoded> {
|
||||
let h = parse_header(frame)?;
|
||||
if frame.len() < h.frame_bytes {
|
||||
return Err(Error::Invalid("truncated syncframe"));
|
||||
}
|
||||
if h.is_eac3() {
|
||||
return self.decode_eac3(&frame[..h.frame_bytes], h);
|
||||
}
|
||||
let fscod = (frame[4] >> 6) as usize;
|
||||
// Dither is reseeded per syncframe from its CRC words (plus the configured seed), so a
|
||||
// frame decodes to the same samples however it was reached (seeking, render caches).
|
||||
|
|
@ -708,39 +755,83 @@ impl Decoder {
|
|||
let l = r.read(6) as usize;
|
||||
r.skip((l + 1) * 8);
|
||||
}
|
||||
let nfchans = NFCHANS[acmod];
|
||||
let mut f = Frame::new(nfchans, fscod);
|
||||
let mut f = Frame::new(NFCHANS[acmod], fscod);
|
||||
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, 6, None)?;
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
}
|
||||
|
||||
/// Decode one E-AC-3 syncframe (`frame` is exactly the syncframe).
|
||||
fn decode_eac3(&mut self, frame: &[u8], h: Header) -> Result<Decoded> {
|
||||
if !h.is_primary() {
|
||||
return Ok(Decoded { header: h, channels: Vec::new() });
|
||||
}
|
||||
// E-AC-3 has a single CRC, at the end (§E2.2.6): reseed the dither from the last words
|
||||
let n = frame.len();
|
||||
let tail = frame.get(n.saturating_sub(4)..).and_then(|t| <[u8; 4]>::try_from(t).ok()).unwrap_or_default();
|
||||
self.dither = self.seed ^ u32::from_be_bytes(tail).wrapping_mul(0x9E37_79B9);
|
||||
let mut r = Bits::new(frame);
|
||||
let bsi = eac3::read_bsi(&mut r)?;
|
||||
let (acmod, lfeon) = (h.acmod as usize & 7, h.lfeon);
|
||||
let nblocks = h.blocks as usize;
|
||||
let mut e = eac3::FrameInfo::read(&mut r, &bsi, acmod, lfeon, nblocks, n / 2)?;
|
||||
let mut f = match self.carry.take() {
|
||||
Some((a, l, fs, f)) if (a, l, fs) == (acmod, lfeon, bsi.fscod) => f,
|
||||
_ => Box::new(Frame::new(NFCHANS[acmod], bsi.fscod)),
|
||||
};
|
||||
let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, nblocks, Some(&mut e))?;
|
||||
self.carry = Some((acmod, lfeon, bsi.fscod, f));
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
}
|
||||
|
||||
/// Decode `nblocks` audio blocks and synthesize them (inverse transform, overlap-add), in coded
|
||||
/// channel order with the LFE last.
|
||||
fn blocks(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, nblocks: usize, mut x: Option<&mut eac3::FrameInfo>) -> Result<Vec<Vec<f32>>> {
|
||||
let nfchans = f.nfchans;
|
||||
let nout = nfchans + lfeon as usize;
|
||||
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(1536); nout];
|
||||
for blk in 0..6 {
|
||||
let mut pcm: Vec<Vec<f32>> = vec![Vec::with_capacity(256 * nblocks); nout];
|
||||
for blk in 0..nblocks {
|
||||
let mut coefs = [[0f32; 256]; 6];
|
||||
self.audio_block(&mut r, &mut f, acmod, lfeon, blk, &mut coefs)?;
|
||||
self.audio_block(r, f, acmod, lfeon, blk, &mut coefs, x.as_deref_mut())?;
|
||||
if r.overrun() {
|
||||
return Err(Error::Invalid("audio block runs past the frame"));
|
||||
}
|
||||
// inverse transform, overlap-add
|
||||
let mut buf = [0f32; 512];
|
||||
for ch in 0..nout {
|
||||
for (ch, out) in pcm.iter_mut().enumerate() {
|
||||
let src = if ch < nfchans { ch } else { LFE };
|
||||
let short = ch < nfchans && f.blksw[ch];
|
||||
imdct(&coefs[src], short, &mut buf);
|
||||
let d = &mut self.delay[src];
|
||||
for n in 0..256 {
|
||||
pcm[ch].push(2.0 * (buf[n] + d[n]));
|
||||
out.push(2.0 * (buf[n] + d[n]));
|
||||
d[n] = buf[256 + n];
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) })
|
||||
Ok(pcm)
|
||||
}
|
||||
|
||||
fn audio_block(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, blk: usize, coefs: &mut [[f32; 256]; 6]) -> Result<()> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn audio_block(
|
||||
&mut self,
|
||||
r: &mut Bits,
|
||||
f: &mut Frame,
|
||||
acmod: usize,
|
||||
lfeon: bool,
|
||||
blk: usize,
|
||||
coefs: &mut [[f32; 256]; 6],
|
||||
mut x: Option<&mut eac3::FrameInfo>,
|
||||
) -> Result<()> {
|
||||
let nf = f.nfchans;
|
||||
for ch in 0..nf {
|
||||
f.blksw[ch] = r.bit();
|
||||
let eac3 = x.is_some();
|
||||
match x.as_deref() {
|
||||
Some(e) if !e.blkswe => f.blksw = [false; 5],
|
||||
_ => (0..nf).for_each(|ch| f.blksw[ch] = r.bit()),
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.dithflag[ch] = r.bit();
|
||||
match x.as_deref() {
|
||||
// dither on when the flags are not sent (§E2.2.4)
|
||||
Some(e) if !e.dithflage => f.dithflag = [true; 5],
|
||||
_ => (0..nf).for_each(|ch| f.dithflag[ch] = r.bit()),
|
||||
}
|
||||
for k in 0..if acmod == 0 { 2 } else { 1 } {
|
||||
if r.bit() {
|
||||
|
|
@ -749,26 +840,62 @@ impl Decoder {
|
|||
f.dynrng[k] = 1.0;
|
||||
}
|
||||
}
|
||||
// E-AC-3 spectral extension strategy and coordinates
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.spx.read(r, blk, acmod, nf)?;
|
||||
}
|
||||
let spx = x.as_deref().map(|e| &e.spx).filter(|s| s.inu);
|
||||
let spxbegf = spx.map(|s| s.begf);
|
||||
let spx_begin = spx.map(|s| s.begin_bin());
|
||||
let spx_chans = spx.map(|s| s.chinspx).unwrap_or([false; 5]);
|
||||
// coupling strategy
|
||||
if r.bit() {
|
||||
f.cplinu = r.bit();
|
||||
let strategy = match x.as_deref() {
|
||||
Some(e) => e.cplstre[blk].then_some(e.cplinu[blk]),
|
||||
None => r.bit().then(|| r.bit()),
|
||||
};
|
||||
if let Some(inu) = strategy {
|
||||
f.cplinu = inu;
|
||||
if f.cplinu {
|
||||
for ch in 0..nf {
|
||||
f.chincpl[ch] = r.bit();
|
||||
if eac3 && r.bit() {
|
||||
return Err(Error::Unsupported("E-AC-3 enhanced coupling".into()));
|
||||
}
|
||||
if eac3 && acmod == 2 {
|
||||
f.chincpl = [true, true, false, false, false];
|
||||
} else {
|
||||
for ch in 0..nf {
|
||||
f.chincpl[ch] = r.bit();
|
||||
}
|
||||
}
|
||||
if acmod == 2 {
|
||||
f.phsflginu = r.bit();
|
||||
}
|
||||
f.cplbegf = r.read(4) as usize;
|
||||
f.cplendf = r.read(4) as usize;
|
||||
if f.cplendf + 3 <= f.cplbegf {
|
||||
f.cplend = match spxbegf {
|
||||
// the coupling region ends where spectral extension begins (§E2.2.4)
|
||||
Some(b) if b < 6 => b + 1,
|
||||
Some(b) => 2 * b - 4,
|
||||
None => r.read(4) as usize + 3,
|
||||
};
|
||||
if f.cplend <= f.cplbegf {
|
||||
return Err(Error::Invalid("coupling end below start"));
|
||||
}
|
||||
let nsub = 3 + f.cplendf - f.cplbegf;
|
||||
// coupling band structure, by absolute sub-band
|
||||
let mut strc = [false; 18];
|
||||
match x.as_deref_mut() {
|
||||
Some(e) => {
|
||||
if r.bit() {
|
||||
for s in f.cplbegf + 1..f.cplend {
|
||||
e.cplbndstrc[s] = r.bit();
|
||||
}
|
||||
}
|
||||
strc = e.cplbndstrc;
|
||||
}
|
||||
None => (f.cplbegf + 1..f.cplend).for_each(|s| strc[s] = r.bit()),
|
||||
}
|
||||
let mut band = 0;
|
||||
f.sub_to_band[0] = 0;
|
||||
for s in 1..nsub {
|
||||
if !r.bit() {
|
||||
for s in 1..f.cplend - f.cplbegf {
|
||||
if !strc[f.cplbegf + s] {
|
||||
band += 1;
|
||||
}
|
||||
f.sub_to_band[s] = band;
|
||||
|
|
@ -776,15 +903,33 @@ impl Decoder {
|
|||
f.ncplbnd = band + 1;
|
||||
} else {
|
||||
f.chincpl = [false; 5];
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.firstcplcos = [true; 5];
|
||||
e.firstcplleak = true;
|
||||
f.phsflginu = false;
|
||||
}
|
||||
}
|
||||
} else if blk == 0 {
|
||||
} else if blk == 0 && !eac3 {
|
||||
return Err(Error::Invalid("no coupling strategy in block 0"));
|
||||
}
|
||||
// coupling coordinates
|
||||
if f.cplinu {
|
||||
let mut any = false;
|
||||
for ch in 0..nf {
|
||||
if f.chincpl[ch] && r.bit() {
|
||||
if !f.chincpl[ch] {
|
||||
if let Some(e) = x.as_deref_mut() {
|
||||
e.firstcplcos[ch] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let coe = match x.as_deref_mut() {
|
||||
Some(e) if e.firstcplcos[ch] => {
|
||||
e.firstcplcos[ch] = false;
|
||||
true
|
||||
}
|
||||
_ => r.bit(),
|
||||
};
|
||||
if coe {
|
||||
any = true;
|
||||
let mstr = r.read(2) as i32;
|
||||
for bnd in 0..f.ncplbnd {
|
||||
|
|
@ -792,7 +937,7 @@ impl Decoder {
|
|||
let m = r.read(4) as f32;
|
||||
let v = if e == 15 { m / 16.0 } else { (m + 16.0) / 32.0 };
|
||||
let co = v * 2f32.powi(-(e + 3 * mstr));
|
||||
for s in 0..(3 + f.cplendf - f.cplbegf) {
|
||||
for s in 0..(f.cplend - f.cplbegf) {
|
||||
if f.sub_to_band[s] == bnd {
|
||||
f.cplco[ch][s] = co;
|
||||
}
|
||||
|
|
@ -803,7 +948,7 @@ impl Decoder {
|
|||
if acmod == 2 && f.phsflginu && any {
|
||||
for bnd in 0..f.ncplbnd {
|
||||
let flag = r.bit();
|
||||
for s in 0..(3 + f.cplendf - f.cplbegf) {
|
||||
for s in 0..(f.cplend - f.cplbegf) {
|
||||
if f.sub_to_band[s] == bnd {
|
||||
f.phsflg[s] = flag;
|
||||
}
|
||||
|
|
@ -811,34 +956,55 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
}
|
||||
// rematrixing
|
||||
if acmod == 2 && r.bit() {
|
||||
let n = if !f.cplinu || f.cplbegf > 2 {
|
||||
4
|
||||
} else if f.cplbegf > 0 {
|
||||
3
|
||||
// rematrixing (E-AC-3 always sends the flags in block 0)
|
||||
if acmod == 2 && ((eac3 && blk == 0) || r.bit()) {
|
||||
let n = if f.cplinu {
|
||||
match f.cplbegf {
|
||||
0 => 2,
|
||||
1 | 2 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
} else {
|
||||
2
|
||||
match spxbegf {
|
||||
Some(b) if b < 2 => 3,
|
||||
_ => 4,
|
||||
}
|
||||
};
|
||||
f.rematflg = [false; 4];
|
||||
for k in 0..n {
|
||||
f.rematflg[k] = r.bit();
|
||||
}
|
||||
}
|
||||
// exponent strategies
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.expstr[ch] = r.read(2) as u8;
|
||||
}
|
||||
if lfeon {
|
||||
f.expstr[LFE] = r.read(1) as u8;
|
||||
// exponent strategies (E-AC-3: from the audio frame)
|
||||
match x.as_deref() {
|
||||
Some(e) => {
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = e.cplexpstr[blk];
|
||||
}
|
||||
f.expstr[..nf].copy_from_slice(&e.chexpstr[blk][..nf]);
|
||||
if lfeon {
|
||||
f.expstr[LFE] = e.lfeexpstr[blk];
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if f.cplinu {
|
||||
f.expstr[CPL] = r.read(2) as u8;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.expstr[ch] = r.read(2) as u8;
|
||||
}
|
||||
if lfeon {
|
||||
f.expstr[LFE] = r.read(1) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
for ch in 0..nf {
|
||||
if f.expstr[ch] != 0 {
|
||||
if f.chincpl[ch] && f.cplinu {
|
||||
f.endmant[ch] = f.cplstrtmant();
|
||||
} else if let (true, Some(begin)) = (spx_chans[ch], spx_begin) {
|
||||
// §E3.3.3: the channel ends where spectral extension begins
|
||||
f.endmant[ch] = begin;
|
||||
} else {
|
||||
let bw = r.read(6) as usize;
|
||||
if bw > 60 {
|
||||
|
|
@ -870,40 +1036,105 @@ impl Decoder {
|
|||
decode_exponents(r, abs, 2, 1, &mut f.exps[LFE], 0, false)?;
|
||||
}
|
||||
// bit allocation parametric information
|
||||
if r.bit() {
|
||||
f.g.sdcycod = r.read(2) as usize;
|
||||
f.g.fdcycod = r.read(2) as usize;
|
||||
f.g.sgaincod = r.read(2) as usize;
|
||||
f.g.dbpbcod = r.read(2) as usize;
|
||||
f.g.floorcod = r.read(3) as usize;
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no bit allocation information in block 0"));
|
||||
match x.as_deref() {
|
||||
Some(e) if !e.bamode => {
|
||||
// §E2.2.4 defaults
|
||||
(f.g.sdcycod, f.g.fdcycod, f.g.sgaincod, f.g.dbpbcod, f.g.floorcod) = (2, 1, 1, 2, 7);
|
||||
}
|
||||
_ => {
|
||||
if r.bit() {
|
||||
f.g.sdcycod = r.read(2) as usize;
|
||||
f.g.fdcycod = r.read(2) as usize;
|
||||
f.g.sgaincod = r.read(2) as usize;
|
||||
f.g.dbpbcod = r.read(2) as usize;
|
||||
f.g.floorcod = r.read(3) as usize;
|
||||
} else if blk == 0 && !eac3 {
|
||||
return Err(Error::Invalid("no bit allocation information in block 0"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.bit() {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
match x.as_deref() {
|
||||
Some(e) => {
|
||||
// SNR offset strategies (Table E2.9), fast gain codes
|
||||
let sets = [CPL, 0, 1, 2, 3, 4, LFE];
|
||||
match e.snroffststr {
|
||||
0 => {
|
||||
f.csnroffst = e.frmcsnroffst;
|
||||
sets.iter().for_each(|&c| f.fsnroffst[c] = e.frmfsnroffst);
|
||||
}
|
||||
s if blk == 0 || r.bit() => {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if s == 1 {
|
||||
let v = r.read(4) as i32;
|
||||
sets.iter().for_each(|&c| f.fsnroffst[c] = v);
|
||||
} else {
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if e.frmfgaincode && r.bit() {
|
||||
if f.cplinu {
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
}
|
||||
if lfeon {
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else {
|
||||
f.fgaincod = [4; 7];
|
||||
}
|
||||
if e.strmtyp == 0 && r.bit() {
|
||||
r.skip(10); // convsnroffst
|
||||
}
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
None => {
|
||||
if r.bit() {
|
||||
f.csnroffst = r.read(6) as i32;
|
||||
if f.cplinu {
|
||||
f.fsnroffst[CPL] = r.read(4) as i32;
|
||||
f.fgaincod[CPL] = r.read(3) as usize;
|
||||
}
|
||||
for ch in 0..nf {
|
||||
f.fsnroffst[ch] = r.read(4) as i32;
|
||||
f.fgaincod[ch] = r.read(3) as usize;
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no SNR offsets in block 0"));
|
||||
}
|
||||
}
|
||||
if lfeon {
|
||||
f.fsnroffst[LFE] = r.read(4) as i32;
|
||||
f.fgaincod[LFE] = r.read(3) as usize;
|
||||
}
|
||||
} else if blk == 0 {
|
||||
return Err(Error::Invalid("no SNR offsets in block 0"));
|
||||
}
|
||||
if f.cplinu && r.bit() {
|
||||
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
|
||||
if f.cplinu {
|
||||
let leak = match x.as_deref_mut() {
|
||||
Some(e) if e.firstcplleak => {
|
||||
e.firstcplleak = false;
|
||||
true
|
||||
}
|
||||
_ => r.bit(),
|
||||
};
|
||||
if leak {
|
||||
f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768);
|
||||
}
|
||||
}
|
||||
// delta bit allocation
|
||||
if blk == 0 {
|
||||
f.deltbae = [2; 7];
|
||||
}
|
||||
if r.bit() {
|
||||
if x.as_deref().is_none_or(|e| e.dbaflde) && r.bit() {
|
||||
if f.cplinu {
|
||||
f.deltbae[CPL] = r.read(2) as u8;
|
||||
}
|
||||
|
|
@ -921,7 +1152,7 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
// skip field
|
||||
if r.bit() {
|
||||
if x.as_deref().is_none_or(|e| e.skipflde) && r.bit() {
|
||||
let l = r.read(9) as usize;
|
||||
r.skip(l * 8);
|
||||
}
|
||||
|
|
@ -1014,6 +1245,16 @@ impl Decoder {
|
|||
}
|
||||
}
|
||||
}
|
||||
// spectral extension: synthesize the high band of each channel from its low band
|
||||
if let Some(e) = x.as_deref()
|
||||
&& e.spx.inu
|
||||
{
|
||||
for ch in 0..nf {
|
||||
if e.spx.chinspx[ch] {
|
||||
e.spx.synthesize(ch, e.spxatten[ch], &mut coefs[ch], || self.noise());
|
||||
}
|
||||
}
|
||||
}
|
||||
// dynamic range
|
||||
for ch in 0..nf {
|
||||
let g = if acmod == 0 && ch == 1 { f.dynrng[1] } else { f.dynrng[0] };
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn headers_and_gains() {
|
|||
let b = [0x0B, 0x77, 0, 0, 30, 8 << 3, 0b1110_0001, 0b0100_0000];
|
||||
let h = parse_header(&b).unwrap();
|
||||
assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes, h.acmod, h.lfeon, h.channels()), (48_000, 448, 1792, 7, true, 6));
|
||||
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 16 << 3, 0, 0]), Err(Error::Unsupported("bsid 16 (E-AC-3)".into())));
|
||||
assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 17 << 3, 0, 0]), Err(Error::Unsupported("bsid 17".into())));
|
||||
assert_eq!(dynrng_gain(0), 1.0);
|
||||
assert!((dynrng_gain(0b1110_0000) - 0.5).abs() < 1e-7);
|
||||
assert!((dynrng_gain(0b0111_1111) - 16.0 * 63.0 / 64.0).abs() < 1e-5);
|
||||
|
|
@ -96,3 +96,176 @@ fn garbage_never_panics() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer for synthetic E-AC-3 syncframes.
|
||||
#[derive(Default)]
|
||||
struct W {
|
||||
b: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl W {
|
||||
fn put(&mut self, bits: u32, v: u32) {
|
||||
for i in (0..bits).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.b.push(0);
|
||||
}
|
||||
let bit = ((v >> i) & 1) as u8;
|
||||
*self.b.last_mut().unwrap() |= bit << (7 - self.n % 8);
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An E-AC-3 syncframe of `words` 16-bit words: syncinfo and a bsi with no optional metadata
|
||||
/// (fscod < 3 with `numblkscod`), then `audio` writes the rest; zero padded.
|
||||
fn eac3_frame(strmtyp: u32, substreamid: u32, words: u32, numblkscod: u32, acmod: u32, audio: impl FnOnce(&mut W)) -> Vec<u8> {
|
||||
let mut w = W::default();
|
||||
w.put(16, 0x0B77);
|
||||
w.put(2, strmtyp);
|
||||
w.put(3, substreamid);
|
||||
w.put(11, words - 1);
|
||||
w.put(2, 0); // 48 kHz
|
||||
w.put(2, numblkscod);
|
||||
w.put(3, acmod);
|
||||
w.put(1, 0); // lfeon
|
||||
w.put(5, 16); // bsid
|
||||
w.put(5, 31); // dialnorm
|
||||
w.put(1, 0); // compre
|
||||
if strmtyp == 1 {
|
||||
w.put(1, 0); // chanmape
|
||||
}
|
||||
w.put(2, 0); // mixmdate, infomdate
|
||||
if strmtyp == 0 && numblkscod != 3 {
|
||||
w.put(1, 0); // convsync
|
||||
}
|
||||
w.put(1, 0); // addbsie
|
||||
audio(&mut w);
|
||||
let mut b = w.b;
|
||||
b.resize(words as usize * 2, 0);
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_headers() {
|
||||
let f = eac3_frame(0, 0, 384, 3, 2, |_| {});
|
||||
let h = parse_header(&f).unwrap();
|
||||
assert_eq!((h.sample_rate, h.blocks, h.samples(), h.frame_bytes, h.bitrate_kbps, h.channels()), (48_000, 6, 1536, 768, 192, 2));
|
||||
assert!(h.is_eac3() && h.is_primary());
|
||||
// one block per syncframe: 256 samples
|
||||
let h = parse_header(&eac3_frame(0, 0, 100, 0, 7, |_| {})).unwrap();
|
||||
assert_eq!((h.blocks, h.samples(), h.channels(), h.bitrate_kbps), (1, 256, 5, 300));
|
||||
// reduced sample rate (fscod 3, fscod2 1): six blocks at 22.05 kHz
|
||||
let mut f = eac3_frame(0, 0, 384, 0, 2, |_| {});
|
||||
f[4] = 0b1101_0100;
|
||||
let h = parse_header(&f).unwrap();
|
||||
assert_eq!((h.sample_rate, h.blocks, h.acmod), (22_050, 6, 2));
|
||||
f[4] = 0b1111_0100;
|
||||
assert_eq!(parse_header(&f), Err(Error::Invalid("reserved fscod2")));
|
||||
// a dependent substream and a second independent program are recognised and skipped
|
||||
let mut d = Decoder::new();
|
||||
for (strmtyp, id) in [(1, 0), (0, 1)] {
|
||||
let f = eac3_frame(strmtyp, id, 64, 3, 2, |_| {});
|
||||
let out = d.decode(&f).unwrap();
|
||||
assert!(!out.header.is_primary() && out.channels.is_empty());
|
||||
}
|
||||
assert_eq!(parse_header(&eac3_frame(3, 0, 64, 3, 2, |_| {})), Err(Error::Unsupported("reserved E-AC-3 stream type 3".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_tools_we_do_not_decode_are_refused() {
|
||||
// mono, six blocks, AHT enabled and used: chexpstr D15 then reuse, so chahtinu is sent
|
||||
let aht = eac3_frame(0, 0, 64, 3, 1, |w| {
|
||||
w.put(1, 1); // expstre
|
||||
w.put(1, 1); // ahte
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0); // transproce … spxattene
|
||||
w.put(2, 1); // chexpstr[0] = D15
|
||||
w.put(10, 0); // chexpstr[1..6] = reuse
|
||||
w.put(5, 0); // convexpstr
|
||||
w.put(1, 1); // chahtinu
|
||||
});
|
||||
assert_eq!(Decoder::new().decode(&aht).unwrap_err(), Error::Unsupported("E-AC-3 adaptive hybrid transform".into()));
|
||||
// stereo with coupling in block 0 using enhanced coupling
|
||||
let ecpl = eac3_frame(0, 0, 64, 3, 2, |w| {
|
||||
w.put(1, 1); // expstre
|
||||
w.put(1, 0); // ahte
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0);
|
||||
w.put(1, 1); // cplinu[0]
|
||||
w.put(5, 0); // cplstre[1..6]
|
||||
w.put(6, 0b01_01_01); // block 0: cplexpstr, chexpstr × 2
|
||||
w.put(30, 0); // blocks 1-5: reuse
|
||||
w.put(10, 0); // convexpstr
|
||||
w.put(10, 0); // frmcsnroffst, frmfsnroffst
|
||||
w.put(1, 0); // blkstrtinfoe
|
||||
w.put(1, 0); // dynrnge
|
||||
w.put(1, 0); // spxinu
|
||||
w.put(1, 1); // ecplinu
|
||||
});
|
||||
assert_eq!(Decoder::new().decode(&ecpl).unwrap_err(), Error::Unsupported("E-AC-3 enhanced coupling".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_single_block_frames_reuse_exponents_across_syncframes() {
|
||||
// mono, one block per syncframe; the first sends exponents (all 10, bandwidth code 0), the
|
||||
// second reuses them. Zero SNR offsets allocate no bits: every coefficient is dither.
|
||||
let frame = |new_exps: bool| {
|
||||
eac3_frame(0, 0, 32, 0, 1, |w| {
|
||||
// (expstre and ahte are not sent with fewer than six blocks)
|
||||
w.put(2, 0); // snroffststr
|
||||
w.put(8, 0); // transproce … spxattene
|
||||
w.put(2, new_exps as u32); // chexpstr[0]: D15 or reuse
|
||||
w.put(1, 0); // convexpstre
|
||||
w.put(10, 0); // frmcsnroffst, frmfsnroffst
|
||||
w.put(1, 0); // dynrnge
|
||||
w.put(1, 0); // spxinu
|
||||
if new_exps {
|
||||
w.put(6, 0); // chbwcod → 73 coefficients
|
||||
w.put(4, 10); // absolute exponent
|
||||
for _ in 0..24 {
|
||||
w.put(7, 62); // three zero deltas
|
||||
}
|
||||
w.put(2, 0); // gainrng
|
||||
}
|
||||
w.put(1, 0); // convsnroffste
|
||||
})
|
||||
};
|
||||
let (first, second) = (frame(true), frame(false));
|
||||
let mut d = Decoder::new();
|
||||
let a = d.decode(&first).unwrap();
|
||||
assert_eq!((a.header.samples(), a.channels.len(), a.channels[0].len()), (256, 1, 256));
|
||||
let b = d.decode(&second).unwrap();
|
||||
let peak = |c: &[f32]| c.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(peak(&b.channels[0]) > 1e-5 && peak(&b.channels[0]) < 0.1, "{}", peak(&b.channels[0]));
|
||||
// after a reset there are no exponents to reuse: no coefficients, silence
|
||||
d.reset();
|
||||
let c = d.decode(&second).unwrap();
|
||||
assert_eq!(peak(&c.channels[0]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_garbage_never_panics() {
|
||||
let mut d = Decoder::new();
|
||||
let mut x = 0x5EEDu32;
|
||||
for len in [8usize, 40, 300, 1500, 4096] {
|
||||
for _ in 0..300 {
|
||||
let mut f: Vec<u8> = (0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x as u8
|
||||
})
|
||||
.collect();
|
||||
f[0] = 0x0B;
|
||||
f[1] = 0x77;
|
||||
// independent substream 0, frmsiz within the buffer, bsid 11-16
|
||||
let words = (len / 2).clamp(4, 2048) - 1;
|
||||
f[2] = (words >> 8) as u8 & 7;
|
||||
f[3] = words as u8;
|
||||
f[5] = (f[5] & 7) | ((11 + x % 6) as u8) << 3;
|
||||
let _ = d.decode(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! AC-3 against ffmpeg: ffmpeg's AC-3 encoder writes raw syncframes from generated tones and
|
||||
//! noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
|
||||
//! AC-3 and E-AC-3 against ffmpeg: ffmpeg's AC-3 and E-AC-3 encoders write raw syncframes from
|
||||
//! generated tones and noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across
|
||||
//! decoders (zero-bit mantissas are filled with decoder-specific dither, §7.3.4; float vs fixed
|
||||
//! arithmetic), so the criterion is SNR against ffmpeg's decode.
|
||||
|
||||
|
|
@ -8,6 +8,8 @@ use std::process::{Command, Stdio};
|
|||
|
||||
struct Spec {
|
||||
name: &'static str,
|
||||
/// ffmpeg encoder and raw muxer: "ac3" or "eac3".
|
||||
codec: &'static str,
|
||||
input: &'static str,
|
||||
rate: u32,
|
||||
channels: u32,
|
||||
|
|
@ -19,6 +21,7 @@ struct Spec {
|
|||
const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "ac3_stereo_192k",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
|
|
@ -28,16 +31,26 @@ const SPECS: &[Spec] = &[
|
|||
// low bitrate stereo: coupling and rematrixing
|
||||
Spec {
|
||||
name: "ac3_stereo_96k_coupling",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
|
||||
rate: 44_100,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "96k", "-channel_coupling", "1"],
|
||||
min_snr: 40.0,
|
||||
},
|
||||
Spec { name: "ac3_mono_32k", input: "sine=frequency=1000:sample_rate=32000", rate: 32_000, channels: 1, enc: &["-b:a", "96k"], min_snr: 60.0 },
|
||||
Spec {
|
||||
name: "ac3_mono_32k",
|
||||
codec: "ac3",
|
||||
input: "sine=frequency=1000:sample_rate=32000",
|
||||
rate: 32_000,
|
||||
channels: 1,
|
||||
enc: &["-b:a", "96k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
// 5.1 with LFE
|
||||
Spec {
|
||||
name: "ac3_51_448k",
|
||||
codec: "ac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
|
||||
rate: 48_000,
|
||||
channels: 6,
|
||||
|
|
@ -47,22 +60,86 @@ const SPECS: &[Spec] = &[
|
|||
// noise (wideband, many zero-bit mantissas): the SNR is limited by dither
|
||||
Spec {
|
||||
name: "ac3_noise_stereo",
|
||||
codec: "ac3",
|
||||
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "128k"],
|
||||
min_snr: 18.0,
|
||||
},
|
||||
// E-AC-3 (Annex E): ffmpeg writes six-block syncframes with frame-based exponent strategies
|
||||
Spec {
|
||||
name: "eac3_stereo_192k",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "192k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
// low bitrate stereo: coupling (with the default band structure) and rematrixing
|
||||
Spec {
|
||||
name: "eac3_stereo_64k_coupling",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100",
|
||||
rate: 44_100,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "64k", "-channel_coupling", "1"],
|
||||
min_snr: 35.0,
|
||||
},
|
||||
Spec {
|
||||
name: "eac3_mono_32k",
|
||||
codec: "eac3",
|
||||
input: "sine=frequency=1000:sample_rate=32000",
|
||||
rate: 32_000,
|
||||
channels: 1,
|
||||
enc: &["-b:a", "64k"],
|
||||
min_snr: 60.0,
|
||||
},
|
||||
// 5.1 with LFE and mixing / informational metadata in the bsi
|
||||
Spec {
|
||||
name: "eac3_51_384k_metadata",
|
||||
codec: "eac3",
|
||||
input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1",
|
||||
rate: 48_000,
|
||||
channels: 6,
|
||||
enc: &[
|
||||
"-b:a",
|
||||
"384k",
|
||||
"-dmix_mode",
|
||||
"loro",
|
||||
"-ltrt_cmixlev",
|
||||
"0.707",
|
||||
"-loro_surmixlev",
|
||||
"0.5",
|
||||
"-dsurex_mode",
|
||||
"on",
|
||||
"-mixing_level",
|
||||
"100",
|
||||
"-room_type",
|
||||
"small",
|
||||
],
|
||||
min_snr: 55.0,
|
||||
},
|
||||
Spec {
|
||||
name: "eac3_noise_stereo",
|
||||
codec: "eac3",
|
||||
input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0",
|
||||
rate: 48_000,
|
||||
channels: 2,
|
||||
enc: &["-b:a", "96k", "-dsur_mode", "on", "-dheadphone_mode", "off"],
|
||||
min_snr: 18.0,
|
||||
},
|
||||
];
|
||||
|
||||
fn make(ff: &Path, s: &Spec) -> Option<PathBuf> {
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
|
||||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let st = Command::new(ff)
|
||||
.args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1"])
|
||||
.args(["-c:a", "ac3"])
|
||||
.args(["-c:a", s.codec])
|
||||
.args(s.enc)
|
||||
.args(["-f", "ac3"])
|
||||
.args(["-f", s.codec])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status();
|
||||
|
|
@ -94,6 +171,7 @@ fn ffmpeg_encoded_streams_decode() {
|
|||
for fr in frames(&data) {
|
||||
let d = dec.decode(fr).unwrap();
|
||||
assert_eq!((d.header.sample_rate, d.channels.len() as u32), (s.rate, s.channels), "{}", s.name);
|
||||
assert_eq!(d.header.is_eac3(), s.codec == "eac3", "{}", s.name);
|
||||
for (o, c) in ours.iter_mut().zip(d.channels) {
|
||||
o.extend(c);
|
||||
}
|
||||
|
|
@ -136,12 +214,52 @@ fn ffmpeg_encoded_streams_decode() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Damaged E-AC-3 syncframes (bit flips, truncation, corrupt sizes) give errors or noise, never
|
||||
/// a panic.
|
||||
#[test]
|
||||
fn damaged_eac3_frames_never_panic() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let mut x = 0x00C0_FFEEu32;
|
||||
let mut rnd = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x
|
||||
};
|
||||
for s in SPECS.iter().filter(|s| s.codec == "eac3") {
|
||||
let f = make(&ff, s).unwrap_or_else(|| panic!("could not generate {}", s.name));
|
||||
let data = std::fs::read(&f).unwrap();
|
||||
let mut dec = filmcraft_ac3::Decoder::new();
|
||||
for fr in frames(&data).into_iter().take(8) {
|
||||
for k in 0..300 {
|
||||
let mut m = fr.to_vec();
|
||||
match k % 3 {
|
||||
0 => {
|
||||
for _ in 0..1 + rnd() % 8 {
|
||||
let bit = rnd() as usize % (m.len() * 8);
|
||||
m[bit / 8] ^= 0x80 >> (bit % 8);
|
||||
}
|
||||
}
|
||||
1 => m.truncate(rnd() as usize % m.len()),
|
||||
_ => {
|
||||
// keep the sync word, scramble frmsiz / fscod / numblkscod / acmod
|
||||
m[2] ^= rnd() as u8;
|
||||
m[3] ^= rnd() as u8;
|
||||
m[4] ^= rnd() as u8;
|
||||
}
|
||||
}
|
||||
let _ = dec.decode(&m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn generate_fixtures() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("ac3 fixtures") else { return };
|
||||
for s in SPECS {
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name));
|
||||
let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec));
|
||||
filmcraft_testkit::fixtures::generate_and_report(&format!("ac3/{}", s.name), &[out], || make(&ff, s));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! **Channel order.** 5.1 audio is always in the SMPTE / WAVE order **L, R, C, LFE, Ls, Rs**
|
||||
//! ([`L`], [`R`], [`C`], [`LFE`], [`LS`], [`RS`]); stereo is L, R; mono is one channel. Codecs with a
|
||||
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the encoder boundary.
|
||||
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the codec boundary.
|
||||
//!
|
||||
//! **Downmix** follows ITU-R BS.775 (Table 2, "3/2 source" rows), with `k = 1/√2` (−3 dB):
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ impl FilmcraftMcp {
|
|||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><3+ digits>.<same extension>`, only when the output is a
|
||||
/// PNG/TIFF/BMP sequence) and the caption sidecar
|
||||
/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar
|
||||
/// (`<stem>.srt` / `<stem>.vtt`), each only if it was written since `since`. Other files that
|
||||
/// merely share the name's stem (`<stem>.aep`, `<stem>2.psd`, `<stem>1.mp4` beside a movie, …) are
|
||||
/// never touched, even when they were saved during the export.
|
||||
|
|
@ -152,7 +152,7 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
|||
/// Whether an export to a file with extension `ext` writes an image sequence (one still per frame).
|
||||
fn is_sequence_extension(ext: &str) -> bool {
|
||||
use filmcraft_engine::export::Format;
|
||||
Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence))
|
||||
Format::from_name(ext).is_some_and(Format::is_image_sequence)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -49,13 +49,10 @@ impl Overlay {
|
|||
}
|
||||
}
|
||||
|
||||
/// The caption face: Inter SemiBold at `px`.
|
||||
fn caption_style(px: f32) -> TextStyle {
|
||||
TextStyle { style: "SemiBold".into(), size: px, ..Default::default() }
|
||||
}
|
||||
|
||||
fn measure(text: &str, px: f32) -> f32 {
|
||||
filmcraft_text::measure(text, &caption_style(px))
|
||||
/// The caption face: the track's font family (Inter when empty or not installed) in SemiBold at `px`.
|
||||
fn caption_style(font: &str, px: f32) -> TextStyle {
|
||||
let family = if font.trim().is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY } else { font.trim() };
|
||||
TextStyle { family: family.into(), style: "SemiBold".into(), size: px, ..Default::default() }
|
||||
}
|
||||
|
||||
fn lin(c: [u8; 4]) -> [f32; 4] {
|
||||
|
|
@ -64,12 +61,12 @@ fn lin(c: [u8; 4]) -> [f32; 4] {
|
|||
}
|
||||
|
||||
/// Word-wrap one line to `max_w` pixels.
|
||||
fn wrap(line: &str, px: f32, max_w: f32) -> Vec<String> {
|
||||
fn wrap(line: &str, ts: &TextStyle, max_w: f32) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for word in line.split(' ') {
|
||||
let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
|
||||
if !cur.is_empty() && measure(&cand, px) > max_w {
|
||||
if !cur.is_empty() && filmcraft_text::measure(&cand, ts) > max_w {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
cur = word.to_string();
|
||||
} else {
|
||||
|
|
@ -115,16 +112,17 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
let scale = h as f32 / 1080.0;
|
||||
let px = (style.size * scale).max(4.0);
|
||||
let max_w = w as f32 * 0.9;
|
||||
let ts = caption_style(&style.font, px);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
// (speaker names are metadata; like Premiere, they are not burned in)
|
||||
for l in c.plain_lines() {
|
||||
lines.extend(wrap(l.trim(), px, max_w));
|
||||
lines.extend(wrap(l.trim(), &ts, max_w));
|
||||
}
|
||||
lines.retain(|l| !l.is_empty());
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px);
|
||||
let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(&ts.family, &ts.style).face).metrics(px);
|
||||
let (asc, desc) = (vm.ascent, vm.descent);
|
||||
let lh = px * style.line_spacing.max(0.8);
|
||||
let pad_x = (px * 0.3).round();
|
||||
|
|
@ -141,7 +139,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
.max(0.0)
|
||||
.round();
|
||||
let side = w as f32 * 0.05;
|
||||
let widths: Vec<f32> = lines.iter().map(|l| measure(l, px)).collect();
|
||||
let widths: Vec<f32> = lines.iter().map(|l| filmcraft_text::measure(l, &ts)).collect();
|
||||
let xs: Vec<f32> = widths
|
||||
.iter()
|
||||
.map(|&lw| match align {
|
||||
|
|
@ -176,7 +174,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) ->
|
|||
}
|
||||
}
|
||||
let baseline = lt + (lh - (asc + desc)) / 2.0 + asc;
|
||||
let l = layout(line, &caption_style(px), &ParagraphStyle::default());
|
||||
let l = layout(line, &ts, &ParagraphStyle::default());
|
||||
let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) };
|
||||
render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy));
|
||||
cover = m.a;
|
||||
|
|
@ -305,6 +303,27 @@ mod tests {
|
|||
assert!(o.px.chunks(4).any(|p| p[0] > 0.9 && p[1] < 0.05 && p[3] > 0.9), "red outline pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_font_is_used() {
|
||||
// the style's font used to be ignored (always Inter)
|
||||
let inter = render_caption(&cap("Hello world"), &CaptionStyle::default(), 1920, 1080).unwrap();
|
||||
let serif = CaptionStyle { font: "Noto Serif".into(), ..Default::default() };
|
||||
let serif = render_caption(&cap("Hello world"), &serif, 1920, 1080).unwrap();
|
||||
assert_ne!((inter.w, &inter.px), (serif.w, &serif.px), "Noto Serif renders like Inter");
|
||||
let mono = CaptionStyle { font: "jetbrains mono".into(), ..Default::default() };
|
||||
let mono = render_caption(&cap("Hello world"), &mono, 1920, 1080).unwrap();
|
||||
assert_ne!((inter.w, &inter.px), (mono.w, &mono.px), "JetBrains Mono renders like Inter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_or_empty_font_falls_back_to_inter() {
|
||||
let inter = render_caption(&cap("Hello"), &CaptionStyle::default(), 1280, 720).unwrap();
|
||||
for font in ["", " ", "No Such Font 123"] {
|
||||
let st = CaptionStyle { font: font.into(), ..Default::default() };
|
||||
assert_eq!(render_caption(&cap("Hello"), &st, 1280, 720), Some(inter.clone()), "font {font:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_track_draws_nothing() {
|
||||
let mut t = CaptionTrack::new(TrackId(1), "S".into(), CaptionFormat::Subtitle);
|
||||
|
|
|
|||
|
|
@ -307,7 +307,8 @@ pub fn parse_clock(s: &str) -> Option<Tick> {
|
|||
|
||||
/// Format ticks as `HH:MM:SS<sep>mmm` (rounded to the nearest millisecond).
|
||||
pub fn format_clock(t: Tick, sep: char) -> String {
|
||||
let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS;
|
||||
// The rounding addition must not overflow for an input-derived Tick near i64::MAX.
|
||||
let ms = (i128::from(t.0.max(0)) + i128::from(TICKS_PER_MS) / 2) / i128::from(TICKS_PER_MS);
|
||||
let (h, m, s, f) = (ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000);
|
||||
format!("{h:02}:{m:02}:{s:02}{sep}{f:03}")
|
||||
}
|
||||
|
|
@ -368,6 +369,8 @@ mod tests {
|
|||
assert_eq!(parse_clock("00:61:00,000"), None);
|
||||
assert_eq!(parse_clock("aa:00:00,000"), None);
|
||||
assert_eq!(format_clock(Tick(TICKS_PER_SECOND * 3723 + TICKS_PER_MS * 45), ','), "01:02:03,045");
|
||||
assert!(format_clock(Tick(i64::MAX), ',').ends_with(",077"));
|
||||
assert_eq!(format_clock(Tick(i64::MIN), '.'), "00:00:00.000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -215,15 +215,22 @@ fn encode_text(text: &str) -> Vec<u8> {
|
|||
if ch == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
{
|
||||
match rest[1..end].trim().to_ascii_lowercase().as_str() {
|
||||
"i" => out.push(0x80),
|
||||
"/i" => out.push(0x81),
|
||||
"u" => out.push(0x82),
|
||||
"/u" => out.push(0x83),
|
||||
_ => {}
|
||||
let tag = &rest[1..end];
|
||||
// Only a syntactically plausible tag is markup. Comparisons such as
|
||||
// "2 < 3 > 1" and "x < y > z" must not lose their middle text.
|
||||
let name = tag.strip_prefix('/').unwrap_or(tag);
|
||||
let is_tag = name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
|
||||
if is_tag {
|
||||
match tag.trim().to_ascii_lowercase().as_str() {
|
||||
"i" => out.push(0x80),
|
||||
"/i" => out.push(0x81),
|
||||
"u" => out.push(0x82),
|
||||
"/u" => out.push(0x83),
|
||||
_ => {}
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
if ch == '\n' {
|
||||
out.push(0x8a);
|
||||
|
|
@ -451,6 +458,22 @@ mod tests {
|
|||
assert!(parse(&b[..500]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_angle_brackets_are_not_discarded_as_markup() {
|
||||
let literal = "2 < 3 > 1, x < y > z, and 5 <7> 6";
|
||||
let doc = Document {
|
||||
cues: vec![Cue { start: FrameRate::FPS_25.tick_of(25), end: FrameRate::FPS_25.tick_of(75), text: literal.into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = write(&doc, Some(FrameRate::FPS_25));
|
||||
let decoded = parse(&encoded).expect("valid EBU STL");
|
||||
assert_eq!(decoded.cues.len(), 1);
|
||||
assert_eq!(decoded.cues[0].text, literal);
|
||||
// Recognized styling still becomes STL control bytes.
|
||||
assert_eq!(encode_text("<i>Hi</i>"), vec![0x80, b'H', b'i', 0x81]);
|
||||
assert_eq!(encode_text("<i >Hi</i >"), vec![0x80, b'H', b'i', 0x81]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programme_start_is_subtracted() {
|
||||
let doc = Document {
|
||||
|
|
|
|||
|
|
@ -252,6 +252,10 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
let (h, m) = (num(parts.first()?)?, num(parts.get(1)?)?);
|
||||
let sec_part = parts.get(2)?;
|
||||
let (si, sf) = sec_part.split_once('.').unwrap_or((sec_part, ""));
|
||||
let seconds = num(si)?;
|
||||
if m >= 60 || seconds >= 60 {
|
||||
return None;
|
||||
}
|
||||
let mut t = (h * 3600 + m * 60) * tps + decimal_ticks(si, sf, tps)?;
|
||||
if let Some(fp) = parts.get(3) {
|
||||
if parts.len() > 4 {
|
||||
|
|
@ -259,13 +263,15 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
}
|
||||
let (fi, fs) = fp.split_once('.').unwrap_or((fp, ""));
|
||||
let frames = num(fi)?;
|
||||
let _ = tm.base;
|
||||
let sub: i128 = if fs.is_empty() { 0 } else { num(fs)? };
|
||||
if frames >= i128::from(tm.base) || sub >= i128::from(tm.sub_frame_rate) {
|
||||
return None;
|
||||
}
|
||||
// frames at the effective rate; sub-frames divide a frame
|
||||
let ft = tm.rate.tick_of(1).0 as i128;
|
||||
t += frames * ft + sub * ft / tm.sub_frame_rate.max(1) as i128;
|
||||
}
|
||||
return Some(Tick(t as i64));
|
||||
return i64::try_from(t).ok().map(Tick);
|
||||
}
|
||||
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
|
||||
let (v, unit) = s.split_at(unit_start);
|
||||
|
|
@ -281,8 +287,12 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
"f" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
let fd = tm.rate.tick_of(1).0 as i128;
|
||||
let whole = tm.rate.tick_of(n as i64).0 as i128;
|
||||
whole + decimal_ticks("0", vf, fd)?
|
||||
let whole = tm.rate.tick_of(i64::try_from(n).ok()?);
|
||||
// tick_of saturates; a saturated count overflowed rather than named a real time
|
||||
if whole == Tick(i64::MAX) {
|
||||
return None;
|
||||
}
|
||||
whole.0 as i128 + decimal_ticks("0", vf, fd)?
|
||||
}
|
||||
"t" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
|
|
@ -290,7 +300,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
|||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Tick(t as i64))
|
||||
i64::try_from(t).ok().map(Tick)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
|
|
@ -552,6 +562,34 @@ mod tests {
|
|||
assert_eq!(parse_time("1:2", t), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_overflowed_time_expressions_are_ignored() {
|
||||
let normal = tm(FrameRate::FPS_25);
|
||||
for expr in [
|
||||
"999999999999999h",
|
||||
"999999999999999m",
|
||||
"999999999999999s",
|
||||
"999999999999999ms",
|
||||
"999999999999999f",
|
||||
"999999999999999t",
|
||||
"00:60:00",
|
||||
"00:00:60",
|
||||
"00:00:01:25",
|
||||
] {
|
||||
assert_eq!(parse_time(expr, normal), None, "{expr}");
|
||||
}
|
||||
let subframes = Timing { sub_frame_rate: 2, ..normal };
|
||||
assert!(parse_time("00:00:01:12.1", subframes).is_some());
|
||||
assert_eq!(parse_time("00:00:01:12.2", subframes), None);
|
||||
// Bad cues must not wrap to a plausible but unrelated timeline position.
|
||||
let xml =
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="999999999999999s" end="2s">Bad</p><p begin="1s" end="2s">Good</p></div></body></tt>"#;
|
||||
let doc = parse(xml).expect("well-formed TTML");
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Good");
|
||||
assert!(!doc.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_styles_spans_and_nesting() {
|
||||
let x = r#"<?xml version="1.0"?>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ impl<'a> CompoundFile<'a> {
|
|||
let mini_cutoff = u32_at(data, 56) as u64;
|
||||
let first_minifat = u32_at(data, 60);
|
||||
let first_difat = u32_at(data, 68);
|
||||
let n_difat = u32_at(data, 72) as usize;
|
||||
let mut header_clsid = [0u8; 16];
|
||||
header_clsid.copy_from_slice(&data[8..24]);
|
||||
let max_sectors = data.len() / sector_size + 1;
|
||||
|
|
@ -108,8 +107,10 @@ impl<'a> CompoundFile<'a> {
|
|||
let mut seen = 0;
|
||||
while d <= MAXREGSECT && fat_sectors.len() < n_fat {
|
||||
seen += 1;
|
||||
if seen > n_difat.max(1) + max_sectors {
|
||||
return Err(invalid("DIFAT chain loops"));
|
||||
// a DIFAT chain cannot have more sectors than the file holds; the header's DIFAT sector
|
||||
// count (up to 2^32 - 1) is not trusted, or a self-pointing sector loops that often
|
||||
if seen > max_sectors {
|
||||
return Err(invalid(format!("DIFAT chain loops: more than {max_sectors} sectors in a {}-byte file", data.len())));
|
||||
}
|
||||
let s = cf.sector(d)?;
|
||||
let per = sector_size / 4 - 1;
|
||||
|
|
@ -169,13 +170,17 @@ impl<'a> CompoundFile<'a> {
|
|||
let mut out = Vec::new();
|
||||
let mut s = start;
|
||||
let mut steps = 0usize;
|
||||
// A chain cannot visit more sectors than the file holds. Not `self.fat.len()`: the FAT can
|
||||
// be far longer (one FAT sector named by many header / DIFAT slots), and a looping chain
|
||||
// would then append a whole sector per FAT entry (hundreds of MB, or an abort).
|
||||
let max_steps = self.data.len() / self.sector_size + 1;
|
||||
while s != ENDOFCHAIN {
|
||||
if s > MAXREGSECT {
|
||||
return Err(invalid(format!("bad sector {s:#x} in a chain")));
|
||||
}
|
||||
steps += 1;
|
||||
if steps > self.fat.len().max(1) {
|
||||
return Err(invalid("sector chain loops"));
|
||||
if steps > max_steps {
|
||||
return Err(invalid(format!("sector chain loops: more than {max_steps} sectors in a {}-byte file", self.data.len())));
|
||||
}
|
||||
let b = self.sector(s)?;
|
||||
out.extend_from_slice(b);
|
||||
|
|
|
|||
|
|
@ -199,3 +199,95 @@ fn mutation_never_panics_or_hangs() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put_u32(b: &mut [u8], at: usize, v: u32) {
|
||||
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
|
||||
/// A 512-byte header (§2.2) with sector shift `shift`: no directory, mini FAT or DIFAT chain, and
|
||||
/// all 109 FAT slots free. Tests fill in the fields they attack.
|
||||
fn bare_header(shift: u16) -> Vec<u8> {
|
||||
let mut h = vec![0u8; 512];
|
||||
h[..8].copy_from_slice(&SIGNATURE);
|
||||
h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
|
||||
h[26..28].copy_from_slice(&(if shift == 9 { 3u16 } else { 4 }).to_le_bytes());
|
||||
h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
|
||||
h[30..32].copy_from_slice(&shift.to_le_bytes());
|
||||
h[32..34].copy_from_slice(&6u16.to_le_bytes());
|
||||
for at in [48, 60, 68] {
|
||||
put_u32(&mut h, at, ENDOFCHAIN);
|
||||
}
|
||||
put_u32(&mut h, 56, MINI_STREAM_CUTOFF);
|
||||
h[76..].fill(0xFF);
|
||||
h
|
||||
}
|
||||
|
||||
/// A DIFAT sector that names no FAT sector and points to itself. The loop guard used to be the
|
||||
/// header's DIFAT sector count (up to 2^32 - 1), so this 1 KB file kept `open` (and so AAF
|
||||
/// sniffing) busy for 14-16 s here (minutes with `u32::MAX`), reading a sector each pass. A DIFAT
|
||||
/// chain cannot be longer than the file, so the walk now stops after as many passes as the file
|
||||
/// has sectors (1024 / 512 + 1 = 3 here) and fails on the 4th.
|
||||
#[test]
|
||||
fn difat_self_loop_fails_fast() {
|
||||
let mut b = bare_header(9);
|
||||
put_u32(&mut b, 44, 1); // one FAT sector, to be found through the DIFAT
|
||||
put_u32(&mut b, 68, 0); // first DIFAT sector
|
||||
put_u32(&mut b, 72, 100_000_000); // claimed DIFAT sector count (inflates the old loop bound)
|
||||
let mut difat = vec![0xFF; 512]; // no FAT sector numbers...
|
||||
put_u32(&mut difat, 508, 0); // ...and the next DIFAT sector is this one
|
||||
b.extend_from_slice(&difat);
|
||||
// The message names the bound the walk stopped at: the file's 3 sectors, not the header's
|
||||
// 100,000,000 (the old guard allowed 100,000,003 passes).
|
||||
let expect = format!("DIFAT chain loops: more than 3 sectors in a {}-byte file", b.len());
|
||||
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
|
||||
}
|
||||
|
||||
/// All 109 header slots name the same FAT sector, so the FAT is 109 copies of it, and the
|
||||
/// directory chain loops (sector 0 → 0). The loop guard used to be the FAT length, so this 450 KB
|
||||
/// file appended a sector per FAT entry before failing: a 457 MB buffer per open (117 GB with
|
||||
/// 64 KB sectors in a 7 MB file). A chain cannot visit more sectors than the file has.
|
||||
#[test]
|
||||
fn duplicated_fat_sector_chain_loop_fails_fast() {
|
||||
let ss = 4096;
|
||||
let mut b = bare_header(12);
|
||||
put_u32(&mut b, 44, 109); // FAT sectors: the 109 header slots...
|
||||
for i in 0..109 {
|
||||
put_u32(&mut b, 76 + i * 4, 0); // ...all naming sector 0
|
||||
}
|
||||
put_u32(&mut b, 48, 0); // the directory starts at sector 0
|
||||
b.resize(ss, 0);
|
||||
let mut fat = vec![0xFF; ss];
|
||||
put_u32(&mut fat, 0, 0); // FAT[0] = 0: sector 0 is followed by itself
|
||||
b.extend_from_slice(&fat);
|
||||
b.resize(110 * ss, 0); // big enough for 109 FAT sectors to pass the size check
|
||||
// The message names the bound the walk stopped at: 111, this file's 110 sectors + 1, not the
|
||||
// 109 * 1024 = 111,616 entries of the inflated FAT that the old guard allowed (a 457 MB buffer).
|
||||
let expect = format!("sector chain loops: more than 111 sectors in a {}-byte file", b.len());
|
||||
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
|
||||
}
|
||||
|
||||
/// Positive control for the chain bound: the longest chains a file can hold still read back, in
|
||||
/// both versions: a directory (read without a size limit) and a stream, each filling every sector
|
||||
/// but the header and the FAT (and the directory, for the stream).
|
||||
#[test]
|
||||
fn chains_filling_the_file_still_read() {
|
||||
for v in [Version::V3, Version::V4] {
|
||||
let ss = v_sector(v);
|
||||
let mut w = Writer::new(v);
|
||||
for i in 0..1000 {
|
||||
w.stream(Writer::ROOT, &format!("e{i}"), Vec::new()).unwrap();
|
||||
}
|
||||
let b = w.finish();
|
||||
assert!(b.len() / ss <= (1001 * 128usize).div_ceil(ss) + 3, "header, directory, FAT");
|
||||
let cf = CompoundFile::open(&b).unwrap();
|
||||
assert_eq!(cf.root().children.len(), 1000);
|
||||
assert_eq!(cf.read_path("e999").unwrap(), Vec::<u8>::new());
|
||||
|
||||
let mut w = Writer::new(v);
|
||||
let data = pattern(64 * ss, 3);
|
||||
w.stream(Writer::ROOT, "big", data.clone()).unwrap();
|
||||
let b = w.finish();
|
||||
assert_eq!(b.len(), (1 + 64 + 2) * ss, "header, stream, directory, FAT");
|
||||
assert_eq!(CompoundFile::open(&b).unwrap().read_path("big").unwrap(), data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@ use crate::{CodecError, Result};
|
|||
|
||||
enum Inner {
|
||||
/// Our own AAC-LC decoder. `up` interpolates HE-AAC's core to the output rate (see [`Upsample2x`]).
|
||||
/// Surround output is reordered to WAV/SMPTE order (see [`aac_to_wav_order`]).
|
||||
Aac { dec: Box<filmcraft_aac::Decoder>, asc: Vec<u8>, up: Option<Box<Upsample2x>> },
|
||||
/// AAC whose configuration arrives with the first frame (ADTS / LATM).
|
||||
AacPending,
|
||||
/// MPEG audio at this rate whose layer arrives with the first frame (MP4 / QuickTime name the
|
||||
/// codec, not the layer, and symphonia's decoder reads only the layer it was made for).
|
||||
MpegPending(u32),
|
||||
/// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps).
|
||||
/// `order` maps output channel → decoded channel (Vorbis → WAV/SMPTE order for surround).
|
||||
Opus { dec: Box<filmcraft_opus::Decoder>, order: Option<&'static [usize]> },
|
||||
/// Bootstrap decoders (MP3, ALAC, FLAC, HE-AAC…) via symphonia.
|
||||
Symphonia(Box<dyn Decoder>),
|
||||
/// Our AC-3 decoder (ATSC A/52).
|
||||
/// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E).
|
||||
Ac3(Box<filmcraft_ac3::Decoder>),
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +56,29 @@ fn vorbis_to_wav_order(channels: usize) -> Option<&'static [usize]> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Channel order for AAC surround (ISO/IEC 14496-3 Table 1.19, our decoder's element order: C, L R,
|
||||
/// surrounds, LFE): output channel → decoded channel, giving the WAV/SMPTE order (L R C LFE Ls Rs)
|
||||
/// used elsewhere, as the AC-3 decoder does and the AAC encoder's caller undoes on export. Only the
|
||||
/// unambiguous channel configurations 3–6; 1, 2, 7 and PCE layouts keep the decoded order.
|
||||
fn aac_to_wav_order(channel_config: u8) -> Option<&'static [usize]> {
|
||||
Some(match channel_config {
|
||||
3 => &[1, 2, 0],
|
||||
4 => &[1, 2, 0, 3],
|
||||
5 => &[1, 2, 0, 3, 4],
|
||||
6 => &[1, 2, 0, 5, 3, 4],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `channels` reordered by `order` (output channel → decoded channel). A decode whose channel count
|
||||
/// does not match the order is returned unchanged.
|
||||
fn reorder(mut channels: Vec<Vec<f32>>, order: Option<&[usize]>) -> Vec<Vec<f32>> {
|
||||
match order {
|
||||
Some(order) if order.len() == channels.len() => order.iter().map(|&c| channels.get_mut(c).map(std::mem::take).unwrap_or_default()).collect(),
|
||||
_ => channels,
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration of an Opus packet in 48 kHz samples from its TOC and frame count (RFC 6716 §3.1),
|
||||
/// without parsing frame lengths. For a multistream packet this is the first stream's TOC, which
|
||||
/// every stream shares. `None` for an empty or invalid (> 120 ms) packet.
|
||||
|
|
@ -109,6 +136,12 @@ impl PacketDecoder {
|
|||
};
|
||||
Self::new(codec, sample_rate, None)
|
||||
}
|
||||
/// MPEG-1/2 audio of a layer the frame headers name (see [`mpeg_audio_layer`]): MP4's
|
||||
/// `esds` object types 0x69 / 0x6B and QuickTime's `.mp3` / `.mp2` cover layers I–III alike
|
||||
/// (ffmpeg stores MP2 as `mp4a` + 0x6B).
|
||||
pub fn lazy_mpeg_audio(sample_rate: u32) -> Self {
|
||||
Self { inner: Inner::MpegPending(sample_rate), channels: 0 }
|
||||
}
|
||||
/// AAC configured by [`Self::ensure_aac`] from in-band headers (ADTS, LATM).
|
||||
pub fn lazy_aac() -> Self {
|
||||
Self { inner: Inner::AacPending, channels: 0 }
|
||||
|
|
@ -124,7 +157,7 @@ impl PacketDecoder {
|
|||
self.inner = Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up: None };
|
||||
Ok(())
|
||||
}
|
||||
/// AC-3 (ATSC A/52).
|
||||
/// AC-3 and E-AC-3 (ATSC A/52, Annex E).
|
||||
pub fn ac3() -> Result<Self> {
|
||||
Ok(Self { inner: Inner::Ac3(Box::new(filmcraft_ac3::Decoder::new())), channels: 0 })
|
||||
}
|
||||
|
|
@ -136,8 +169,8 @@ impl PacketDecoder {
|
|||
use filmcraft_isobmff::CodecConfig as C;
|
||||
match c {
|
||||
C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }),
|
||||
C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None),
|
||||
C::Ac3 { .. } => Self::ac3(),
|
||||
C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)),
|
||||
C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(),
|
||||
C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())),
|
||||
C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None),
|
||||
C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
|
||||
|
|
@ -146,36 +179,44 @@ impl PacketDecoder {
|
|||
}
|
||||
/// Decode one packet into planar channels.
|
||||
pub fn decode(&mut self, data: &[u8], ts: u64) -> Result<Vec<Vec<f32>>> {
|
||||
if let Inner::MpegPending(rate) = self.inner {
|
||||
let layer = mpeg_audio_layer(data).ok_or_else(|| CodecError::Decode("MPEG audio: no frame header".into()))?;
|
||||
self.inner = Self::mpeg_audio(layer, rate)?.inner;
|
||||
}
|
||||
let dec = match &mut self.inner {
|
||||
Inner::Aac { dec, up, .. } => {
|
||||
let mut out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
if let Some(up) = up {
|
||||
out = up.process(out);
|
||||
}
|
||||
let out = reorder(out, aac_to_wav_order(dec.config().channel_config));
|
||||
self.channels = out.len();
|
||||
return Ok(out);
|
||||
}
|
||||
Inner::Opus { dec, order } => {
|
||||
// A corrupt packet is concealed like a lost one (keeps timing and decoder state).
|
||||
let mut out = match dec.decode(Some(data)) {
|
||||
let out = match dec.decode(Some(data)) {
|
||||
Ok(o) => o,
|
||||
Err(_) => dec.decode(None).map_err(|e| CodecError::Decode(e.to_string()))?,
|
||||
};
|
||||
if let Some(order) = order {
|
||||
out = order.iter().map(|&c| std::mem::take(&mut out[c])).collect();
|
||||
}
|
||||
let out = reorder(out, *order);
|
||||
self.channels = out.len();
|
||||
return Ok(out);
|
||||
}
|
||||
Inner::Symphonia(d) => d,
|
||||
Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())),
|
||||
Inner::MpegPending(_) => return Err(CodecError::Decode("MPEG audio: no frame header".into())),
|
||||
Inner::Ac3(d) => {
|
||||
// a packet may hold several syncframes (MP4 / Matroska)
|
||||
// a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent
|
||||
// substreams and further programs decode to no channels and are skipped
|
||||
let mut out: Vec<Vec<f32>> = Vec::new();
|
||||
let mut p = 0;
|
||||
while p < data.len() {
|
||||
let f = d.decode(&data[p..]).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
p += f.header.frame_bytes;
|
||||
if f.channels.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if out.is_empty() {
|
||||
out = f.channels;
|
||||
} else {
|
||||
|
|
@ -218,11 +259,21 @@ impl PacketDecoder {
|
|||
Inner::Opus { dec, .. } => dec.reset(),
|
||||
Inner::Symphonia(d) => d.reset(),
|
||||
Inner::Ac3(d) => d.reset(),
|
||||
Inner::AacPending => {}
|
||||
Inner::AacPending | Inner::MpegPending(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer (1, 2 or 3) named by the MPEG-1/2 audio frame header that starts `frame`: the 12-bit
|
||||
/// sync word (11 bits for MPEG-2.5), then `ID` and the 2-bit `layer` field, 3 for layer I down to 1
|
||||
/// for layer III (ISO/IEC 11172-3 §2.4.2.3). `None` without a sync word or for the reserved layer 0.
|
||||
pub fn mpeg_audio_layer(frame: &[u8]) -> Option<u8> {
|
||||
match *frame {
|
||||
[0xFF, b1, ..] if b1 & 0xE0 == 0xE0 && (b1 >> 1) & 3 != 0 => Some(4 - ((b1 >> 1) & 3)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The output rate of an AAC stream: twice the core rate for HE-AAC (ISO/IEC 14496-3 §1.6.5).
|
||||
/// Explicit signalling names the rate in the `AudioSpecificConfig`; with implicit signalling (an
|
||||
/// AAC-LC config at 24 kHz or less) SBR is only visible as fill-element data in the access units,
|
||||
|
|
@ -256,21 +307,39 @@ pub enum FixedFrames<'a> {
|
|||
Aac(&'a [u8]),
|
||||
/// MPEG-1/2 audio, layer I, II or III.
|
||||
MpegAudio,
|
||||
/// AC-3 (and E-AC-3).
|
||||
/// AC-3 (and E-AC-3 in a stream of single syncframes, MPEG-TS).
|
||||
Ac3,
|
||||
/// E-AC-3 packets of whole syncframes (MP4, Matroska): an access unit may hold several
|
||||
/// syncframes and dependent substreams.
|
||||
Eac3,
|
||||
}
|
||||
|
||||
/// Samples each packet decodes to at the stream's output `rate`, for codecs where that is the same
|
||||
/// for every packet: AAC 1024 (2048 when [`PacketDecoder::aac`] upsamples an HE-AAC core); MPEG
|
||||
/// audio and AC-3 from the frame header of `first`, the stream's first packet. `None` when it is
|
||||
/// not known: an AAC configuration our decoder does not read, a header that does not parse, or a
|
||||
/// header whose rate is not `rate`.
|
||||
/// audio and AC-3 from the frame header of `first`, the stream's first packet; E-AC-3 from the
|
||||
/// syncframes of the program we decode in `first`. `None` when it is not known: an AAC
|
||||
/// configuration our decoder does not read, a header that does not parse, or a header whose rate
|
||||
/// is not `rate`.
|
||||
pub fn fixed_packet_samples(codec: FixedFrames, first: &[u8], rate: u32) -> Option<i64> {
|
||||
let mpegts = match codec {
|
||||
FixedFrames::Aac(asc) => {
|
||||
let core = filmcraft_aac::Decoder::new(asc).ok()?.sample_rate();
|
||||
return Some(if upsamples_core(core, rate) { 2048 } else { 1024 });
|
||||
}
|
||||
FixedFrames::Eac3 => {
|
||||
let mut samples = 0usize;
|
||||
let mut p = 0;
|
||||
while let Some(h) = first.get(p..).and_then(|b| filmcraft_ac3::parse_header(b).ok()) {
|
||||
if h.is_primary() {
|
||||
if h.sample_rate != rate {
|
||||
return None;
|
||||
}
|
||||
samples += h.samples();
|
||||
}
|
||||
p += h.frame_bytes;
|
||||
}
|
||||
return i64::try_from(samples).ok().filter(|&n| n > 0);
|
||||
}
|
||||
FixedFrames::MpegAudio => filmcraft_mpegts::Codec::MpegAudio,
|
||||
FixedFrames::Ac3 => filmcraft_mpegts::Codec::Ac3,
|
||||
};
|
||||
|
|
@ -382,7 +451,7 @@ impl Upsample2x {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether AC-3 audio decodes (our ATSC A/52 decoder).
|
||||
/// Whether AC-3 and E-AC-3 audio decode (our ATSC A/52 decoder).
|
||||
pub const AC3_DECODER: bool = true;
|
||||
|
||||
/// An AudioSpecificConfig for AAC with these parameters (ISO/IEC 14496-3 §1.6.2.1, GA specific
|
||||
|
|
@ -811,6 +880,63 @@ mod he_aac_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod surround_order_tests {
|
||||
use super::*;
|
||||
|
||||
/// Power of `x` at `hz` (Goertzel), normalised by length.
|
||||
fn power(x: &[f32], hz: f32, rate: f32) -> f32 {
|
||||
let k = 2.0 * (std::f32::consts::TAU * hz / rate).cos();
|
||||
let (mut s1, mut s2) = (0.0f32, 0.0f32);
|
||||
for &v in x {
|
||||
let s0 = v + k * s1 - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
(s1 * s1 + s2 * s2 - k * s1 * s2) / x.len() as f32
|
||||
}
|
||||
|
||||
/// #721: 5.1 AAC (configuration 6, coded C L R Ls Rs LFE) decodes to WAV/SMPTE order
|
||||
/// L R C LFE Ls Rs, as the rest of FilmCraft and the AAC export path expect.
|
||||
#[test]
|
||||
fn aac_51_decodes_in_wav_order() {
|
||||
let rate = 48_000u32;
|
||||
// tones per speaker, in WAV order: L, R, C, LFE, Ls, Rs
|
||||
let hz = [300.0f32, 400.0, 500.0, 60.0, 700.0, 800.0];
|
||||
let tone = |f: f32| (0..rate as usize).map(|i| 0.25 * (std::f32::consts::TAU * f * i as f32 / rate as f32).sin()).collect::<Vec<f32>>();
|
||||
let wav: Vec<Vec<f32>> = hz.iter().map(|&f| tone(f)).collect();
|
||||
// the encoder takes AAC element order (as the exporter feeds it): C L R Ls Rs LFE
|
||||
let coded: Vec<&[f32]> = [2usize, 0, 1, 4, 5, 3].iter().map(|&c| wav[c].as_slice()).collect();
|
||||
let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 6, 384_000)).unwrap();
|
||||
let mut units = enc.encode(&coded);
|
||||
units.extend(enc.flush());
|
||||
|
||||
let mut dec = PacketDecoder::aac(&enc.audio_specific_config(), rate).unwrap();
|
||||
let mut out = vec![Vec::new(); 6];
|
||||
for u in &units {
|
||||
for (o, c) in out.iter_mut().zip(dec.decode(u, 0).unwrap()) {
|
||||
o.extend(c);
|
||||
}
|
||||
}
|
||||
assert_eq!(dec.channels, 6);
|
||||
for (ch, samples) in out.iter().enumerate() {
|
||||
let window = &samples[4800..38_400];
|
||||
let own = power(window, hz[ch], rate as f32);
|
||||
for (other, &f) in hz.iter().enumerate().filter(|&(o, _)| o != ch) {
|
||||
let p = power(window, f, rate as f32);
|
||||
assert!(own > 100.0 * p, "channel {ch}: {} Hz {own} vs {f} Hz (channel {other}) {p}", hz[ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_leaves_mismatched_channel_counts_alone() {
|
||||
let chans = vec![vec![1.0], vec![2.0]];
|
||||
assert_eq!(reorder(chans.clone(), aac_to_wav_order(6)), chans);
|
||||
assert_eq!(reorder(vec![vec![0.0], vec![1.0], vec![2.0]], aac_to_wav_order(3)), vec![vec![1.0], vec![2.0], vec![0.0]]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod packet_time_tests {
|
||||
use super::*;
|
||||
|
|
@ -887,5 +1013,60 @@ mod packet_time_tests {
|
|||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 48_000), None);
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF], 44_100), None);
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Ac3, &[0x0B, 0x77], 48_000), None);
|
||||
// E-AC-3 access units: 8-byte syncframes (frmsiz 3) at 48 kHz, stereo. A six-block
|
||||
// independent frame with a dependent substream (7.1) is 1536 samples; two one-block
|
||||
// frames are 512
|
||||
let frame = |strmtyp: u8, numblkscod: u8| [0x0B, 0x77, strmtyp << 6, 3, (numblkscod << 4) | (2 << 1), 16 << 3, 0, 0];
|
||||
let au = |frames: &[[u8; 8]]| frames.concat();
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3), frame(1, 3)]), 48_000), Some(1536));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 0), frame(0, 0)]), 48_000), Some(512));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3)]), 44_100), None);
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mpeg_audio_tests {
|
||||
use super::*;
|
||||
|
||||
/// A silent mono 48 kHz MPEG-1 frame of `layer` (2: 64 kbps, 192 bytes; 3: 56 kbps, 168
|
||||
/// bytes): header, then all-zero bit allocation / side information.
|
||||
fn silent_frame(layer: u8) -> Vec<u8> {
|
||||
let (b1, len) = if layer == 2 { (0xFD, 192) } else { (0xFB, 168) };
|
||||
let mut f = vec![0xFF, b1, 0x44, 0xC0];
|
||||
f.resize(len, 0);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_comes_from_the_frame_header() {
|
||||
assert_eq!(mpeg_audio_layer(&[0xFF, 0xFF, 0x10, 0xC0]), Some(1));
|
||||
assert_eq!(mpeg_audio_layer(&silent_frame(2)), Some(2));
|
||||
assert_eq!(mpeg_audio_layer(&silent_frame(3)), Some(3));
|
||||
// MPEG-2 layer III, MPEG-2.5 layer II
|
||||
assert_eq!(mpeg_audio_layer(&[0xFF, 0xF3, 0x90, 0x44]), Some(3));
|
||||
assert_eq!(mpeg_audio_layer(&[0xFF, 0xE5, 0x90, 0x44]), Some(2));
|
||||
// reserved layer, no sync word, too short
|
||||
for bad in [&[0xFF, 0xF9, 0x44, 0xC0][..], &[0xFF, 0x1D], &[0x00, 0xFD], &[0xFF], &[]] {
|
||||
assert_eq!(mpeg_audio_layer(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #800: ffmpeg stores MP2 in MP4 as `mp4a` + object type 0x6B, which names no layer. Handing
|
||||
/// it to the layer III decoder rejected every frame and the track played as silence.
|
||||
#[test]
|
||||
fn mp4_mpeg_audio_decodes_the_layer_its_frames_name() {
|
||||
for layer in [2u8, 3] {
|
||||
let mut d = PacketDecoder::for_isobmff(&filmcraft_isobmff::CodecConfig::Mp3, 48_000).unwrap();
|
||||
// a packet without a frame header is an error, not a crash, and configures nothing
|
||||
assert!(d.decode(&[0u8; 16], 0).is_err());
|
||||
d.reset();
|
||||
for k in 0..3u64 {
|
||||
let out = d.decode(&silent_frame(layer), k * 1152).unwrap_or_else(|e| panic!("layer {layer}: {e}"));
|
||||
assert_eq!(out.len(), 1, "layer {layer}");
|
||||
assert_eq!(out[0].len(), 1152, "layer {layer}");
|
||||
assert!(out[0].iter().all(|&s| s == 0.0), "layer {layer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,18 @@ pub trait VideoSamples {
|
|||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>>;
|
||||
}
|
||||
|
||||
/// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when
|
||||
/// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before
|
||||
/// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the
|
||||
/// GOP before, so a decoder starting at their random-access picture leaves them out.
|
||||
fn decode_start(s: &dyn VideoSamples, i: usize) -> usize {
|
||||
let mut key = s.sync_before(i);
|
||||
while key > 0 && s.pts(i) < s.pts(key) {
|
||||
key = s.sync_before(key - 1).min(key - 1);
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
struct State {
|
||||
decoder: Option<Box<dyn VideoDecoder>>,
|
||||
/// The decoder only produces intra pictures: frames decode independently and in parallel.
|
||||
|
|
@ -604,12 +616,13 @@ impl GopCache {
|
|||
if st.intra {
|
||||
return self.intra_frame(st, s, i, want_pts);
|
||||
}
|
||||
let mut key = s.sync_before(i);
|
||||
let mut key = decode_start(s, i);
|
||||
// Continue the running decoder when it has passed the wanted sample's sync sample and
|
||||
// either has not reached the sample yet or has been fed it without outputting it yet
|
||||
// (a frame-threaded decoder holds many pictures in flight). Otherwise the frame was
|
||||
// evicted or lies in another GOP: restart at the sync sample.
|
||||
let running = st.next != usize::MAX && st.next <= n;
|
||||
// evicted or lies in another GOP: restart at the sync sample. A run that started after
|
||||
// `key` (at the random-access picture of a wanted leading picture) left the picture out.
|
||||
let running = st.next != usize::MAX && st.next <= n && st.start <= key;
|
||||
// Decoding on through a short stretch into the next GOP is cheaper than a restart, and
|
||||
// playback wants those frames anyway.
|
||||
let near = st.next <= key && key - st.next <= CONTINUE_THROUGH;
|
||||
|
|
@ -694,7 +707,7 @@ impl GopCache {
|
|||
want_pts: i64,
|
||||
n: usize,
|
||||
) -> crate::Result<Vec<crate::video::DecodedFrame>> {
|
||||
let mut key = s.sync_before(i);
|
||||
let mut key = decode_start(s, i);
|
||||
// Continue the running decoder when the wanted sample is ahead within this GOP run.
|
||||
let continuing = *next != usize::MAX && *next > key && *next <= i + 16 && *next <= n;
|
||||
if !continuing {
|
||||
|
|
@ -1261,4 +1274,94 @@ mod tests {
|
|||
}
|
||||
assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before);
|
||||
}
|
||||
|
||||
/// An open-GOP stream like iPhone HEVC: 8 pictures a GOP; after the first, each GOP stores
|
||||
/// its random-access picture (display 8·g) first, then two leading pictures shown before it
|
||||
/// (8·g − 2, 8·g − 1, predicted from the GOP before), then the rest. pts = 1000 · display.
|
||||
struct OpenGop {
|
||||
/// (display index, sync) in decode order
|
||||
order: Vec<(i64, bool)>,
|
||||
resets: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl OpenGop {
|
||||
fn new(gops: i64) -> Self {
|
||||
let mut order: Vec<(i64, bool)> = (0..6).map(|d| (d, d == 0)).collect();
|
||||
for g in 1..gops {
|
||||
order.extend([(8 * g, true), (8 * g - 2, false), (8 * g - 1, false)]);
|
||||
order.extend((8 * g + 1..8 * g + 6).map(|d| (d, false)));
|
||||
}
|
||||
Self { order, resets: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaves out the leading pictures of the random-access picture a run starts at (RASL
|
||||
/// pictures, as the HEVC decoders do), outputs the others at once.
|
||||
struct OpenGopDec {
|
||||
run_start: Option<i64>,
|
||||
resets: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl VideoDecoder for OpenGopDec {
|
||||
fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
|
||||
let start = *self.run_start.get_or_insert(pts);
|
||||
Ok(if pts < start { Vec::new() } else { vec![picture((pts, false))] })
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
Vec::new()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.resets.fetch_add(1, Ordering::Relaxed);
|
||||
self.run_start = None;
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"open gop"
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoSamples for OpenGop {
|
||||
fn count(&self) -> usize {
|
||||
self.order.len()
|
||||
}
|
||||
fn pts(&self, i: usize) -> i64 {
|
||||
self.order[i].0 * 1000
|
||||
}
|
||||
fn sync_before(&self, i: usize) -> usize {
|
||||
(0..=i).rev().find(|&k| self.order[k].1).unwrap_or(0)
|
||||
}
|
||||
fn sample_at(&self, t: i64) -> Option<usize> {
|
||||
self.order.iter().position(|&(d, _)| d == t / 1000)
|
||||
}
|
||||
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
|
||||
Ok(vec![i as u8])
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
Ok(Box::new(OpenGopDec { run_start: None, resets: self.resets.clone() }))
|
||||
}
|
||||
}
|
||||
|
||||
/// A leading picture of an open GOP, asked for first (a clip trimmed to start on it, the
|
||||
/// playhead parked on it), came out black: decoding started at the random-access picture
|
||||
/// after it, which leaves it out.
|
||||
#[test]
|
||||
fn leading_pictures_of_an_open_gop_decode_from_the_gop_before() {
|
||||
let s = OpenGop::new(6);
|
||||
for d in [6usize, 7, 14, 15, 39] {
|
||||
let c = GopCache::new(None);
|
||||
assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d);
|
||||
}
|
||||
// the random-access picture first, then its leading pictures: the run that started at
|
||||
// it left them out, so they restart a GOP earlier
|
||||
let c = GopCache::new(None);
|
||||
assert_eq!(index_of(&c.frame(&s, 16_000).expect("frame")), 16);
|
||||
assert_eq!(index_of(&c.frame(&s, 15_000).expect("frame")), 15);
|
||||
assert_eq!(index_of(&c.frame(&s, 14_000).expect("frame")), 14);
|
||||
// playing through: one seek, every picture in turn
|
||||
let s = OpenGop::new(6);
|
||||
let c = GopCache::new(None);
|
||||
for d in 0..46usize {
|
||||
assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d);
|
||||
}
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
|||
out
|
||||
}
|
||||
|
||||
/// What an AV1 sequence header fixes about the pictures: profile, maximum size and the colour
|
||||
/// configuration (bit depth, chroma format, colour description). `None` when it does not parse.
|
||||
fn av1_format(seq: &[u8]) -> Option<(u8, u32, u32, filmcraft_av1::ColorConfig)> {
|
||||
let h = filmcraft_av1::SequenceHeader::parse(seq).ok()?;
|
||||
Some((h.profile, h.max_frame_width, h.max_frame_height, h.color))
|
||||
}
|
||||
|
||||
/// VP9 colour as our decoder reports it: `color_space` and range from the bitstream, transfer and
|
||||
/// primaries from the container when it says. (Shared with `Vp9Decoder`.)
|
||||
pub(crate) fn vp9_color(width: u32, height: u32, color_space: u8, full_range: bool, transfer: Option<Transfer>, primaries: Option<Primaries>) -> ColorInfo {
|
||||
|
|
@ -243,14 +250,58 @@ impl FrameStreamInfo {
|
|||
}
|
||||
|
||||
/// Whether `sample` declares parameters the hardware session was not set up for: an AV1
|
||||
/// sequence header that differs from `av1C`'s, a VP9 key frame of another size, bit depth or
|
||||
/// chroma format. The decoder then hands the stream to our software decoder.
|
||||
/// sequence header whose picture format (profile, size, colour configuration) differs from
|
||||
/// `av1C`'s, a VP9 key frame of another size, bit depth or chroma format. The decoder then
|
||||
/// hands the stream to our software decoder.
|
||||
pub fn parameters_changed(&self, sample: &[u8]) -> bool {
|
||||
match self.codec {
|
||||
FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref() != Some(p)),
|
||||
// Only what the picture format depends on counts: encoders such as SVT-AV1 put a
|
||||
// provisional sequence header in `av1C` whose coding tool flags (CDEF, restoration,
|
||||
// warped motion…) differ from the in-band one, and decoders follow the in-band one.
|
||||
FrameCodec::Av1 => obus(sample).into_iter().any(|(t, p)| t == 1 && self.sequence_header.as_deref().is_none_or(|s| av1_format(s) != av1_format(p))),
|
||||
FrameCodec::Vp9 => {
|
||||
self.picture_params(sample).is_some_and(|p| p.size != self.size || p.bit_depth != self.bit_depth || p.subsampling != self.subsampling)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An `av1C` sequence header from SVT-AV1 (1280x720 10-bit) and the in-band one of the same
|
||||
/// stream: they differ only in coding tool flags.
|
||||
const AV1C: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x01, 0x7c, 0x20, 0x20];
|
||||
const IN_BAND: [u8; 14] = [0x0a, 0x0c, 0x02, 0x00, 0x00, 0x2d, 0x6a, 0x67, 0xfd, 0x9e, 0x35, 0x7c, 0xe0, 0x20];
|
||||
|
||||
fn info() -> FrameStreamInfo {
|
||||
let c = filmcraft_isobmff::Av1Config {
|
||||
seq_profile: 0,
|
||||
seq_level_idx_0: 0,
|
||||
seq_tier_0: false,
|
||||
high_bitdepth: true,
|
||||
twelve_bit: false,
|
||||
monochrome: false,
|
||||
chroma_subsampling_x: true,
|
||||
chroma_subsampling_y: true,
|
||||
chroma_sample_position: 0,
|
||||
initial_presentation_delay_minus_one: None,
|
||||
config_obus: AV1C.to_vec(),
|
||||
};
|
||||
FrameStreamInfo::from_av1(&c, (0, 0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_sequence_headers_compare_by_picture_format() {
|
||||
let i = info();
|
||||
assert_eq!((i.size, i.bit_depth), ((1280, 720), 10));
|
||||
assert!(!i.parameters_changed(&AV1C), "the same header");
|
||||
// used to send every SVT-AV1 MP4 to the software decoder from its first frame
|
||||
assert!(!i.parameters_changed(&IN_BAND), "only coding tool flags differ");
|
||||
// a header that does not parse is a change
|
||||
assert!(i.parameters_changed(&[0x0a, 0x02, 0xe0, 0x00]));
|
||||
// no sequence header in the sample: nothing changed
|
||||
assert!(!i.parameters_changed(&[0x12, 0x00]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ pub mod mxf;
|
|||
pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
mod webm_alpha;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ pub struct MkvSource {
|
|||
/// Video codec as an ISO-BMFF sample entry (for the decoder factories).
|
||||
ventry: Option<SampleEntry>,
|
||||
video: GopCache,
|
||||
/// The alpha layer of a VP9 track with `AlphaMode` 1 (WebM transparency, #402).
|
||||
alpha: Option<Arc<crate::webm_alpha::AlphaLayer>>,
|
||||
/// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`.
|
||||
audios: Vec<MkvAudio>,
|
||||
}
|
||||
|
|
@ -66,7 +68,7 @@ pub struct MkvSource {
|
|||
///
|
||||
/// Block timestamps are quantised to `TimestampScale` (usually 1 ms), so they are only accurate to a
|
||||
/// tick: a 1024-sample AAC frame at 48 kHz lasts 21.33 ms. Packets that all decode to the same
|
||||
/// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the
|
||||
/// length (AAC, MPEG audio, AC-3, E-AC-3) therefore run on from one another, resynchronising to the
|
||||
/// timestamp only across gaps of more than half a packet (and more than two ticks); see
|
||||
/// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their
|
||||
/// timestamps.
|
||||
|
|
@ -84,6 +86,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
|
|||
Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate),
|
||||
Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate),
|
||||
Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate),
|
||||
Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else {
|
||||
|
|
@ -102,10 +105,9 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec
|
|||
fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec<i64> {
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
let (n, d) = tb(t);
|
||||
let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1);
|
||||
let delay_ns = t.codec_delay_ns as i128;
|
||||
// The demuxer's rounding of CodecDelay to ticks (half away from zero), undone here.
|
||||
let delay_ticks = ((delay_ns + scale_ns / 2) / scale_ns) as i64;
|
||||
// The demuxer's rounding of CodecDelay to ticks, undone here.
|
||||
let delay_ticks = codec_delay_ticks(t);
|
||||
let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 };
|
||||
let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip);
|
||||
let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime {
|
||||
|
|
@ -116,6 +118,46 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o
|
|||
crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate))
|
||||
}
|
||||
|
||||
/// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before
|
||||
/// subtracting it from every timestamp of the track.
|
||||
fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
|
||||
let (n, d) = tb(t);
|
||||
let scale_ns = (n as i128 * 1_000_000_000 / d as i128).max(1);
|
||||
i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// The track timestamp that media time zero stands for (#715): the earliest start among the
|
||||
/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps
|
||||
/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture
|
||||
/// seconds in; taken as media time, that repeated the opening picture until then. An audio track
|
||||
/// starts where its first packet does before `CodecDelay` was taken off (the delay only hides
|
||||
/// priming), and never before zero.
|
||||
fn media_origin(file: &MkvFile, vtrack: Option<usize>, atracks: &[usize]) -> i64 {
|
||||
let start = |i: usize| {
|
||||
let t = file.tracks.get(i)?;
|
||||
let first = t.samples.iter().map(|s| s.pts).min()?;
|
||||
Some(if t.kind == TrackKind::Audio { first.saturating_add(codec_delay_ticks(t)).max(0) } else { first })
|
||||
};
|
||||
vtrack.into_iter().chain(atracks.iter().copied()).filter_map(start).min().unwrap_or(0).max(0)
|
||||
}
|
||||
|
||||
/// Move every track back by `origin` timestamp ticks, so the streams keep their relative timing
|
||||
/// (a delay between picture and sound survives).
|
||||
fn rebase(file: &mut MkvFile, origin: i64) {
|
||||
if origin <= 0 {
|
||||
return;
|
||||
}
|
||||
for s in file.tracks.iter_mut().flat_map(|t| t.samples.iter_mut()) {
|
||||
s.pts = s.pts.saturating_sub(origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the last sample of track `i`, in its timebase.
|
||||
fn track_end(file: &MkvFile, i: usize) -> Option<(i64, &filmcraft_matroska::Track)> {
|
||||
let t = file.tracks.get(i)?;
|
||||
Some((t.samples.iter().map(|s| s.pts.saturating_add(i64::try_from(s.duration).unwrap_or(i64::MAX))).max().unwrap_or(0), t))
|
||||
}
|
||||
|
||||
/// Two timestamp ticks in sample frames at `rate`, plus one: how far a packet's Matroska timestamp
|
||||
/// can be from its exact start.
|
||||
fn stamp_tolerance(t: &filmcraft_matroska::Track, rate: i64) -> i64 {
|
||||
|
|
@ -144,7 +186,7 @@ fn tb(t: &filmcraft_matroska::Track) -> (i64, i64) {
|
|||
|
||||
fn to_tick(t: &filmcraft_matroska::Track, pts: i64) -> Tick {
|
||||
let (n, d) = tb(t);
|
||||
Tick::from_rational(pts * n, 1, d)
|
||||
Tick::from_rational(pts, n, d)
|
||||
}
|
||||
|
||||
/// A time as the track timestamp to look a frame up by: rounded to the nearest tick, as muxers
|
||||
|
|
@ -273,7 +315,7 @@ impl MkvSource {
|
|||
/// Open from a random-access reader: only the index is read now, samples on demand.
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let bytes = crate::Src(reader);
|
||||
let file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let mut file = filmcraft_matroska::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty());
|
||||
// every audio track is a stream (the track count comes from the file: capped)
|
||||
let atracks: Vec<usize> = file
|
||||
|
|
@ -287,6 +329,24 @@ impl MkvSource {
|
|||
if vtrack.is_none() && atracks.is_empty() {
|
||||
return Err(CodecError::Unsupported("no playable tracks".into()));
|
||||
}
|
||||
let main = vtrack.or(atracks.first().copied());
|
||||
// The Segment's end (`Info/Duration` or the last sample), in Segment time.
|
||||
let segment_end = match file.duration_ns() {
|
||||
Some(ns) => Tick::from_rational(i64::try_from(ns).unwrap_or(i64::MAX), 1, 1_000_000_000),
|
||||
None => main.and_then(|i| track_end(&file, i)).map(|(end, t)| to_tick(t, end)).unwrap_or_default(),
|
||||
};
|
||||
// Media time zero is the first picture (or sound): the streams start at their common origin.
|
||||
let origin = media_origin(&file, vtrack, &atracks);
|
||||
rebase(&mut file, origin);
|
||||
// `Info/Duration` is the Segment's end: the media lasts from the origin to there (never less
|
||||
// than its main track's samples).
|
||||
let duration = match main.and_then(|i| track_end(&file, i)) {
|
||||
Some((end, t)) if origin > 0 => {
|
||||
let (n, d) = tb(t);
|
||||
(segment_end - Tick::from_rational(origin, n, d)).max(to_tick(t, end))
|
||||
}
|
||||
_ => segment_end,
|
||||
};
|
||||
let mut explicit_color = None;
|
||||
let mut ventry = None;
|
||||
// display rotation from the track's Projection (portrait phone video is stored landscape)
|
||||
|
|
@ -310,7 +370,7 @@ impl MkvSource {
|
|||
if explicit {
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let secs = duration.seconds();
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
VideoStreamInfo {
|
||||
|
|
@ -340,17 +400,6 @@ impl MkvSource {
|
|||
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
|
||||
})
|
||||
.collect();
|
||||
let duration = match file.duration_ns() {
|
||||
Some(ns) => Tick::from_rational(ns as i64, 1, 1_000_000_000),
|
||||
None => vtrack
|
||||
.or(atracks.first().copied())
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let end = t.samples.iter().map(|s| s.pts + s.duration as i64).max().unwrap_or(0);
|
||||
to_tick(t, end)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
|
|
@ -380,7 +429,12 @@ impl MkvSource {
|
|||
Some(MkvAudio { track: i, state, starts, preroll })
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), audios })
|
||||
// WebM transparency: VP9 alpha in each block's BlockAdditional (ID 1)
|
||||
let alpha = vtrack.and_then(|i| file.tracks.get(i)).filter(|t| {
|
||||
matches!(t.codec, Codec::Vp9 { .. }) && t.video.as_ref().is_some_and(|v| v.alpha_mode != 0) && t.samples.iter().any(|s| s.addition.is_some())
|
||||
});
|
||||
let alpha = alpha.map(|t| Arc::new(crate::webm_alpha::AlphaLayer::new(bytes.clone(), t.samples.iter().map(|s| (s.pts, s.addition)))));
|
||||
Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), alpha, audios })
|
||||
}
|
||||
|
||||
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
|
||||
|
|
@ -394,7 +448,7 @@ impl MkvSource {
|
|||
Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?),
|
||||
Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None),
|
||||
Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate),
|
||||
Codec::Ac3 => PacketDecoder::ac3(),
|
||||
Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(),
|
||||
// symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header.
|
||||
Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)),
|
||||
Codec::Vorbis { headers } if headers.len() == 3 => {
|
||||
|
|
@ -486,9 +540,15 @@ impl VideoSamples for MkvVideo<'_> {
|
|||
self.src.read(self.track, i)
|
||||
}
|
||||
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
|
||||
match &self.src.ventry {
|
||||
Some(e) => make_video_decoder(e),
|
||||
None => Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec)))),
|
||||
let Some(e) = &self.src.ventry else {
|
||||
return Err(CodecError::Unsupported(format!("no decoder for {} video", codec_label(&self.src.file.tracks[self.track].codec))));
|
||||
};
|
||||
let color = make_video_decoder(e)?;
|
||||
let Some(layer) = &self.src.alpha else { return Ok(color) };
|
||||
// the alpha layer always goes through our own decoder (it is luma only)
|
||||
match crate::software_video_decoder(e) {
|
||||
Ok(alpha) => Ok(Box::new(crate::webm_alpha::AlphaDecoder::new(color, alpha, layer.clone()))),
|
||||
Err(_) => Ok(color),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -601,3 +661,52 @@ mod multi_audio_tests {
|
|||
assert!(source.audio_stream(2, 0, 50, 48_000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use filmcraft_frame::PixelData;
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::{FrameRequest, MediaSource};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
/// A flat grey Motion-JPEG picture: its level says which frame it is.
|
||||
fn picture(k: u8) -> Vec<u8> {
|
||||
let img = image::GrayImage::from_pixel(16, 16, image::Luma([20 + 20 * k]));
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95).encode_image(&img).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// #715: a remux that keeps its source's timestamps (first picture at 1.48 s, sound 0.2 s after
|
||||
/// it) starts at its first picture, lasts as long as its pictures, and keeps the sound's delay.
|
||||
/// It repeated picture 0 until 1.48 s, and the clip ran to the Segment's absolute end.
|
||||
#[test]
|
||||
fn timestamp_preserving_remux_starts_at_the_first_picture() {
|
||||
let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG");
|
||||
v.video_size = Some((16, 16));
|
||||
v.default_duration_ns = Some(40_000_000);
|
||||
let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
|
||||
a.audio = Some((48_000.0, 1, Some(16)));
|
||||
let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![v, a], MuxOptions::default()).unwrap();
|
||||
for k in 0..10u8 {
|
||||
let pts = 1_480_000_000 + i64::from(k) * 40_000_000;
|
||||
w.write_frame(0, pts, true, &picture(k), None).unwrap();
|
||||
if k == 5 {
|
||||
let pcm: Vec<u8> = (0..960).flat_map(|_| 16_384i16.to_le_bytes()).collect();
|
||||
w.write_frame(1, 1_680_000_000, true, &pcm, Some(20_000_000)).unwrap();
|
||||
}
|
||||
}
|
||||
let src = super::MkvSource::open("shifted.mkv", w.finish().unwrap().into_inner().into()).unwrap();
|
||||
assert_eq!(src.info().duration, Tick::from_rational(10, 1, 25), "ten pictures at 25 fps");
|
||||
let level = |k: i64| {
|
||||
let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).unwrap();
|
||||
let PixelData::Rgba8(px) = &f.data else { panic!("MJPEG decodes to RGBA8") };
|
||||
px[0]
|
||||
};
|
||||
let seen: Vec<u8> = (0..10).map(level).collect();
|
||||
assert!(seen.windows(2).all(|p| p[0] < p[1]), "one picture per frame, in order: {seen:?}");
|
||||
let sound = src.audio(0, 48_000 * 2 / 5, 48_000).unwrap();
|
||||
assert!(sound.channels[0][9_590].abs() < 0.001, "silent before the delay");
|
||||
assert!((sound.channels[0][9_610] - 0.5).abs() < 0.001, "sound 0.2 s after the first picture");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex};
|
|||
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::{AudioBuffer, Region, VideoFrame};
|
||||
use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_isobmff::{ByteSource, CleanAperture, CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
|
|
@ -57,8 +57,9 @@ pub fn sniff(b: &[u8]) -> bool {
|
|||
}
|
||||
|
||||
/// Audio packet start positions in source sample frames at `rate`: the running total of the sample
|
||||
/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3) run
|
||||
/// on from one another, resynchronising to the total only across gaps of more than half a packet.
|
||||
/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3,
|
||||
/// E-AC-3) run on from one another, resynchronising to the total only across gaps of more than half
|
||||
/// a packet.
|
||||
/// A remux from Matroska (OBS's) carries the millisecond rounding of its timestamps into the
|
||||
/// durations: 1008, 1008, 1056… for 1024-sample AAC frames at 48 kHz (see
|
||||
/// [`crate::audio::contiguous_starts`]).
|
||||
|
|
@ -81,6 +82,7 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec
|
|||
Some(CodecConfig::Aac(a)) => fixed_packet_samples(FixedFrames::Aac(&a.asc), &[], rate),
|
||||
Some(CodecConfig::Mp3) => fixed_packet_samples(FixedFrames::MpegAudio, &first(), rate),
|
||||
Some(CodecConfig::Ac3 { .. }) => fixed_packet_samples(FixedFrames::Ac3, &first(), rate),
|
||||
Some(CodecConfig::Eac3 { .. }) => fixed_packet_samples(FixedFrames::Eac3, &first(), rate),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else { return stamps };
|
||||
|
|
@ -162,6 +164,48 @@ fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorIn
|
|||
crate::stream_color::resolve(w, h, &sources)
|
||||
}
|
||||
|
||||
/// First bytes of a Sony `rtmd` sample needed for the start-timecode block (`frames` is last).
|
||||
const RTMD_HEADER_LEN: usize = 0x12;
|
||||
|
||||
/// Sony XAVC `rtmd` (real-time metadata) start timecode fields, read from the first sample: hours,
|
||||
/// minutes, seconds, a drop-frame flag and frames. Sony stores them as raw decimal bytes in the
|
||||
/// sample's fixed header. The layout is proprietary and undocumented, so it is reproduced here from
|
||||
/// real footage; it is the same block ffmpeg reports as the `timecode` tag of an `rtmd` stream.
|
||||
///
|
||||
/// Fields outside SMPTE bounds — including the labels drop-frame counting skips — are rejected
|
||||
/// (no fabricated start time from damaged metadata).
|
||||
fn rtmd_fields(sample: &[u8], rate: FrameRate) -> Option<(i64, i64, i64, i64, bool)> {
|
||||
let f = sample.get(0x0d..0x12)?;
|
||||
let (h, m, s) = (f[0] as i64, f[1] as i64, f[2] as i64);
|
||||
let frames = f[4] as i64;
|
||||
let drop = f[3] != 0;
|
||||
let base = rate.timecode_base();
|
||||
if h > 23 || m > 59 || s > 59 || frames >= base {
|
||||
return None;
|
||||
}
|
||||
// Drop-frame counting skips the first `base / 15` frame labels of every minute except each
|
||||
// tenth minute, so those labels never occur in valid footage (SMPTE ST 12-1).
|
||||
if drop && rate.supports_drop_frame() && s == 0 && m % 10 != 0 && frames < base / 15 {
|
||||
return None;
|
||||
}
|
||||
Some((h, m, s, frames, drop))
|
||||
}
|
||||
|
||||
/// Start timecode from a Sony `rtmd` track as a frame count at `rate` (issue #460), used when the
|
||||
/// file has no `tmcd` track. `None` if there is no `rtmd` track or it has no usable first sample.
|
||||
/// Only the fixed-size header is read (a damaged `stsz` size can't drive a huge allocation).
|
||||
fn rtmd_start_timecode(file: &Mp4File, bytes: &crate::Src, rate: FrameRate) -> Option<i64> {
|
||||
let t = file.tracks.iter().find(|t| t.entries.first().is_some_and(|e| e.format.0 == *b"rtmd"))?;
|
||||
let s = t.samples.first()?;
|
||||
if s.size < RTMD_HEADER_LEN as u32 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = [0u8; RTMD_HEADER_LEN];
|
||||
bytes.read_at(s.offset, &mut buf).ok()?;
|
||||
let (h, m, s, f, drop) = rtmd_fields(&buf, rate)?;
|
||||
Some(filmcraft_time::fields_to_frames(h, m, s, f, rate, drop))
|
||||
}
|
||||
|
||||
impl Mp4Source {
|
||||
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
|
||||
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
|
||||
|
|
@ -170,7 +214,7 @@ impl Mp4Source {
|
|||
/// Open from a random-access reader: only the index is read now, samples on demand.
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let bytes = crate::Src(reader);
|
||||
let file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let mut file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
// A track needs samples, a sample description and a timescale to be playable (damaged
|
||||
// files can lack them: indexing `entries[0]` or dividing by the timescale used to panic).
|
||||
let playable = |t: &&filmcraft_isobmff::Track, kind| t.kind == kind && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0;
|
||||
|
|
@ -271,6 +315,9 @@ impl Mp4Source {
|
|||
Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64),
|
||||
_ => None,
|
||||
});
|
||||
// Sony XAVC files with no `tmcd` track carry the start timecode in an `rtmd` metadata
|
||||
// track; convert its fields to a frame count using the video frame rate.
|
||||
let start_timecode = start_timecode.or_else(|| rtmd_start_timecode(&file, &bytes, video.as_ref()?.frame_rate));
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
|
|
@ -281,7 +328,7 @@ impl Mp4Source {
|
|||
start_timecode,
|
||||
file_size: Some(bytes.0.len()),
|
||||
};
|
||||
let audios: Vec<Mp4Audio> = atracks
|
||||
let mut audios: Vec<Mp4Audio> = atracks
|
||||
.iter()
|
||||
.zip(&info.audio_streams)
|
||||
.filter_map(|(&i, ainfo)| {
|
||||
|
|
@ -300,6 +347,7 @@ impl Mp4Source {
|
|||
Some(Mp4Audio { track: i, state, starts, offset, preroll })
|
||||
})
|
||||
.collect();
|
||||
rebase_unedited_video(&mut file, vtrack, &mut audios);
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios })
|
||||
}
|
||||
|
||||
|
|
@ -499,7 +547,18 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option
|
|||
CodecConfig::Opus(o) => (crate::audio::OPUS_RATE, (o.output_channels as u32).max(1), None),
|
||||
_ => (if ap.sample_rate > 0.0 { ap.sample_rate as u32 } else { t.timescale }, ap.channels.max(1), None),
|
||||
};
|
||||
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits })
|
||||
let codec = match &entry.codec {
|
||||
// these entries name no layer (ffmpeg stores MP2 as `mp4a` + 0x6B): the first frame does
|
||||
CodecConfig::Mp3 => {
|
||||
let head = t.samples.first().and_then(|s| filmcraft_media::reader::read_range(&*bytes.0, s.offset, (s.size as usize).min(4)).ok());
|
||||
match head.as_deref().and_then(crate::audio::mpeg_audio_layer) {
|
||||
Some(1 | 2) => "MPEG Audio".into(),
|
||||
_ => codec_label(&entry.codec),
|
||||
}
|
||||
}
|
||||
c => codec_label(c),
|
||||
};
|
||||
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits })
|
||||
}
|
||||
|
||||
fn codec_label(c: &CodecConfig) -> String {
|
||||
|
|
@ -605,6 +664,35 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, Media
|
|||
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
/// Start the file at its first picture when its video track has no edit list (#714). Without an
|
||||
/// edit list the media timeline is the presentation, so a B-frame stream's first picture sits at its
|
||||
/// composition delay (two frames on an x264 MP4 written without `elst`, and on fragmented MP4s):
|
||||
/// media time zero repeated it and the clip, which lasts the media duration, lost its last pictures.
|
||||
/// Every track moves back by the earliest start among them, video and audio alike, so the streams
|
||||
/// stay in sync and a delay between them keeps its meaning; a track with an edit list is never
|
||||
/// rebased on its own.
|
||||
fn rebase_unedited_video(file: &mut Mp4File, vtrack: Option<usize>, audios: &mut [Mp4Audio]) {
|
||||
let Some(vi) = vtrack else { return };
|
||||
let Some(t) = file.tracks.get(vi).filter(|t| t.edits.is_empty() && t.timescale > 0) else { return };
|
||||
let Some(first) = t.samples.iter().map(|s| s.pts).min().map(|p| p.saturating_add(t.edit_offset)).filter(|&p| p > 0) else { return };
|
||||
let vts = i64::from(t.timescale);
|
||||
let video_start = Tick::from_rational(first, 1, vts);
|
||||
let audio_ts = |a: &Mp4Audio| file.tracks.get(a.track).map_or(1, |t| i64::from(t.timescale.max(1)));
|
||||
// an audio track starts at its offset (a negative one only hides priming before zero)
|
||||
let shift = audios.iter().fold(video_start, |m, a| m.min(Tick::from_rational(a.offset.max(0), 1, audio_ts(a))));
|
||||
if shift <= Tick::ZERO {
|
||||
return;
|
||||
}
|
||||
for a in audios.iter_mut() {
|
||||
let d = shift.to_rational_round(1, audio_ts(a));
|
||||
a.offset = a.offset.saturating_sub(d);
|
||||
}
|
||||
let v = if shift == video_start { first } else { shift.to_rational_round(1, vts) };
|
||||
if let Some(t) = file.tracks.get_mut(vi) {
|
||||
t.edit_offset = t.edit_offset.saturating_sub(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a track plays. With an edit list that is the sum of its edits (ISO/IEC 14496-12
|
||||
/// §8.6.6, movie timescale), as `tkhd` records it. The media duration (`mdhd`) runs on the decode
|
||||
/// timeline: it is longer than the presentation by the B-frame delay an edit skips (`media_time`, one
|
||||
|
|
@ -700,6 +788,30 @@ mod tests {
|
|||
assert_eq!(frames(&Mp4Source::open("zero.mov", mov_with_edits(zero)).expect("open")), 4);
|
||||
}
|
||||
|
||||
/// #714: a video track without an edit list whose pictures carry a composition delay (B-frame
|
||||
/// style, as x264 writes them with no `elst`) starts at its first picture. Media time zero
|
||||
/// repeated picture 0 for the length of the delay and the last pictures never showed.
|
||||
#[test]
|
||||
fn unedited_composition_delay_starts_at_the_first_picture() {
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(SampleEntry::prores(FourCc(*b"apch"), 64, 32), 25)).expect("track");
|
||||
for k in 0..4u16 {
|
||||
let mut fr = filmcraft_prores::Frame::new(64, 32, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
|
||||
fr.y.fill(100 + 200 * k);
|
||||
let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, 64, 32).encode(&fr).expect("encode");
|
||||
mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 2, is_sync: true }).expect("sample");
|
||||
}
|
||||
let src = Mp4Source::open("delay.mov", mux.finish().expect("finish").into_inner().into()).expect("open");
|
||||
assert_eq!(src.info().duration, Tick::from_rational(4, 1, 25));
|
||||
let luma = |k: i64| {
|
||||
let f = src.video_frame(FrameRequest::full(Tick::from_rational(k, 1, 25))).expect("frame");
|
||||
let l = f.luma8();
|
||||
l.iter().map(|&v| u32::from(v)).sum::<u32>() / l.len().max(1) as u32
|
||||
};
|
||||
let seen: Vec<u32> = (0..4).map(luma).collect();
|
||||
assert!(seen.windows(2).all(|w| w[0] < w[1]), "one picture per frame, in order: {seen:?}");
|
||||
}
|
||||
|
||||
/// Overwrite the big-endian u32 `skip` bytes after the first `fourcc` box type.
|
||||
fn patch_u32(b: &mut [u8], fourcc: &[u8; 4], skip: usize, v: u32) {
|
||||
let k = b.windows(4).position(|x| x == fourcc).expect("box") + 4 + skip;
|
||||
|
|
@ -914,6 +1026,48 @@ mod tests {
|
|||
assert_eq!((v.pixel_format.as_str(), v.has_alpha), (want, alpha), "{}", String::from_utf8_lossy(fourcc));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtmd_fields_parse_and_reject_damaged_metadata() {
|
||||
let rate = FrameRate::FPS_59_94;
|
||||
// 03:37:12:34, non-drop (a Sony FX3/a6400 XAVC header).
|
||||
let mut s = [0u8; 24];
|
||||
s[0x0d..0x12].copy_from_slice(&[3, 37, 12, 0, 34]);
|
||||
assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, false)));
|
||||
s[0x10] = 1;
|
||||
assert_eq!(rtmd_fields(&s, rate), Some((3, 37, 12, 34, true)));
|
||||
// Too short to hold the block: no fields, no panic.
|
||||
assert_eq!(rtmd_fields(&[0u8; 6], rate), None);
|
||||
// Damaged fields are rejected rather than normalized into a plausible time.
|
||||
for bad in [[3, 37, 12, 0, 60], [3, 60, 0, 0, 0], [24, 0, 0, 0, 0], [0, 0, 60, 0, 0]] {
|
||||
let mut b = [0u8; 24];
|
||||
b[0x0d..0x12].copy_from_slice(&bad);
|
||||
assert_eq!(rtmd_fields(&b, rate), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtmd_fields_reject_drop_frame_skipped_labels() {
|
||||
let df = |h, m, s, f| {
|
||||
let mut b = [0u8; 24];
|
||||
b[0x0d..0x12].copy_from_slice(&[h, m, s, 1, f]);
|
||||
rtmd_fields(&b, FrameRate::FPS_59_94)
|
||||
};
|
||||
// At 59.94 DF, frames 00-03 of a non-tenth minute are skipped, so they never occur.
|
||||
assert_eq!(df(0, 1, 0, 0), None);
|
||||
assert_eq!(df(0, 1, 0, 3), None);
|
||||
assert_eq!(df(0, 1, 0, 4), Some((0, 1, 0, 4, true)));
|
||||
assert_eq!(df(0, 10, 0, 0), Some((0, 10, 0, 0, true))); // tenth minute: label exists
|
||||
assert_eq!(df(0, 1, 1, 0), Some((0, 1, 1, 0, true))); // seconds > 0: label exists
|
||||
// At 29.97 DF only frames 00-01 are skipped.
|
||||
let df30 = |m, s, f| {
|
||||
let mut b = [0u8; 24];
|
||||
b[0x0d..0x12].copy_from_slice(&[0, m, s, 1, f]);
|
||||
rtmd_fields(&b, FrameRate::FPS_29_97)
|
||||
};
|
||||
assert_eq!(df30(1, 0, 0), None);
|
||||
assert_eq!(df30(1, 0, 2), Some((0, 1, 0, 2, true)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ fn vkind(c: &Codec) -> Option<VKind> {
|
|||
}
|
||||
|
||||
fn audio_supported(c: &Codec) -> bool {
|
||||
matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3)
|
||||
matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3 | Codec::Eac3)
|
||||
}
|
||||
|
||||
/// The coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding). Never
|
||||
|
|
@ -505,7 +505,7 @@ impl MpegSource {
|
|||
};
|
||||
let mut latm = None;
|
||||
let (codec, rate, channels, per_frame, bits) = match &st.codec {
|
||||
Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 => {
|
||||
Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 | Codec::Eac3 => {
|
||||
let Some(fi) = filmcraft_mpegts::frame_info(&st.codec, &first) else {
|
||||
return self.audio_unsupported(&codec_name, format!("{codec_name}: bad frame header"));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -387,6 +387,68 @@ fn mkv_vp9_422_seeks_bit_exact() {
|
|||
check_vp9_seeks("noise_vp9_422.mkv", "Matroska", "yuv422p", &["-profile:v", "1", "-deadline", "realtime", "-speed", "8"], 30);
|
||||
}
|
||||
|
||||
/// #402: a WebM whose VP9 track carries an alpha layer (`AlphaMode` 1, a second VP9 stream in
|
||||
/// each block's `BlockAdditional`) decodes with that alpha. Frames requested out of order (seeks
|
||||
/// across key frames, then forward) match libvpx's decode exactly, colour and alpha.
|
||||
#[test]
|
||||
fn vp9_webm_alpha_layer_matches_libvpx() {
|
||||
let (w, h, frames) = (176usize, 144usize, 30usize);
|
||||
// the alpha changes every frame, so a picture paired with the wrong alpha frame shows
|
||||
let Some(path) = fixture_path(
|
||||
"alpha_vp9.webm",
|
||||
&[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=176x144:r=25,format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(X*2+N*7\\,256)'",
|
||||
"-frames:v",
|
||||
"30",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-pix_fmt",
|
||||
"yuva420p",
|
||||
"-g",
|
||||
"10",
|
||||
"-deadline",
|
||||
"realtime",
|
||||
"-speed",
|
||||
"8",
|
||||
"-b:v",
|
||||
"600k",
|
||||
],
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// the reference: ffmpeg's libvpx decoder (its native VP9 decoder ignores the alpha layer)
|
||||
let src_path = path.to_string_lossy().into_owned();
|
||||
let Some(reference) =
|
||||
fixture("alpha_vp9.webm.yuva420p.yuv", &["-c:v", "libvpx-vp9", "-i", &src_path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuva420p"])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (luma, chroma) = (w * h, (w / 2) * (h / 2));
|
||||
let frame_len = luma * 2 + chroma * 2;
|
||||
if reference.len() != frame_len * frames {
|
||||
eprintln!("libvpx reference has {} bytes, not {}; skipping", reference.len(), frame_len * frames);
|
||||
return;
|
||||
}
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
|
||||
let src = crate::open_bytes("alpha_vp9.webm", bytes).unwrap();
|
||||
let info = src.info().clone();
|
||||
assert_eq!(info.container, "WebM");
|
||||
assert!(info.video.as_ref().unwrap().has_alpha);
|
||||
let rate = info.frame_rate();
|
||||
let order: Vec<usize> = [29, 3, 17, 12, 0, 25, 21, 9].into_iter().chain(10..frames).collect();
|
||||
for k in order {
|
||||
let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap();
|
||||
let r = &reference[k * frame_len..(k + 1) * frame_len];
|
||||
let filmcraft_frame::PixelData::Yuv8 { planes, alpha, .. } = &f.data else { panic!("frame {k}: not 8-bit YUV: {}", f.format_label()) };
|
||||
assert_eq!(planes[0].as_slice(), &r[..luma], "frame {k}: luma");
|
||||
let alpha = alpha.as_ref().unwrap_or_else(|| panic!("frame {k}: no alpha plane"));
|
||||
assert_eq!(alpha.as_slice(), &r[luma + 2 * chroma..], "frame {k}: alpha");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start
|
||||
/// decoding at a real VP9 key frame.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ impl H264Decoder {
|
|||
fn make_inner(avcc: &[u8], threads: usize) -> Result<filmcraft_h264::Decoder> {
|
||||
let mut dec = filmcraft_h264::Decoder::with_threads(threads);
|
||||
if !avcc.is_empty() {
|
||||
dec.configure_avcc(avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
dec.configure_avcc(avcc).map_err(h264_error)?;
|
||||
}
|
||||
Ok(dec)
|
||||
}
|
||||
|
|
@ -264,9 +264,19 @@ impl H264Decoder {
|
|||
}
|
||||
}
|
||||
|
||||
/// An H.264 decoder error as a [`CodecError`]: a feature the decoder does not implement (High
|
||||
/// 4:2:2, field pictures...) is [`CodecError::Unsupported`], so the media pool shows the
|
||||
/// unreadable slate instead of a black picture (#626); anything else is a decode error.
|
||||
fn h264_error(e: filmcraft_h264::Error) -> CodecError {
|
||||
match e {
|
||||
filmcraft_h264::Error::Unsupported(what) => CodecError::Unsupported(format!("H.264: {what}")),
|
||||
e => CodecError::Decode(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for H264Decoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let pics = self.dec.decode(sample, pts).map_err(h264_error)?;
|
||||
Ok(pics.into_iter().map(Self::convert).collect())
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
|
|
@ -328,7 +338,7 @@ pub struct HevcDecoder {
|
|||
|
||||
impl HevcDecoder {
|
||||
pub fn new(hvcc: Vec<u8>) -> Result<Self> {
|
||||
let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(hevc_error)?;
|
||||
let (length_size, highest_tid) = hvcc_length_size_and_tid(&hvcc);
|
||||
Ok(Self { hvcc, dec, length_size, highest_tid })
|
||||
}
|
||||
|
|
@ -368,9 +378,17 @@ impl HevcDecoder {
|
|||
}
|
||||
}
|
||||
|
||||
/// An HEVC decoder error as a [`CodecError`] (see [`h264_error`]).
|
||||
fn hevc_error(e: filmcraft_hevc::Error) -> CodecError {
|
||||
match e {
|
||||
filmcraft_hevc::Error::Unsupported(what) => CodecError::Unsupported(format!("HEVC: {what}")),
|
||||
e => CodecError::Decode(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for HevcDecoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
|
||||
let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let pics = self.dec.decode(sample, pts).map_err(hevc_error)?;
|
||||
Ok(pics.into_iter().map(Self::convert).collect())
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,290 @@
|
|||
//! WebM alpha (#402): a VP9 track with `AlphaMode` 1 carries, in each block's `BlockAdditional`
|
||||
//! with `BlockAddID` 1, a second VP9 stream coded independently of the colour one. Its luma plane,
|
||||
//! full range, is the frame's alpha (the WebM convention, as libvpx-based decoders read it).
|
||||
//!
|
||||
//! [`AlphaDecoder`] wraps the colour decoder: every sample it decodes, it also decodes that
|
||||
//! sample's alpha frame with a second (software) VP9 decoder and attaches the luma plane to the
|
||||
//! colour picture with the same timestamp, holding a colour picture back until its alpha is out. The alpha stream has key frames of its own, which need
|
||||
//! not coincide with the colour ones: after a seek (or any other break in the order samples are
|
||||
//! fed), the alpha decoder restarts from the nearest alpha key frame at or before the sample.
|
||||
//!
|
||||
//! Alpha is best effort: a missing, damaged or undecodable alpha frame leaves that picture opaque,
|
||||
//! never fails the colour decode.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_frame::{PixelData, VideoFrame};
|
||||
|
||||
use crate::video::{DecodedFrame, VideoDecoder};
|
||||
|
||||
/// How far back the alpha decoder looks for a key frame when it resynchronises.
|
||||
const MAX_RESYNC: usize = 600;
|
||||
/// Decoded alpha pictures waiting for their colour picture, and colour pictures waiting for their
|
||||
/// alpha (VP9 pictures come out in order, so a few are plenty; past that a picture goes out opaque
|
||||
/// rather than stall).
|
||||
const MAX_PENDING: usize = 8;
|
||||
|
||||
/// Where each sample's alpha frame is stored.
|
||||
pub(crate) struct AlphaLayer {
|
||||
src: crate::Src,
|
||||
/// Per sample, in file (decode) order.
|
||||
additions: Vec<Option<(u64, u32)>>,
|
||||
pts: Vec<i64>,
|
||||
/// First sample with each pts.
|
||||
by_pts: HashMap<i64, usize>,
|
||||
}
|
||||
|
||||
impl AlphaLayer {
|
||||
/// `samples`: (pts, addition) of every sample of the track, in file order.
|
||||
pub(crate) fn new(src: crate::Src, samples: impl Iterator<Item = (i64, Option<(u64, u32)>)>) -> Self {
|
||||
let (mut additions, mut all_pts) = (Vec::new(), Vec::new());
|
||||
let mut by_pts = HashMap::new();
|
||||
for (i, (pts, add)) in samples.enumerate() {
|
||||
additions.push(add);
|
||||
all_pts.push(pts);
|
||||
by_pts.entry(pts).or_insert(i);
|
||||
}
|
||||
Self { src, additions, pts: all_pts, by_pts }
|
||||
}
|
||||
|
||||
fn read(&self, i: usize) -> Option<Vec<u8>> {
|
||||
let (offset, size) = (*self.additions.get(i)?)?;
|
||||
let end = offset.checked_add(size as u64)?;
|
||||
if end > self.src.0.len() {
|
||||
return None;
|
||||
}
|
||||
let mut data = vec![0; size as usize];
|
||||
self.src.0.read_at(offset, &mut data).ok()?;
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour decoder plus the decoder of its alpha layer.
|
||||
///
|
||||
/// Both decoders may hand pictures back later than the sample that made them (frame threading),
|
||||
/// and not necessarily after the same call. A colour picture is therefore held until its alpha
|
||||
/// picture has come out, or until it is certain that it won't: the alpha decoder has moved past
|
||||
/// it (pictures come out in order), it failed, or the sample has no alpha frame.
|
||||
pub(crate) struct AlphaDecoder {
|
||||
color: Box<dyn VideoDecoder>,
|
||||
alpha: Box<dyn VideoDecoder>,
|
||||
layer: Arc<AlphaLayer>,
|
||||
/// Sample the alpha decoder expects next; `None` after a reset or a failure.
|
||||
next: Option<usize>,
|
||||
/// Decoded alpha pictures not yet paired, by pts.
|
||||
pending: VecDeque<(i64, VideoFrame)>,
|
||||
/// Latest pts the alpha decoder has produced since the last reset.
|
||||
alpha_max: Option<i64>,
|
||||
/// Colour pictures waiting for their alpha, in output order.
|
||||
held: VecDeque<DecodedFrame>,
|
||||
}
|
||||
|
||||
impl AlphaDecoder {
|
||||
pub(crate) fn new(color: Box<dyn VideoDecoder>, alpha: Box<dyn VideoDecoder>, layer: Arc<AlphaLayer>) -> Self {
|
||||
Self { color, alpha, layer, next: None, pending: VecDeque::new(), alpha_max: None, held: VecDeque::new() }
|
||||
}
|
||||
|
||||
fn keep(&mut self, frames: Vec<DecodedFrame>) {
|
||||
for f in frames {
|
||||
self.alpha_max = Some(self.alpha_max.map_or(f.pts, |m| m.max(f.pts)));
|
||||
self.pending.push_back((f.pts, f.frame));
|
||||
}
|
||||
while self.pending.len() > MAX_PENDING {
|
||||
self.pending.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the alpha frame of the sample with `pts`, first catching up from an alpha key frame
|
||||
/// when the sample doesn't follow the previous one.
|
||||
fn feed_alpha(&mut self, pts: i64) {
|
||||
let Some(&i) = self.layer.by_pts.get(&pts) else {
|
||||
self.next = None;
|
||||
return;
|
||||
};
|
||||
if self.next != Some(i) {
|
||||
self.alpha.reset();
|
||||
self.pending.clear();
|
||||
self.alpha_max = None;
|
||||
let is_key = |k: usize| self.layer.read(k).is_some_and(|d| filmcraft_vp9::is_keyframe(&d));
|
||||
let start = (i.saturating_sub(MAX_RESYNC)..=i).rev().find(|&k| is_key(k));
|
||||
if let Some(start) = start {
|
||||
for k in start..i {
|
||||
let (Some(d), Some(&p)) = (self.layer.read(k), self.layer.pts.get(k)) else { continue };
|
||||
match self.alpha.decode(&d, p) {
|
||||
Ok(frames) => self.keep(frames),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next = Some(i + 1);
|
||||
let Some(d) = self.layer.read(i) else { return };
|
||||
match self.alpha.decode(&d, pts) {
|
||||
Ok(frames) => self.keep(frames),
|
||||
Err(e) => {
|
||||
log::debug!("WebM alpha frame at pts {pts}: {e}");
|
||||
self.next = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the alpha picture for `pts` can still come out of the alpha decoder.
|
||||
fn alpha_may_come(&self, pts: i64) -> bool {
|
||||
let has_frame = self.layer.by_pts.get(&pts).is_some_and(|&i| self.layer.additions.get(i).is_some_and(|a| a.is_some()));
|
||||
has_frame && self.next.is_some() && self.alpha_max.is_none_or(|m| m < pts)
|
||||
}
|
||||
|
||||
/// Release held colour pictures, in order, as far as their alpha is settled. `all`: release
|
||||
/// every one (flush), with or without alpha.
|
||||
fn release(&mut self, all: bool) -> Vec<DecodedFrame> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = self.held.front() {
|
||||
let pts = f.pts;
|
||||
let found = self.pending.iter().position(|(p, _)| *p == pts);
|
||||
if found.is_none() && !all && self.held.len() <= MAX_PENDING && self.alpha_may_come(pts) {
|
||||
break;
|
||||
}
|
||||
let Some(mut f) = self.held.pop_front() else { break };
|
||||
if let Some(k) = found
|
||||
&& let Some((_, a)) = self.pending.remove(k)
|
||||
{
|
||||
set_alpha(&mut f.frame, &a);
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoDecoder for AlphaDecoder {
|
||||
fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result<Vec<DecodedFrame>> {
|
||||
let out = self.color.decode(sample, pts)?;
|
||||
self.held.extend(out);
|
||||
self.feed_alpha(pts);
|
||||
Ok(self.release(false))
|
||||
}
|
||||
fn flush(&mut self) -> Vec<DecodedFrame> {
|
||||
let out = self.color.flush();
|
||||
self.held.extend(out);
|
||||
let rest = self.alpha.flush();
|
||||
self.keep(rest);
|
||||
self.release(true)
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.color.reset();
|
||||
self.alpha.reset();
|
||||
self.next = None;
|
||||
self.pending.clear();
|
||||
self.alpha_max = None;
|
||||
self.held.clear();
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
self.color.name()
|
||||
}
|
||||
fn intra_only(&self) -> bool {
|
||||
self.color.intra_only()
|
||||
}
|
||||
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
|
||||
self.color.is_random_access(sample)
|
||||
}
|
||||
fn is_disposable(&self, sample: &[u8]) -> bool {
|
||||
self.color.is_disposable(sample)
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.color.set_draft(on);
|
||||
self.alpha.set_draft(on);
|
||||
}
|
||||
fn thread_limited(&self) -> bool {
|
||||
self.color.thread_limited()
|
||||
}
|
||||
}
|
||||
|
||||
/// The luma plane of `a` (an alpha picture) as `frame`'s alpha, converted to `frame`'s bit depth.
|
||||
/// Nothing happens when the sizes differ or either picture has no usable planes.
|
||||
pub(crate) fn set_alpha(frame: &mut VideoFrame, a: &VideoFrame) {
|
||||
if (frame.width, frame.height) != (a.width, a.height) {
|
||||
return;
|
||||
}
|
||||
let n = frame.width as usize * frame.height as usize;
|
||||
// alpha codes as 16-bit with their depth, so every pairing converts through one path
|
||||
let (codes, abits): (Vec<u16>, u32) = match &a.data {
|
||||
PixelData::Yuv8 { planes, .. } => (planes[0].iter().map(|&v| v as u16).collect(), 8),
|
||||
PixelData::Yuv16 { planes, bits, .. } => (planes[0].to_vec(), (*bits).clamp(1, 16)),
|
||||
_ => return,
|
||||
};
|
||||
if codes.len() != n {
|
||||
return;
|
||||
}
|
||||
let rescale = |v: u16, to: u32| -> u32 {
|
||||
let (from_max, to_max) = ((1u32 << abits) - 1, (1u32 << to.clamp(1, 16)) - 1);
|
||||
(v as u32 * to_max + from_max / 2) / from_max
|
||||
};
|
||||
match &mut frame.data {
|
||||
PixelData::Yuv8 { alpha, .. } => *alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, 8) as u8).collect())),
|
||||
PixelData::Yuv16 { alpha, bits, .. } => {
|
||||
let to = *bits;
|
||||
*alpha = Some(Arc::new(codes.iter().map(|&v| rescale(v, to) as u16).collect()));
|
||||
}
|
||||
PixelData::Rgba8(rgba) => {
|
||||
let px = Arc::make_mut(rgba);
|
||||
for (p, &v) in px.as_chunks_mut::<4>().0.iter_mut().zip(&codes) {
|
||||
p[3] = rescale(v, 8) as u8;
|
||||
}
|
||||
}
|
||||
PixelData::RgbaF32(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_frame::Chroma;
|
||||
|
||||
fn yuv8(w: u32, h: u32, luma: Vec<u8>) -> VideoFrame {
|
||||
let n = (w * h) as usize;
|
||||
let c = n / 4;
|
||||
let data = PixelData::Yuv8 { planes: [Arc::new(luma), Arc::new(vec![128; c]), Arc::new(vec![128; c])], chroma: Chroma::C420, alpha: None };
|
||||
VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO }
|
||||
}
|
||||
|
||||
fn yuv16(w: u32, h: u32, bits: u32) -> VideoFrame {
|
||||
let n = (w * h) as usize;
|
||||
let data =
|
||||
PixelData::Yuv16 { planes: [Arc::new(vec![0; n]), Arc::new(vec![0; n / 4]), Arc::new(vec![0; n / 4])], chroma: Chroma::C420, bits, alpha: None };
|
||||
VideoFrame { width: w, height: h, data, color: Default::default(), par: (1, 1), pts: filmcraft_time::Tick::ZERO }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_luma_becomes_the_alpha_plane_at_the_frame_depth() {
|
||||
let a = yuv8(2, 2, vec![0, 128, 255, 64]);
|
||||
// 8-bit picture: the luma as is
|
||||
let mut f = yuv8(2, 2, vec![9; 4]);
|
||||
set_alpha(&mut f, &a);
|
||||
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
|
||||
assert_eq!(alpha.as_deref(), Some(&vec![0, 128, 255, 64]));
|
||||
// 10-bit picture: full range kept (255 → 1023)
|
||||
let mut f = yuv16(2, 2, 10);
|
||||
set_alpha(&mut f, &a);
|
||||
let PixelData::Yuv16 { alpha, .. } = &f.data else { panic!() };
|
||||
assert_eq!(alpha.as_deref(), Some(&vec![0, 514, 1023, 257]));
|
||||
// RGBA picture (VP9 RGB profiles): the A channel
|
||||
let mut f = VideoFrame::rgba8(2, 2, vec![7; 16]);
|
||||
set_alpha(&mut f, &a);
|
||||
let PixelData::Rgba8(px) = &f.data else { panic!() };
|
||||
assert_eq!(px.chunks(4).map(|p| p[3]).collect::<Vec<_>>(), [0, 128, 255, 64]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alpha_picture_of_another_size_is_ignored() {
|
||||
let mut f = yuv8(4, 2, vec![9; 8]);
|
||||
set_alpha(&mut f, &yuv8(2, 2, vec![0; 4]));
|
||||
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
|
||||
assert!(alpha.is_none());
|
||||
// a luma plane shorter than the picture (damaged) is ignored too
|
||||
let mut f = yuv8(2, 2, vec![9; 4]);
|
||||
set_alpha(&mut f, &yuv8(2, 2, vec![0; 3]));
|
||||
let PixelData::Yuv8 { alpha, .. } = &f.data else { panic!() };
|
||||
assert!(alpha.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
//! E-AC-3 (Dolby Digital Plus) audio in MP4, Matroska and MPEG-TS against ffmpeg: ffmpeg encodes
|
||||
//! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific
|
||||
//! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
const STEREO: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000";
|
||||
const SURROUND: &str =
|
||||
"aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1";
|
||||
|
||||
/// Worst channel SNR (dB) of `ours` (planar) against ffmpeg's interleaved `want`, with ours
|
||||
/// shifted by `lag` samples, over the samples both cover past the first 1000.
|
||||
fn snr(ours: &[Vec<f32>], want: &[f32], ch: usize, lag: i64) -> f64 {
|
||||
let n = want.len() / ch;
|
||||
(0..ch)
|
||||
.map(|c| {
|
||||
let (mut sig, mut err) = (0f64, 0f64);
|
||||
for i in 1000..n - 1000 {
|
||||
let Some(&a) = ours[c].get((i as i64 + lag) as usize) else { continue };
|
||||
let (a, b) = (a as f64, want[i * ch + c] as f64);
|
||||
sig += b * b;
|
||||
err += (a - b) * (a - b);
|
||||
}
|
||||
10.0 * (sig / err.max(1e-30)).log10()
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_in_mp4_matroska_and_transport_streams() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let cases: [(&str, &str, u32, &[&str]); 4] = [
|
||||
("eac3_51.mp4", SURROUND, 6, &["-b:a", "384k"]),
|
||||
("eac3_51.mkv", SURROUND, 6, &["-b:a", "384k"]),
|
||||
("eac3_stereo.mov", STEREO, 2, &["-b:a", "192k"]),
|
||||
("eac3_stereo.ts", STEREO, 2, &["-b:a", "192k"]),
|
||||
];
|
||||
for (name, input, channels, enc) in cases {
|
||||
let args: Vec<&str> = ["-f", "lavfi", "-i", input, "-t", "2", "-c:a", "eac3"].into_iter().chain(enc.iter().copied()).collect();
|
||||
let f = fixture(&ff, name, &args).unwrap_or_else(|| panic!("could not write {name}"));
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.sample_rate, a.channels), (48_000, channels), "{name}: {}", a.codec);
|
||||
let want = ffmpeg_audio_f32(&ff, &f, &[]);
|
||||
let ch = channels as usize;
|
||||
let n = want.len() / ch;
|
||||
// MPEG-TS audio starts at its first PTS relative to the presentation start
|
||||
let start = match name.ends_with(".ts") {
|
||||
true => filmcraft_codecs::MpegSource::open(name, bytes(&f)).unwrap().audio_extent().map_or(0, |e| e.0),
|
||||
false => 0,
|
||||
};
|
||||
let ours = src.audio(start, n, 48_000).unwrap().channels;
|
||||
assert_eq!(ours.len(), ch, "{name}");
|
||||
// Matroska stores CodecDelay (the encoder's 256-sample priming) in ns and timestamps in
|
||||
// ms, and we place packets at the rounded value (240 samples): a shared Matroska timing
|
||||
// limit (AAC and AC-3 alike), not a decoding one. Elsewhere the samples line up exactly.
|
||||
let (worst, lag) = (-32..=32).map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a });
|
||||
let max_lag = if name.ends_with(".mkv") { 16 } else { 0 };
|
||||
assert!(lag.abs() <= max_lag, "{name}: decode is {lag} samples off ffmpeg's");
|
||||
// a window read on a fresh source (random access) gives the same samples
|
||||
let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
|
||||
let at = n / 3;
|
||||
let win = fresh.audio(start + at as i64, 4800, 48_000).unwrap().channels;
|
||||
let mut seek_err = 0f32;
|
||||
for c in 0..ch {
|
||||
for (k, v) in win[c].iter().enumerate() {
|
||||
seek_err = seek_err.max((v - ours[c][at + k]).abs());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{name}: {} ({} Hz, {ch} ch), worst channel SNR vs ffmpeg {worst:.1} dB (lag {lag}), random access max diff {seek_err:.1e}",
|
||||
a.codec, a.sample_rate
|
||||
);
|
||||
assert!(worst >= 50.0, "{name}: SNR {worst:.1} dB");
|
||||
assert!(seek_err <= 1e-6, "{name}: random-access audio differs by {seek_err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG,
|
||||
//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of
|
||||
//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF
|
||||
//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG,
|
||||
//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it
|
||||
//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats
|
||||
//! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF
|
||||
//! TimeReference must give the start timecode.
|
||||
|
||||
mod common;
|
||||
|
|
@ -114,6 +115,62 @@ fn webp_sequence_lossless() {
|
|||
check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_and_dpx_sequences_exact() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
check(&ff, "tga", &[], 0);
|
||||
check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1);
|
||||
}
|
||||
|
||||
/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format
|
||||
/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly
|
||||
/// when `tol` is 0).
|
||||
fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) {
|
||||
let name = format!("still_{pix_fmt}{}.{ext}", extra.join(""));
|
||||
let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" };
|
||||
let vf = format!("scale=95:63{alpha}");
|
||||
let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt];
|
||||
args.extend_from_slice(extra);
|
||||
let Some(f) = fixture(ff, &name, &args) else {
|
||||
eprintln!("SKIPPED: ffmpeg cannot write {name}");
|
||||
return;
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(src.info().kind, MediaKind::Still, "{name}");
|
||||
let v = src.info().video.as_ref().unwrap();
|
||||
assert_eq!((v.width, v.height), (95, 63), "{name}");
|
||||
let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap());
|
||||
// deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers)
|
||||
let want: Vec<u8> = if tol == 0 {
|
||||
ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"])
|
||||
} else {
|
||||
let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]);
|
||||
wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect()
|
||||
};
|
||||
assert_eq!(ours.len(), want.len(), "{name}");
|
||||
let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||||
assert!(d <= tol, "{name}: differs by {d}");
|
||||
assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}");
|
||||
eprintln!("{name}: max diff {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_stills_every_layout() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] {
|
||||
still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default)
|
||||
still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpx_stills_every_layout() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] {
|
||||
still_matches(&ff, "dpx", pix_fmt, &[], tol);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_frames_hold_the_previous_frame() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
|
|
|
|||
|
|
@ -163,3 +163,87 @@ fn cropped_pictures_match_ffmpeg() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open GOP (x265 `open-gop`, like iPhone HEVC): each CRA after the first is followed in the
|
||||
/// file by RASL pictures shown before it, predicted from the GOP before. Asked for first (a clip
|
||||
/// trimmed to start on one, the playhead parked on one), such a picture came out black: decoding
|
||||
/// started at the CRA after it, which leaves its RASL pictures out. Every frame, asked of a fresh
|
||||
/// source and in an order that visits each CRA before its leading pictures, is ffmpeg's, from MP4
|
||||
/// and from Matroska.
|
||||
#[test]
|
||||
fn open_gop_leading_pictures_decode_alone() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let args = [
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=160x96:rate=30:duration=2,noise=alls=10:allf=t",
|
||||
"-c:v",
|
||||
"libx265",
|
||||
"-x265-params",
|
||||
"log-level=error:keyint=12:min-keyint=12:scenecut=0:open-gop=1:bframes=3",
|
||||
"-tag:v",
|
||||
"hvc1",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
];
|
||||
let Some(mp4) = fixture(&ff, "open_gop_x265.mp4", &args) else {
|
||||
eprintln!("SKIPPED: ffmpeg without libx265");
|
||||
return;
|
||||
};
|
||||
let mkv = fixture(&ff, "open_gop_x265.mkv", &["-i", mp4.to_str().unwrap(), "-c", "copy"]).unwrap();
|
||||
let raw = ffmpeg_frames(&ff, &mp4, "yuv420p");
|
||||
let frame_bytes = 160 * 96 * 3 / 2;
|
||||
let want: Vec<_> = raw.chunks(frame_bytes).map(|f| raw_planes(f, 160, 96, 80, 48, 1)).collect();
|
||||
assert_eq!(want.len(), 60);
|
||||
for path in [&mp4, &mkv] {
|
||||
let name = path.file_name().unwrap().to_str().unwrap();
|
||||
let open = || filmcraft_codecs::open_bytes(name, bytes(path)).unwrap();
|
||||
let rate = open().info().frame_rate();
|
||||
let frame = |src: &filmcraft_media::SharedSource, i: usize| {
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(i as i64))).unwrap_or_else(|e| panic!("{name}: frame {i}: {e}"));
|
||||
assert!(planes(&f) == want[i], "{name}: frame {i} differs from ffmpeg's");
|
||||
};
|
||||
for i in 0..want.len() {
|
||||
frame(&open(), i);
|
||||
}
|
||||
let src = open();
|
||||
for cra in [12, 24, 36, 48] {
|
||||
for i in [cra, cra - 1, cra - 2, cra - 3] {
|
||||
frame(&src, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #800: MP2 in MP4 (`mp4a` + object type 0x6B, which names no layer) and in MOV (QuickTime
|
||||
/// `.mp2`) decodes sample-exactly as ffmpeg does: same length, the encoder delay (481 samples)
|
||||
/// trimmed by the edit list, and the same samples from the first one on, also after a seek. It
|
||||
/// used to go to the layer III decoder (MP4: silence) or not decode at all (MOV).
|
||||
#[test]
|
||||
fn mp2_in_mp4_and_mov_matches_ffmpeg() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
for (name, rate) in [("mp2_stereo_224k_48k.mp4", 48_000u32), ("mp2_stereo_224k_44k.mov", 44_100)] {
|
||||
let input = format!("anoisesrc=d=1:c=pink:r={rate}:a=0.3");
|
||||
let Some(f) = fixture(&ff, name, &["-f", "lavfi", "-i", &input, "-ac", "2", "-c:a", "mp2", "-b:a", "224k"]) else {
|
||||
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
|
||||
continue;
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (rate, 2, "MPEG Audio"), "{name}");
|
||||
let want = ffmpeg_audio_f32(&ff, &f, &[]);
|
||||
let n = want.len() / 2;
|
||||
assert!(n > rate as usize * 9 / 10, "{name}: ffmpeg decoded {n} samples");
|
||||
let worst = |from: usize, len: usize| {
|
||||
let got = src.audio(from as i64, len, rate).unwrap();
|
||||
(0..len).map(|k| (0..2).map(|c| (got.channels[c][k] - want[2 * (from + k) + c]).abs()).fold(0f32, f32::max)).fold(0f32, f32::max)
|
||||
};
|
||||
let level = want.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
assert!(level > 0.1, "{name}: the source is silent ({level})");
|
||||
// a seek into the middle first, then the whole track from the start
|
||||
let (mid, all) = (worst(n / 2 + 77, 3000), worst(0, n));
|
||||
println!("{name}: {n} samples, max error mid {mid:.2e}, whole {all:.2e}");
|
||||
assert!(mid <= 1e-4 && all <= 1e-4, "{name}: differs from ffmpeg by {mid} (mid) / {all} (whole)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
//! Sony XAVC MP4 with no `tmcd` track: the start timecode lives in an `rtmd` metadata track
|
||||
//! (issue #460). A hand-built file exercises the whole probe path; the byte layout of the `rtmd`
|
||||
//! sample is the one real XAVC footage uses (checked against ffprobe's `timecode` tag).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
/// Minimal ISO-BMFF box writer (the demuxer only needs headers and sample tables here).
|
||||
#[derive(Default)]
|
||||
struct B(Vec<u8>);
|
||||
|
||||
impl B {
|
||||
fn u16(&mut self, v: u16) {
|
||||
self.0.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fn u32(&mut self, v: u32) {
|
||||
self.0.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fn bytes(&mut self, b: &[u8]) {
|
||||
self.0.extend_from_slice(b);
|
||||
}
|
||||
fn zeros(&mut self, n: usize) {
|
||||
self.0.resize(self.0.len() + n, 0);
|
||||
}
|
||||
/// A container box (`size` + `kind` + children).
|
||||
fn container(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) {
|
||||
let at = self.0.len();
|
||||
self.u32(0);
|
||||
self.bytes(kind);
|
||||
f(self);
|
||||
let len = (self.0.len() - at) as u32;
|
||||
self.0[at..at + 4].copy_from_slice(&len.to_be_bytes());
|
||||
}
|
||||
/// A full box (`size` + `kind` + version/flags + body).
|
||||
fn full(&mut self, kind: &[u8; 4], f: impl FnOnce(&mut B)) {
|
||||
self.container(kind, |b| {
|
||||
b.zeros(4);
|
||||
f(b);
|
||||
});
|
||||
}
|
||||
fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) {
|
||||
self.container(kind, |b| b.bytes(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/// A `stsd` visual sample entry (JPEG keeps the video track decodable enough to open).
|
||||
fn video_entry(b: &mut B) {
|
||||
b.bytes(&[0; 6]);
|
||||
b.u16(1);
|
||||
b.u16(0);
|
||||
b.u16(0);
|
||||
b.bytes(&[0; 4]);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u16(1920);
|
||||
b.u16(1080);
|
||||
b.u32(0x0048_0000);
|
||||
b.u32(0x0048_0000);
|
||||
b.u32(0);
|
||||
b.u16(1);
|
||||
b.bytes(&[0; 32]);
|
||||
b.u16(24);
|
||||
b.u16(0xFFFF);
|
||||
}
|
||||
|
||||
/// A video track at 59.94 fps, plus a Sony `rtmd` track whose first sample is `rtmd_sample`.
|
||||
fn file_with_rtmd(rtmd_sample: &[u8]) -> Vec<u8> {
|
||||
file_with_rtmd_claimed(rtmd_sample, rtmd_sample.len() as u32)
|
||||
}
|
||||
|
||||
/// As [`file_with_rtmd`] but the `rtmd` sample's `stsz` size is `claimed` (which may exceed the
|
||||
/// bytes actually present, as a corrupt file can).
|
||||
fn file_with_rtmd_claimed(rtmd_sample: &[u8], claimed: u32) -> Vec<u8> {
|
||||
let mut b = B::default();
|
||||
b.leaf(b"ftyp", b"isom\x00\x00\x00\x00isom");
|
||||
let mdat_start = b.0.len();
|
||||
let mdat: Vec<u8> = [&[1, 2, 3, 4][..], rtmd_sample].concat();
|
||||
b.leaf(b"mdat", &mdat);
|
||||
let video_off = (mdat_start + 8) as u32;
|
||||
let rtmd_off = video_off + 4;
|
||||
b.container(b"moov", |b| {
|
||||
b.full(b"mvhd", |b| {
|
||||
b.zeros(8);
|
||||
b.u32(1000);
|
||||
b.u32(1001);
|
||||
b.zeros(80);
|
||||
});
|
||||
// Video track (id 1): one sample of 1001/60000 s → 59.94 fps.
|
||||
b.container(b"trak", |b| {
|
||||
b.full(b"tkhd", |b| {
|
||||
b.zeros(8);
|
||||
b.u32(1);
|
||||
b.zeros(4);
|
||||
b.u32(1001);
|
||||
b.zeros(52);
|
||||
b.u32(1920 << 16);
|
||||
b.u32(1080 << 16);
|
||||
});
|
||||
b.container(b"mdia", |b| {
|
||||
b.full(b"mdhd", |b| {
|
||||
b.zeros(8);
|
||||
b.u32(60000);
|
||||
b.u32(1001);
|
||||
b.u16(0x55C4);
|
||||
b.u16(0);
|
||||
});
|
||||
b.full(b"hdlr", |b| {
|
||||
b.zeros(4);
|
||||
b.bytes(b"vide");
|
||||
b.zeros(12);
|
||||
b.bytes(b"Video\0");
|
||||
});
|
||||
b.container(b"minf", |b| {
|
||||
b.container(b"stbl", |b| {
|
||||
b.full(b"stsd", |b| {
|
||||
b.u32(1);
|
||||
b.container(b"jpeg", video_entry);
|
||||
});
|
||||
b.full(b"stts", |b| {
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1001);
|
||||
});
|
||||
b.full(b"stsc", |b| {
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
});
|
||||
b.full(b"stsz", |b| {
|
||||
b.u32(0);
|
||||
b.u32(1);
|
||||
b.u32(4);
|
||||
});
|
||||
b.full(b"stco", |b| {
|
||||
b.u32(1);
|
||||
b.u32(video_off);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
// Sony rtmd metadata track (id 2).
|
||||
b.container(b"trak", |b| {
|
||||
b.full(b"tkhd", |b| {
|
||||
b.zeros(8);
|
||||
b.u32(2);
|
||||
b.zeros(4);
|
||||
b.u32(1);
|
||||
b.zeros(52);
|
||||
b.zeros(8);
|
||||
});
|
||||
b.container(b"mdia", |b| {
|
||||
b.full(b"mdhd", |b| {
|
||||
b.zeros(8);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u16(0x55C4);
|
||||
b.u16(0);
|
||||
});
|
||||
b.full(b"hdlr", |b| {
|
||||
b.zeros(4);
|
||||
b.bytes(b"meta");
|
||||
b.zeros(12);
|
||||
b.bytes(b"Timed Metadata\0");
|
||||
});
|
||||
b.container(b"minf", |b| {
|
||||
b.container(b"stbl", |b| {
|
||||
b.full(b"stsd", |b| {
|
||||
b.u32(1);
|
||||
b.leaf(b"rtmd", &[0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
b.full(b"stts", |b| {
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
});
|
||||
b.full(b"stsc", |b| {
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
b.u32(1);
|
||||
});
|
||||
b.full(b"stsz", |b| {
|
||||
b.u32(0);
|
||||
b.u32(1);
|
||||
b.u32(claimed);
|
||||
});
|
||||
b.full(b"stco", |b| {
|
||||
b.u32(1);
|
||||
b.u32(rtmd_off);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
b.0
|
||||
}
|
||||
|
||||
/// A Sony `rtmd` sample: hours, minutes, seconds at 0x0d–0x0f, a drop-frame flag at 0x10 and the
|
||||
/// frame number at 0x11, then the start of the SMPTE KLV key.
|
||||
fn rtmd_sample(h: u8, m: u8, s: u8, f: u8, drop: u8) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 24];
|
||||
v[0x0d] = h;
|
||||
v[0x0e] = m;
|
||||
v[0x0f] = s;
|
||||
v[0x10] = drop;
|
||||
v[0x11] = f;
|
||||
v[0x12..0x16].copy_from_slice(&[0x06, 0x0e, 0x2b, 0x34]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtmd_start_timecode_is_read() {
|
||||
let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 34, 0));
|
||||
let src = filmcraft_codecs::open_bytes("fx3.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
let rate = FrameRate::new(60000, 1001);
|
||||
let tc = src.info().start_timecode.expect("rtmd start timecode");
|
||||
assert_eq!(tc, 781_954);
|
||||
assert_eq!(filmcraft_time::format_timecode_frames(tc, rate, false), "03:37:12:34");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtmd_without_usable_sample_is_ignored() {
|
||||
// Too short to carry the timecode block: no start timecode, and no panic.
|
||||
let file = file_with_rtmd(&[0u8; 6]);
|
||||
let src = filmcraft_codecs::open_bytes("short.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
assert_eq!(src.info().start_timecode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_claimed_rtmd_sample_does_not_exhaust_memory() {
|
||||
// The stsz size claims 16 MiB but only the header bytes exist: only the fixed-size header is
|
||||
// read, so a corrupt size cannot drive a huge allocation during open (issue #460 review).
|
||||
let file = file_with_rtmd_claimed(&rtmd_sample(1, 2, 3, 4, 0), 16 * 1024 * 1024);
|
||||
let src = filmcraft_codecs::open_bytes("big.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
assert_eq!(src.info().start_timecode, Some(223_384)); // 01:02:03:04 at 59.94
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_timecode_fields_are_not_published() {
|
||||
// Frame 60 at 59.94 fps (valid range is 0–59): unusable metadata, not a fabricated time.
|
||||
let file = file_with_rtmd(&rtmd_sample(3, 37, 12, 60, 0));
|
||||
let src = filmcraft_codecs::open_bytes("bad.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
assert_eq!(src.info().start_timecode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_skipped_labels_are_not_published() {
|
||||
// 00:01:00;00 at 59.94 fps: frames 00-03 of a non-tenth minute don't exist in drop-frame
|
||||
// counting, so this metadata is unusable rather than a start time.
|
||||
let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 0, 1));
|
||||
let src = filmcraft_codecs::open_bytes("df_bad.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
assert_eq!(src.info().start_timecode, None);
|
||||
// The first real label of that minute (frame 04) is accepted.
|
||||
let file = file_with_rtmd(&rtmd_sample(0, 1, 0, 4, 1));
|
||||
let src = filmcraft_codecs::open_bytes("df_ok.mp4", Arc::from(file.into_boxed_slice())).unwrap();
|
||||
assert_eq!(src.info().start_timecode, Some(3600));
|
||||
}
|
||||
|
|
@ -214,6 +214,26 @@ fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Keep a clip's one-sided transitions (a fade in from nothing, a fade out to nothing) on its edges
|
||||
/// after a regular trim moved them, no longer than the clip (#374). Transitions between two clips
|
||||
/// are left alone: a regular trim cannot move their cut.
|
||||
fn fades_follow_edges(track: &mut Track, clip: ClipId) {
|
||||
let Some((start, end, len)) = track.item(clip).map(|i| (i.start, i.end(), i.duration)) else { return };
|
||||
for tr in &mut track.transitions {
|
||||
match (tr.from, tr.to) {
|
||||
(None, Some(to)) if to == clip => {
|
||||
tr.duration = tr.duration.min(len);
|
||||
tr.start = start;
|
||||
}
|
||||
(Some(from), None) if from == clip => {
|
||||
tr.duration = tr.duration.min(len);
|
||||
tr.start = end - tr.duration;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn place(track: &mut Track, item: TrackItem) {
|
||||
let idx = track.items.partition_point(|i| i.start <= item.start);
|
||||
track.items.insert(idx, item);
|
||||
|
|
@ -448,6 +468,10 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result<Vec<T
|
|||
/// Close the gap containing `t` on a track (Ripple Delete on a gap).
|
||||
pub fn close_gap(seq: &mut Sequence, track: TrackId, t: Tick) -> Result<()> {
|
||||
let tr = seq.track(track).ok_or(EditError::NoTrack(track))?;
|
||||
if tr.locked {
|
||||
// the other tracks would move while this one stayed put
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
if tr.item_at(t).is_some() {
|
||||
return Err(EditError::Nothing);
|
||||
}
|
||||
|
|
@ -487,17 +511,99 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_
|
|||
it.start = (*start).max(Tick::ZERO);
|
||||
placed.push((*dest, it));
|
||||
}
|
||||
// carry transitions with moved clips? Premiere drops transitions whose partner is not moved.
|
||||
let carried = carried_transitions(&work, &placed);
|
||||
// a transition whose partner stays behind is dropped, as in Premiere
|
||||
delete_items(&mut work, &moves.iter().map(|m| m.0).collect::<Vec<_>>());
|
||||
if insert_mode {
|
||||
insert(&mut work, placed, ctx)?;
|
||||
} else {
|
||||
overwrite(&mut work, placed, ctx)?;
|
||||
}
|
||||
for (dest, tr) in carried {
|
||||
let t = track_mut(&mut work, dest)?;
|
||||
if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) {
|
||||
t.transitions.push(tr);
|
||||
t.sort();
|
||||
}
|
||||
}
|
||||
*seq = work;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy items to (track, time) with overwrite or insert semantics, leaving the originals where
|
||||
/// they are (Option-drag). `moves`: (item, destination track, start of the copy). The copies get
|
||||
/// new ids; copies of linked clips are linked to each other, not to the originals, and the
|
||||
/// transitions that would travel with a move are copied with them. Returns the copies' ids in
|
||||
/// the order of `moves`.
|
||||
pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result<Vec<ClipId>> {
|
||||
let mut work = seq.clone();
|
||||
let mut placed = Vec::new();
|
||||
for (c, dest, start) in moves {
|
||||
let (_, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?;
|
||||
let mut it = it.clone();
|
||||
if work.track(*dest).ok_or(EditError::NoTrack(*dest))?.locked {
|
||||
return Err(EditError::Locked);
|
||||
}
|
||||
it.start = (*start).max(Tick::ZERO);
|
||||
placed.push((*dest, it));
|
||||
}
|
||||
// worked out on the original ids, then moved over to the copies
|
||||
let carried = carried_transitions(&work, &placed);
|
||||
let mut ids = HashMap::new();
|
||||
let mut links = HashMap::new();
|
||||
for (_, it) in &mut placed {
|
||||
let id = ClipId(ctx.alloc());
|
||||
ids.insert(it.id, id);
|
||||
it.id = id;
|
||||
if let Some(l) = it.link {
|
||||
it.link = Some(*links.entry(l).or_insert_with(|| ctx.alloc()));
|
||||
}
|
||||
}
|
||||
// a copy whose linked partner is not copied is not linked
|
||||
let mut counts: HashMap<u64, usize> = HashMap::new();
|
||||
for l in placed.iter().filter_map(|(_, it)| it.link) {
|
||||
*counts.entry(l).or_default() += 1;
|
||||
}
|
||||
for (_, it) in &mut placed {
|
||||
if it.link.is_some_and(|l| counts.get(&l).copied().unwrap_or(0) < 2) {
|
||||
it.link = None;
|
||||
}
|
||||
}
|
||||
let out = if insert_mode { insert(&mut work, placed, ctx)? } else { overwrite(&mut work, placed, ctx)? };
|
||||
for (dest, mut tr) in carried {
|
||||
tr.id = TransitionId(ctx.alloc());
|
||||
tr.from = tr.from.and_then(|c| ids.get(&c).copied());
|
||||
tr.to = tr.to.and_then(|c| ids.get(&c).copied());
|
||||
let t = track_mut(&mut work, dest)?;
|
||||
if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) && (tr.from.is_some() || tr.to.is_some()) {
|
||||
t.transitions.push(tr);
|
||||
t.sort();
|
||||
}
|
||||
}
|
||||
*seq = work;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The transitions that travel with a move (#227, #374): those whose clips all move, onto one
|
||||
/// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move
|
||||
/// together), shifted to where those clips land.
|
||||
fn carried_transitions(seq: &Sequence, placed: &[(TrackId, TrackItem)]) -> Vec<(TrackId, Transition)> {
|
||||
let landing = |c: ClipId| {
|
||||
let (dest, it) = placed.iter().find(|(_, it)| it.id == c)?;
|
||||
let (_, old) = seq.find_item(c)?;
|
||||
Some((*dest, it.start - old.start))
|
||||
};
|
||||
let mut carried = Vec::new();
|
||||
for tr in seq.all_tracks().filter(|t| !t.locked).flat_map(|t| t.transitions.iter()) {
|
||||
let lands: Option<Vec<(TrackId, Tick)>> = [tr.from, tr.to].into_iter().flatten().map(landing).collect();
|
||||
let Some((&(dest, delta), rest)) = lands.as_deref().and_then(<[_]>::split_first) else { continue };
|
||||
if rest.iter().all(|l| *l == (dest, delta)) {
|
||||
carried.push((dest, Transition { start: tr.start + delta, ..tr.clone() }));
|
||||
}
|
||||
}
|
||||
carried
|
||||
}
|
||||
|
||||
fn media_len(ctx: &EditCtx, item: &TrackItem) -> Option<Tick> {
|
||||
if item.frame_hold.is_some() {
|
||||
return None;
|
||||
|
|
@ -569,6 +675,31 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
|
|||
Ok(d)
|
||||
}
|
||||
|
||||
/// What moving one edge by `d` (already clamped) does to the item itself: its length, the media
|
||||
/// it starts at (a reversed clip consumes media from its other end; a frame hold has none to
|
||||
/// move), and, for the In edge with `move_start`, where it starts. The trims and the timeline's
|
||||
/// live trim preview share it, so the preview shows the media the edit will keep (#374).
|
||||
pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) {
|
||||
let speed = it.speed.abs();
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
if move_start {
|
||||
it.start += d;
|
||||
}
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple).
|
||||
/// Linked partners should be trimmed by the caller with the same delta.
|
||||
pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
|
|
@ -580,27 +711,10 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
|||
let old_end = it.end();
|
||||
let old_start = it.start;
|
||||
let own_link = it.link;
|
||||
let speed = it.speed.abs();
|
||||
let mut work = seq.clone();
|
||||
{
|
||||
let (_, it) = work.find_item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
if mode == TrimMode::Regular {
|
||||
it.start += d;
|
||||
}
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
move_edge(it, edge, d, mode == TrimMode::Regular);
|
||||
}
|
||||
if mode == TrimMode::Ripple {
|
||||
let (at, shift) = match edge {
|
||||
|
|
@ -630,6 +744,8 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta:
|
|||
}
|
||||
}
|
||||
transitions_follow_cuts(seq, &mut work);
|
||||
} else if let Ok(track) = track_mut(&mut work, tid) {
|
||||
fades_follow_edges(track, clip);
|
||||
}
|
||||
work.check().map_err(EditError::Other)?;
|
||||
*seq = work;
|
||||
|
|
@ -658,25 +774,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta
|
|||
let mut origins: Vec<(TrackId, Tick)> = Vec::new();
|
||||
for c in clips {
|
||||
let (tid, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?;
|
||||
let speed = it.speed.abs();
|
||||
let from = match edge {
|
||||
Edge::In => it.start + Tick(1),
|
||||
Edge::Out => it.end(),
|
||||
};
|
||||
match edge {
|
||||
Edge::In => {
|
||||
if !it.reverse && it.frame_hold.is_none() {
|
||||
it.source_in += src_of(d, speed);
|
||||
}
|
||||
it.duration -= d;
|
||||
}
|
||||
Edge::Out => {
|
||||
if it.reverse {
|
||||
it.source_in -= src_of(d, speed);
|
||||
}
|
||||
it.duration += d;
|
||||
}
|
||||
}
|
||||
move_edge(it, edge, d, false);
|
||||
if !origins.iter().any(|(t, _)| *t == tid) {
|
||||
origins.push((tid, from));
|
||||
}
|
||||
|
|
@ -876,6 +978,27 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
|||
Ok(d)
|
||||
}
|
||||
|
||||
/// Slide several items (a linked picture/sound pair, a selection) by one common delta: the most
|
||||
/// restrictive item's neighbour limit clamps them all, so their relative timing is kept.
|
||||
/// Returns the delta applied.
|
||||
pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {
|
||||
let mut d = delta;
|
||||
for c in clips {
|
||||
let x = slide(&mut seq.clone(), *c, d, ctx)?;
|
||||
if x.0.signum() != d.0.signum() {
|
||||
d = Tick::ZERO;
|
||||
} else if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
if d != Tick::ZERO {
|
||||
for c in clips {
|
||||
slide(seq, *c, d, ctx)?;
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media.
|
||||
pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<f64> {
|
||||
let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?;
|
||||
|
|
|
|||
|
|
@ -464,6 +464,67 @@ fn transitions_stay_on_their_cuts() {
|
|||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_follow_a_regular_trim() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 10, 20, 10);
|
||||
let fade = |id, start, from, to| Transition {
|
||||
id: TransitionId(id),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(start),
|
||||
duration: f(6),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![fade(1, 10, None, Some(a)), fade(2, 24, Some(a), None)];
|
||||
let spans = |fx: &Fx| fx.seq.track(v1).unwrap().transitions.iter().map(|t| (t.start, t.duration)).collect::<Vec<_>>();
|
||||
let mut n = fx.next;
|
||||
trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, f(2), &mut Fx::ctx(&mut n)).unwrap();
|
||||
trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(4), &mut Fx::ctx(&mut n)).unwrap();
|
||||
// the fade in starts with the clip (12), the fade out ends with it (34)
|
||||
assert_eq!(spans(&fx), vec![(f(12), f(6)), (f(28), f(6))]);
|
||||
// a clip trimmed shorter than its fades keeps them inside it
|
||||
trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(18), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(spans(&fx), vec![(f(12), f(4)), (f(12), f(4))]);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_travel_with_moved_clips() {
|
||||
// V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out
|
||||
let mut fx = Fx::new();
|
||||
let (v1, v2) = (fx.v(0), fx.v(1));
|
||||
let a = fx.put(v1, 0, 10, 10);
|
||||
let b = fx.put(v1, 10, 10, 10);
|
||||
let c = fx.put(v1, 20, 10, 10);
|
||||
let tr = |id, start, from, to| Transition {
|
||||
id: TransitionId(id),
|
||||
effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),
|
||||
start: f(start),
|
||||
duration: f(4),
|
||||
from,
|
||||
to,
|
||||
align: Default::default(),
|
||||
reverse: false,
|
||||
};
|
||||
fx.seq.track_mut(v1).unwrap().transitions = vec![tr(1, 0, None, Some(a)), tr(2, 18, Some(b), Some(c)), tr(3, 26, Some(c), None)];
|
||||
let at = |fx: &Fx, track: TrackId| -> Vec<(u64, i64)> { fx.seq.track(track).unwrap().transitions.iter().map(|t| (t.id.0, R.frame_at(t.start))).collect() };
|
||||
let mut n = fx.next;
|
||||
// a fade at a clip's edge goes with it, to another track too
|
||||
move_items(&mut fx.seq, &[(a, v2, f(40))], false, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!((at(&fx, v1), at(&fx, v2)), (vec![(2, 18), (3, 26)], vec![(1, 40)]));
|
||||
// a cut whose two clips move together keeps its transition, in an insert move as well
|
||||
move_items(&mut fx.seq, &[(b, v1, f(60)), (c, v1, f(70))], true, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(at(&fx, v1), vec![(2, 68), (3, 76)]);
|
||||
// a crossfade whose partner stays behind is dropped; the other clip's fade stays with it
|
||||
move_items(&mut fx.seq, &[(c, v1, f(90))], false, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(at(&fx, v1), vec![(3, 96)]);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_stretch_and_speed() {
|
||||
let mut fx = Fx::new();
|
||||
|
|
@ -749,3 +810,40 @@ fn roll_reversed_clips_keeps_media_mapping() {
|
|||
let (_, r) = fx.seq.find_item(ib).unwrap();
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
||||
/// #374: moving an edge must not change which media sits under any timeline time the clip still
|
||||
/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the
|
||||
/// item as the trim itself does.
|
||||
#[test]
|
||||
fn moving_an_edge_keeps_the_media_under_the_timeline() {
|
||||
let fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
for (speed, reverse, hold) in [(1.0, false, None), (2.0, false, None), (0.5, false, None), (1.0, true, None), (2.0, true, None), (1.0, false, Some(f(50)))]
|
||||
{
|
||||
for (edge, d) in [(Edge::In, f(7)), (Edge::In, -f(4)), (Edge::Out, -f(9)), (Edge::Out, f(3))] {
|
||||
let mut fresh = Fx::new();
|
||||
let c = fresh.put(v1, 100, 40, 200);
|
||||
{
|
||||
let it = fresh.seq.find_item_mut(c).unwrap().1;
|
||||
(it.speed, it.reverse, it.frame_hold) = (speed, reverse, hold);
|
||||
}
|
||||
let before = fresh.seq.find_item(c).unwrap().1.clone();
|
||||
let mut moved = before.clone();
|
||||
move_edge(&mut moved, edge, d, true);
|
||||
let label = format!("speed {speed} reverse {reverse} hold {hold:?} {edge:?} {d:?}");
|
||||
// every frame still covered shows the same media as before the move
|
||||
let mut t = moved.start.max(before.start);
|
||||
while t < moved.end().min(before.end()) {
|
||||
let (a, b) = (before.source_time_at(t), moved.source_time_at(t));
|
||||
assert!((a - b).0.abs() <= 1, "{label}: at {t:?} {a:?} became {b:?}");
|
||||
t += f(1);
|
||||
}
|
||||
// and the item is what the real (unclamped) trim makes of it
|
||||
let mut n = fresh.next;
|
||||
let got = trim(&mut fresh.seq, c, edge, TrimMode::Regular, d, &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(got, d, "{label}: not clamped");
|
||||
let trimmed = fresh.seq.find_item(c).unwrap().1;
|
||||
assert_eq!((trimmed.start, trimmed.duration, trimmed.source_in), (moved.start, moved.duration, moved.source_in), "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -510,10 +510,14 @@ fn frame_blending_renders_in_between_frames() {
|
|||
*a = (*a + b) * 0.5;
|
||||
}
|
||||
assert!(mean_abs_diff(&avg, &blended) < mean_abs_diff(&sampled, &blended), "closer to the average than to either frame");
|
||||
// optical flow falls back to blending
|
||||
// optical flow synthesises a motion-compensated frame instead of the cross-fade
|
||||
s.execute("clip.timeInterpolation.opticalFlow", json!({})).unwrap();
|
||||
s.execute("playhead.set", json!({"time": t.0})).unwrap();
|
||||
assert!(mean_abs_diff(&s.render_program(0.125).unwrap(), &blended) < 1e-6);
|
||||
let flowed = s.render_program(0.125).unwrap();
|
||||
// the moving traffic is motion compensated, the static city stays as blended
|
||||
let d = mean_abs_diff(&flowed, &blended);
|
||||
assert!(d > 1e-6, "optical flow should not be the plain cross-fade: {d}");
|
||||
assert!(d < mean_abs_diff(&blended, &sampled), "optical flow stays near the blend on mostly static footage: {d}");
|
||||
// at 100 % nothing is blended
|
||||
s.execute("clip.speedDuration", json!({"speed": 100.0})).unwrap();
|
||||
assert!(filmcraft_render::interpolation_blend(&clip(&s, c.id.0), t, src_rate).is_none());
|
||||
|
|
@ -923,3 +927,64 @@ fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
|
|||
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)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_keeps_keyframes_and_resets_one_parameter() {
|
||||
// #284: Reset Effect used to replace the effect with a fresh one, deleting every keyframe
|
||||
let mut s = demo();
|
||||
let a = v1(&s)[0].clone();
|
||||
let sec = Tick(TICKS_PER_SECOND);
|
||||
let (t0, t1, mid) = (a.start, a.start + sec, a.start + Tick(TICKS_PER_SECOND / 2));
|
||||
for (t, v) in [(t0, 150.0), (t1, 50.0)] {
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "scale", "value": v, "keyframe": true, "time": t.0})).unwrap();
|
||||
}
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 30.0})).unwrap();
|
||||
let idx = clip(&s, a.id.0).effects.iter().position(|e| e.effect == "motion").unwrap();
|
||||
s.execute("playhead.set", json!({"time": mid.0})).unwrap();
|
||||
s.execute("effects.reset", json!({"clip": a.id.0, "index": idx})).unwrap();
|
||||
let it = clip(&s, a.id.0);
|
||||
let scale = it.effect("motion").unwrap().param("scale").unwrap();
|
||||
// (the playhead snaps to a frame)
|
||||
let mid_media = it.source_time_at(s.playhead());
|
||||
let kf: Vec<(Tick, f64)> = scale.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect();
|
||||
assert_eq!(kf, vec![(it.source_time_at(t0), 150.0), (mid_media, 100.0), (it.source_time_at(t1), 50.0)], "keyframes kept, default at the playhead");
|
||||
assert_eq!(f64_param(&it, "motion", "rotation"), 0.0);
|
||||
// one parameter: only it goes back to its default
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation", "value": 45.0})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": a.id.0, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap();
|
||||
s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "rotation"})).unwrap();
|
||||
let it = clip(&s, a.id.0);
|
||||
assert_eq!(f64_param(&it, "motion", "rotation"), 0.0);
|
||||
assert_eq!(f64_param(&it, "opacity", "opacity"), 40.0);
|
||||
assert_eq!(it.effect("motion").unwrap().param("scale").unwrap().keyframes.len(), 3);
|
||||
assert!(s.execute("effects.resetParam", json!({"clip": a.id.0, "effect": "motion", "param": "nope"})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(f64_param(&clip(&s, a.id.0), "motion", "rotation"), 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() {
|
||||
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: it keeps 10 frames of tail handle, the sound keeps 20
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 10})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
|
||||
for _ in 0..3 {
|
||||
s.execute("timeline.slipLeft5", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 30)));
|
||||
// with linked selection off each selected clip still slips on its own
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [v, a]})).unwrap();
|
||||
s.execute("timeline.slipLeft5", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 35)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,16 +82,20 @@ 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() {
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() && s.state.gap_selection.is_none() {
|
||||
Err("nothing selected".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Clips or captions selected (Ripple Delete: a transition has no length of its own to close).
|
||||
/// Clips, captions or a gap 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(()) }
|
||||
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.gap_selection.is_none() {
|
||||
Err("nothing selected".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn has_source(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
|
|
@ -140,6 +144,29 @@ pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<
|
|||
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
|
||||
(!clips.is_empty()).then_some(clips)
|
||||
}
|
||||
/// Why the clips a caller named in `clips` / `clip` stand in for no selection (#591): a clip of
|
||||
/// another sequence, an id of no clip (a track, a project item…) or a value that is not an id.
|
||||
/// `None` when the caller named nothing or no sequence is open, so the selection's reason stands.
|
||||
pub(crate) fn named_clips_missing(s: &Session, spec: &CommandSpec, p: &Value) -> Option<String> {
|
||||
s.active_sequence()?;
|
||||
let first = if spec.params.contains("\"clips\":[id]")
|
||||
&& let Some(a) = p.get("clips").and_then(Value::as_array)
|
||||
{
|
||||
a.first()?
|
||||
} else if spec.params.contains("\"clip\":id") {
|
||||
p.get("clip").filter(|v| !v.is_null())?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let Some(id) = first.as_u64() else {
|
||||
return Some(format!("{first} is not a clip id (clip ids are the `clip` numbers of the items in sequence.inspect)"));
|
||||
};
|
||||
let elsewhere = s.project.sequences().find(|i| i.as_sequence().is_some_and(|q| q.find_item(ClipId(id)).is_some()));
|
||||
Some(match elsewhere {
|
||||
Some(i) => format!("clip {id} is in sequence \"{}\", not the active one: open it first with sequence.open {{\"item\":{}}}", i.name, i.id.0),
|
||||
None => format!("no clip {id} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"),
|
||||
})
|
||||
}
|
||||
/// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection
|
||||
/// holds bins beside items (one id space, see `project.select`), so a bin named here counts too;
|
||||
/// the project's own top bin does not, since no command acts on it (#244).
|
||||
|
|
@ -279,7 +306,9 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
|
|||
/// every clip of the call lands later by the same amount, so the spacing asked for is kept.
|
||||
/// `overwritten` lists every clip the move covered without being asked to move it: its length
|
||||
/// before and after (ticks) and the clips what is left of it now lives on as (none = removed; a
|
||||
/// clip a moved clip lands inside is split in two).
|
||||
/// clip a moved clip lands inside is split in two). With `copy: true` (Option-drag) the clips stay
|
||||
/// where they are and copies land at the destinations instead; the copies are selected and listed
|
||||
/// as `copied`.
|
||||
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "timeline.move";
|
||||
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
|
||||
|
|
@ -322,19 +351,31 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
moves.push((clip, track, start));
|
||||
}
|
||||
let ins = bool_p(p, "insert").unwrap_or(false);
|
||||
let copy = bool_p(p, "copy").unwrap_or(false);
|
||||
// every clip on the sequence now: id -> (track, start, end, project item)
|
||||
let spots = |s: &Session| -> Vec<(u64, TrackId, Tick, Tick, ItemId)> {
|
||||
let q = s.active_sequence();
|
||||
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
|
||||
};
|
||||
let before = spots(s);
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
let copied = if copy {
|
||||
let label = if ins { "Copy (Insert)" } else { "Copy" };
|
||||
s.edit_sequence(label, |q, ctx, st| {
|
||||
let ids = edit::copy_items(q, &moves, ins, ctx)?;
|
||||
st.selection = ids.clone();
|
||||
st.transition_selection.clear();
|
||||
Ok(ids)
|
||||
})?
|
||||
} else {
|
||||
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
|
||||
Vec::new()
|
||||
};
|
||||
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
|
||||
// shortened clip may live on under a new id: name each clip the move changed without being
|
||||
// asked to, with what is left of it, so the caller hears about it instead of finding out later.
|
||||
let after = spots(s);
|
||||
let moved = |c: u64| moves.iter().any(|m| m.0.0 == c);
|
||||
let new = |c: u64| !before.iter().any(|b| b.0 == c);
|
||||
let moved = |c: u64| moves.iter().any(|m| m.0.0 == c) && !copy;
|
||||
let new = |c: u64| !before.iter().any(|b| b.0 == c) && !copied.iter().any(|k| k.0 == c);
|
||||
let mut overwritten = Vec::new();
|
||||
for &(c, track, start, end, item) in before.iter().filter(|b| !moved(b.0)) {
|
||||
let kept = after.iter().find(|a| a.0 == c);
|
||||
|
|
@ -346,9 +387,30 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let now: i64 = pieces.iter().map(|a| (a.3 - a.2).0).sum();
|
||||
overwritten.push(json!({"clip": c, "was": (end - start).0, "now": now, "pieces": pieces.iter().map(|a| a.0).collect::<Vec<_>>()}));
|
||||
}
|
||||
if copy {
|
||||
return Ok(json!({"copied": copied.iter().map(|c| c.0).collect::<Vec<_>>(), "overwritten": overwritten}));
|
||||
}
|
||||
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>(), "overwritten": overwritten}))
|
||||
}
|
||||
|
||||
/// Slip clips together: the media delta of each clip is scaled down by the same factor so that the
|
||||
/// most constrained clip (media end or start) still moves, and no linked partner slips further.
|
||||
pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> {
|
||||
let mut scale = 1.0_f64;
|
||||
for (c, d) in deltas {
|
||||
if d.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let x = edit::slip(&mut q.clone(), *c, *d, ctx)?;
|
||||
scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64);
|
||||
}
|
||||
for (c, d) in deltas {
|
||||
let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d };
|
||||
edit::slip(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand a clip selection with linked partners (when linked selection is on).
|
||||
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
||||
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
|
||||
|
|
@ -366,6 +428,31 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
|
|||
out
|
||||
}
|
||||
|
||||
/// The delta a trim of `clips` (a clip and its linked partners) can apply together: `delta`
|
||||
/// clamped by each one's media and neighbours.
|
||||
fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result<Tick> {
|
||||
let mut d = delta;
|
||||
for c in clips {
|
||||
let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?;
|
||||
if x.abs() < d.abs() {
|
||||
d = x;
|
||||
}
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The delta `timeline.trim` would apply for `delta`, without editing anything: the Timeline's
|
||||
/// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip
|
||||
/// instead of following the pointer past it (#653).
|
||||
pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result<Tick> {
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id);
|
||||
let starts = |id: ItemId| crate::media_start(&s.project, id);
|
||||
let mut next = s.project.next_id;
|
||||
let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() };
|
||||
clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -688,6 +775,20 @@ pub(crate) fn place_item(
|
|||
})
|
||||
}
|
||||
|
||||
/// How long an item runs on the Timeline without In/Out marks: its duration, or for a still (and a
|
||||
/// generator without a length) Settings ▸ Timeline ▸ Still Image Default Duration.
|
||||
pub(crate) fn full_duration(s: &Session, pi: &filmcraft_project::ProjectItem) -> Tick {
|
||||
match &pi.kind {
|
||||
ItemKind::Media(m)
|
||||
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|
||||
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
|
||||
{
|
||||
s.prefs.timeline.still_duration(s.sequence_rate())
|
||||
}
|
||||
_ => pi.duration(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
|
||||
let item = s.state.source_item?;
|
||||
let pi = s.project.item(item)?;
|
||||
|
|
@ -913,7 +1014,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?}"#, always, |s, p| {
|
||||
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?,"replace":bool?}"#, always, |s, p| {
|
||||
let bin = u64_p(p, "bin").map(filmcraft_project::BinId);
|
||||
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
|
|
@ -922,6 +1023,10 @@ fn build() -> Vec<CommandSpec> {
|
|||
if paths.is_empty() {
|
||||
return Err(bad("file.import", "need `paths`"));
|
||||
}
|
||||
if bool_p(p, "replace").unwrap_or(false) {
|
||||
let name = s.project.name.clone();
|
||||
s.execute("file.newProject", json!({ "name": name }))?;
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut sequences = Vec::new();
|
||||
|
|
@ -1180,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
|
|
@ -1218,6 +1323,12 @@ fn build() -> Vec<CommandSpec> {
|
|||
let ids = s.state.transition_selection.clone();
|
||||
return remove_transitions(s, &ids, "Clear");
|
||||
}
|
||||
// a selected gap closes, as in Premiere Pro (#648, #668)
|
||||
if s.state.caption_selection.is_empty()
|
||||
&& let Some(g) = s.state.gap_selection
|
||||
{
|
||||
return ripple_delete_gap(s, g);
|
||||
}
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, false);
|
||||
}
|
||||
|
|
@ -1231,6 +1342,11 @@ fn build() -> Vec<CommandSpec> {
|
|||
}),
|
||||
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() {
|
||||
if s.state.caption_selection.is_empty()
|
||||
&& let Some(g) = s.state.gap_selection
|
||||
{
|
||||
return ripple_delete_gap(s, g);
|
||||
}
|
||||
let caps = s.state.caption_selection.clone();
|
||||
return crate::captions::delete(s, &caps, true);
|
||||
}
|
||||
|
|
@ -1250,11 +1366,13 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| {
|
||||
let all: Vec<ClipId> = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default();
|
||||
s.state.selection = all;
|
||||
s.state.gap_selection = None;
|
||||
Ok(json!({"selected": s.state.selection.len()}))
|
||||
}),
|
||||
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| {
|
||||
s.state.selection.clear();
|
||||
s.state.caption_selection.clear();
|
||||
s.state.gap_selection = None;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Shift+/"), "{}", has_project_selection, |s, _| {
|
||||
|
|
@ -1648,21 +1766,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
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());
|
||||
s.edit_sequence("Ripple Delete", |q, _, st| {
|
||||
let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default();
|
||||
edit::close_gap(q, tr, t)?;
|
||||
if st.ripple_sequence_markers {
|
||||
// the gap ended where the first clip that moved started
|
||||
let moved = before
|
||||
.iter()
|
||||
.filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start)))
|
||||
.min_by_key(|m| m.0);
|
||||
if let Some((end, len)) = moved {
|
||||
crate::sequence_tools::ripple_markers(&mut q.markers, end, -len);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
close_gap(s, tr, t)?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("sequence.snap", "Snap in Timeline", ["Sequence"], Some("S"), r#"{"on":bool?}"#, always, |s, p| {
|
||||
|
|
@ -2034,7 +2138,10 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(Value::Null)
|
||||
}),
|
||||
// ================= Source monitor =================
|
||||
cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| {
|
||||
cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?,"clip":id?}"#, always, |s, p| {
|
||||
if let Some(clip) = clip_p(p, "clip") {
|
||||
return crate::source_monitor::open_clip(s, clip);
|
||||
}
|
||||
let id = item_p(p, "item")
|
||||
.or(s.state.project_selection.first().copied())
|
||||
.ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?;
|
||||
|
|
@ -2045,6 +2152,9 @@ fn build() -> Vec<CommandSpec> {
|
|||
s.events.push(crate::Event::OpenSource(id));
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
// Source panel: Close / Close All, for the clip shown and the list of recent clips (#313)
|
||||
cmd!("source.close", "Close", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, false)),
|
||||
cmd!("source.closeAll", "Close All", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, true)),
|
||||
cmd!("source.setPlayhead", "Set Source Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64}"#, always, |s, p| {
|
||||
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?;
|
||||
let view = crate::clip_ops::source_view(s, item);
|
||||
|
|
@ -2074,9 +2184,12 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Place Clip",
|
||||
[],
|
||||
None,
|
||||
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
|
||||
r#"{"item":id,"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
if crate::sequence_tools::wants_new_track(p) {
|
||||
return crate::sequence_tools::place_on_new_tracks(s, p);
|
||||
}
|
||||
let stream_flag = |key: &str| match p.get(key) {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
|
|
@ -2115,16 +2228,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
|
||||
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
|
||||
}
|
||||
let full = match &pi.kind {
|
||||
// Settings ▸ Timeline ▸ Still Image Default Duration
|
||||
ItemKind::Media(m)
|
||||
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|
||||
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
|
||||
{
|
||||
s.prefs.timeline.still_duration(s.sequence_rate())
|
||||
}
|
||||
_ => pi.duration(),
|
||||
};
|
||||
let full = full_duration(s, pi);
|
||||
let (mi, mo) = match &pi.kind {
|
||||
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
|
||||
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
|
||||
|
|
@ -2146,8 +2250,9 @@ fn build() -> Vec<CommandSpec> {
|
|||
// 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
|
||||
// and a clip selection replaces a transition or gap one
|
||||
s.state.transition_selection.clear();
|
||||
s.state.gap_selection = None;
|
||||
if bool_p(p, "toggle").unwrap_or(false) {
|
||||
for c in clips {
|
||||
if let Some(i) = s.state.selection.iter().position(|x| *x == c) {
|
||||
|
|
@ -2168,12 +2273,13 @@ fn build() -> Vec<CommandSpec> {
|
|||
}
|
||||
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}),
|
||||
cmd!("timeline.selectGap", "Select Gap", [], None, r#"{"track":"V1"|id,"time":ticks|"frame":n|"seconds":f}"#, has_seq, select_gap),
|
||||
cmd!(
|
||||
"timeline.move",
|
||||
"Move Clips",
|
||||
[],
|
||||
None,
|
||||
r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#,
|
||||
r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#,
|
||||
has_seq,
|
||||
move_clips
|
||||
),
|
||||
|
|
@ -2198,13 +2304,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| {
|
||||
let before = q.find_item(c).map(|(_, i)| i.range());
|
||||
// clamp across all linked partners, then apply the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
let x = edit::clamp_trim(q, *c, edge, mode, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?;
|
||||
if mode == TrimMode::Ripple {
|
||||
// the clip and its linked partners ripple as one edit
|
||||
let mut group = vec![c];
|
||||
|
|
@ -2326,17 +2426,8 @@ fn build() -> Vec<CommandSpec> {
|
|||
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 {
|
||||
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(())
|
||||
let deltas: Vec<(ClipId, Tick)> = clips.iter().map(|c| (*c, d)).collect();
|
||||
slip_together(q, ctx, &deltas)
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
|
|
@ -2349,20 +2440,8 @@ fn build() -> Vec<CommandSpec> {
|
|||
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
|
||||
.unwrap_or_default();
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slide", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slide them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
let x = edit::slide(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slide(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// clamp across all linked partners, then slide them by the common delta
|
||||
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
|
||||
|
|
@ -2571,15 +2650,34 @@ fn build() -> Vec<CommandSpec> {
|
|||
{
|
||||
e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone()));
|
||||
}
|
||||
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"))?;
|
||||
if keyframe {
|
||||
// the stopwatch and the value in one step
|
||||
prm.put_keyframe(mt, v);
|
||||
} else {
|
||||
prm.set_at(mt, v);
|
||||
let (keyframes, v) = {
|
||||
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"))?;
|
||||
let keyframes = if keyframe {
|
||||
// the stopwatch and the value in one step
|
||||
prm.put_keyframe(mt, v.clone());
|
||||
prm.keyframes.len()
|
||||
} else {
|
||||
prm.set_at(mt, v.clone());
|
||||
prm.keyframes.len()
|
||||
};
|
||||
(keyframes, v)
|
||||
};
|
||||
if e.effect == "ultra_key"
|
||||
&& pid == "setting"
|
||||
&& let ParamValue::Choice(setting) = v
|
||||
&& let Some(rows) = filmcraft_project::effect::ultra_key_setting(setting)
|
||||
{
|
||||
for (id, value) in rows {
|
||||
if let Some(p) = e.param_mut(id) {
|
||||
p.value = ParamValue::Float(*value);
|
||||
p.keyframes.clear();
|
||||
} else {
|
||||
e.params.insert((*id).to_string(), filmcraft_project::Param::new(ParamValue::Float(*value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(prm.keyframes.len())
|
||||
Ok(keyframes)
|
||||
})?;
|
||||
// 0 keyframes: the value is static (`time` was not used)
|
||||
Ok(json!({"keyframes": keyframes}))
|
||||
|
|
@ -2619,15 +2717,35 @@ fn build() -> Vec<CommandSpec> {
|
|||
cmd!("effects.reset", "Reset Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?;
|
||||
let idx = u64_p(p, "index").unwrap_or(0) as usize;
|
||||
let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Effect", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
if let Some(e) = it.effects.get_mut(idx)
|
||||
&& let Some(d) = e.def()
|
||||
{
|
||||
let mut fresh = d.instance();
|
||||
resolve_auto_points(&mut fresh, (fw, fh), (fw, fh));
|
||||
*e = fresh;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
if let Some(e) = it.effects.get_mut(idx) {
|
||||
reset_params(e, None, frame, mt);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("effects.resetParam", "Reset Parameter", [], None, r#"{"clip":id,"effect":str|index,"param":str}"#, has_seq, |s, p| {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.resetParam", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string();
|
||||
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
|
||||
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
|
||||
let ph = s.playhead();
|
||||
s.edit_sequence("Reset Parameter", |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
|
||||
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?;
|
||||
if !reset_params(e, Some(&pid), frame, mt) {
|
||||
return Err(bad("effects.resetParam", format!("no param `{pid}`")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -2729,7 +2847,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
s.fix_state();
|
||||
Ok(json!({"items": items.len(), "bins": bins.len()}))
|
||||
}),
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
|
||||
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| {
|
||||
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
|
||||
None => s.state.project_selection.clone(),
|
||||
|
|
@ -2834,6 +2952,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::keyboard::commands());
|
||||
v.extend(crate::project_panel::commands());
|
||||
v.extend(crate::media_browser::commands());
|
||||
v.extend(crate::paste_media::commands());
|
||||
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
|
||||
// and their menu order
|
||||
crate::clip_ops::apply_layout(&mut v);
|
||||
|
|
@ -2843,6 +2962,30 @@ fn build() -> Vec<CommandSpec> {
|
|||
v
|
||||
}
|
||||
|
||||
/// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset
|
||||
/// buttons do: a parameter that is not animated gets its default as its static value, and an
|
||||
/// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`.
|
||||
/// Masks are left alone. Returns whether any parameter was reset.
|
||||
fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool {
|
||||
let Some(d) = e.def() else { return false };
|
||||
let mut fresh = d.instance();
|
||||
resolve_auto_points(&mut fresh, frame, frame);
|
||||
let mut any = false;
|
||||
for (id, prm) in fresh.params {
|
||||
if only.is_some_and(|o| o != id) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
match e.params.get_mut(&id) {
|
||||
Some(old) if old.is_animated() => old.set_at(mt, prm.value),
|
||||
_ => {
|
||||
e.params.insert(id, prm);
|
||||
}
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?;
|
||||
let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string();
|
||||
|
|
@ -3350,6 +3493,7 @@ fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) ->
|
|||
}
|
||||
s.state.selection.clear();
|
||||
s.state.caption_selection.clear();
|
||||
s.state.gap_selection = None;
|
||||
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<_>>()}))
|
||||
|
|
@ -3366,6 +3510,59 @@ fn remove_transitions(s: &mut Session, ids: &[TransitionId], label: &str) -> Res
|
|||
Ok(json!({"removed": n}))
|
||||
}
|
||||
|
||||
/// `timeline.selectGap`: select the gap on `track` at `time` (clicking empty space between
|
||||
/// clips, #648, #668). It replaces the clip, transition, caption and edit point selections, as
|
||||
/// in Premiere Pro. Empty time after a track's last clip is not a gap.
|
||||
fn select_gap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "timeline.selectGap";
|
||||
let track = track_p(s, p, "track", CMD)?.ok_or_else(|| bad(CMD, "need `track`"))?;
|
||||
let t = time_p(s, p, "").ok_or_else(|| bad(CMD, "need `time`, `frame` or `seconds`"))?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let tr = q.track(track).ok_or_else(|| bad(CMD, format!("no track {}", track.0)))?;
|
||||
if tr.locked {
|
||||
return Err(EngineError::Edit(edit::EditError::Locked));
|
||||
}
|
||||
let range = edit::through::track_gaps(tr).into_iter().find(|g| g.contains(t)).ok_or_else(|| bad(CMD, "there is no gap there"))?;
|
||||
s.state.gap_selection = Some(crate::GapSelection { track, range });
|
||||
s.state.selection.clear();
|
||||
s.state.caption_selection.clear();
|
||||
s.state.transition_selection.clear();
|
||||
s.state.edit_points.clear();
|
||||
s.state.trim_shift = Default::default();
|
||||
Ok(json!({"track": track.0, "start": range.start.0, "end": range.end().0}))
|
||||
}
|
||||
|
||||
/// Close the selected gap (Delete, Backspace or Ripple Delete on a gap).
|
||||
fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result<Value> {
|
||||
let still = s.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| edit::through::track_gaps(tr).contains(&g.range));
|
||||
if !still {
|
||||
s.state.gap_selection = None;
|
||||
return Err(EngineError::Other("the selected gap is no longer there".into()));
|
||||
}
|
||||
close_gap(s, g.track, g.range.start)?;
|
||||
s.state.gap_selection = None;
|
||||
Ok(json!({"closed": g.range.duration.0}))
|
||||
}
|
||||
|
||||
/// `sequence.closeGap`: ripple the gap on `track` at `t` closed (one undo step, "Ripple Delete").
|
||||
fn close_gap(s: &mut Session, tr: TrackId, t: Tick) -> Result<()> {
|
||||
s.edit_sequence("Ripple Delete", |q, _, st| {
|
||||
let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default();
|
||||
edit::close_gap(q, tr, t)?;
|
||||
if st.ripple_sequence_markers {
|
||||
// the gap ended where the first clip that moved started
|
||||
let moved = before
|
||||
.iter()
|
||||
.filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start)))
|
||||
.min_by_key(|m| m.0);
|
||||
if let Some((end, len)) = moved {
|
||||
crate::sequence_tools::ripple_markers(&mut q.markers, end, -len);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `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
|
||||
|
|
@ -3588,6 +3785,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence
|
|||
"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<_>>(),
|
||||
"gapSelection": s.state.gap_selection.map(|g| json!({"track": g.track.0, "start": g.range.start.0, "end": g.range.end().0})),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,10 +119,15 @@ fn clip_commands_take_explicit_clips_without_a_selection() {
|
|||
fn naming_nothing_usable_does_not_enable_a_command() {
|
||||
let mut s = demo();
|
||||
let before = s.project.clone();
|
||||
// no ids, ids of no clip, the wrong type, and a key the command does not take
|
||||
for p in [json!({"clips": []}), json!({"clips": [987_654_321u64]}), json!({"clips": "all"}), json!({"clips": [-1, 1.5, null]}), json!({"items": [1]})] {
|
||||
// no ids, the wrong type, and a key the command does not take
|
||||
for p in [json!({"clips": []}), json!({"clips": "all"}), json!({"items": [1]})] {
|
||||
assert_eq!(disabled(s.execute("edit.rippleDelete", p.clone())), "nothing selected", "{p}");
|
||||
}
|
||||
// ids of no clip and values that are not ids: still disabled, and the reason names them (#591)
|
||||
let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [987_654_321u64]})));
|
||||
assert!(why.starts_with("no clip 987654321 in the active sequence"), "{why}");
|
||||
let why = disabled(s.execute("edit.rippleDelete", json!({"clips": [-1, 1.5, null]})));
|
||||
assert!(why.starts_with("-1 is not a clip id"), "{why}");
|
||||
assert_eq!(disabled(s.execute("clip.replaceFromBin", json!({"clips": [v1(&s)[0].id.0], "item": 987_654_321u64}))), "select a clip in the Project panel");
|
||||
// `edit.cut` takes no `clips`: it works on the selection only
|
||||
assert_eq!(disabled(s.execute("edit.cut", json!({"clips": [v1(&s)[0].id.0]}))), "no clips selected");
|
||||
|
|
@ -159,6 +164,44 @@ fn link_and_speed_take_explicit_clips_without_a_selection() {
|
|||
assert!(s.state.selection.is_empty() && !s.is_enabled("clip.speedDuration"), "menu enablement still follows the selection");
|
||||
}
|
||||
|
||||
/// #591: a caller that names clips which cannot stand in for the selection hears why, instead of
|
||||
/// "no clips selected": a clip of another sequence, an id of something else, a string id.
|
||||
#[test]
|
||||
fn named_clips_that_are_not_usable_say_why() {
|
||||
let mut s = demo();
|
||||
let demo_seq = s.state.active_sequence.unwrap();
|
||||
let demo_name = s.project.item(demo_seq).unwrap().name.clone();
|
||||
let last = v1(&s).last().unwrap().clone();
|
||||
// a track id and a project item id are not clip ids
|
||||
let track = s.active_sequence().unwrap().video_tracks[0].id.0;
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [track], "speed": 50})));
|
||||
assert_eq!(why, format!("no clip {track} in the active sequence (clip ids are the `clip` numbers of the items in sequence.inspect)"));
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.item.0], "speed": 50})));
|
||||
assert!(why.starts_with(&format!("no clip {} in the active sequence", last.item.0)), "{why}");
|
||||
// a string is not an id
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0.to_string()], "speed": 50})));
|
||||
assert!(why.starts_with(&format!("\"{}\" is not a clip id", last.id.0)), "{why}");
|
||||
// `clip`, where the command documents it
|
||||
let why = disabled(s.execute("graphics.setText", json!({"clip": "x", "text": "x"})));
|
||||
assert!(why.starts_with("\"x\" is not a clip id"), "{why}");
|
||||
// a clip of a sequence that is not the active one
|
||||
s.execute("file.newSequence", json!({"name": "Other"})).unwrap();
|
||||
assert_ne!(s.state.active_sequence, Some(demo_seq));
|
||||
let why = disabled(s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})));
|
||||
assert_eq!(
|
||||
why,
|
||||
format!("clip {} is in sequence \"{demo_name}\", not the active one: open it first with sequence.open {{\"item\":{}}}", last.id.0, demo_seq.0)
|
||||
);
|
||||
// and doing what it says works
|
||||
s.execute("sequence.open", json!({"item": demo_seq.0})).unwrap();
|
||||
s.execute("clip.speedDuration", json!({"clips": [last.id.0], "speed": 200})).unwrap();
|
||||
assert!(clip(&s, last.id).unwrap().duration < last.duration);
|
||||
// naming nothing keeps the selection's reason, for menus and callers alike
|
||||
s.state.selection.clear();
|
||||
assert_eq!(disabled(s.execute("clip.speedDuration", json!({"speed": 50}))), "no clips selected");
|
||||
assert_eq!(disabled(s.execute("clip.speedDuration", json!({"clips": [], "speed": 50}))), "no clips selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_targets_lifts_only_the_selection_condition() {
|
||||
// no sequence: named clips cannot stand in for one
|
||||
|
|
|
|||
|
|
@ -64,6 +64,21 @@ 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 wav_resolve_honours_explicit_sample_rates() {
|
||||
let mut s = demo();
|
||||
for rate in [4000, 192_000, 192_001, 200_000, 352_800, 384_000] {
|
||||
let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).unwrap();
|
||||
assert_eq!(result["settings"]["audio"]["sample_rate"], rate);
|
||||
assert_eq!(result["output"]["sampleRate"], rate);
|
||||
}
|
||||
let result = s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":null}}})).unwrap();
|
||||
assert_eq!(result["output"]["sampleRate"], 48_000);
|
||||
for rate in [0, 384_001] {
|
||||
assert!(s.execute("export.resolve", json!({"format":"wav", "settings":{"audio":{"sample_rate":rate}}})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_camel_case_settings_are_honoured() {
|
||||
let mut s = demo();
|
||||
|
|
@ -122,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()),
|
||||
None => (1920, 1080),
|
||||
};
|
||||
if matches!(fmt.as_str(), "png" | "tiff" | "bmp") {
|
||||
if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") {
|
||||
let files = files_in(&sub);
|
||||
let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string();
|
||||
assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps");
|
||||
|
|
@ -133,6 +148,10 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
let codec = match fmt.as_str() {
|
||||
"png" => "png",
|
||||
"tiff" => "tiff",
|
||||
"jpg" => "mjpeg",
|
||||
"tga" => "targa",
|
||||
"dpx" => "dpx",
|
||||
"exr" => "exr",
|
||||
_ => "bmp",
|
||||
};
|
||||
assert_eq!(v["codec_name"], codec, "{name}");
|
||||
|
|
@ -159,6 +178,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]);
|
||||
continue;
|
||||
}
|
||||
"aac" => {
|
||||
// AAC Audio: an MPEG-4 audio file, sound only
|
||||
assert!(out.ends_with(".m4a"), "{name}: {out}");
|
||||
assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}");
|
||||
assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}");
|
||||
let a = stream(&j, "audio").unwrap();
|
||||
assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}");
|
||||
assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]);
|
||||
continue;
|
||||
}
|
||||
"gif" => {
|
||||
let v = stream(&j, "video").unwrap();
|
||||
assert_eq!(v["codec_name"], "gif");
|
||||
|
|
@ -202,9 +231,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
"proxy" => ("Proxy", 45.0),
|
||||
"lt" => ("LT", 102.0),
|
||||
"standard" => ("Standard", 147.0),
|
||||
"4444" => ("4444", 330.0),
|
||||
"4444xq" => ("XQ", 500.0),
|
||||
_ => ("HQ", 220.0),
|
||||
};
|
||||
assert_eq!(v["profile"], profile, "{name}");
|
||||
if matches!(profile, "4444" | "XQ") {
|
||||
let pix = v["pix_fmt"].as_str().unwrap_or_default();
|
||||
let alpha = settings["alpha"].as_bool().unwrap_or(false);
|
||||
assert!(pix.contains("444") && pix.starts_with("yuva") == alpha, "{name}: {pix}");
|
||||
}
|
||||
assert!(vbr <= mbps * px * 1.5, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px);
|
||||
assert_eq!(a["codec_name"], "pcm_s24le", "{name}");
|
||||
}
|
||||
|
|
@ -256,6 +292,25 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
|
|||
/// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset
|
||||
/// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's
|
||||
/// measured video bitrate stays within the VBR maximum and close to the target.
|
||||
#[test]
|
||||
fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() {
|
||||
let mut s = demo();
|
||||
let dir = Scratch::new("aac-audio");
|
||||
let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"});
|
||||
let run = |s: &mut Session, path: String| {
|
||||
let mut p = range.clone();
|
||||
p["path"] = json!(path);
|
||||
s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string()
|
||||
};
|
||||
let adts = run(&mut s, dir.path("Mix.aac"));
|
||||
assert!(adts.ends_with("Mix.aac"), "{adts}");
|
||||
let b = std::fs::read(&adts).unwrap();
|
||||
assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header");
|
||||
let m4a = run(&mut s, dir.path("Mix"));
|
||||
assert!(m4a.ends_with("Mix.m4a"), "{m4a}");
|
||||
assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_presets_hit_their_bitrates() {
|
||||
let mut s = demo();
|
||||
|
|
@ -303,7 +358,7 @@ fn user_presets_persist_with_favourites_import_and_export() {
|
|||
let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(favs, 2);
|
||||
let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len();
|
||||
assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ");
|
||||
// persisted: a fresh session with the same data directory sees both
|
||||
let mut s2 = demo();
|
||||
s2.export_presets.set_dir(&dir.0);
|
||||
|
|
@ -481,6 +536,20 @@ fn hardware_encoding_is_off_unless_asked_for() {
|
|||
assert_eq!(old.hardware_encoding, Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crf_param_picks_constant_quality() {
|
||||
use filmcraft_export::BitrateMode::{Cbr, Crf, Vbr1Pass};
|
||||
let s = demo();
|
||||
let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| (st.bitrate_mode, st.crf));
|
||||
assert_eq!(setting(json!({"path": "x.mp4"})).unwrap(), (Vbr1Pass, 23.0));
|
||||
// a factor alone means CRF mode; the mode can also be named, or another one kept explicitly
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "crf": 18})).unwrap(), (Crf, 18.0));
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "crf"})).unwrap(), (Crf, 23.0));
|
||||
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "cbr", "crf": 18})).unwrap(), (Cbr, 18.0));
|
||||
let e = setting(json!({"path": "x.mp4", "bitrateMode": "abr"})).unwrap_err().to_string();
|
||||
assert!(e.contains("crf"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_exports_several_sequences_and_ranges() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::Ordering;
|
||||
|
||||
use filmcraft_export::presets::{DEFAULT_PRESET, preset_key};
|
||||
use filmcraft_export::{ExportPreset, ExportSettings, Format, GpuRendering, HardwareEncoding};
|
||||
use filmcraft_export::{BitrateMode, ExportPreset, ExportSettings, Format, GpuRendering, HardwareEncoding};
|
||||
use filmcraft_project::{ItemId, Project};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -213,7 +213,13 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
|||
settings.max_bitrate_kbps = Some(v);
|
||||
}
|
||||
if let Some(v) = str_p(p, "bitrateMode") {
|
||||
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass"))?;
|
||||
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass | crf"))?;
|
||||
}
|
||||
if let Some(v) = f64_p(p, "crf") {
|
||||
settings.crf = v as f32;
|
||||
if p.get("bitrateMode").is_none() {
|
||||
settings.bitrate_mode = BitrateMode::Crf;
|
||||
}
|
||||
}
|
||||
if let Some(v) = crate::commands::checked_u32_p(p, "keyframeDistance", cmd)? {
|
||||
settings.keyframe_distance = Some(v);
|
||||
|
|
@ -354,7 +360,14 @@ fn file_safe(n: &str) -> String {
|
|||
fn with_extension(path: &str, settings: &ExportSettings) -> String {
|
||||
let ext = settings.extension();
|
||||
match Path::new(path).extension().and_then(|e| e.to_str()) {
|
||||
Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) => path.to_string(),
|
||||
Some(e)
|
||||
if e.eq_ignore_ascii_case(ext)
|
||||
|| (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff"))
|
||||
|| (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg"))
|
||||
|| (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) =>
|
||||
{
|
||||
path.to_string()
|
||||
}
|
||||
Some(_) if settings.is_image_sequence() || matches!(settings.format, Format::Aiff) => format!("{}.{ext}", path.rsplit_once('.').map_or(path, |x| x.0)),
|
||||
_ => format!("{path}.{ext}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,3 +101,54 @@ fn frame_export_snapshots_are_independent_of_later_monitor_selection() {
|
|||
Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap().settings.frame_rate = FrameRate { num: 1, den: i64::MAX };
|
||||
assert!(keyboard::frame_export_target(&s, &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A source whose pictures are still on their way, as a WebCodecs decode or a Blob read on the web.
|
||||
struct Loading(filmcraft_media::MediaInfo);
|
||||
|
||||
impl filmcraft_media::MediaSource for Loading {
|
||||
fn info(&self) -> &filmcraft_media::MediaInfo {
|
||||
&self.0
|
||||
}
|
||||
fn video_frame(&self, _: filmcraft_media::FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {
|
||||
filmcraft_media::pending::mark();
|
||||
Err(filmcraft_media::MediaError::Decode("still decoding".into()))
|
||||
}
|
||||
fn audio(&self, _: i64, _: usize, _: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
|
||||
filmcraft_media::pending::mark();
|
||||
Err(filmcraft_media::MediaError::Decode("still loading".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_still_loading_is_reported_not_saved_blank() {
|
||||
let (mut s, item) = session();
|
||||
let generator = s.media.cached(item).unwrap();
|
||||
s.media.insert(item, Arc::new(Loading(generator.info().clone())));
|
||||
let dir = std::env::temp_dir().join(format!("fc-frame-loading-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for target in ["program", "source"] {
|
||||
let path = dir.join(format!("{target}.png"));
|
||||
let err = s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("still loading"), "{target}: {err}");
|
||||
assert!(!path.exists(), "{target}: nothing is written");
|
||||
}
|
||||
// once the picture is there, the same export succeeds
|
||||
s.media.insert(item, generator);
|
||||
for target in ["program", "source"] {
|
||||
let path = dir.join(format!("{target}.png"));
|
||||
s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap();
|
||||
assert!(path.exists(), "{target}");
|
||||
}
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_earlier_deferred_read_does_not_fail_the_export() {
|
||||
let (mut s, _) = session();
|
||||
filmcraft_media::pending::mark();
|
||||
let path = std::env::temp_dir().join(format!("fc-frame-stale-pending-{}.png", std::process::id()));
|
||||
s.execute("file.exportFrame", json!({"target":"program","path":path.to_string_lossy()})).unwrap();
|
||||
assert!(!filmcraft_media::pending::is_set());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
//! Selecting a gap between clips and closing it with Delete / Backspace / Ripple Delete (#648, #668):
|
||||
//! `timeline.selectGap` selects the empty time on one track, `edit.clear` and `edit.rippleDelete`
|
||||
//! close it as one "Ripple Delete" undo step.
|
||||
|
||||
use filmcraft_project::ClipId;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{GapSelection, Session};
|
||||
|
||||
fn range(s: &Session, c: ClipId) -> (Tick, Tick) {
|
||||
s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap()
|
||||
}
|
||||
|
||||
/// Two 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`.
|
||||
fn two_clips(s: &mut Session, frames: [i64; 2]) -> [(ClipId, ClipId); 2] {
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap();
|
||||
let rate = s.sequence_rate();
|
||||
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap();
|
||||
for f in frames {
|
||||
s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap();
|
||||
}
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
[0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_between_two_clips_selects_the_gap_and_delete_closes_it() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, _), (picture2, sound2)] = two_clips(&mut s, [0, 96]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let (f48, f60, f96) = (f(48), f(60), f(96));
|
||||
s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap();
|
||||
let before = s.project.clone();
|
||||
|
||||
let r = s.execute("timeline.selectGap", json!({"track": "V1", "time": f60.0})).unwrap();
|
||||
assert_eq!(r, json!({"track": s.active_sequence().unwrap().video_tracks[0].id.0, "start": f48.0, "end": f96.0}));
|
||||
assert!(s.state.selection.is_empty(), "the gap replaces the clip selection");
|
||||
assert!(s.is_enabled("edit.clear") && s.is_enabled("edit.rippleDelete"), "Delete and Ripple Delete apply to the gap");
|
||||
|
||||
// Delete / Backspace
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(range(&s, picture2), (f48, f96), "the next clip moves up into the gap");
|
||||
assert_eq!(range(&s, sound2), (f48, f96), "and its sound with it");
|
||||
assert_eq!(range(&s, picture1).0, Tick::ZERO, "the clip before stays");
|
||||
assert!(s.state.gap_selection.is_none(), "the gap is gone");
|
||||
s.active_sequence().unwrap().check().unwrap();
|
||||
|
||||
assert_eq!(s.undo().as_deref(), Some("Ripple Delete"), "one undo step");
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_delete_closes_a_selected_gap_too() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
s.execute("timeline.selectGap", json!({"track": "A1", "frame": 50})).unwrap();
|
||||
s.execute("edit.rippleDelete", json!({})).unwrap();
|
||||
assert_eq!(range(&s, picture2).0, f(48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_time_before_the_first_clip_is_a_gap() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, _), (picture2, _)] = two_clips(&mut s, [24, 72]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let r = s.execute("timeline.selectGap", json!({"track": "V1", "frame": 5})).unwrap();
|
||||
assert_eq!((r["start"].clone(), r["end"].clone()), (json!(0), json!(f(24).0)));
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!((range(&s, picture1).0, range(&s, picture2).0), (Tick::ZERO, f(48)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_time_after_the_last_clip_is_not_a_gap() {
|
||||
let mut s = Session::default();
|
||||
two_clips(&mut s, [0, 96]);
|
||||
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 400})).is_err());
|
||||
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 10})).is_err(), "inside a clip");
|
||||
assert!(s.state.gap_selection.is_none());
|
||||
assert!(!s.is_enabled("edit.clear"), "nothing to delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_clip_or_deselecting_all_drops_the_gap() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, _), _] = two_clips(&mut s, [0, 96]);
|
||||
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap();
|
||||
assert!(s.state.gap_selection.is_none());
|
||||
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.gap_selection.is_none());
|
||||
}
|
||||
|
||||
/// An edit that fills the gap leaves no gap selected; Delete then removes nothing it should not.
|
||||
#[test]
|
||||
fn an_edit_that_changes_the_gap_drops_it() {
|
||||
let mut s = Session::default();
|
||||
let [(picture1, _), (picture2, _)] = two_clips(&mut s, [0, 96]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": picture1.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap();
|
||||
assert!(s.state.gap_selection.is_none(), "the gap changed");
|
||||
assert!(s.execute("edit.clear", json!({})).is_err());
|
||||
assert_eq!(range(&s, picture2).0, f(96), "nothing moved");
|
||||
}
|
||||
|
||||
/// A stale selection (set from outside, as the control channel can) is refused, not acted on.
|
||||
#[test]
|
||||
fn a_stale_gap_is_refused() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
let track = s.active_sequence().unwrap().video_tracks[0].id;
|
||||
s.state.gap_selection = Some(GapSelection { track, range: filmcraft_time::TimeRange::from_bounds(f(10), f(20)) });
|
||||
assert!(s.execute("edit.clear", json!({})).is_err());
|
||||
assert!(s.state.gap_selection.is_none());
|
||||
assert_eq!(range(&s, picture2).0, f(96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_on_a_locked_track_cannot_be_selected_or_closed() {
|
||||
let mut s = Session::default();
|
||||
let [_, (picture2, _)] = two_clips(&mut s, [0, 96]);
|
||||
let rate = s.sequence_rate();
|
||||
let f = |n: i64| rate.tick_of(n);
|
||||
s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap();
|
||||
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).is_err());
|
||||
// Sequence ▸ Close Gap moved the other tracks while the locked one stayed put
|
||||
assert!(s.execute("sequence.closeGap", json!({"track": "V1", "frame": 60})).is_err());
|
||||
assert_eq!(range(&s, picture2).0, f(96));
|
||||
let sound2 = s.active_sequence().unwrap().audio_tracks[0].items[1].id;
|
||||
assert_eq!(range(&s, sound2).0, f(96), "the unlocked sound stays in sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_select_gap_parameters_are_errors() {
|
||||
let mut s = Session::default();
|
||||
assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 1})).is_err(), "no sequence");
|
||||
two_clips(&mut s, [0, 96]);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"track": "V1"}),
|
||||
json!({"frame": 60}),
|
||||
json!({"track": "V99", "frame": 60}),
|
||||
json!({"track": 999_999, "frame": 60}),
|
||||
json!({"track": "V1", "time": i64::MAX}),
|
||||
json!({"track": "V1", "time": i64::MIN}),
|
||||
json!({"track": "V1", "frame": i64::MAX}),
|
||||
json!({"track": "V1", "seconds": f64::MAX}),
|
||||
json!({"track": [], "frame": "x"}),
|
||||
] {
|
||||
let _ = s.execute("timeline.selectGap", p);
|
||||
}
|
||||
assert!(s.state.gap_selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gap_selection_is_part_of_the_ui_state() {
|
||||
let mut s = Session::default();
|
||||
two_clips(&mut s, [0, 96]);
|
||||
s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap();
|
||||
let v = serde_json::to_value(&s.state).unwrap();
|
||||
let back: crate::EditorState = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.gap_selection, s.state.gap_selection);
|
||||
}
|
||||
|
|
@ -11,6 +11,35 @@ use serde_json::{Value, json};
|
|||
use crate::Session;
|
||||
use crate::media_test_util::tmp_dir;
|
||||
|
||||
#[test]
|
||||
fn import_accepts_uppercase_extensions() {
|
||||
let dir = tmp_dir("import-uppercase");
|
||||
let wav = dir.join("TONE.WAV");
|
||||
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1);
|
||||
assert!(s.project.items.values().any(|it| it.name == "TONE.WAV"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_replace_starts_from_an_empty_project() {
|
||||
let dir = tmp_dir("import-replace");
|
||||
let wav = dir.join("tone.wav");
|
||||
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap();
|
||||
let mut s = demo();
|
||||
let demo_items = s.project.items.len();
|
||||
assert!(demo_items > 1);
|
||||
assert!(!s.state.open_sequences.is_empty());
|
||||
s.execute("file.import", json!({"paths": [wav.to_string_lossy()], "replace": true})).unwrap();
|
||||
assert!(s.state.open_sequences.is_empty());
|
||||
assert_eq!(s.project.items.len(), 1);
|
||||
assert!(s.project.items.values().any(|it| it.name == "tone.wav"));
|
||||
let mut merge = demo();
|
||||
merge.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
|
||||
assert!(merge.project.items.len() > demo_items);
|
||||
}
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
|||
proj.resolve_placed_auto_points(|seq| !before.contains(&seq), |source| !is_file_media(source));
|
||||
Ok(seqs)
|
||||
})?;
|
||||
// Link media: probe each new file-backed item that exists.
|
||||
// Link media: probe each new file-backed item that exists, through the host's reader as a
|
||||
// direct import does (only the container index is read now, not the whole file: #461).
|
||||
let new_media: Vec<(ItemId, String)> = s
|
||||
.project
|
||||
.items
|
||||
|
|
@ -76,11 +77,7 @@ pub fn import(s: &mut Session, path: &str, bytes: &[u8], format: Format) -> Resu
|
|||
let mut linked = 0;
|
||||
let mut offline = Vec::new();
|
||||
for (id, mpath) in new_media {
|
||||
let opened = s.services.read_file(&mpath).ok().and_then(|b| {
|
||||
let fname = std::path::Path::new(&mpath).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
s.media.open_bytes(&fname, b.into()).ok()
|
||||
});
|
||||
match opened {
|
||||
match s.media.open_file(&mpath, &*s.services).ok() {
|
||||
Some(src) => {
|
||||
let info = src.info().clone();
|
||||
let identity = crate::relink::identity_of(&*s.services, &mpath).ok();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Interchange imports and the "auto" (NaN) point defaults of the effects they add: the points
|
||||
//! are resolved as placing a clip resolves them, the anchor once the media's real size is known,
|
||||
//! and a project saved after an import opens again.
|
||||
//! and a project saved after an import opens again. Also how the media they name are linked.
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_media::DemoScene;
|
||||
|
|
@ -216,3 +216,43 @@ fn relinking_later_resolves_the_anchor_that_waited_for_the_real_size() {
|
|||
assert_eq!(motion_point(&first_clip(&t, seq), "anchor"), Vec2::new(160.0, 90.0));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Linking the media an interchange document names opens each file through the host's reader, as
|
||||
/// a direct import does, instead of reading it whole into memory (#461: minutes for 4K files).
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn an_interchange_import_links_media_without_reading_the_files_whole() {
|
||||
/// The desktop filesystem, except that reading a media file whole fails.
|
||||
struct NoWholeMediaReads;
|
||||
impl crate::Services for NoWholeMediaReads {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
if path.ends_with(".mov") {
|
||||
return Err(std::io::Error::other(format!("{path}: read whole")));
|
||||
}
|
||||
crate::FsServices.read_file(path)
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
crate::FsServices.write_file(path, data)
|
||||
}
|
||||
fn file_size(&self, path: &str) -> std::io::Result<u64> {
|
||||
crate::FsServices.file_size(path)
|
||||
}
|
||||
fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
|
||||
crate::FsServices.read_range(path, offset, len)
|
||||
}
|
||||
fn reader(&self, path: &str) -> Option<std::io::Result<filmcraft_media::SharedReader>> {
|
||||
crate::FsServices.reader(path)
|
||||
}
|
||||
}
|
||||
let d = tmp_dir("ix-streamed-link");
|
||||
std::fs::create_dir_all(d.join("media")).unwrap();
|
||||
make_movie(&d.join("media/shot.mov"), DemoScene::OceanSunset, 320, 180, 24);
|
||||
let doc = d.join("cut.xml");
|
||||
std::fs::write(&doc, xml_with_a_scale_only_motion_filter()).unwrap();
|
||||
let mut s = Session::new(std::sync::Arc::new(NoWholeMediaReads));
|
||||
let (r, seq) = import(&mut s, &doc);
|
||||
assert_eq!(r["linkedMedia"], 1, "{r}");
|
||||
let clip = first_clip(&s, seq);
|
||||
assert_eq!(s.project.source_size(clip.item), Some((320, 180)), "the file's real size, from its index");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKi
|
|||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, time_p, with_links};
|
||||
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, slip_together, time_p, with_links};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
|
|
@ -248,14 +248,21 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
|
|||
let rate = s.sequence_rate();
|
||||
let clips = with_links(s, &s.state.selection.clone());
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
// media delta per clip (source frames run at the clip's speed)
|
||||
let deltas: Vec<(ClipId, Tick)> = clips
|
||||
.iter()
|
||||
.filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64))))
|
||||
.collect();
|
||||
let linked = s.state.linked_selection;
|
||||
// media delta per clip (source frames run at the clip's speed); linked partners slip as one group
|
||||
let mut groups: Vec<(Option<u64>, Vec<(ClipId, Tick)>)> = Vec::new();
|
||||
for c in &clips {
|
||||
let Some((_, i)) = seq.find_item(*c) else { continue };
|
||||
let d = Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64);
|
||||
let link = i.link.filter(|_| linked);
|
||||
match groups.iter_mut().find(|(l, _)| link.is_some() && *l == link) {
|
||||
Some((_, g)) => g.push((*c, d)),
|
||||
None => groups.push((link, vec![(*c, d)])),
|
||||
}
|
||||
}
|
||||
s.edit_sequence("Slip", |q, ctx, _| {
|
||||
for (c, d) in &deltas {
|
||||
edit::slip(q, *c, *d, ctx)?;
|
||||
for (_, g) in &groups {
|
||||
slip_together(q, ctx, g)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -265,12 +272,8 @@ fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
|
|||
fn slide_selection(s: &mut Session, frames: i64) -> Result<Value> {
|
||||
let d = s.sequence_rate().tick_of(frames);
|
||||
let clips = with_links(s, &s.state.selection.clone());
|
||||
s.edit_sequence("Slide", |q, ctx, _| {
|
||||
for c in &clips {
|
||||
edit::slide(q, *c, d, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// one common delta, clamped by the most restrictive member, keeps linked partners in sync
|
||||
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
|
|
@ -628,13 +631,20 @@ fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
};
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
// On the web a frame can still be decoding (WebCodecs) or its bytes loading (Blob reads): the
|
||||
// render then leaves that clip out, so saving it would pass a blank or partial picture off as
|
||||
// the requested frame (#228). Report it instead and write nothing; a later call succeeds.
|
||||
let _ = filmcraft_media::pending::take();
|
||||
let img = if target.source {
|
||||
filmcraft_render::render_item(&s.project, target.item, target.time, 1.0, &provider)
|
||||
.ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))?
|
||||
} else {
|
||||
let opts = filmcraft_render::RenderOptions { scale: 1.0, captions: true, ..Default::default() };
|
||||
filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider)
|
||||
Some(filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider))
|
||||
};
|
||||
if filmcraft_media::pending::take() {
|
||||
return Err(EngineError::Other("the frame is still loading; export it again in a moment".into()));
|
||||
}
|
||||
let img = img.ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))?;
|
||||
let (w, h) = (
|
||||
u32::try_from(img.w).map_err(|_| bad("file.exportFrame", "image width exceeds limits"))?,
|
||||
u32::try_from(img.h).map_err(|_| bad("file.exportFrame", "image height exceeds limits"))?,
|
||||
|
|
|
|||
|
|
@ -376,3 +376,29 @@ fn reveal_nested_sequence_opens_it_at_the_matching_frame() {
|
|||
assert_ne!(s.state.active_sequence, Some(outer));
|
||||
assert_eq!(s.playhead(), nest_clip.source_in + rate.tick_of(7), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_slide_keeps_linked_partners_aligned_at_a_neighbour_limit() {
|
||||
// #712: a three-frame sound-only follower limits only the sound partner
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newProject", json!({"name": "Slide"})).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());
|
||||
let r = s.execute("timeline.place", json!({"item": item, "track": "A1", "time": fr.0 * 60, "sourceIn": 0, "duration": fr.0 * 3})).unwrap();
|
||||
let f = r["clips"][0].as_u64().unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [v]})).unwrap();
|
||||
s.execute("timeline.slideRight5", json!({})).unwrap();
|
||||
assert_eq!(clip(&s, v).start, Tick(fr.0 * 2), "the pair moves only as far as the sound may");
|
||||
assert_eq!(clip(&s, a).start, clip(&s, v).start, "picture and sound stay aligned");
|
||||
let fl = clip(&s, f);
|
||||
assert_eq!((fl.start, fl.duration, fl.source_in), (Tick(fr.0 * 62), fr, Tick(fr.0 * 2)));
|
||||
// nothing more to give: a further slide right moves neither partner
|
||||
s.execute("timeline.slideRight", json!({})).unwrap();
|
||||
assert_eq!((clip(&s, v).start, clip(&s, a).start), (Tick(fr.0 * 2), Tick(fr.0 * 2)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ mod marker_export;
|
|||
pub mod masks;
|
||||
pub mod media_browser;
|
||||
pub mod media_pool;
|
||||
pub mod media_watch;
|
||||
pub mod mixer;
|
||||
pub mod multicam;
|
||||
pub mod narration;
|
||||
pub mod panels;
|
||||
pub mod paste_media;
|
||||
pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod previews;
|
||||
|
|
@ -57,7 +59,7 @@ use std::sync::Arc;
|
|||
|
||||
use filmcraft_edit::EditCtx;
|
||||
use filmcraft_project::{ClipId, ItemId, Project, Sequence, TrackId, TrackKind};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -221,6 +223,13 @@ pub struct Targeting {
|
|||
pub audio_dest: Option<TrackId>,
|
||||
}
|
||||
|
||||
/// A gap on one track of the active sequence (see [`EditorState::gap_selection`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GapSelection {
|
||||
pub track: TrackId,
|
||||
pub range: TimeRange,
|
||||
}
|
||||
|
||||
/// Editing state that commands depend on (not project data, but headless-relevant).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct EditorState {
|
||||
|
|
@ -230,6 +239,10 @@ pub struct EditorState {
|
|||
/// Item loaded in the Source monitor and its playhead (media time).
|
||||
pub source_item: Option<ItemId>,
|
||||
pub source_playhead: Tick,
|
||||
/// Clips opened in the Source monitor, most recent first: the Source panel's list of recent
|
||||
/// clips (#313). Kept up to date after every command; items gone from the project drop out.
|
||||
#[serde(default)]
|
||||
pub source_history: Vec<ItemId>,
|
||||
/// Selected timeline items.
|
||||
pub selection: Vec<ClipId>,
|
||||
/// Selected project panel items.
|
||||
|
|
@ -266,6 +279,12 @@ pub struct EditorState {
|
|||
/// them. Selecting clips clears it and selecting transitions clears the clip selection.
|
||||
#[serde(default)]
|
||||
pub transition_selection: Vec<filmcraft_project::TransitionId>,
|
||||
/// The selected gap (#648, #668): empty time on one track, clicked in the Timeline, from the
|
||||
/// end of a clip (or the sequence start) up to the next clip. Delete, Backspace and Ripple
|
||||
/// Delete close it. Selecting clips, transitions or edit points clears it, and so does an edit
|
||||
/// that changes the gap.
|
||||
#[serde(default)]
|
||||
pub gap_selection: Option<GapSelection>,
|
||||
/// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip.
|
||||
#[serde(default)]
|
||||
pub graphic_layers: Vec<usize>,
|
||||
|
|
@ -387,6 +406,11 @@ pub struct Session {
|
|||
pub browser: media_browser::BrowserState,
|
||||
/// Nesting depth of [`Session::execute`] (commands that run other commands).
|
||||
exec_depth: u32,
|
||||
/// Each media file's size and modification time when last seen (Settings ▸ Media ▸
|
||||
/// Automatically refresh growing files, [`media_watch`]).
|
||||
pub media_stamps: std::collections::HashMap<String, media_watch::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`Session::start_media_scan`]).
|
||||
pub media_scan: Option<media_watch::MediaScan>,
|
||||
}
|
||||
|
||||
/// An export advanced a batch at a time on the host's thread ([`Session::pump_jobs`]).
|
||||
|
|
@ -476,6 +500,8 @@ impl Session {
|
|||
log: Default::default(),
|
||||
browser: Default::default(),
|
||||
exec_depth: 0,
|
||||
media_stamps: Default::default(),
|
||||
media_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -645,6 +671,9 @@ impl Session {
|
|||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("internal error in `{id}` (see the crash log); the command did not complete"))));
|
||||
self.exec_depth -= 1;
|
||||
if self.exec_depth == 0 {
|
||||
source_monitor::track_history(self);
|
||||
}
|
||||
// source graphics: an edited instance updates the shared layers and the other instances
|
||||
if r.is_ok() && spec.journal && id.starts_with("graphics.") && !self.project.source_graphics.is_empty() {
|
||||
graphic_templates::sync_source_graphics(self);
|
||||
|
|
@ -694,8 +723,11 @@ impl Session {
|
|||
}
|
||||
let clips = commands::named_clips(self, spec, params);
|
||||
let items = commands::named_items(self, spec, params);
|
||||
// clips were named but none is one of the active sequence: say which and why, rather
|
||||
// than "no clips selected" to a caller that did not mean the selection (#591)
|
||||
let missing = if clips.is_none() { commands::named_clips_missing(self, spec, params) } else { None };
|
||||
if clips.is_none() && items.is_none() {
|
||||
return by_selection;
|
||||
return by_selection.map_err(|why| missing.unwrap_or(why));
|
||||
}
|
||||
let mut clips = clips.unwrap_or_else(|| self.state.selection.clone());
|
||||
let mut items = items.unwrap_or_else(|| self.state.project_selection.clone());
|
||||
|
|
@ -704,7 +736,7 @@ impl Session {
|
|||
let by_params = (spec.enabled)(self);
|
||||
self.state.selection = clips;
|
||||
self.state.project_selection = items;
|
||||
by_params
|
||||
by_params.map_err(|why| missing.unwrap_or(why))
|
||||
}
|
||||
|
||||
/// Whether the command can run on the current selection (what the menus show).
|
||||
|
|
@ -776,6 +808,7 @@ impl Session {
|
|||
self.history.redo.clear();
|
||||
self.history.merge_key = None;
|
||||
self.state = st;
|
||||
self.drop_stale_gap();
|
||||
self.bump();
|
||||
Ok(r)
|
||||
}
|
||||
|
|
@ -795,6 +828,7 @@ impl Session {
|
|||
self.refuse_self_nesting(&p)?;
|
||||
self.project = Arc::new(p);
|
||||
self.state = st;
|
||||
self.drop_stale_gap();
|
||||
self.bump();
|
||||
Ok(r)
|
||||
}
|
||||
|
|
@ -880,6 +914,7 @@ impl Session {
|
|||
{
|
||||
self.state.source_item = None;
|
||||
}
|
||||
source_monitor::track_history(self);
|
||||
if let Some(seq) = self.state.active_sequence.and_then(|s| p.sequence(s)) {
|
||||
self.state.selection.retain(|c| seq.find_item(*c).is_some());
|
||||
self.state.caption_selection.retain(|c| seq.find_caption(*c).is_some());
|
||||
|
|
@ -888,6 +923,17 @@ impl Session {
|
|||
self.state.caption_selection.clear();
|
||||
}
|
||||
self.state.project_selection.retain(|i| p.item(*i).is_some());
|
||||
self.drop_stale_gap();
|
||||
}
|
||||
|
||||
/// Forget the selected gap when it is no longer a gap of the active sequence (a clip moved
|
||||
/// into it or next to it, its track went, another sequence became the active one).
|
||||
fn drop_stale_gap(&mut self) {
|
||||
if let Some(g) = self.state.gap_selection
|
||||
&& !self.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| filmcraft_edit::through::track_gaps(tr).contains(&g.range))
|
||||
{
|
||||
self.state.gap_selection = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn bump(&mut self) {
|
||||
|
|
@ -1058,6 +1104,8 @@ mod export_tests;
|
|||
#[cfg(test)]
|
||||
mod file_tests;
|
||||
#[cfg(test)]
|
||||
mod gap_selection_tests;
|
||||
#[cfg(test)]
|
||||
mod image_sequence_tests;
|
||||
#[cfg(test)]
|
||||
mod interchange_auto_points_tests;
|
||||
|
|
@ -1072,6 +1120,8 @@ mod media_browser_tests;
|
|||
#[cfg(test)]
|
||||
mod media_test_util;
|
||||
#[cfg(test)]
|
||||
mod media_watch_tests;
|
||||
#[cfg(test)]
|
||||
mod mixer_tests;
|
||||
#[cfg(test)]
|
||||
mod multicam_tests;
|
||||
|
|
@ -1088,6 +1138,8 @@ mod panels_tests;
|
|||
#[cfg(test)]
|
||||
mod par_tests;
|
||||
#[cfg(test)]
|
||||
mod paste_media_tests;
|
||||
#[cfg(test)]
|
||||
mod presets_tests;
|
||||
#[cfg(test)]
|
||||
mod previews_tests;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
spec(
|
||||
"masks.add",
|
||||
"Create Mask",
|
||||
r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#,
|
||||
r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"rectangle"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?,"cornerRadius":px?}"#,
|
||||
add,
|
||||
),
|
||||
spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove),
|
||||
|
|
@ -64,6 +64,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
track,
|
||||
),
|
||||
spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth),
|
||||
spec("masks.roundCorners", "Round Mask Corners", r#"{"clip":id?,"effect":index|id?,"mask":n?,"radius":px,"vertices":[n]?}"#, round_corners),
|
||||
CommandSpec {
|
||||
id: "masks.select",
|
||||
label: "Select Mask",
|
||||
|
|
@ -212,7 +213,12 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None => match shape.as_str() {
|
||||
"ellipse" => MaskPath::ellipse(center, size * 0.5),
|
||||
"polygon" | "rectangle" | "rect" | "4-point" => {
|
||||
MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0)
|
||||
let (x0, y0, x1, y1) = (center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0);
|
||||
match f64_p(p, "cornerRadius") {
|
||||
Some(r) if !r.is_finite() || r < 0.0 => return Err(bad("masks.add", "`cornerRadius` is pixels ≥ 0")),
|
||||
Some(r) => MaskPath::rounded_rect(x0, y0, x1, y1, r),
|
||||
None => MaskPath::rect(x0, y0, x1, y1),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")),
|
||||
},
|
||||
|
|
@ -454,6 +460,40 @@ fn toggle_smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Round the mask's sharp corners (Premiere 26's rounded rectangle masks): every corner between
|
||||
/// two straight edges (or only the listed `vertices`) becomes a circular fillet of `radius`
|
||||
/// pixels, shrunk to fit half of each edge. The same corners are rounded in every Mask Path
|
||||
/// keyframe so an animated path keeps interpolating. Returns how many corners were rounded.
|
||||
fn round_corners(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "masks.roundCorners";
|
||||
let radius = f64_p(p, "radius").filter(|r| r.is_finite() && *r > 0.0).ok_or_else(|| bad(ID, "need `radius` (pixels, > 0)"))?;
|
||||
let only: Option<Vec<usize>> = match p.get("vertices") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let list = v.as_array().ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?;
|
||||
let mut out = list
|
||||
.iter()
|
||||
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices")))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
};
|
||||
let rounded = edit_mask(s, p, "Round Mask Corners", move |m, t| {
|
||||
let mut corners = m.path_at(t).roundable_corners();
|
||||
if let Some(only) = &only {
|
||||
corners.retain(|i| only.binary_search(i).is_ok());
|
||||
}
|
||||
if corners.is_empty() {
|
||||
return Err(bad(ID, "no sharp corner between straight edges to round"));
|
||||
}
|
||||
drop_all(m, t, |path| *path = path.with_rounded_corners(radius, &corners));
|
||||
Ok(corners.len())
|
||||
})?;
|
||||
Ok(json!({"corners": rounded}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) {
|
||||
s.state.selected_mask = None;
|
||||
|
|
|
|||
|
|
@ -317,3 +317,66 @@ fn tracking_runs_in_the_background_and_can_be_cancelled() {
|
|||
assert!(n < 48, "stopped early ({n} keyframes)");
|
||||
assert!(s.execute("masks.track", json!({"direction": "backward"})).is_err(), "nothing before the first frame");
|
||||
}
|
||||
|
||||
/// #813: a rectangle mask with rounded corners (created rounded, or rounded afterwards) cuts the
|
||||
/// clip's corners away while the edges stay where they were; one undo step, every keyframe.
|
||||
#[test]
|
||||
fn rounded_rectangle_masks_cut_their_corners() {
|
||||
let (mut s, clip) = demo();
|
||||
// a 960×540 opacity mask in the middle of the 1920×1080 frame, rounded by 200 px
|
||||
s.execute("masks.add", json!({"clip": clip.0, "shape": "rectangle", "center": [960, 540], "size": [960, 540], "cornerRadius": 200})).unwrap();
|
||||
s.execute("masks.set", json!({"feather": 0})).unwrap();
|
||||
let list = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(list["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 8);
|
||||
let img = s.render_program(0.25).unwrap();
|
||||
// just inside the rectangle's corner: cut away; middle of an edge and the centre: kept
|
||||
let corner = px(&img, 0.26, 0.26);
|
||||
assert!(corner[0] + corner[1] + corner[2] < 1e-4, "rounded corner cut: {corner:?}");
|
||||
assert!(px(&img, 0.5, 0.26)[3] > 0.99, "top edge kept");
|
||||
assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside kept");
|
||||
|
||||
// round an existing 4-point mask: same picture, one undo step back to sharp corners
|
||||
s.undo();
|
||||
s.undo();
|
||||
s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap();
|
||||
s.execute("masks.set", json!({"feather": 0})).unwrap();
|
||||
let sharp = s.render_program(0.25).unwrap();
|
||||
assert!(px(&sharp, 0.26, 0.26)[3] > 0.99, "a sharp corner keeps the clip");
|
||||
let r = s.execute("masks.roundCorners", json!({"radius": 200})).unwrap();
|
||||
assert_eq!(r["corners"], 4);
|
||||
assert_eq!(s.render_program(0.25).unwrap().px, img.px, "rounding afterwards = creating rounded");
|
||||
// nothing left to round; bad radii are refused without changing the mask
|
||||
assert!(s.execute("masks.roundCorners", json!({"radius": 50})).is_err());
|
||||
for radius in [json!(0), json!(-5), json!("big"), json!(null)] {
|
||||
assert!(s.execute("masks.roundCorners", json!({"radius": radius})).is_err(), "{radius}");
|
||||
}
|
||||
s.undo();
|
||||
assert_eq!(s.render_program(0.25).unwrap().px, sharp.px, "one undo step restores the sharp corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() {
|
||||
let (mut s, clip) = demo();
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
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();
|
||||
let rate = s.active_sequence().unwrap().settings.frame_rate;
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"delta": [300, 0]})).unwrap();
|
||||
// only vertices 0 and 2 (top-left, bottom-right), out-of-range indices ignored
|
||||
let r = s.execute("masks.roundCorners", json!({"radius": 30, "vertices": [0, 2, 99]})).unwrap();
|
||||
assert_eq!(r["corners"], 2);
|
||||
let m = &s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().find(|e| e.effect == "opacity").unwrap().masks[0];
|
||||
assert_eq!(m.path.keyframes.len(), 2);
|
||||
for k in &m.path.keyframes {
|
||||
let filmcraft_project::ParamValue::Path(p) = &k.value else { panic!("path keyframe") };
|
||||
assert_eq!(p.len(), 6, "both keyframes rounded the same corners");
|
||||
assert_eq!(p.vertices.iter().filter(|v| v.is_corner()).count(), 2, "vertices 1 and 3 stay sharp");
|
||||
}
|
||||
// halfway the path still interpolates (equal vertex counts)
|
||||
let l = s.execute("masks.list", json!({"time": (it.start + rate.tick_of(5)).0})).unwrap();
|
||||
let v = l["masks"][0]["path"]["vertices"].as_array().unwrap();
|
||||
assert_eq!(v.len(), 6);
|
||||
assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
//! | `mediaBrowser.favorite` | Add to / Remove from Favorites |
|
||||
//! | `mediaBrowser.clearRecent` | Clear Recent Directories |
|
||||
//! | `mediaBrowser.settings` | file types, view (list / thumbnails), columns (Edit Columns…), Import as Image Sequence, Hover Scrub, thumbnail size |
|
||||
//! | `mediaBrowser.import` | Import (the selection or `paths`; Project ▸ Ingest settings apply) |
|
||||
//! | `mediaBrowser.import` | Import (the selection or `paths`; a folder becomes a bin, its sub-folders bins inside it; Project ▸ Ingest settings apply) |
|
||||
//! | `mediaBrowser.openInSource` | Open In Source Monitor (Shift+O) |
|
||||
//! | `mediaBrowser.probe` | a file's media properties (frame rate, duration, video / audio info), cached |
|
||||
//!
|
||||
|
|
@ -421,32 +421,134 @@ fn paths_p(s: &Session, p: &Value) -> Vec<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files (directories import their
|
||||
/// supported files, like dragging a folder in Premiere).
|
||||
/// Most folders one import walks, and how deep it goes: a folder import mirrors the tree as bins,
|
||||
/// and symlinked folders can loop.
|
||||
const FOLDER_IMPORT_MAX_FOLDERS: usize = 4096;
|
||||
const FOLDER_IMPORT_MAX_DEPTH: usize = 32;
|
||||
|
||||
/// A folder to import as a bin: its name, its files and its sub-folders that hold files.
|
||||
struct Folder {
|
||||
name: String,
|
||||
files: Vec<String>,
|
||||
subs: Vec<Folder>,
|
||||
}
|
||||
|
||||
/// The files the browser shows in `dir` and its sub-folders, as a tree; `None` when the tree
|
||||
/// holds none (or `budget` folders were walked already).
|
||||
fn walk_folder(services: &dyn Services, dir: &str, depth: usize, budget: &mut usize) -> Option<Folder> {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
let entries = list(services, dir, "all").ok()?;
|
||||
let mut folder = Folder { name: base_name(dir), files: Vec::new(), subs: Vec::new() };
|
||||
for e in entries {
|
||||
if !e.is_dir {
|
||||
folder.files.push(e.path);
|
||||
} else if depth < FOLDER_IMPORT_MAX_DEPTH
|
||||
&& let Some(sub) = walk_folder(services, &e.path, depth + 1, budget)
|
||||
{
|
||||
folder.subs.push(sub);
|
||||
}
|
||||
}
|
||||
(!folder.files.is_empty() || !folder.subs.is_empty()).then_some(folder)
|
||||
}
|
||||
|
||||
/// Add a `file.import` result to the merged one: arrays add up, other values (the Ingest result)
|
||||
/// are listed.
|
||||
fn merge_import(out: &mut serde_json::Map<String, Value>, r: Value) {
|
||||
let Value::Object(r) = r else { return };
|
||||
for (k, v) in r {
|
||||
let slot = out.entry(k).or_insert_with(|| json!([]));
|
||||
let Value::Array(all) = slot else { continue };
|
||||
match v {
|
||||
Value::Null => {}
|
||||
Value::Array(a) => all.extend(a),
|
||||
other => all.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `file.import` and merge its result; a failure becomes an error of the merged result.
|
||||
fn import_into(s: &mut Session, files: &[String], bin: Option<u64>, out: &mut serde_json::Map<String, Value>) {
|
||||
let mut q = json!({"paths": files, "imageSequence": false});
|
||||
if let Some(b) = bin {
|
||||
q["bin"] = json!(b);
|
||||
}
|
||||
match s.execute("file.import", q) {
|
||||
Ok(r) => merge_import(out, r),
|
||||
Err(e) => merge_import(out, json!({"errors": [e.to_string()]})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a folder as a bin named after it, in `parent`: its files go into the bin, each
|
||||
/// sub-folder becomes a bin inside it (Premiere's Media Browser keeps the folder structure).
|
||||
fn import_folder(s: &mut Session, folder: &Folder, parent: Option<u64>, out: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
let name = folder.name.clone();
|
||||
let bin = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&name, parent.map(filmcraft_project::BinId))))?;
|
||||
merge_import(out, json!({"bins": [bin.0]}));
|
||||
if !folder.files.is_empty() {
|
||||
import_into(s, &folder.files, Some(bin.0), out);
|
||||
}
|
||||
// recursion as deep as the walk went, which is bounded
|
||||
for sub in &folder.subs {
|
||||
import_folder(s, sub, Some(bin.0), out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files. A folder becomes a bin
|
||||
/// named after it (in `bin`) holding its files, and each sub-folder a bin inside that, as in
|
||||
/// Premiere; the result then also lists the new `bins`. Import as Image Sequence takes the first
|
||||
/// file instead.
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths = paths_p(s, p);
|
||||
if paths.is_empty() {
|
||||
return Err(bad("mediaBrowser.import", "select files in the Media Browser"));
|
||||
}
|
||||
let seq = bool_p(p, "imageSequence").unwrap_or(s.prefs.media_browser.import_as_image_sequence);
|
||||
let bin = u64_p(p, "bin");
|
||||
let mut files = Vec::new();
|
||||
let mut folders = Vec::new();
|
||||
let mut empty = Vec::new();
|
||||
let mut budget = FOLDER_IMPORT_MAX_FOLDERS;
|
||||
for path in paths {
|
||||
// a directory imports its media files (not recursive)
|
||||
let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none();
|
||||
match list(&*s.services, &path, "all") {
|
||||
Ok(entries) if is_dir => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
|
||||
_ => files.push(path),
|
||||
if !is_dir {
|
||||
files.push(path);
|
||||
} else if seq {
|
||||
// an image sequence: the folder's frames
|
||||
match list(&*s.services, &path, "all") {
|
||||
Ok(entries) => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
|
||||
Err(_) => files.push(path),
|
||||
}
|
||||
} else {
|
||||
match walk_folder(&*s.services, &path, 0, &mut budget) {
|
||||
Some(f) => folders.push(f),
|
||||
None => empty.push(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
// an image sequence imports from its first selected frame only
|
||||
if seq {
|
||||
files.truncate(1);
|
||||
}
|
||||
let mut q = json!({"paths": files, "imageSequence": seq});
|
||||
if let Some(b) = u64_p(p, "bin") {
|
||||
q["bin"] = json!(b);
|
||||
if folders.is_empty() {
|
||||
if files.is_empty() && !empty.is_empty() {
|
||||
return Err(bad("mediaBrowser.import", format!("no media files in {}", empty.join(", "))));
|
||||
}
|
||||
let mut q = json!({"paths": files, "imageSequence": seq});
|
||||
if let Some(b) = bin {
|
||||
q["bin"] = json!(b);
|
||||
}
|
||||
return s.execute("file.import", q);
|
||||
}
|
||||
s.execute("file.import", q)
|
||||
let mut out = serde_json::Map::new();
|
||||
merge_import(&mut out, json!({"items": [], "errors": empty.iter().map(|e| format!("{e}: no media files")).collect::<Vec<_>>(), "bins": []}));
|
||||
if !files.is_empty() {
|
||||
import_into(s, &files, bin, &mut out);
|
||||
}
|
||||
for f in &folders {
|
||||
import_folder(s, f, bin, &mut out)?;
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The project item that already references a file.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ impl FakeFs {
|
|||
"/home/me/Movies/edit.edl",
|
||||
"/home/me/Movies/subs.srt",
|
||||
"/home/me/Movies/clip.mov",
|
||||
"/home/me/Movies/CLIP.MOV",
|
||||
] {
|
||||
files.insert(f.to_string(), vec![0; 16]);
|
||||
}
|
||||
|
|
@ -100,7 +101,7 @@ fn listing_puts_folders_first_and_hides_unsupported_files() {
|
|||
assert_eq!(l["dir"], "/home/me");
|
||||
assert_eq!(names(&l), ["Movies"]);
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]);
|
||||
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "CLIP.MOV", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]);
|
||||
let e = &l["entries"][0];
|
||||
assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder")));
|
||||
let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap();
|
||||
|
|
@ -117,7 +118,7 @@ fn file_type_filter() {
|
|||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]);
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap();
|
||||
assert_eq!(names(&l), ["Selects", "clip.mov"]);
|
||||
assert_eq!(names(&l), ["Selects", "CLIP.MOV", "clip.mov"]);
|
||||
// the setting persists and applies by default
|
||||
s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap();
|
||||
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
|
||||
|
|
@ -210,6 +211,47 @@ fn import_the_selection_and_open_in_source() {
|
|||
assert_eq!(r["items"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Importing a folder keeps its structure (#411): the folder becomes a bin named after it, each
|
||||
/// sub-folder with media a bin inside that, and every file lands in its folder's bin.
|
||||
#[test]
|
||||
fn importing_a_folder_mirrors_its_sub_folders_as_bins() {
|
||||
let mut fs = FakeFs::new();
|
||||
let wav = filmcraft_media::wav::write_wav16(&[0.0; 4800], 2, 48_000);
|
||||
fs.dirs.extend(["/Shoot", "/Shoot/Day 1", "/Shoot/Day 1/Cam A", "/Shoot/Empty"].map(String::from));
|
||||
for f in ["/Shoot/slate.wav", "/Shoot/Day 1/room.wav", "/Shoot/Day 1/Cam A/take1.wav", "/Shoot/Day 1/Cam A/take2.wav"] {
|
||||
fs.files.get_mut().unwrap().insert(f.into(), wav.clone());
|
||||
}
|
||||
let mut s = Session::new(Arc::new(fs));
|
||||
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Shoot"]})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 4, "{r}");
|
||||
assert_eq!(r["bins"].as_array().unwrap().len(), 3, "{r}");
|
||||
let tree = |b: &filmcraft_project::Bin| -> Vec<String> {
|
||||
b.children
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
filmcraft_project::BinEntry::Bin(b) => format!("bin {}", b.name),
|
||||
filmcraft_project::BinEntry::Item(i) => s.project.item(*i).unwrap().name.clone(),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let sub = |b: &filmcraft_project::Bin, name: &str| -> filmcraft_project::Bin {
|
||||
b.children
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
filmcraft_project::BinEntry::Bin(b) if b.name == name => Some(b.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(tree(&s.project.root), ["bin Shoot"]);
|
||||
let shoot = sub(&s.project.root, "Shoot");
|
||||
// a folder without media gets no bin
|
||||
assert_eq!(tree(&shoot), ["slate.wav", "bin Day 1"]);
|
||||
let day = sub(&shoot, "Day 1");
|
||||
assert_eq!(tree(&day), ["room.wav", "bin Cam A"]);
|
||||
assert_eq!(tree(&sub(&day, "Cam A")), ["take1.wav", "take2.wav"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_with_ingest_settings() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! - Sources are cached per item **and per reference** (the file path, the offline flag), so a
|
||||
//! relink, Make Offline or their undo picks up the right file on the next frame.
|
||||
//! - Media that can't be opened (missing, unreadable) or was made offline renders the offline
|
||||
//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI.
|
||||
//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI. So
|
||||
//! do the frames of a file that opens but whose picture FilmCraft cannot decode (a codec or
|
||||
//! profile without a decoder).
|
||||
//! - With proxies enabled ([`MediaPool::set_use_proxies`]) items with an attached proxy are read
|
||||
//! from it. Proxy frames are smaller; the source still reports the full-resolution size, and the
|
||||
//! compositor derives its pixel scale from the frame it gets, so effects and Motion render the
|
||||
|
|
@ -34,7 +36,7 @@ pub struct MediaPool {
|
|||
/// item → (reference key, source)
|
||||
sources: RwLock<HashMap<ItemId, (String, SharedSource)>>,
|
||||
proxies: RwLock<HashMap<ItemId, (String, SharedSource)>>,
|
||||
offline: RwLock<HashMap<ItemId, OfflineStatus>>,
|
||||
offline: Arc<RwLock<HashMap<ItemId, OfflineStatus>>>,
|
||||
use_proxies: AtomicBool,
|
||||
/// Openers tried before the built-in ones (MP4/MOV + codecs register here).
|
||||
pub openers: RwLock<Vec<Opener>>,
|
||||
|
|
@ -46,7 +48,7 @@ impl Default for MediaPool {
|
|||
Self {
|
||||
sources: RwLock::new(HashMap::new()),
|
||||
proxies: RwLock::new(HashMap::new()),
|
||||
offline: RwLock::new(HashMap::new()),
|
||||
offline: Arc::new(RwLock::new(HashMap::new())),
|
||||
use_proxies: AtomicBool::new(false),
|
||||
openers: RwLock::new(filmcraft_codecs::openers()),
|
||||
}
|
||||
|
|
@ -139,8 +141,16 @@ impl MediaPool {
|
|||
|
||||
/// Cache a source opened from `path` for an item.
|
||||
pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) {
|
||||
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src));
|
||||
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
|
||||
let src = self.unsupported_as_slate(item, path, src);
|
||||
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src));
|
||||
}
|
||||
|
||||
/// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it
|
||||
/// cannot decode.
|
||||
fn unsupported_as_slate(&self, item: ItemId, path: &str, src: SharedSource) -> SharedSource {
|
||||
let slate = SlateSource::new(src.info().clone(), &file_name(path), OfflineReason::Unreadable);
|
||||
Arc::new(UnsupportedAsSlate { inner: src, slate, item, path: path.to_string(), offline: self.offline.clone() })
|
||||
}
|
||||
|
||||
pub fn remove(&self, item: ItemId) {
|
||||
|
|
@ -249,7 +259,7 @@ impl MediaPool {
|
|||
} {
|
||||
Ok(s) => {
|
||||
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
|
||||
s
|
||||
self.unsupported_as_slate(item, path, s)
|
||||
}
|
||||
Err(_) if filmcraft_media::pending::is_set() => {
|
||||
// the host is still fetching the file's bytes: show the slate for now, retry later
|
||||
|
|
@ -373,6 +383,42 @@ impl MediaSource for SlateSource {
|
|||
}
|
||||
}
|
||||
|
||||
/// A file's source whose picture shows the unreadable slate for each frame its decoder reports as
|
||||
/// unsupported (H.264 High 4:2:2, a codec without a decoder...), instead of no picture at all,
|
||||
/// and marks the item unreadable with the reason, so the monitors and the Project panel say why
|
||||
/// (#626). Its sound plays as before.
|
||||
struct UnsupportedAsSlate {
|
||||
inner: SharedSource,
|
||||
slate: SlateSource,
|
||||
item: ItemId,
|
||||
path: String,
|
||||
offline: Arc<RwLock<HashMap<ItemId, OfflineStatus>>>,
|
||||
}
|
||||
|
||||
impl MediaSource for UnsupportedAsSlate {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
match self.inner.video_frame(req) {
|
||||
Err(MediaError::Unsupported(why)) => {
|
||||
self.offline.write().unwrap_or_else(|e| e.into_inner()).entry(self.item).or_insert_with(|| {
|
||||
log::warn!("media unreadable: {}: {why}", self.path);
|
||||
OfflineStatus { reason: OfflineReason::Unreadable, path: self.path.clone(), error: why }
|
||||
});
|
||||
self.slate.video_frame(req)
|
||||
}
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.inner.audio(start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.inner.audio_stream(stream, start, frames, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// A proxy standing in for its full-resolution media: reports the full-resolution info and hands
|
||||
/// out the proxy's (smaller) frames. A proxy whose aspect ratio differs from the original is
|
||||
/// resampled to the original's aspect so it lines up exactly.
|
||||
|
|
@ -501,4 +547,75 @@ mod tests {
|
|||
assert!(matches!(MediaPool::default().open_file(&missing, &crate::FsServices), Err(MediaError::Offline(_))));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A file whose first frame decodes and whose later frames need a decoder FilmCraft lacks.
|
||||
struct PartlyDecodable(MediaInfo);
|
||||
impl MediaSource for PartlyDecodable {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.0
|
||||
}
|
||||
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
match req.time.0 {
|
||||
0 => Ok(Arc::new(VideoFrame::rgba8(2, 2, vec![7; 16]))),
|
||||
1 => Err(MediaError::Decode("damaged slice".into())),
|
||||
_ => Err(MediaError::Unsupported("H.264: field pictures".into())),
|
||||
}
|
||||
}
|
||||
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
Ok(AudioBuffer::silence(sample_rate, 3, frames))
|
||||
}
|
||||
}
|
||||
|
||||
/// #626: frames the decoder does not support show the unreadable slate (not nothing) and the
|
||||
/// item says why; decodable frames, damaged ones and the sound are left alone.
|
||||
#[test]
|
||||
fn undecodable_frames_show_the_unreadable_slate() {
|
||||
let info = GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.0; 4] }, 16, 8, Default::default(), filmcraft_time::Tick(100))
|
||||
.info()
|
||||
.clone();
|
||||
let pool = MediaPool::default();
|
||||
let item = ItemId(1);
|
||||
pool.insert_file(item, "/media/cam/a.mov", Arc::new(PartlyDecodable(info)));
|
||||
let src = pool.cached(item).unwrap();
|
||||
let at = |t| src.video_frame(FrameRequest { time: filmcraft_time::Tick(t), scale: 1.0 });
|
||||
assert_eq!(at(0).unwrap().to_rgba8(), vec![7; 16]);
|
||||
assert!(matches!(at(1), Err(MediaError::Decode(_))));
|
||||
assert!(pool.offline_status(item).is_none(), "nothing unsupported yet");
|
||||
let f = at(2).unwrap();
|
||||
assert_eq!((f.width, f.height), (16, 8));
|
||||
assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(16, 8, "a.mov", OfflineReason::Unreadable));
|
||||
let st = pool.offline_status(item).unwrap();
|
||||
assert_eq!((st.reason, st.path.as_str(), st.error.as_str()), (OfflineReason::Unreadable, "/media/cam/a.mov", "H.264: field pictures"));
|
||||
assert_eq!(src.audio(0, 4, 48_000).unwrap().channels.len(), 3);
|
||||
// linking the item to another file forgets the reason
|
||||
pool.insert_file(item, "/media/cam/b.mov", src.clone());
|
||||
assert!(pool.offline_status(item).is_none());
|
||||
}
|
||||
|
||||
/// #626 end to end: an H.264 profile FilmCraft does not decode yet (4:4:4) imports and shows
|
||||
/// the unreadable slate with the decoder's reason, instead of a black picture.
|
||||
#[test]
|
||||
fn ffmpeg_unsupported_h264_profile_shows_the_unreadable_slate() {
|
||||
let Some(ffmpeg) = filmcraft_testkit::oracle::ffmpeg_or_skip("media_pool") else { return };
|
||||
let path = filmcraft_testkit::fixtures_dir("engine/media_pool").join("h264_444_64x32.mp4");
|
||||
let Some(path) = filmcraft_testkit::fixtures::generate(&path, |tmp| {
|
||||
std::process::Command::new(&ffmpeg)
|
||||
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=64x32:rate=24:duration=0.5"])
|
||||
.args(["-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv444p"])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let mut s = crate::Session::default();
|
||||
let r = s.execute("file.import", serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let src = s.media.source_for(&s.project, item, &*s.services).unwrap();
|
||||
let f = src.video_frame(FrameRequest { time: filmcraft_time::Tick::ZERO, scale: 1.0 }).unwrap();
|
||||
assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(64, 32, "h264_444_64x32.mp4", OfflineReason::Unreadable));
|
||||
let st = s.media.offline_status(item).unwrap();
|
||||
assert_eq!(st.reason, OfflineReason::Unreadable);
|
||||
assert!(st.error.contains("H.264") && st.error.contains("chroma_format_idc 3"), "{}", st.error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
//! Settings ▸ Media ▸ Automatically refresh growing files: media files that change on disk while
|
||||
//! the project is open are read again, whether they are still being written (a recording, an
|
||||
//! ingest) or were replaced (a render from EffectCraft or After Effects written over the file a
|
||||
//! clip uses).
|
||||
//!
|
||||
//! Each file media item's file is compared by size and modification time with what it was when
|
||||
//! last seen. The desktop app takes the stamps every *Refresh growing files every* seconds and as
|
||||
//! soon as its window comes back to the front, on a worker thread so a file on a slow network share
|
||||
//! never holds up the interface ([`Session::start_media_scan`], [`Session::poll_media_scan`]);
|
||||
//! `media.refreshChanged` takes them on the spot (CLI, agents).
|
||||
//!
|
||||
//! Refreshing opens the file again: the item gets the file's new [`MediaInfo`] (a longer
|
||||
//! duration, another frame size) and identity (so relinking compares against the new file), the
|
||||
//! media pool swaps in the new source (the old one still indexes the file as it was), and the
|
||||
//! view is refreshed without an undo step or modifying a clean project ([`Session::bump_view`]):
|
||||
//! the file changed, not the edit. A file seen for the first time is only remembered; one that
|
||||
//! can't be opened (still being written) is tried again once it changes again, not on every look.
|
||||
//!
|
||||
//! [`MediaInfo`]: filmcraft_media::MediaInfo
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use filmcraft_project::{ItemId, ItemKind, MediaRef, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A file's size and modification time (nanoseconds since 1970).
|
||||
pub type Stamp = (u64, u128);
|
||||
|
||||
/// Stamps of the project's media files taken on a worker thread.
|
||||
pub type MediaScan = Arc<Mutex<Option<Vec<(String, Option<Stamp>)>>>>;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The files of `p`'s file media items (made-offline ones aside).
|
||||
pub fn media_files(p: &Project) -> BTreeSet<String> {
|
||||
p.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Media(m) if !m.offline => match &m.media {
|
||||
MediaRef::File { path } => Some(path.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `media.refreshChanged`: stamp the project's media files now and read the changed ones again.
|
||||
pub(crate) fn refresh_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let now = media_files(&s.project).into_iter().map(|p| (p.clone(), stamp(&p))).collect();
|
||||
s.apply_media_stamps(now)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start stamping the project's media files on a worker thread, unless a look is still
|
||||
/// running (or the build has no files to look at: the web app).
|
||||
pub fn start_media_scan(&mut self) {
|
||||
if self.media_scan.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let files = media_files(&self.project);
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let slot: MediaScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("media-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|p| (p.clone(), stamp(&p))).collect();
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps);
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
self.media_scan = Some(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a finished look (`None` while there is none, or it is still running).
|
||||
pub fn poll_media_scan(&mut self) -> Option<Result<Value>> {
|
||||
let stamps = self.media_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
self.media_scan = None;
|
||||
Some(self.apply_media_stamps(stamps))
|
||||
}
|
||||
|
||||
/// Compare the media files' stamps with the ones last seen and read the changed files
|
||||
/// again. Returns `{refreshed: [item ids]}`.
|
||||
fn apply_media_stamps(&mut self, now: Vec<(String, Option<Stamp>)>) -> Result<Value> {
|
||||
// Only the files the project uses now are remembered.
|
||||
let before: HashMap<String, Stamp> = std::mem::take(&mut self.media_stamps);
|
||||
let mut changed = BTreeSet::new();
|
||||
for (path, stamp) in now {
|
||||
match (before.get(&path), stamp) {
|
||||
// Not there right now (an app saving by delete and rename): keep the last stamp.
|
||||
(Some(old), None) => {
|
||||
self.media_stamps.insert(path, *old);
|
||||
}
|
||||
(old, Some(new)) => {
|
||||
if old.is_some_and(|o| *o != new) {
|
||||
changed.insert(path.clone());
|
||||
}
|
||||
self.media_stamps.insert(path, new);
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Ok(json!({"refreshed": []}));
|
||||
}
|
||||
let mut fresh = Vec::new();
|
||||
let mut failed = BTreeSet::new();
|
||||
for (id, it) in &self.project.items {
|
||||
let Some(m) = it.as_media().filter(|m| !m.offline) else { continue };
|
||||
let MediaRef::File { path } = &m.media else { continue };
|
||||
if !changed.contains(path) || failed.contains(path) {
|
||||
continue;
|
||||
}
|
||||
// Opened the way import opens it, so the info is what a new import would get.
|
||||
match self.media.open_file(path, &*self.services) {
|
||||
Ok(src) => {
|
||||
let mut info = src.info().clone();
|
||||
if info.kind == filmcraft_media::MediaKind::Still
|
||||
&& let Some(v) = info.video.as_mut()
|
||||
{
|
||||
v.frame_rate = crate::settings::timebase_rate(&self.prefs.media.indeterminate_timebase);
|
||||
}
|
||||
let identity = crate::relink::identity_of(&*self.services, path).ok();
|
||||
fresh.push((*id, path.clone(), info, identity, src));
|
||||
}
|
||||
Err(_) => {
|
||||
failed.insert(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// A file that can't be opened (still being written, or not media FilmCraft reads) keeps its
|
||||
// new stamp: it is tried again once it changes again, not on every look.
|
||||
if fresh.is_empty() {
|
||||
return Ok(json!({"refreshed": []}));
|
||||
}
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
let mut refreshed: Vec<ItemId> = Vec::new();
|
||||
for (id, path, info, identity, src) in fresh {
|
||||
if let Some(m) = p.item_mut(id).and_then(|i| i.as_media_mut()) {
|
||||
m.info = info;
|
||||
if identity.is_some() {
|
||||
m.identity = identity;
|
||||
}
|
||||
}
|
||||
self.media.insert_file(id, &path, src);
|
||||
refreshed.push(id);
|
||||
}
|
||||
self.bump_view();
|
||||
Ok(json!({"refreshed": refreshed.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! Media files changing on disk while the project is open (Settings ▸ Media ▸ Automatically
|
||||
//! refresh growing files, `media_watch`): a re-rendered movie is read again (new length, new
|
||||
//! picture, new identity) without an undo step, a file still being written is tried again, and
|
||||
//! the background look does the same.
|
||||
|
||||
use filmcraft_media::DemoScene;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir};
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 90;
|
||||
|
||||
/// Write a movie over `path` so its modification time moves even on coarse file systems.
|
||||
fn rerender(path: &std::path::Path, scene: DemoScene, frames: i64) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
make_movie(path, scene, W, H, frames);
|
||||
}
|
||||
|
||||
fn refresh(s: &mut Session) -> serde_json::Value {
|
||||
s.execute("media.refreshChanged", json!({})).unwrap()
|
||||
}
|
||||
|
||||
/// What frame 3 of a fresh import of `scene` looks like.
|
||||
fn reference(name: &str, scene: DemoScene, frames: i64) -> Vec<u8> {
|
||||
let root = tmp_dir(name);
|
||||
let f = root.join("ref.mov");
|
||||
make_movie(&f, scene, W, H, frames);
|
||||
let (mut r, _, _) = session_with(&[&f]);
|
||||
frame_rgba(&mut r, 3, 1.0).2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rerendered_movie_is_read_again_without_an_undo_step() {
|
||||
let root = tmp_dir("watch-rerender");
|
||||
let a = root.join("a.mov");
|
||||
make_movie(&a, DemoScene::Aurora, W, H, 12);
|
||||
let (mut s, items, _) = session_with(&[&a]);
|
||||
let item = items[0];
|
||||
let before = frame_rgba(&mut s, 3, 1.0).2;
|
||||
let length = s.project.item(item).unwrap().duration();
|
||||
s.saved_revision = s.revision;
|
||||
let undo = s.history.undo.len();
|
||||
// First look: remembered, nothing to read.
|
||||
assert_eq!(refresh(&mut s), json!({"refreshed": []}));
|
||||
rerender(&a, DemoScene::CityNight, 24);
|
||||
assert_eq!(refresh(&mut s), json!({"refreshed": [item.0]}));
|
||||
let m = s.project.item(item).unwrap().as_media().unwrap().clone();
|
||||
assert_eq!(m.info.duration.0, length.0 * 2, "the new length");
|
||||
assert_eq!(m.identity, crate::relink::identity_of(&*s.services, &a.to_string_lossy()).ok(), "relinking compares with the new file");
|
||||
let after = frame_rgba(&mut s, 3, 1.0).2;
|
||||
assert!(psnr(&after, &reference("watch-rerender-ref", DemoScene::CityNight, 24)) > 40.0, "the new picture");
|
||||
assert!(psnr(&after, &before) < 30.0, "not the old one");
|
||||
assert_eq!(s.history.undo.len(), undo, "the file changed, not the edit");
|
||||
assert!(!s.is_dirty());
|
||||
assert_eq!(refresh(&mut s), json!({"refreshed": []}), "acted on once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_still_being_written_is_tried_again() {
|
||||
let root = tmp_dir("watch-partial");
|
||||
let a = root.join("a.mov");
|
||||
make_movie(&a, DemoScene::Aurora, W, H, 12);
|
||||
let (mut s, items, _) = session_with(&[&a]);
|
||||
refresh(&mut s);
|
||||
// The other app has only written the start of the file so far.
|
||||
let full = root.join("full.mov");
|
||||
make_movie(&full, DemoScene::CityNight, W, H, 24);
|
||||
let bytes = std::fs::read(&full).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(&a, &bytes[..64]).unwrap();
|
||||
assert_eq!(refresh(&mut s), json!({"refreshed": []}));
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
std::fs::write(&a, &bytes).unwrap();
|
||||
assert_eq!(refresh(&mut s), json!({"refreshed": [items[0].0]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_look_reads_changed_media_again() {
|
||||
let root = tmp_dir("watch-scan");
|
||||
let a = root.join("a.mov");
|
||||
make_movie(&a, DemoScene::Aurora, W, H, 12);
|
||||
let (mut s, items, _) = session_with(&[&a]);
|
||||
let look = |s: &mut Session| {
|
||||
s.start_media_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(r) = s.poll_media_scan() {
|
||||
return r.unwrap();
|
||||
}
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
};
|
||||
assert_eq!(look(&mut s), json!({"refreshed": []}));
|
||||
rerender(&a, DemoScene::CityNight, 24);
|
||||
assert_eq!(look(&mut s), json!({"refreshed": [items[0].0]}));
|
||||
assert!(s.media_scan.is_none());
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
//! |---|---|
|
||||
//! | `events.list` / `events.clear` | the Events panel: warnings and errors of commands, background jobs and the auto-save worker ([`EventLog`]) |
|
||||
//! | `metadata.get` / `metadata.set` | the Metadata panel: an item's clip and file properties; editable log fields (undoable) |
|
||||
//! | `project.notes` / `project.setNotes` | the Project Notes panel: free plain text saved in the project (undoable) |
|
||||
//!
|
||||
//! The Progress panel uses `jobs.list` / `jobs.cancel`; the Timecode panel and the Reference
|
||||
//! Monitor are frontend state only.
|
||||
|
|
@ -339,6 +340,38 @@ fn metadata_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"item": id.0, "name": it.name, "label": it.label.name(), "metadata": it.metadata, "changed": !unchanged}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ project notes
|
||||
|
||||
/// Longest project notes accepted (bytes of UTF-8), so a script or a pasted file cannot grow the
|
||||
/// project (and every undo step) without bound.
|
||||
pub const MAX_NOTES_BYTES: usize = 1 << 20;
|
||||
|
||||
/// `project.notes`: the project notes.
|
||||
fn notes_get(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"text": s.project.notes}))
|
||||
}
|
||||
|
||||
/// `project.setNotes {text, merge?}`: replace the project notes as one undo step. Calls with the
|
||||
/// same `merge` key and no other edit in between share that step (typing in the panel).
|
||||
fn notes_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = p.get("text").and_then(Value::as_str).ok_or_else(|| bad("project.setNotes", "need `text` (a string)"))?;
|
||||
if text.len() > MAX_NOTES_BYTES {
|
||||
return Err(bad("project.setNotes", format!("the notes are too long ({} bytes; at most {MAX_NOTES_BYTES})", text.len())));
|
||||
}
|
||||
let changed = s.project.notes != text;
|
||||
if changed {
|
||||
let body = |pr: &mut Project, _: &mut crate::EditorState| {
|
||||
pr.notes = text.to_string();
|
||||
Ok(())
|
||||
};
|
||||
match str_p(p, "merge") {
|
||||
Some(key) => s.edit_merged("Edit Project Notes", &format!("project.notes:{key}"), body)?,
|
||||
None => s.edit("Edit Project Notes", body)?,
|
||||
}
|
||||
}
|
||||
Ok(json!({"text": s.project.notes, "changed": changed}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ registry
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
|
|
@ -370,5 +403,16 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
run: metadata_set,
|
||||
journal: true,
|
||||
},
|
||||
q("project.notes", "Get Project Notes", "{}", notes_get),
|
||||
CommandSpec {
|
||||
id: "project.setNotes",
|
||||
label: "Edit Project Notes",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"text":str,"merge":str?}"#,
|
||||
enabled: always,
|
||||
run: notes_set,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,3 +156,40 @@ fn events_report_background_jobs() {
|
|||
s.poll_persistence();
|
||||
assert_eq!(s.log.entries.len(), 6);
|
||||
}
|
||||
|
||||
/// #620: project notes are saved in the project file; typing (one `merge` key) is one undo step.
|
||||
#[test]
|
||||
fn project_notes_are_saved_and_typing_is_one_undo_step() {
|
||||
let mut s = demo();
|
||||
assert_eq!(s.execute("project.notes", json!({})).unwrap()["text"], "");
|
||||
let undo0 = s.history.undo.len();
|
||||
for text in ["T", "To", "To do: fix the logo at 00:01:02:03"] {
|
||||
s.execute("project.setNotes", json!({"text": text, "merge": "1"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1, "one typing session, one undo step");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Edit Project Notes");
|
||||
// the same text again changes nothing and adds no step
|
||||
let r = s.execute("project.setNotes", json!({"text": "To do: fix the logo at 00:01:02:03"})).unwrap();
|
||||
assert_eq!(r["changed"], json!(false));
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1);
|
||||
|
||||
let loaded = filmcraft_format::decode(&filmcraft_format::encode(&s.project, false)).unwrap();
|
||||
assert_eq!(loaded.project.notes, "To do: fix the logo at 00:01:02:03");
|
||||
// an older file without notes still loads, with none
|
||||
let mut doc: Value = serde_json::from_slice(&filmcraft_format::encode(&filmcraft_project::Project::new("old"), false)).unwrap();
|
||||
doc["schema_version"] = json!(14);
|
||||
assert_eq!(filmcraft_format::decode(&serde_json::to_vec(&doc).unwrap()).unwrap().project.notes, "");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.notes, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_notes_refuse_bad_input() {
|
||||
let mut s = demo();
|
||||
assert!(s.execute("project.setNotes", json!({})).is_err());
|
||||
assert!(s.execute("project.setNotes", json!({"text": 5})).is_err());
|
||||
let huge = "x".repeat(crate::panels::MAX_NOTES_BYTES + 1);
|
||||
assert!(s.execute("project.setNotes", json!({"text": huge})).is_err());
|
||||
assert_eq!(s.project.notes, "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
//! Paste media from the system clipboard (#611). The desktop app reads the clipboard: copied files
|
||||
//! paste by path, and an image is first saved by [`save_pasted_image`] as `Pasted Image <n>.png`
|
||||
//! where voice-over takes and narrations go (Scratch Disks ▸ Captured Audio and Video, else next
|
||||
//! to the project).
|
||||
//!
|
||||
//! `edit.pasteMedia` imports the files and puts them on the Timeline at the playhead, end to end,
|
||||
//! without overwriting anything: each clip goes on the first free track from the targeted one
|
||||
//! outward (up for video, down for audio), and a track is added when every track is busy there.
|
||||
//! With `insert` they are inserted on the targeted tracks instead, as Paste Insert does.
|
||||
|
||||
use filmcraft_project::{BinId, ClipId, ItemId, TrackId, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Write a pasted image (PNG bytes) to a new `Pasted Image <n>.png`; returns its path.
|
||||
pub fn save_pasted_image(s: &mut Session, png: &[u8]) -> Result<String> {
|
||||
let dir = crate::voiceover::media_dir(s, &Value::Null, "Pasted Images");
|
||||
let names: std::collections::HashSet<String> = s.project.items.values().map(|i| i.name.clone()).collect();
|
||||
// bounded like voiceover::unique_wav_path: `file_size` is a filesystem call per try
|
||||
let path = (1..=99_999u32)
|
||||
.map(|k| format!("Pasted Image {k}.png"))
|
||||
.map(|file| (std::path::Path::new(&dir).join(&file).to_string_lossy().into_owned(), file))
|
||||
.find(|(path, file)| !names.contains(file) && s.services.file_size(path).is_err())
|
||||
.map(|(path, _)| path)
|
||||
.ok_or_else(|| EngineError::Other(format!("{dir}: no free file name for \"Pasted Image <n>.png\"")))?;
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
|
||||
}
|
||||
s.services.write_file(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn paste_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.pasteMedia";
|
||||
let paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
if paths.is_empty() {
|
||||
return Err(bad(cmd, "need `paths`"));
|
||||
}
|
||||
let place = bool_p(p, "place").unwrap_or(true);
|
||||
if place && s.active_sequence().is_none() {
|
||||
return Err(EngineError::NoSequence);
|
||||
}
|
||||
let insert = bool_p(p, "insert").unwrap_or(false);
|
||||
let n0 = s.history.undo.len();
|
||||
let r = import_and_place(s, &paths, u64_p(p, "bin").map(BinId), place, insert);
|
||||
crate::clip_ops::collapse_history(s, n0, if insert { "Paste Insert" } else { "Paste" });
|
||||
r
|
||||
}
|
||||
|
||||
fn import_and_place(s: &mut Session, paths: &[String], bin: Option<BinId>, place: bool, insert: bool) -> Result<Value> {
|
||||
let mut import = json!({"paths": paths});
|
||||
if let Some(b) = bin {
|
||||
import["bin"] = json!(b.0);
|
||||
}
|
||||
let imported = s.execute("file.import", import)?;
|
||||
let items: Vec<ItemId> = imported["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect()).unwrap_or_default();
|
||||
if !place {
|
||||
return Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": []}));
|
||||
}
|
||||
let mut at = s.playhead();
|
||||
let mut clips: Vec<ClipId> = Vec::new();
|
||||
for item in &items {
|
||||
let Some(pi) = s.project.item(*item) else { continue };
|
||||
let (dur, video, audio) = (crate::commands::full_duration(s, pi), pi.has_video(), pi.has_audio());
|
||||
let mut params = json!({"item": item.0, "time": at.0, "insert": insert});
|
||||
if !insert {
|
||||
if video {
|
||||
params["track"] = json!(free_track(s, TrackKind::Video, at, at + dur)?.0);
|
||||
}
|
||||
if audio {
|
||||
params["audioTrack"] = json!(free_track(s, TrackKind::Audio, at, at + dur)?.0);
|
||||
}
|
||||
}
|
||||
let placed = s.execute("timeline.place", params)?;
|
||||
let ids: Vec<ClipId> = placed["clips"].as_array().map(|a| a.iter().filter_map(Value::as_u64).map(ClipId).collect()).unwrap_or_default();
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
at = ids.iter().filter_map(|c| seq.find_item(*c)).map(|(_, it)| it.end()).max().unwrap_or(at + dur);
|
||||
clips.extend(ids);
|
||||
}
|
||||
// the playhead goes to the end of what was pasted, as after Paste
|
||||
s.set_playhead(at);
|
||||
s.state.selection = clips.clone();
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "clips": clips.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// The first unlocked track of `kind` from the targeted one outward with nothing in `start..end`.
|
||||
/// When every track is busy there, a new one is added (on top for video, at the bottom for audio).
|
||||
fn free_track(s: &mut Session, kind: TrackKind, start: Tick, end: Tick) -> Result<TrackId> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let tg = s.targeting();
|
||||
let (tracks, dest) = match kind {
|
||||
TrackKind::Video => (&seq.video_tracks, tg.video_dest),
|
||||
TrackKind::Audio => (&seq.audio_tracks, tg.audio_dest),
|
||||
};
|
||||
let from = dest.and_then(|d| tracks.iter().position(|t| t.id == d)).unwrap_or(0);
|
||||
let free = tracks.iter().skip(from).find(|t| !t.locked && !t.items.iter().any(|i| i.start < end && i.end() > start)).map(|t| t.id);
|
||||
if let Some(t) = free {
|
||||
return Ok(t);
|
||||
}
|
||||
let (params, key) = match kind {
|
||||
TrackKind::Video => (json!({"video": 1}), "video"),
|
||||
TrackKind::Audio => (json!({"video": 0, "audio": 1}), "audio"),
|
||||
};
|
||||
let added = s.execute("sequence.addTracks", params)?;
|
||||
added[key][0].as_u64().map(TrackId).ok_or_else(|| EngineError::Other("no track was added".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "edit.pasteMedia",
|
||||
label: "Paste Media",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r#"{"paths":[str],"insert":bool?,"place":bool=true,"bin":binId?}"#,
|
||||
enabled: always,
|
||||
run: paste_media,
|
||||
journal: true,
|
||||
}]
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
//! Pasting files and images from the system clipboard (#611).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use filmcraft_project::{BinEntry, BinId, ClipId, ItemId, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::media_test_util::tmp_dir;
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn write_png(path: &Path) -> String {
|
||||
image::RgbaImage::from_pixel(64, 36, image::Rgba([40, 120, 200, 255])).save(path).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn write_wav(path: &Path, seconds: f32) -> String {
|
||||
std::fs::write(path, crate::voiceover::write_wav_f32(&vec![0.0; (48_000.0 * seconds) as usize], 48_000)).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Every clip of the active sequence: (track kind, track index, clip id, start, end).
|
||||
fn clips(s: &Session) -> Vec<(TrackKind, usize, u64, Tick, Tick)> {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let mut v = Vec::new();
|
||||
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
v.extend(t.items.iter().map(|c| (kind, i, c.id.0, c.start, c.end())));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn pasted(r: &Value) -> Vec<u64> {
|
||||
r["clips"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// An image pasted over footage goes on the first video track that is free there, from the
|
||||
/// targeted one up; every clip already on the Timeline stays as it was. One undo step takes the
|
||||
/// clip and the imported item back; the playhead moves to its end, as after Paste.
|
||||
#[test]
|
||||
fn a_pasted_image_sits_above_the_footage_without_overwriting_it() {
|
||||
let dir = tmp_dir("paste-media-image");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("shot.png"));
|
||||
let at = s.playhead();
|
||||
let (before, items, undo) = (clips(&s), s.project.items.len(), s.history.undo.len());
|
||||
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 == 0 && c.3 <= at && c.4 > at), "the playhead is over a V1 clip");
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png]})).unwrap();
|
||||
let ids = pasted(&r);
|
||||
assert_eq!(ids.len(), 1, "a still has picture only");
|
||||
let after = clips(&s);
|
||||
for c in &before {
|
||||
assert!(after.contains(c), "{c:?} is untouched");
|
||||
}
|
||||
let new = *after.iter().find(|c| c.2 == ids[0]).unwrap();
|
||||
assert_eq!((new.0, new.3), (TrackKind::Video, at));
|
||||
assert!(new.1 > 0, "not on V1, where the footage is");
|
||||
assert!(!before.iter().any(|c| c.0 == TrackKind::Video && c.1 == new.1 && c.3 < new.4 && c.4 > new.3), "on a track that was free there");
|
||||
assert!(before.iter().any(|c| c.0 == TrackKind::Video && c.1 < new.1 && c.3 < new.4 && c.4 > new.3), "so it sits above a clip");
|
||||
assert_eq!(new.4 - new.3, s.prefs.timeline.still_duration(s.sequence_rate()), "for the still image default duration");
|
||||
assert_eq!(s.playhead(), new.4);
|
||||
assert_eq!(s.state.selection, vec![ClipId(ids[0])]);
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.undo().as_deref(), Some("Paste"));
|
||||
assert_eq!((clips(&s), s.project.items.len()), (before, items), "undo takes back the clip and the import");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// With every video track busy at the playhead a new one is added on top; undo removes it too.
|
||||
/// Audio goes on the first free audio track from the targeted one down, or a new one at the bottom.
|
||||
#[test]
|
||||
fn a_track_is_added_when_every_track_is_busy() {
|
||||
let dir = tmp_dir("paste-media-tracks");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("a.png"));
|
||||
let wav = write_wav(&dir.join("b.wav"), 2.0);
|
||||
let at = s.playhead();
|
||||
// fill every track at the playhead
|
||||
for _ in 0..8 {
|
||||
let q = s.active_sequence().unwrap();
|
||||
let busy = |ts: &[filmcraft_project::Track]| ts.iter().all(|t| t.items.iter().any(|c| c.start <= at && c.end() > at + Tick::from_seconds_f64(5.0)));
|
||||
if busy(&q.video_tracks) && busy(&q.audio_tracks) {
|
||||
break;
|
||||
}
|
||||
s.set_playhead(at);
|
||||
s.execute("edit.pasteMedia", json!({"paths": [png.clone(), wav.clone()]})).unwrap();
|
||||
}
|
||||
let (nv, na) = {
|
||||
let q = s.active_sequence().unwrap();
|
||||
(q.video_tracks.len(), q.audio_tracks.len())
|
||||
};
|
||||
let before = clips(&s);
|
||||
s.set_playhead(at);
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png, wav]})).unwrap();
|
||||
let ids = pasted(&r);
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1), "one new track of each kind");
|
||||
let after = clips(&s);
|
||||
let image = after.iter().find(|c| c.2 == ids[0]).unwrap();
|
||||
assert_eq!((image.0, image.1), (TrackKind::Video, nv), "the image on the new top track");
|
||||
let sound = after.iter().find(|c| c.2 == ids[1]).unwrap();
|
||||
assert_eq!((sound.0, sound.1, sound.3), (TrackKind::Audio, na, image.4), "the sound on the new bottom track, after the image");
|
||||
for c in &before {
|
||||
assert!(after.contains(c), "{c:?} is untouched");
|
||||
}
|
||||
s.undo();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv, na), "undo removes the tracks it added");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `insert` is Paste Insert: on the targeted video track, pushing later clips right.
|
||||
#[test]
|
||||
fn paste_insert_inserts_on_the_targeted_track() {
|
||||
let dir = tmp_dir("paste-media-insert");
|
||||
let mut s = demo();
|
||||
for t in ["V2", "V3", "A2", "A3"] {
|
||||
let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false}));
|
||||
}
|
||||
let png = write_png(&dir.join("i.png"));
|
||||
let at = s.playhead();
|
||||
let last = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap();
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "insert": true})).unwrap();
|
||||
let new = *clips(&s).iter().find(|c| c.2 == pasted(&r)[0]).unwrap();
|
||||
let still = s.prefs.timeline.still_duration(s.sequence_rate());
|
||||
assert_eq!((new.0, new.1, new.3), (TrackKind::Video, 0, at), "on the targeted track, V1");
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.end()).max().unwrap(), last + still, "later clips moved right");
|
||||
assert_eq!(s.undo().as_deref(), Some("Paste Insert"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without `place` (the Project panel) the files are only imported, into `bin`.
|
||||
#[test]
|
||||
fn without_place_files_are_only_imported() {
|
||||
let dir = tmp_dir("paste-media-import");
|
||||
let mut s = demo();
|
||||
let png = write_png(&dir.join("p.png"));
|
||||
let bin = s.execute("file.newBin", json!({"name": "Pasted"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let before = clips(&s);
|
||||
let r = s.execute("edit.pasteMedia", json!({"paths": [png], "place": false, "bin": bin})).unwrap();
|
||||
assert_eq!(clips(&s), before);
|
||||
let item = ItemId(r["items"][0].as_u64().unwrap());
|
||||
let pasted_bin = s.project.root.find_bin(BinId(bin)).unwrap();
|
||||
assert!(pasted_bin.children.iter().any(|e| matches!(e, BinEntry::Item(i) if *i == item)), "imported into the bin");
|
||||
assert!(s.execute("edit.pasteMedia", json!({"paths": []})).is_err(), "nothing to paste");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A pasted image is written as `Pasted Image <n>.png` next to the saved project, with the first
|
||||
/// number no file or project item uses.
|
||||
#[test]
|
||||
fn pasted_images_are_saved_next_to_the_project() {
|
||||
let dir = tmp_dir("paste-media-save");
|
||||
let mut s = demo();
|
||||
s.path = Some(dir.join("film.fcproj").to_string_lossy().into_owned());
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let first = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
|
||||
assert_eq!(Path::new(&first), dir.join("Pasted Image 1.png"));
|
||||
assert_eq!(std::fs::read(&first).unwrap(), png);
|
||||
let second = crate::paste_media::save_pasted_image(&mut s, &png).unwrap();
|
||||
assert_eq!(Path::new(&second), dir.join("Pasted Image 2.png"));
|
||||
s.execute("edit.pasteMedia", json!({"paths": [second]})).unwrap();
|
||||
assert!(s.project.items.values().any(|i| i.name == "Pasted Image 2.png"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -365,12 +365,24 @@ pub fn instantiate(p: &EffectPreset, target: &TrackItem, size: (u32, u32)) -> Ve
|
|||
out
|
||||
}
|
||||
|
||||
/// Put preset effects on a clip: intrinsic ones replace the clip's instance, others are inserted
|
||||
/// before the intrinsic effects.
|
||||
/// Keep `old`'s keyframes that lie outside the span `new` animates, so presets keyframing the same
|
||||
/// parameter at different times (Fade In, then Fade Out) add up instead of replacing each other.
|
||||
fn merge_keyframes(new: &mut EffectInstance, old: &EffectInstance) {
|
||||
for (id, p) in new.params.iter_mut() {
|
||||
let (Some(first), Some(last)) = (p.keyframes.first().map(|k| k.time), p.keyframes.last().map(|k| k.time)) else { continue };
|
||||
let Some(o) = old.params.get(id) else { continue };
|
||||
p.keyframes.extend(o.keyframes.iter().filter(|k| k.time < first || k.time > last).cloned());
|
||||
p.keyframes.sort_by_key(|k| k.time);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put preset effects on a clip: intrinsic ones replace the clip's instance (keeping its keyframes
|
||||
/// outside the span the preset animates), others are inserted before the intrinsic effects.
|
||||
pub fn put_effects(it: &mut TrackItem, effects: Vec<EffectInstance>) {
|
||||
for e in effects {
|
||||
for mut e in effects {
|
||||
let intrinsic = e.def().is_some_and(|d| d.intrinsic);
|
||||
if intrinsic && let Some(slot) = it.effects.iter_mut().find(|x| x.effect == e.effect) {
|
||||
merge_keyframes(&mut e, slot);
|
||||
*slot = e;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,25 @@ fn builtins_apply_and_user_presets_persist() {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #333: Fade In then Fade Out on one clip keeps both ramps (they keyframe the same Opacity).
|
||||
#[test]
|
||||
fn fade_in_and_fade_out_add_up_on_one_clip() {
|
||||
let (mut s, clips) = demo();
|
||||
let c = clips[0];
|
||||
s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap();
|
||||
s.execute("presets.apply", json!({"preset": "Fade Out", "clips": [c.0]})).unwrap();
|
||||
let it = item(&s, c);
|
||||
let op = it.effect("opacity").unwrap().param("opacity").unwrap();
|
||||
let keys: Vec<(Tick, ParamValue)> = op.keyframes.iter().map(|k| (k.time, k.value.clone())).collect();
|
||||
let (tin, tout) = (it.source_in, it.source_in + media_len(&it));
|
||||
let f = ParamValue::Float;
|
||||
assert_eq!(keys, vec![(tin, f(0.0)), (tin + secs(1.0), f(100.0)), (tout - secs(1.0), f(100.0)), (tout, f(0.0))], "both fades survive");
|
||||
// re-applying Fade In replaces its own ramp, not the Fade Out one
|
||||
s.execute("presets.apply", json!({"preset": "Fade In", "clips": [c.0]})).unwrap();
|
||||
let it = item(&s, c);
|
||||
assert_eq!(it.effect("opacity").unwrap().param("opacity").unwrap().keyframes.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_geometry_scales_to_the_target_frame_size() {
|
||||
let (s, clips) = demo();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@
|
|||
//!
|
||||
//! Segments, content hashes and the yellow/red cost estimate come from
|
||||
//! [`filmcraft_render::preview`]. A rendered segment is a ProRes 422 QuickTime file named
|
||||
//! `<hash>.mov` in the project's preview cache directory:
|
||||
//! `<hash>.mov` in the project's preview cache directory, and a rendered part of a segment (In/Out
|
||||
//! inside it: Render In to Out renders only In to Out, like Premiere) is `<hash>-<off>-<len>.mov`,
|
||||
//! frames `off..off + len` counted from the segment start. Because the offsets are relative to the
|
||||
//! segment, a ripple edit that moves the segment keeps its parts, as it keeps whole previews. The
|
||||
//! render bar is green over the rendered frames only; later renders fill the gaps.
|
||||
//!
|
||||
//! The folder:
|
||||
//!
|
||||
//! * saved project `/path/Film.fcproj` → `/path/FilmCraft Previews/Film/`;
|
||||
//! * unsaved project → a per-process folder in the system temp dir; its files move into the
|
||||
|
|
@ -19,7 +25,7 @@
|
|||
|
||||
use filmcraft_audio_dsp::channels::{Layout, Mixdown};
|
||||
use filmcraft_render::audio::to_layout;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
|
@ -28,7 +34,7 @@ use filmcraft_frame::{AudioBuffer, VideoFrame};
|
|||
use filmcraft_media::{FrameRequest, SharedSource};
|
||||
use filmcraft_project::{ItemId, Project};
|
||||
use filmcraft_render::preview::{AudioSegment, Need, Segment, segment_at, video_segments};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -73,9 +79,11 @@ pub struct PreviewStore {
|
|||
dir: RwLock<Option<PathBuf>>,
|
||||
/// Where unsaved projects' previews go (Settings ▸ Media Cache); None = the system temp dir.
|
||||
temp_root: RwLock<Option<PathBuf>>,
|
||||
/// File names present: `<hash>.mov` (video) and `<hash>.wav` (audio).
|
||||
/// File names present: `<hash>.mov` / `<hash>-<off>-<len>.mov` (video) and `<hash>.wav` (audio).
|
||||
files: RwLock<HashSet<String>>,
|
||||
/// Opened preview files (most recent last).
|
||||
/// The video files of `files` by segment hash (rebuilt whenever `files` changes).
|
||||
video: RwLock<HashMap<String, Vec<Part>>>,
|
||||
/// Opened preview files by file name (most recent last).
|
||||
sources: Mutex<Vec<(String, SharedSource)>>,
|
||||
/// Loaded audio previews: interleaved stereo f32 (most recent last).
|
||||
audio: Mutex<Vec<(String, Arc<Vec<f32>>)>>,
|
||||
|
|
@ -90,6 +98,72 @@ pub struct PreviewStore {
|
|||
fn video_name(hash: &str) -> String {
|
||||
format!("{hash}.mov")
|
||||
}
|
||||
|
||||
/// A rendered stretch of one segment: frames `off..off + len` counted from the segment start, in
|
||||
/// file `name`. A whole-segment preview has `off` 0 and `len` [`WHOLE`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Part {
|
||||
off: i64,
|
||||
len: i64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
const WHOLE: i64 = i64::MAX;
|
||||
|
||||
/// File name of the preview of frames `off..off + len` of a segment of `frames` frames.
|
||||
fn part_name(hash: &str, off: i64, len: i64, frames: i64) -> String {
|
||||
if off == 0 && len >= frames { video_name(hash) } else { format!("{hash}-{off}-{len}.mov") }
|
||||
}
|
||||
|
||||
/// `(hash, off, len)` of a video preview file name (`<hash>.mov` or `<hash>-<off>-<len>.mov`).
|
||||
fn parse_video_name(name: &str) -> Option<(&str, i64, i64)> {
|
||||
let stem = name.strip_suffix(".mov")?;
|
||||
if is_hash(stem) {
|
||||
return Some((stem, 0, WHOLE));
|
||||
}
|
||||
let mut it = stem.split('-');
|
||||
let (hash, off, len) = (it.next()?, it.next()?, it.next()?);
|
||||
if it.next().is_some() || !is_hash(hash) {
|
||||
return None;
|
||||
}
|
||||
let (off, len) = (off.parse::<u32>().ok()?, len.parse::<u32>().ok()?);
|
||||
(len > 0).then_some((hash, i64::from(off), i64::from(len)))
|
||||
}
|
||||
|
||||
/// The parts of `[a, b)` not covered by the sorted, merged intervals `cov`.
|
||||
fn gaps(cov: &[(i64, i64)], a: i64, b: i64) -> Vec<(i64, i64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = a;
|
||||
for &(x, y) in cov {
|
||||
if y <= pos {
|
||||
continue;
|
||||
}
|
||||
if x >= b {
|
||||
break;
|
||||
}
|
||||
if x > pos {
|
||||
out.push((pos, x));
|
||||
}
|
||||
pos = pos.max(y);
|
||||
}
|
||||
if pos < b {
|
||||
out.push((pos, b));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// First frame whose start is at or after `t`.
|
||||
fn frame_ceil(rate: FrameRate, t: Tick) -> i64 {
|
||||
let f = rate.frame_at(t);
|
||||
if rate.tick_of(f) < t { f.saturating_add(1) } else { f }
|
||||
}
|
||||
|
||||
/// Frames of In/Out (`range`) inside `seg`, counted from the segment start (empty when apart).
|
||||
fn rel_range(seg: &Segment, rate: FrameRate, range: &TimeRange) -> (i64, i64) {
|
||||
let a = rate.frame_at(range.start).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0));
|
||||
let b = frame_ceil(rate, range.end()).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0));
|
||||
(a, b.max(a))
|
||||
}
|
||||
fn audio_name(hash: &str) -> String {
|
||||
format!("{hash}.wav")
|
||||
}
|
||||
|
|
@ -132,15 +206,14 @@ impl PreviewStore {
|
|||
{
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if let Some(h) = name.strip_suffix(".mov").or_else(|| name.strip_suffix(".wav"))
|
||||
&& is_hash(h)
|
||||
{
|
||||
if parse_video_name(&name).is_some() || name.strip_suffix(".wav").is_some_and(is_hash) {
|
||||
files.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.dir.write().unwrap_or_else(|e| e.into_inner()) = dir;
|
||||
*self.files.write().unwrap_or_else(|e| e.into_inner()) = files;
|
||||
self.reindex();
|
||||
self.sources.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.audio.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.bump();
|
||||
|
|
@ -166,9 +239,47 @@ impl PreviewStore {
|
|||
self.set_dir(Some(dir));
|
||||
}
|
||||
|
||||
/// Whether the video preview of segment `hash` exists.
|
||||
pub fn has(&self, hash: &str) -> bool {
|
||||
self.files.read().unwrap_or_else(|e| e.into_inner()).contains(&video_name(hash))
|
||||
/// Rebuild the by-hash index of the video previews from the file names.
|
||||
fn reindex(&self) {
|
||||
let mut video: HashMap<String, Vec<Part>> = HashMap::new();
|
||||
for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
if let Some((hash, off, len)) = parse_video_name(name) {
|
||||
video.entry(hash.to_string()).or_default().push(Part { off, len, name: name.clone() });
|
||||
}
|
||||
}
|
||||
*self.video.write().unwrap_or_else(|e| e.into_inner()) = video;
|
||||
}
|
||||
|
||||
/// The rendered frames of `seg`, counted from its start: sorted, merged, within the segment.
|
||||
pub fn coverage(&self, seg: &Segment) -> Vec<(i64, i64)> {
|
||||
let video = self.video.read().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(parts) = video.get(&seg.hash) else { return Vec::new() };
|
||||
let n = seg.frames.max(0);
|
||||
let mut iv: Vec<(i64, i64)> = parts.iter().map(|p| (p.off.clamp(0, n), p.off.saturating_add(p.len).clamp(0, n))).filter(|(a, b)| a < b).collect();
|
||||
iv.sort_unstable();
|
||||
let mut out: Vec<(i64, i64)> = Vec::new();
|
||||
for (a, b) in iv {
|
||||
match out.last_mut() {
|
||||
Some(l) if a <= l.1 => l.1 = l.1.max(b),
|
||||
_ => out.push((a, b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether previews cover every frame of `seg`.
|
||||
pub fn is_rendered(&self, seg: &Segment) -> bool {
|
||||
matches!(self.coverage(seg).as_slice(), [(0, b)] if *b == seg.frames)
|
||||
}
|
||||
|
||||
/// Names of the video preview files of `seg` that hold frames of `a..b` (from the segment start).
|
||||
fn parts_in(&self, seg: &Segment, a: i64, b: i64) -> Vec<String> {
|
||||
if a >= b {
|
||||
return Vec::new();
|
||||
}
|
||||
let video = self.video.read().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(parts) = video.get(&seg.hash) else { return Vec::new() };
|
||||
parts.iter().filter(|p| p.off < b && a < p.off.saturating_add(p.len)).map(|p| p.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether the audio preview of audio segment `hash` exists.
|
||||
|
|
@ -181,17 +292,14 @@ impl PreviewStore {
|
|||
self.files.read().unwrap_or_else(|e| e.into_inner()).len()
|
||||
}
|
||||
|
||||
pub fn path_for(&self, hash: &str) -> Option<PathBuf> {
|
||||
self.dir().map(|d| d.join(video_name(hash)))
|
||||
}
|
||||
|
||||
pub fn audio_path_for(&self, hash: &str) -> Option<PathBuf> {
|
||||
self.dir().map(|d| d.join(audio_name(hash)))
|
||||
}
|
||||
|
||||
/// Register a finished video preview file.
|
||||
pub fn add(&self, hash: &str) {
|
||||
self.files.write().unwrap_or_else(|e| e.into_inner()).insert(video_name(hash));
|
||||
/// Register a finished video preview file (`<hash>.mov` or `<hash>-<off>-<len>.mov`).
|
||||
fn add_video(&self, name: &str) {
|
||||
self.files.write().unwrap_or_else(|e| e.into_inner()).insert(name.to_string());
|
||||
self.reindex();
|
||||
self.bump();
|
||||
}
|
||||
|
||||
|
|
@ -204,23 +312,39 @@ impl PreviewStore {
|
|||
/// Delete the preview files (video and audio) of `hashes`, or every file when None. Returns
|
||||
/// how many files were removed.
|
||||
pub fn delete(&self, hashes: Option<&[String]>) -> usize {
|
||||
let Some(dir) = self.dir() else { return 0 };
|
||||
let victims: Vec<String> = {
|
||||
let files = self.files.read().unwrap_or_else(|e| e.into_inner());
|
||||
match hashes {
|
||||
Some(h) => h.iter().flat_map(|h| [video_name(h), audio_name(h)]).filter(|n| files.contains(n)).collect(),
|
||||
Some(h) => files
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
let owner = parse_video_name(n).map(|(hash, _, _)| hash).or_else(|| n.strip_suffix(".wav"));
|
||||
owner.is_some_and(|o| h.iter().any(|h| h == o))
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => files.iter().cloned().collect(),
|
||||
}
|
||||
};
|
||||
let gone = |h: &String| victims.contains(&video_name(h)) || victims.contains(&audio_name(h));
|
||||
self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h));
|
||||
self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h));
|
||||
self.delete_files(&victims)
|
||||
}
|
||||
|
||||
/// Delete the preview files named `names`. Returns how many were removed.
|
||||
fn delete_files(&self, names: &[String]) -> usize {
|
||||
let Some(dir) = self.dir() else { return 0 };
|
||||
let victims: Vec<String> = {
|
||||
let files = self.files.read().unwrap_or_else(|e| e.into_inner());
|
||||
names.iter().filter(|n| files.contains(*n)).cloned().collect()
|
||||
};
|
||||
self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(n, _)| !victims.contains(n));
|
||||
self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !victims.contains(&audio_name(h)));
|
||||
let mut files = self.files.write().unwrap_or_else(|e| e.into_inner());
|
||||
for n in &victims {
|
||||
let _ = std::fs::remove_file(dir.join(n));
|
||||
files.remove(n);
|
||||
}
|
||||
drop(files);
|
||||
self.reindex();
|
||||
self.bump();
|
||||
victims.len()
|
||||
}
|
||||
|
|
@ -341,25 +465,41 @@ impl PreviewStore {
|
|||
segs
|
||||
}
|
||||
|
||||
/// Green when previews cover the whole segment, else its colour without a preview.
|
||||
pub fn state_of(&self, seg: &Segment) -> BarState {
|
||||
if self.has(&seg.hash) {
|
||||
return BarState::Green;
|
||||
}
|
||||
match seg.need {
|
||||
Need::None => BarState::None,
|
||||
Need::Realtime => BarState::Yellow,
|
||||
Need::Render => BarState::Red,
|
||||
}
|
||||
if self.is_rendered(seg) { BarState::Green } else { need_state(seg) }
|
||||
}
|
||||
|
||||
/// The render bar: one span per segment, adjacent spans of the same colour merged.
|
||||
/// The render bar: per segment, green over its rendered frames and its own colour elsewhere;
|
||||
/// adjacent spans of the same colour merged.
|
||||
pub fn bar(&self, project: &Arc<Project>, seq: ItemId) -> Vec<BarSpan> {
|
||||
let Some(rate) = project.sequence(seq).map(|q| q.settings.frame_rate) else { return Vec::new() };
|
||||
let mut out: Vec<BarSpan> = Vec::new();
|
||||
for s in self.segments(project, seq).iter() {
|
||||
let state = self.state_of(s);
|
||||
match out.last_mut() {
|
||||
Some(l) if l.end == s.start && l.state == state => l.end = s.end,
|
||||
_ => out.push(BarSpan { start: s.start, end: s.end, state }),
|
||||
let need = need_state(s);
|
||||
let mut spans = Vec::new();
|
||||
let mut pos = 0;
|
||||
for (a, b) in self.coverage(s) {
|
||||
if a > pos {
|
||||
spans.push((pos, a, need));
|
||||
}
|
||||
spans.push((a, b, BarState::Green));
|
||||
pos = b;
|
||||
}
|
||||
if pos < s.frames {
|
||||
spans.push((pos, s.frames, need));
|
||||
}
|
||||
let at = |f: i64| match f {
|
||||
0 => s.start,
|
||||
f if f >= s.frames => s.end,
|
||||
f => rate.tick_of(s.first_frame.saturating_add(f)),
|
||||
};
|
||||
for (a, b, state) in spans {
|
||||
let (start, end) = (at(a), at(b));
|
||||
match out.last_mut() {
|
||||
Some(l) if l.end == start && l.state == state => l.end = end,
|
||||
_ => out.push(BarSpan { start, end, state }),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -372,29 +512,33 @@ impl PreviewStore {
|
|||
}
|
||||
let segs = self.segments(project, seq);
|
||||
let seg = segment_at(&segs, frame)?;
|
||||
if !self.has(&seg.hash) {
|
||||
return None;
|
||||
}
|
||||
let src = self.open(pool, &seg.hash)?;
|
||||
let rel = frame.checked_sub(seg.first_frame)?;
|
||||
let (name, off) = {
|
||||
let video = self.video.read().unwrap_or_else(|e| e.into_inner());
|
||||
let p = video.get(&seg.hash)?.iter().find(|p| p.off <= rel && rel < p.off.saturating_add(p.len))?;
|
||||
(p.name.clone(), p.off)
|
||||
};
|
||||
let src = self.open(pool, &name)?;
|
||||
let rate = project.sequence(seq)?.settings.frame_rate;
|
||||
src.video_frame(FrameRequest { time: rate.tick_of(frame - seg.first_frame), scale }).ok()
|
||||
src.video_frame(FrameRequest { time: rate.tick_of(rel - off), scale }).ok()
|
||||
}
|
||||
|
||||
fn open(&self, pool: &MediaPool, hash: &str) -> Option<SharedSource> {
|
||||
/// The opened preview file `name` (`<hash>.mov` or a part).
|
||||
fn open(&self, pool: &MediaPool, name: &str) -> Option<SharedSource> {
|
||||
{
|
||||
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(i) = g.iter().position(|(h, _)| h == hash) {
|
||||
if let Some(i) = g.iter().position(|(n, _)| n == name) {
|
||||
let e = g.remove(i);
|
||||
let s = e.1.clone();
|
||||
g.push(e);
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
let path = self.path_for(hash)?;
|
||||
let path = self.dir()?.join(name);
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
let src = pool.open_bytes(&format!("{hash}.mov"), bytes.into()).ok()?;
|
||||
let src = pool.open_bytes(name, bytes.into()).ok()?;
|
||||
let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner());
|
||||
g.push((hash.to_string(), src.clone()));
|
||||
g.push((name.to_string(), src.clone()));
|
||||
if g.len() > 6 {
|
||||
g.remove(0);
|
||||
}
|
||||
|
|
@ -402,6 +546,14 @@ impl PreviewStore {
|
|||
}
|
||||
}
|
||||
|
||||
fn need_state(seg: &Segment) -> BarState {
|
||||
match seg.need {
|
||||
Need::None => BarState::None,
|
||||
Need::Realtime => BarState::Yellow,
|
||||
Need::Render => BarState::Red,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hash(s: &str) -> bool {
|
||||
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
|
@ -438,33 +590,42 @@ fn in_out(s: &Session) -> Result<(ItemId, TimeRange)> {
|
|||
Ok((id, TimeRange::from_bounds(a, b.max(a))))
|
||||
}
|
||||
|
||||
fn overlaps(seg: &Segment, r: &TimeRange) -> bool {
|
||||
seg.start < r.end() && r.start < seg.end
|
||||
/// One preview file to render: frames `off..off + len` of segment `seg` (from its start).
|
||||
struct Todo {
|
||||
seg: Segment,
|
||||
off: i64,
|
||||
len: i64,
|
||||
}
|
||||
|
||||
/// Start rendering previews as a background job. `{"wait": true}` renders synchronously.
|
||||
///
|
||||
/// The In/Out modes render only the frames between In and Out (a segment that extends past them
|
||||
/// gets a partial preview), and every mode renders only frames that have no preview yet.
|
||||
pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||
let (seq, range) = in_out(s)?;
|
||||
let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate;
|
||||
let store = s.previews.clone();
|
||||
let dir = store.dir().ok_or_else(|| EngineError::Other("render previews are not available here (no preview folder)".into()))?;
|
||||
let segs = store.segments(&s.project, seq);
|
||||
let selection: HashSet<_> = s.state.selection.iter().copied().collect();
|
||||
let todo: Vec<Segment> = segs
|
||||
.iter()
|
||||
.filter(|g| !store.has(&g.hash))
|
||||
.filter(|g| match mode {
|
||||
RenderMode::EffectsInToOut => g.need != Need::None && overlaps(g, &range),
|
||||
RenderMode::InToOut => overlaps(g, &range),
|
||||
RenderMode::Selection => g.clips.iter().any(|c| selection.contains(c)),
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut todo: Vec<Todo> = Vec::new();
|
||||
for g in segs.iter() {
|
||||
let (a, b) = match mode {
|
||||
RenderMode::EffectsInToOut if g.need == Need::None => continue,
|
||||
RenderMode::EffectsInToOut | RenderMode::InToOut => rel_range(g, rate, &range),
|
||||
RenderMode::Selection if g.clips.iter().any(|c| selection.contains(c)) => (0, g.frames.max(0)),
|
||||
RenderMode::Selection => continue,
|
||||
};
|
||||
for (off, end) in gaps(&store.coverage(g), a, b) {
|
||||
todo.push(Todo { seg: g.clone(), off, len: end - off });
|
||||
}
|
||||
}
|
||||
if todo.is_empty() {
|
||||
s.toast("Nothing to render: previews are up to date");
|
||||
return Ok(json!({"job": null, "segments": 0}));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?;
|
||||
let frames: i64 = todo.iter().map(|g| g.frames).sum();
|
||||
let frames: i64 = todo.iter().map(|t| t.len).fold(0, i64::saturating_add);
|
||||
let id = s.jobs.len() as u64 + 1;
|
||||
let job = crate::Job {
|
||||
id,
|
||||
|
|
@ -484,17 +645,19 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
|||
let mut bytes = 0u64;
|
||||
let mut done_frames = 0u64;
|
||||
let mut outcome: std::result::Result<(), String> = Ok(());
|
||||
for (k, g) in todo.iter().enumerate() {
|
||||
for (k, t) in todo.iter().enumerate() {
|
||||
if prog.cancel.load(Ordering::Relaxed) {
|
||||
outcome = Err("cancelled".into());
|
||||
break;
|
||||
}
|
||||
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("Rendering segment {} of {nseg}", k + 1);
|
||||
let part = dir.join(format!("{}.mov.part", g.hash));
|
||||
let name = part_name(&t.seg.hash, t.off, t.len, t.seg.frames);
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
let first = t.seg.first_frame.saturating_add(t.off);
|
||||
let settings = filmcraft_export::ExportSettings {
|
||||
format: filmcraft_export::Format::ProRes,
|
||||
path: part.to_string_lossy().to_string(),
|
||||
range: Some(TimeRange::from_bounds(g.start, g.end)),
|
||||
range: Some(TimeRange::from_bounds(rate.tick_of(first), rate.tick_of(first.saturating_add(t.len)))),
|
||||
scale: 1.0,
|
||||
include_audio: false,
|
||||
quality: 90,
|
||||
|
|
@ -510,14 +673,14 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
|||
Ok(r) => {
|
||||
bytes += r.bytes;
|
||||
done_frames += r.frames;
|
||||
let fin = dir.join(format!("{}.mov", g.hash));
|
||||
let fin = dir.join(&name);
|
||||
if let Err(e) = std::fs::rename(&part, &fin) {
|
||||
outcome = Err(e.to_string());
|
||||
break;
|
||||
}
|
||||
store.add(&g.hash);
|
||||
store.add_video(&name);
|
||||
// open it now so the first playback doesn't wait for the file read
|
||||
let _ = store.open(&pool, &g.hash);
|
||||
let _ = store.open(&pool, &name);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
|
|
@ -692,8 +855,18 @@ pub fn read_wav_f32(b: &[u8]) -> Option<Vec<f32>> {
|
|||
pub fn delete(s: &mut Session, in_to_out: bool) -> Result<Value> {
|
||||
let n = if in_to_out {
|
||||
let (seq, range) = in_out(s)?;
|
||||
let hashes: Vec<String> = s.previews.segments(&s.project, seq).iter().filter(|g| overlaps(g, &range)).map(|g| g.hash.clone()).collect();
|
||||
s.previews.delete(Some(&hashes))
|
||||
let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate;
|
||||
// the files holding frames between In and Out (a partial preview outside them stays)
|
||||
let names: Vec<String> = s
|
||||
.previews
|
||||
.segments(&s.project, seq)
|
||||
.iter()
|
||||
.flat_map(|g| {
|
||||
let (a, b) = rel_range(g, rate, &range);
|
||||
s.previews.parts_in(g, a, b)
|
||||
})
|
||||
.collect();
|
||||
s.previews.delete_files(&names)
|
||||
} else {
|
||||
s.previews.delete(None)
|
||||
};
|
||||
|
|
@ -717,6 +890,7 @@ pub fn bar_json(s: &Session) -> Result<Value> {
|
|||
"startSeconds": g.start.seconds(),
|
||||
"endSeconds": g.end.seconds(),
|
||||
"state": s.previews.state_of(g),
|
||||
"renderedFrames": s.previews.coverage(g).iter().map(|(a, b)| b - a).sum::<i64>(),
|
||||
"costMs": (g.cost_ms * 10.0).round() / 10.0,
|
||||
"budgetMs": (rate.frame_duration().seconds() * 1000.0 * filmcraft_render::preview::REALTIME_BUDGET * 10.0).round() / 10.0,
|
||||
"hash": g.hash,
|
||||
|
|
|
|||
|
|
@ -19,18 +19,16 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
|||
|
||||
/// A 64×36 24 fps sequence: V1 = matte with Tint (0..24) + plain matte (24..48), A1 = tone (0..48).
|
||||
fn session(name: &str) -> (Session, ClipId) {
|
||||
session_with(name, Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] })
|
||||
}
|
||||
|
||||
/// [`session`] with `video` in place of the matte on V1.
|
||||
fn session_with(name: &str, video: Generator) -> (Session, ClipId) {
|
||||
let mut s = Session::default();
|
||||
s.previews.set_dir(Some(tmp(name)));
|
||||
let r = FrameRate::FPS_24;
|
||||
let mut p = (*s.project).clone();
|
||||
let matte = crate::demo::add_generator(
|
||||
&mut p,
|
||||
&s.media,
|
||||
GeneratorSource::new(Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }, 64, 36, r, Tick(10 * TICKS_PER_SECOND)),
|
||||
"Matte",
|
||||
Label::Iris,
|
||||
None,
|
||||
);
|
||||
let matte = crate::demo::add_generator(&mut p, &s.media, GeneratorSource::new(video, 64, 36, r, Tick(10 * TICKS_PER_SECOND)), "Matte", Label::Iris, None);
|
||||
let tone = crate::demo::add_generator(
|
||||
&mut p,
|
||||
&s.media,
|
||||
|
|
@ -135,6 +133,79 @@ fn render_turns_green_edit_invalidates_and_undo_restores() {
|
|||
let _ = std::fs::remove_dir_all(s.previews.dir().unwrap());
|
||||
}
|
||||
|
||||
fn max_diff(a: &[u8], b: &[u8]) -> i32 {
|
||||
a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap()
|
||||
}
|
||||
|
||||
fn set_marks(s: &mut Session, marks: Option<(i64, i64)>) {
|
||||
let r = FrameRate::FPS_24;
|
||||
s.edit_sequence("marks", |q, _, _| {
|
||||
q.mark_in = marks.map(|m| r.tick_of(m.0));
|
||||
q.mark_out = marks.map(|m| r.tick_of(m.1));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Render-bar spans as (first frame, end frame, state).
|
||||
fn spans(s: &Session) -> Vec<(i64, i64, BarState)> {
|
||||
let r = FrameRate::FPS_24;
|
||||
s.previews.bar(&s.project, s.state.active_sequence.unwrap()).iter().map(|b| (r.frame_at(b.start), r.frame_at(b.end), b.state)).collect()
|
||||
}
|
||||
|
||||
/// #424: Render Effects In to Out renders the frames between In and Out, not every segment that
|
||||
/// In/Out touches; a later render fills only the gaps, and Delete Render Files In to Out removes
|
||||
/// only the files holding marked frames.
|
||||
#[test]
|
||||
fn render_in_to_out_renders_only_the_marked_frames() {
|
||||
use BarState::{Green, None as NoBar, Yellow};
|
||||
let (mut s, _) = session_with("range", Generator::CountingLeader);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
set_marks(&mut s, Some((6, 11)));
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
assert_eq!(v["frames"], 6, "frames 6..=11, not the whole 24-frame segment: {v}");
|
||||
assert_eq!(spans(&s), vec![(0, 6, Yellow), (6, 12, Green), (12, 24, Yellow), (24, 48, NoBar)]);
|
||||
assert_eq!(states(&s), vec!["yellow", "none"], "a partly rendered segment is not green");
|
||||
assert_eq!(crate::previews::bar_json(&s).unwrap()["segments"][0]["renderedFrames"], 6);
|
||||
for f in [5, 12, 30] {
|
||||
assert!(s.previews.frame(&s.media, &s.project, seq, f, 1.0).is_none(), "frame {f} has no preview");
|
||||
}
|
||||
// the preview's frames line up with the sequence (the leader changes from frame to frame)
|
||||
let live = |s: &Session, f: i64| {
|
||||
filmcraft_render::render_sequence(
|
||||
&s.project,
|
||||
seq,
|
||||
FrameRate::FPS_24.tick_of(f),
|
||||
Default::default(),
|
||||
&s.media.provider(s.project.clone(), s.services.clone()),
|
||||
)
|
||||
.over_black_rgba8()
|
||||
};
|
||||
let prev = s.previews.frame(&s.media, &s.project, seq, 9, 1.0).expect("frame 9 has a preview").to_rgba8();
|
||||
assert!(max_diff(&live(&s, 9), &live(&s, 3)) > 16, "the test needs frames that differ");
|
||||
let d = max_diff(&live(&s, 9), &prev);
|
||||
assert!(d <= 4, "preview of frame 9 differs from the live frame 9 by {d}");
|
||||
// the files survive re-indexing the folder (as on reopening the project)
|
||||
s.previews.set_dir(s.previews.dir());
|
||||
assert_eq!(spans(&s)[1], (6, 12, Green));
|
||||
// In/Out over the whole sequence: only the two gaps around the part are rendered
|
||||
set_marks(&mut s, None);
|
||||
let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
|
||||
job_ok(&s, &v);
|
||||
assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(2), Some(18)), "{v}");
|
||||
assert_eq!(states(&s), vec!["green", "none"]);
|
||||
assert_eq!(s.previews.count(), 3);
|
||||
let prev = s.previews.frame(&s.media, &s.project, seq, 20, 1.0).expect("frame 20 has a preview").to_rgba8();
|
||||
assert!(max_diff(&live(&s, 20), &prev) <= 4);
|
||||
// Delete Render Files In to Out keeps the parts outside In/Out
|
||||
set_marks(&mut s, Some((7, 8)));
|
||||
let v = s.execute("sequence.deleteRenderFilesInToOut", json!({})).unwrap();
|
||||
assert_eq!(v["deleted"], 1);
|
||||
assert_eq!(spans(&s), vec![(0, 6, Green), (6, 12, Yellow), (12, 24, Green), (24, 48, NoBar)]);
|
||||
let _ = std::fs::remove_dir_all(s.previews.dir().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_survive_save_and_open() {
|
||||
let (mut s, _) = session("save");
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
//! | `project.freeform.saveArrangement` / `.restoreArrangement` / `.arrangements` / `.deleteArrangement` | saved arrangements |
|
||||
//! | `project.freeform.options` | Freeform View Options… (grid, snap, names, durations) |
|
||||
//! | `project.renameBin` | rename a bin (inline rename, undoable) |
|
||||
//! | `project.selectAll` / `project.deselectAll` | select every item in the project / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) |
|
||||
//! | `project.selectAll` / `project.deselectAll` | select the items a bin shows (`bin`, plus the sub-bins open in List view in `expanded`; without `bin` every item in the project) / clear the Project panel selection (Cmd+A / Cmd+Shift+A with the panel focused) |
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use filmcraft_project::{Bin, BinEntry, BinId, ItemId, ItemKind, MediaRef, Project, ProjectItem, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeDisplay, format_time};
|
||||
|
|
@ -1171,6 +1171,43 @@ fn rename_bin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"bin": bin.0, "name": name}))
|
||||
}
|
||||
|
||||
/// Select All in the Project panel. With `bin`, only what a panel showing that bin lists: its
|
||||
/// items, plus the items of the sub-bins named in `expanded` (bins twirled open in List view),
|
||||
/// never those of a closed bin. Without `bin`, every item in the project.
|
||||
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "project.selectAll";
|
||||
// strict: a negative or fractional `bin` is an error, not bin 0
|
||||
let id = match p.get("bin") {
|
||||
None | Some(Value::Null) => {
|
||||
s.state.project_selection = s.project.items.keys().copied().collect();
|
||||
return Ok(json!({"selected": s.state.project_selection.len()}));
|
||||
}
|
||||
Some(b) => BinId(b.as_u64().ok_or_else(|| bad(CMD, "`bin` must be a bin id"))?),
|
||||
};
|
||||
let bin = s.project.root.find_bin(id).ok_or_else(|| bad(CMD, format!("no bin {}", id.0)))?;
|
||||
let expanded: BTreeSet<BinId> = match p.get("expanded") {
|
||||
None | Some(Value::Null) => Default::default(),
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().map(|v| v.as_u64().map(BinId).ok_or_else(|| bad(CMD, "`expanded` must be a list of bin ids"))).collect::<Result<_>>()?
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`expanded` must be a list of bin ids")),
|
||||
};
|
||||
// iterative walk: a hostile project file cannot overflow the stack with deeply nested bins
|
||||
let mut sel = Vec::new();
|
||||
let mut stack = vec![bin];
|
||||
while let Some(b) = stack.pop() {
|
||||
for e in &b.children {
|
||||
match e {
|
||||
BinEntry::Item(i) if s.project.item(*i).is_some_and(|it| listed(&s.project, it)) => sel.push(*i),
|
||||
BinEntry::Bin(sub) if expanded.contains(&sub.id) => stack.push(sub),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.project_selection = sel;
|
||||
Ok(json!({"bin": id.0, "selected": s.state.project_selection.len()}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------ registry
|
||||
|
||||
pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
|
|
@ -1223,10 +1260,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
freeform_options,
|
||||
),
|
||||
c("project.renameBin", "Rename Bin", r#"{"bin":binId,"name":str}"#, always, rename_bin),
|
||||
c("project.selectAll", "Select All Project Items", "{}", always, |s, _| {
|
||||
s.state.project_selection = s.project.items.keys().copied().collect();
|
||||
Ok(json!({"selected": s.state.project_selection.len()}))
|
||||
}),
|
||||
c("project.selectAll", "Select All Project Items", r#"{"bin":binId?,"expanded":[binId]?}"#, always, select_all),
|
||||
c("project.deselectAll", "Deselect All Project Items", "{}", always, |s, _| {
|
||||
s.state.project_selection.clear();
|
||||
Ok(Value::Null)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! sorting, view presets, Freeform positions / stacks / arrangements (undoable, saved with the
|
||||
//! project) and bin renames.
|
||||
|
||||
use filmcraft_project::{ItemId, ItemKind};
|
||||
use filmcraft_project::{BinEntry, ItemId, ItemKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
|
@ -305,3 +305,75 @@ fn select_all_selects_every_project_item() {
|
|||
assert!(!s.state.selection.is_empty());
|
||||
assert_eq!(s.state.project_selection, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_in_a_bin_skips_closed_bins_and_other_bins() {
|
||||
// #456: Cmd+A in the Project panel selected every item in the project, closed bins included
|
||||
let mut s = demo();
|
||||
let outer = s.execute("file.newBin", json!({"name": "Footage A"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let inner = s.execute("file.newBin", json!({"name": "Day 1", "parent": outer})).unwrap()["bin"].as_u64().unwrap();
|
||||
let ids = media(&s);
|
||||
assert!(ids.len() >= 2);
|
||||
s.execute("project.moveToBin", json!({"items": [ids[0].0], "bin": outer})).unwrap();
|
||||
s.execute("project.moveToBin", json!({"items": [ids[1].0], "bin": inner})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
|
||||
// only the bin's own items: the closed sub-bin's item stays unselected
|
||||
let r = s.execute("project.selectAll", json!({"bin": outer})).unwrap();
|
||||
assert_eq!(r["selected"].as_u64(), Some(1));
|
||||
assert_eq!(s.state.project_selection, vec![ids[0]]);
|
||||
|
||||
// a sub-bin twirled open in List view counts
|
||||
s.execute("project.selectAll", json!({"bin": outer, "expanded": [inner]})).unwrap();
|
||||
let mut sel = s.state.project_selection.clone();
|
||||
sel.sort();
|
||||
assert_eq!(sel, vec![ids[0], ids[1]]);
|
||||
|
||||
// the root: its direct items only, nothing from the (closed) bins
|
||||
let root = s.project.root.id;
|
||||
let mut want: Vec<ItemId> = s
|
||||
.project
|
||||
.root
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|e| if let BinEntry::Item(i) = e { Some(*i) } else { None })
|
||||
.filter(|i| s.project.item(*i).is_some_and(|it| project_panel::listed(&s.project, it)))
|
||||
.collect();
|
||||
want.sort();
|
||||
s.execute("project.selectAll", json!({"bin": root.0})).unwrap();
|
||||
let mut sel = s.state.project_selection.clone();
|
||||
sel.sort();
|
||||
assert_eq!(sel, want);
|
||||
assert!(!sel.contains(&ids[0]) && !sel.contains(&ids[1]));
|
||||
assert!(sel.len() < s.project.items.len());
|
||||
|
||||
// an open bin inside a closed one is not shown, so it does not count either
|
||||
s.execute("project.selectAll", json!({"bin": root.0, "expanded": [inner]})).unwrap();
|
||||
assert!(!s.state.project_selection.contains(&ids[1]));
|
||||
// ... but open all the way down, it does
|
||||
s.execute("project.selectAll", json!({"bin": root.0, "expanded": [outer, inner]})).unwrap();
|
||||
assert!(s.state.project_selection.contains(&ids[0]) && s.state.project_selection.contains(&ids[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_rejects_bad_bins_without_touching_the_selection() {
|
||||
let mut s = demo();
|
||||
let ids = media(&s);
|
||||
let root = s.project.root.id.0;
|
||||
s.execute("project.select", json!({"items": [ids[0].0]})).unwrap();
|
||||
for p in [
|
||||
json!({"bin": 987_654_321u64}),
|
||||
json!({"bin": "footage"}),
|
||||
json!({"bin": -1}),
|
||||
json!({"bin": root, "expanded": "all"}),
|
||||
json!({"bin": root, "expanded": ["x"]}),
|
||||
] {
|
||||
assert!(s.execute("project.selectAll", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(s.state.project_selection, vec![ids[0]], "{p}");
|
||||
}
|
||||
// unknown ids in `expanded` are simply not open bins
|
||||
assert!(s.execute("project.selectAll", json!({"bin": root, "expanded": [987_654_321u64]})).is_ok());
|
||||
// `bin: null` is the same as no bin: the whole project
|
||||
let r = s.execute("project.selectAll", json!({"bin": null})).unwrap();
|
||||
assert_eq!(r["selected"].as_u64(), Some(s.project.items.len() as u64));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
//! | `clip.automateToSequence` | Clip ▸ Automate to Sequence… |
|
||||
//! | `help.systemReport` | query behind Help ▸ System Compatibility Report… |
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind};
|
||||
use filmcraft_project::{BinEntry, 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};
|
||||
|
|
@ -704,18 +704,42 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// 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> {
|
||||
// Index the bin tree once. Told apart from items with a set membership test (rather than a
|
||||
// full `find_bin` per queued id, which is O(n²) when exporting many items from a flat
|
||||
// project) and expanded from the indexed children (rather than a second walk per bin).
|
||||
let mut bins: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut children: BTreeMap<u64, (Vec<ItemId>, Vec<u64>)> = BTreeMap::new();
|
||||
let mut bin_stack = vec![&p.root];
|
||||
while let Some(b) = bin_stack.pop() {
|
||||
bins.insert(b.id.0);
|
||||
let mut direct = (Vec::new(), Vec::new());
|
||||
for c in &b.children {
|
||||
match c {
|
||||
BinEntry::Item(i) => direct.0.push(*i),
|
||||
BinEntry::Bin(x) => {
|
||||
direct.1.push(x.id.0);
|
||||
bin_stack.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
children.insert(b.id.0, direct);
|
||||
}
|
||||
|
||||
let mut keep: BTreeSet<ItemId> = BTreeSet::new();
|
||||
let mut todo: Vec<ItemId> = roots.to_vec();
|
||||
let mut seen_bins: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut bin_todo: Vec<u64> = Vec::new();
|
||||
while let Some(id) = todo.pop() {
|
||||
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);
|
||||
if bins.contains(&id.0) && id.0 != p.root.id.0 {
|
||||
bin_todo.push(id.0);
|
||||
while let Some(bid) = bin_todo.pop() {
|
||||
if !seen_bins.insert(bid) {
|
||||
continue;
|
||||
}
|
||||
if let Some((items, subs)) = children.get(&bid) {
|
||||
todo.extend(items.iter().copied());
|
||||
bin_todo.extend(subs.iter().copied());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -653,6 +653,16 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
run: relink,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "media.refreshChanged",
|
||||
label: "Refresh Changed Media",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: always,
|
||||
run: crate::media_watch::refresh_changed,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "media.replaceFootage",
|
||||
label: "Replace Footage…",
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
|||
"Delete Tracks…",
|
||||
&["Sequence"],
|
||||
None,
|
||||
r#"{"video":"empty"|"V2"|id?,"audio":"empty"|"A2"|id?}"#,
|
||||
r#"{"video":"empty"|"V2"|id?,"audio":"empty"|"A2"|id?,"captions":"empty"|"C2"|id?}"#,
|
||||
has_seq,
|
||||
delete_tracks,
|
||||
)],
|
||||
|
|
@ -723,6 +723,54 @@ pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"video": video, "audio": audio, "submix": submix}))
|
||||
}
|
||||
|
||||
/// `timeline.place` names `"new"` as its `track` or `audioTrack`.
|
||||
pub(crate) fn wants_new_track(p: &Value) -> bool {
|
||||
["track", "audioTrack"].iter().any(|k| p.get(*k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")))
|
||||
}
|
||||
|
||||
/// `timeline.place` with `"track":"new"` and/or `"audioTrack":"new"` (#483: media dropped in the
|
||||
/// empty space above the video tracks or below the audio tracks): a track is added after the
|
||||
/// last one of its kind for each stream the item carries (and the call enables), then the clip
|
||||
/// goes on it. One undo step; when the placement fails, the new tracks go away again.
|
||||
pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.place";
|
||||
let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?;
|
||||
let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?;
|
||||
let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new"));
|
||||
let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true);
|
||||
let new_v = is_new("track") && pi.has_video() && enabled("video");
|
||||
let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio");
|
||||
if !new_v && !new_a {
|
||||
return Err(bad(cmd, "no new track to add: the item has no picture or sound for it"));
|
||||
}
|
||||
let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?;
|
||||
let mark = s.history.undo.len();
|
||||
let mut q = p.clone();
|
||||
for (key, kind) in [("track", "video"), ("audioTrack", "audio")] {
|
||||
if is_new(key) {
|
||||
q[key] = added.get(kind).and_then(|ids| ids.get(0)).cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
}
|
||||
let place = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run;
|
||||
match place(s, &q) {
|
||||
Ok(v) => {
|
||||
// the new tracks and the clip are one undo step
|
||||
if mark >= 1
|
||||
&& s.history.undo.len() == mark + 1
|
||||
&& let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop())
|
||||
{
|
||||
s.history.undo.push((label, before));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
s.undo();
|
||||
s.history.redo.pop();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
|
@ -748,6 +796,18 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
doomed.extend(mine);
|
||||
}
|
||||
// caption tracks: unlike video/audio, a sequence may have none, so all of them can go
|
||||
match p.get("captions") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(v) if v.as_str().is_some_and(|x| matches!(x, "empty" | "allEmpty" | "all-empty")) => {
|
||||
doomed.extend(q.caption_tracks.iter().filter(|t| t.captions.is_empty()).map(|t| t.id));
|
||||
}
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => {
|
||||
let id = crate::captions::track_param(s, &json!({"track": v})).ok_or_else(|| bad("sequence.deleteTracks", "unknown caption track"))?;
|
||||
doomed.push(id);
|
||||
}
|
||||
Some(_) => return Err(bad("sequence.deleteTracks", "`captions` must be \"empty\", a name like \"C2\" or a track id")),
|
||||
}
|
||||
if doomed.is_empty() {
|
||||
return Err(EngineError::Other("no tracks to delete".into()));
|
||||
}
|
||||
|
|
@ -755,7 +815,9 @@ fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.edit_sequence("Delete Tracks", |q, _, st| {
|
||||
q.video_tracks.retain(|t| !doomed.contains(&t.id));
|
||||
q.audio_tracks.retain(|t| !doomed.contains(&t.id));
|
||||
q.caption_tracks.retain(|t| !doomed.contains(&t.id));
|
||||
st.selection.retain(|c| q.find_item(*c).is_some());
|
||||
st.caption_selection.retain(|c| q.find_caption(*c).is_some());
|
||||
if let Some(tg) = st.targeting.get_mut(&seq_id) {
|
||||
tg.targeted.retain(|t| !doomed.contains(t));
|
||||
if tg.video_dest.is_some_and(|t| doomed.contains(&t)) {
|
||||
|
|
|
|||
|
|
@ -350,6 +350,42 @@ fn delete_tracks_empty_and_specific() {
|
|||
assert_eq!(s.active_sequence().unwrap(), &q);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_tracks_removes_caption_tracks() {
|
||||
let mut s = demo();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
let n0 = before.caption_tracks.len();
|
||||
for _ in 0..3 {
|
||||
s.execute("captions.newTrack", json!({"format": "Subtitle"})).unwrap();
|
||||
}
|
||||
// new tracks are inserted on top: C1 gets a caption, C2 and C3 stay empty
|
||||
let cap = s.execute("captions.add", json!({"track": "C1", "text": "Hi", "seconds": 1.0})).unwrap()["caption"].as_u64().unwrap();
|
||||
s.execute("captions.select", json!({"captions": [cap]})).unwrap();
|
||||
let empty = s.active_sequence().unwrap().caption_tracks.iter().filter(|t| t.captions.is_empty()).count();
|
||||
assert!(empty >= 2);
|
||||
let r = s.execute("sequence.deleteTracks", json!({"captions": "empty"})).unwrap();
|
||||
assert_eq!(r["deleted"], json!(empty));
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert!(q.caption_tracks.iter().all(|t| !t.captions.is_empty()));
|
||||
assert_eq!(q.video_tracks.len(), before.video_tracks.len(), "video tracks untouched");
|
||||
assert!(s.execute("sequence.deleteTracks", json!({"captions": "empty"})).is_err(), "nothing left to delete");
|
||||
// unknown names, ids and value types are errors, not panics
|
||||
for bad in [json!("C99"), json!("C0"), json!("Cx"), json!(999_999), json!(true), json!(["C1"])] {
|
||||
assert!(s.execute("sequence.deleteTracks", json!({"captions": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// a specific track (with its captions); the last caption track may go too
|
||||
let left = s.active_sequence().unwrap().caption_tracks.len();
|
||||
for _ in 0..left {
|
||||
s.execute("sequence.deleteTracks", json!({"captions": "C1"})).unwrap();
|
||||
}
|
||||
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
|
||||
assert!(s.state.caption_selection.is_empty(), "selection of deleted captions cleared");
|
||||
for _ in 0..left + 1 {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.active_sequence().unwrap().caption_tracks.len(), n0 + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_points_on_the_sequence() {
|
||||
let (mut s, bars) = fresh();
|
||||
|
|
@ -539,3 +575,35 @@ fn go_to_in_out_without_marks_go_to_the_sequence_ends() {
|
|||
s.execute("markers.goToIn", json!({})).unwrap();
|
||||
assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap());
|
||||
}
|
||||
|
||||
/// #483: media dropped in the empty space past the last track goes on a new track (with
|
||||
/// `"new"`), one undo step; a placement that fails leaves no new track behind.
|
||||
#[test]
|
||||
fn place_on_new_tracks_adds_them_in_one_undo_step() {
|
||||
let (mut s, item) = fresh();
|
||||
let before = s.active_sequence().unwrap().clone();
|
||||
let (nv, na) = (before.video_tracks.len(), before.audio_tracks.len());
|
||||
let undo = s.history.undo.len();
|
||||
let r = s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "new", "time": 0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na + 1));
|
||||
let clips: Vec<ClipId> = r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
|
||||
assert_eq!(clips.len(), 2);
|
||||
assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[0]], "the picture is on the new top video track");
|
||||
assert_eq!(q.audio_tracks[na].items.iter().map(|i| i.id).collect::<Vec<_>>(), [clips[1]], "the sound is on the new last audio track");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
|
||||
// only the picture gets a new track; the sound follows the given track
|
||||
s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A1", "time": 0})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (nv + 1, na));
|
||||
assert_eq!((q.video_tracks[nv].items.len(), q.audio_tracks[0].items.len()), (1, 1));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
// a placement that fails after the tracks were added: nothing changes
|
||||
assert!(s.execute("timeline.place", json!({"item": item, "track": "new", "audioTrack": "A99", "time": 0})).is_err());
|
||||
assert_eq!(*s.active_sequence().unwrap(), before);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ pub const PREFS_VERSION: u32 = 2;
|
|||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct GeneralPrefs {
|
||||
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or
|
||||
/// `system` (the default: the operating system's preferred language when the interface has it,
|
||||
/// otherwise English).
|
||||
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`,
|
||||
/// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the
|
||||
/// interface has it, otherwise English).
|
||||
pub interface_language: String,
|
||||
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
|
||||
pub at_startup: String,
|
||||
|
|
@ -104,6 +104,9 @@ pub struct AppearancePrefs {
|
|||
pub accessible_contrast: bool,
|
||||
/// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`).
|
||||
pub highlight_color: String,
|
||||
/// "UI Scale": `auto` (default: the scale the operating system reports) or a fixed
|
||||
/// percentage of [`UI_SCALES`] (`100` = one point per physical pixel).
|
||||
pub ui_scale: String,
|
||||
}
|
||||
|
||||
impl Default for AppearancePrefs {
|
||||
|
|
@ -115,11 +118,19 @@ impl Default for AppearancePrefs {
|
|||
light_theme: "light".into(),
|
||||
accessible_contrast: false,
|
||||
highlight_color: DEFAULT_HIGHLIGHT.into(),
|
||||
ui_scale: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearancePrefs {
|
||||
/// The fixed UI scale (physical pixels per point) "UI Scale" asks for, or `None` for `auto`
|
||||
/// (and for a value outside [`UI_SCALES`]), which keeps the operating system's scale.
|
||||
pub fn ui_scale_factor(&self) -> Option<f32> {
|
||||
// `auto` doesn't parse; every other listed value is a positive whole percentage
|
||||
UI_SCALES.iter().any(|(k, _)| *k == self.ui_scale).then(|| self.ui_scale.parse::<f32>().ok()).flatten().map(|pct| pct / 100.0)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -669,6 +680,17 @@ const BIN_OPEN: &[(&str, &str)] = &[("openInPlace", "Open in place"), ("openNewT
|
|||
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 UI_SCALES: &[(&str, &str)] = &[
|
||||
("auto", "Auto (system)"),
|
||||
("75", "75%"),
|
||||
("100", "100%"),
|
||||
("125", "125%"),
|
||||
("150", "150%"),
|
||||
("175", "175%"),
|
||||
("200", "200%"),
|
||||
("250", "250%"),
|
||||
("300", "300%"),
|
||||
];
|
||||
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")];
|
||||
|
|
@ -752,6 +774,9 @@ static CATEGORIES: &[Category] = &[
|
|||
("pt-br", "Português (Brasil)"),
|
||||
("uk", "Українська"),
|
||||
("zh-cn", "简体中文"),
|
||||
("de", "Deutsch"),
|
||||
("ru", "Русский"),
|
||||
("fr", "Français"),
|
||||
]),
|
||||
true,
|
||||
),
|
||||
|
|
@ -795,6 +820,7 @@ static CATEGORIES: &[Category] = &[
|
|||
f("appearance.colorTheme", "Color Theme", Kind::Choice(THEMES), true),
|
||||
b("appearance.accessibleContrast", "Accessible color contrast", true),
|
||||
f("appearance.highlightColor", "Highlight Color", Kind::Color, true),
|
||||
f("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
],
|
||||
},
|
||||
Category {
|
||||
|
|
@ -964,9 +990,9 @@ static CATEGORIES: &[Category] = &[
|
|||
Row::Group(
|
||||
"Growing Files",
|
||||
&[
|
||||
b("media.refreshGrowingFiles", "Automatically refresh growing files", false),
|
||||
b("media.refreshGrowingFiles", "Automatically refresh growing files", true),
|
||||
b("media.resumeGrowingPlayback", "Automatically resume playback for growing files in Source Monitor", false),
|
||||
sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", false),
|
||||
sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", true),
|
||||
],
|
||||
),
|
||||
b("media.hardwareDecoding", "Enable hardware accelerated decoding (requires restart)", false),
|
||||
|
|
@ -1074,7 +1100,7 @@ static CATEGORIES: &[Category] = &[
|
|||
f("timeline.multichannelMonoMediaTracks", "Multichannel Mono Media", Kind::Choice(TRACKS), false),
|
||||
],
|
||||
),
|
||||
b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", true),
|
||||
b("timeline.focusTimelineOnEdit", "Set focus on the Timeline when performing Insert/Overwrite edits", false),
|
||||
b("timeline.snapPlayhead", "Snap playhead in Timeline when Snap is enabled", true),
|
||||
b("timeline.returnToBeginning", "At playback end, return to beginning when restarting playback", true),
|
||||
b("timeline.outOfSyncUnlinked", "Display out of sync indicators for unlinked clips", false),
|
||||
|
|
|
|||
|
|
@ -29,11 +29,15 @@ fn interface_language_persists_and_rejects_hostile_values() {
|
|||
let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() };
|
||||
set(&mut s, "general.interfaceLanguage", json!("es"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "es");
|
||||
for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] {
|
||||
for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!("fr-FR"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] {
|
||||
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
|
||||
assert_eq!(s.prefs.general.interface_language, "es");
|
||||
}
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "es");
|
||||
set(&mut s, "general.interfaceLanguage", json!("de"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "de");
|
||||
set(&mut s, "general.interfaceLanguage", json!("fr"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "fr");
|
||||
// back to following the operating system (#218)
|
||||
set(&mut s, "general.interfaceLanguage", json!("system"));
|
||||
assert_eq!(Preferences::load(&path).general.interface_language, "system");
|
||||
|
|
@ -251,6 +255,18 @@ fn schema_command_describes_every_category() {
|
|||
assert_eq!(auto["value"], "pageScroll");
|
||||
assert_eq!(auto["wired"], true);
|
||||
assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll"));
|
||||
|
||||
// This option is stored, but Insert/Overwrite does not yet implement focus transfer.
|
||||
let focus = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap();
|
||||
assert_eq!(focus["kind"], json!({"type": "bool"}));
|
||||
assert_eq!(focus["wired"], false);
|
||||
assert_eq!(focus["value"], false);
|
||||
set(&mut s, "timeline.focusTimelineOnEdit", json!(true));
|
||||
let updated = s.execute("prefs.schema", json!({"category": "timeline"})).unwrap();
|
||||
let focus = updated["categories"][0]["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap();
|
||||
assert_eq!(focus["value"], true);
|
||||
assert_eq!(focus["wired"], false);
|
||||
|
||||
let one = s.execute("prefs.schema", json!({"category": "trim"})).unwrap();
|
||||
assert_eq!(one["categories"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("prefs.schema", json!({"category": "nope"})).is_err());
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use crate::clip_ops::source_view;
|
||||
use crate::commands::bad;
|
||||
use crate::{EngineError, Result, Session};
|
||||
use filmcraft_project::{ItemId, Label, Marker, MarkerId, MarkerKind, Project};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, Project};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -90,6 +90,52 @@ pub fn source_command(id: &str) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// Most clips the Source monitor's list of recent clips keeps.
|
||||
pub const SOURCE_HISTORY_MAX: usize = 20;
|
||||
|
||||
/// Keep the Source monitor's recent clips in step with the clip it shows: the open clip moves to
|
||||
/// the front, items no longer in the project drop out.
|
||||
pub(crate) fn track_history(s: &mut Session) {
|
||||
let current = s.state.source_item;
|
||||
let project = &s.project;
|
||||
let history = &mut s.state.source_history;
|
||||
history.retain(|i| project.item(*i).is_some());
|
||||
if let Some(cur) = current.filter(|i| project.item(*i).is_some())
|
||||
&& history.first() != Some(&cur)
|
||||
{
|
||||
history.retain(|i| *i != cur);
|
||||
history.insert(0, cur);
|
||||
}
|
||||
history.truncate(SOURCE_HISTORY_MAX);
|
||||
}
|
||||
|
||||
/// Enablement of Close / Close All: a clip is open in the Source monitor.
|
||||
pub(crate) fn has_source_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
s.state.source_item.map(|_| ()).ok_or_else(|| "no clip is open in the Source monitor".into())
|
||||
}
|
||||
|
||||
/// Source panel ▸ Close (`all`: Close All). The open clip leaves the Source monitor and its list
|
||||
/// of recent clips, and the next most recent one opens in its place, as in Premiere; Close All
|
||||
/// empties the list and the monitor.
|
||||
pub(crate) fn close(s: &mut Session, all: bool) -> Result<Value> {
|
||||
if all {
|
||||
s.state.source_history.clear();
|
||||
} else if let Some(cur) = s.state.source_item {
|
||||
s.state.source_history.retain(|i| *i != cur);
|
||||
}
|
||||
let next = s.state.source_history.iter().copied().find(|i| s.project.item(*i).is_some());
|
||||
match next {
|
||||
Some(i) => {
|
||||
s.execute("source.open", json!({"item": i.0}))?;
|
||||
}
|
||||
None => {
|
||||
s.state.source_item = None;
|
||||
s.state.source_playhead = Tick::ZERO;
|
||||
}
|
||||
}
|
||||
Ok(json!({"item": next.map(|i| i.0)}))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -203,6 +249,27 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result<Option<Value
|
|||
Ok(Some(Value::Null))
|
||||
}
|
||||
|
||||
/// `source.open {"clip"}` (double-clicking a Timeline clip, as in Premiere): the clip's media
|
||||
/// opens in the Source monitor, parked on the frame shown under the playhead when the playhead
|
||||
/// is over the clip, otherwise on the clip's first frame.
|
||||
pub(crate) fn open_clip(s: &mut Session, clip: ClipId) -> Result<Value> {
|
||||
let id = "source.open";
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = seq.find_item(clip).ok_or_else(|| bad(id, "no such clip in the active sequence"))?;
|
||||
let t = s.playhead();
|
||||
let time = it.source_time_at(if t >= it.start && t < it.end() { t } else { it.start });
|
||||
let item = it.item;
|
||||
match s.project.item(item).map(|i| &i.kind) {
|
||||
None => return Err(bad(id, "the clip's source is unavailable")),
|
||||
Some(ItemKind::Graphic { .. }) => return Err(bad(id, "a graphic clip has no source to open")),
|
||||
Some(_) => {}
|
||||
}
|
||||
s.execute(id, json!({"item": item.0}))?;
|
||||
// source.setPlayhead snaps to the source frame grid and keeps the playhead inside a subclip
|
||||
s.execute("source.setPlayhead", json!({"time": time.0}))?;
|
||||
Ok(json!({"item": item.0, "time": s.state.source_playhead.0}))
|
||||
}
|
||||
|
||||
fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result<Option<&'a str>> {
|
||||
p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose()
|
||||
}
|
||||
|
|
@ -288,6 +355,53 @@ mod tests {
|
|||
s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap();
|
||||
assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty());
|
||||
}
|
||||
/// #313: the Source monitor remembers the clips opened in it, most recent first; Close shows
|
||||
/// the next most recent one and Close All empties the monitor.
|
||||
#[test]
|
||||
fn source_clips_are_remembered_and_closed_in_turn() {
|
||||
let mut s = fixture();
|
||||
let a = ItemId(5);
|
||||
let mut others: Vec<ItemId> = s.project.items.values().filter(|i| i.as_media().is_some() && i.id != a).map(|i| i.id).collect();
|
||||
others.sort();
|
||||
let [b, c] = [others[0], others[1]];
|
||||
for i in [b, c, a] {
|
||||
s.execute("source.open", json!({"item": i.0})).unwrap();
|
||||
}
|
||||
assert_eq!(s.state.source_history, [a, c, b]);
|
||||
assert_eq!(s.execute("source.close", json!({})).unwrap()["item"], c.0);
|
||||
assert_eq!((s.state.source_item, s.state.source_history.clone()), (Some(c), vec![c, b]));
|
||||
// an item deleted from the project leaves the list
|
||||
s.execute("project.delete", json!({"items": [b.0]})).unwrap();
|
||||
assert!(s.project.item(b).is_none());
|
||||
assert!(!s.state.source_history.contains(&b));
|
||||
s.execute("source.closeAll", json!({})).unwrap();
|
||||
assert_eq!((s.state.source_item, s.state.source_history.len()), (None, 0));
|
||||
assert!(s.execute("source.close", json!({})).is_err(), "nothing left to close");
|
||||
}
|
||||
#[test]
|
||||
fn timeline_clip_opens_in_source_at_the_matching_frame() {
|
||||
let mut s = fixture();
|
||||
let seq = s.active_sequence().unwrap();
|
||||
let it = seq
|
||||
.all_tracks()
|
||||
.flat_map(|t| t.items.iter())
|
||||
.find(|i| i.source_in > Tick::ZERO && s.project.item(i.item).is_some_and(|p| p.as_media().is_some()))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let rate = s.sequence_rate();
|
||||
let inside = it.start + rate.tick_of(3);
|
||||
s.set_playhead(inside);
|
||||
let r = s.execute("source.open", json!({"clip": it.id.0})).unwrap();
|
||||
assert_eq!(r["item"], it.item.0);
|
||||
assert_eq!(s.state.source_item, Some(it.item));
|
||||
let view = source_view(&s, it.item).unwrap();
|
||||
assert_eq!(s.state.source_playhead, view.rate.snap(it.source_time_at(inside)));
|
||||
// playhead off the clip: the clip's first frame
|
||||
s.set_playhead(it.end() + rate.tick_of(1));
|
||||
s.execute("source.open", json!({"clip": it.id.0})).unwrap();
|
||||
assert_eq!(s.state.source_playhead, view.rate.snap(it.source_in));
|
||||
assert!(s.execute("source.open", json!({"clip": u64::MAX})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn invalid_marker_parameters_do_not_edit_the_project() {
|
||||
let mut s = fixture();
|
||||
|
|
|
|||
|
|
@ -247,3 +247,38 @@ fn a_move_that_lands_on_another_clip_reports_what_it_cut() {
|
|||
let r = s.execute("timeline.move", json!({"moves": [{"clip": a1.id.0, "track": a_track.0, "time": a2.start.0}], "insert": true, "linked": false})).unwrap();
|
||||
assert_eq!(r["overwritten"], json!([]));
|
||||
}
|
||||
|
||||
/// Option-drag (`copy: true`) moved the clips instead of copying them.
|
||||
#[test]
|
||||
fn copy_leaves_the_originals_and_links_the_copies() {
|
||||
let mut s = demo();
|
||||
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0);
|
||||
let to = v.end() + s.sequence_rate().tick_of(24);
|
||||
let before = s.project.clone();
|
||||
// the picture is listed; its linked sound is copied with it
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true})).unwrap();
|
||||
let copies: Vec<ClipId> = r["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
|
||||
assert_eq!(copies.len(), 2, "picture and sound are copied: {r}");
|
||||
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start)), "the originals stay");
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (vt, vc) = q.find_item(copies[0]).unwrap();
|
||||
let (at, ac) = q.find_item(copies[1]).unwrap();
|
||||
assert_eq!((vt, vc.start, vc.item, vc.source_in, vc.duration), (q.video_tracks[1].id, to, v.item, v.source_in, v.duration));
|
||||
assert_eq!((at, ac.start), (a_track, to), "the sound copy keeps its offset on its own track");
|
||||
assert!(vc.link.is_some() && vc.link == ac.link && vc.link != v.link, "the copies are linked to each other only");
|
||||
assert_eq!(s.state.selection, copies, "the copies are selected");
|
||||
q.check().unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.project, *before, "one undo step");
|
||||
// copying just one side (linked: false) leaves the copy unlinked
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": to.0}], "copy": true, "linked": false})).unwrap();
|
||||
let c = ClipId(r["copied"][0].as_u64().unwrap());
|
||||
assert_eq!(r["copied"].as_array().unwrap().len(), 1);
|
||||
assert!(s.active_sequence().unwrap().find_item(c).unwrap().1.link.is_none());
|
||||
// a copy onto a locked track is refused and changes nothing
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap();
|
||||
let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true, "linked": false}));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::<usize>(), 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.state.edit_points = vec![ep];
|
||||
}
|
||||
s.state.selection.clear();
|
||||
s.state.gap_selection = None;
|
||||
reset_shift(s);
|
||||
Ok(json!({"editPoints": s.state.edit_points}))
|
||||
}
|
||||
|
|
@ -155,6 +156,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
s.state.edit_points = points;
|
||||
s.state.selection.clear();
|
||||
s.state.gap_selection = None;
|
||||
reset_shift(s);
|
||||
Ok(json!({"editPoints": s.state.edit_points}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,3 +236,21 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() {
|
|||
assert_eq!(q.find_item(a).unwrap().1.end(), ph);
|
||||
assert_eq!(q.find_item(b).unwrap().1.end(), ph);
|
||||
}
|
||||
|
||||
/// #653: the delta the Timeline previews while an edge is dragged is the one `timeline.trim`
|
||||
/// applies on release: dragging the last clip's Out edge far past the end of its media stops at
|
||||
/// the media's end instead of following the pointer.
|
||||
#[test]
|
||||
fn trim_delta_stops_at_the_end_of_the_media() {
|
||||
let mut s = demo();
|
||||
let last = *v1(&s).last().unwrap();
|
||||
let clip = ClipId(last.0);
|
||||
let far = Tick(3600 * filmcraft_time::TICKS_PER_SECOND);
|
||||
let shown = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, far).unwrap();
|
||||
assert!(Tick::ZERO < shown && shown < far, "clamped to the media left after the clip: {shown:?}");
|
||||
let r = s.execute("timeline.trim", json!({"clip": last.0, "edge": "out", "mode": "regular", "delta": far.0})).unwrap();
|
||||
assert_eq!(r["delta"], json!(shown.0), "the preview and the trim agree");
|
||||
let small = s.sequence_rate().frame_duration();
|
||||
let back = commands::trim_delta(&s, clip, filmcraft_edit::Edge::Out, filmcraft_edit::TrimMode::Regular, -small).unwrap();
|
||||
assert_eq!(back, -small, "a trim within the media is not clamped");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,3 +115,16 @@ fn new_effect_params_are_settable_and_keyframable() {
|
|||
let img = s.render_program(0.125).unwrap();
|
||||
assert!(img.px.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultra_key_setting_writes_aggressive_parameters() {
|
||||
let mut s = demo();
|
||||
let clip = first_v1_clip(&s);
|
||||
s.execute("effects.apply", json!({"clips": [clip], "effect": "ultra_key"})).unwrap();
|
||||
s.execute("effects.setParam", json!({"clip": clip, "effect": "ultra_key", "param": "setting", "value": 2})).unwrap();
|
||||
let e = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effect("ultra_key").unwrap();
|
||||
assert_eq!(e.param("tolerance").unwrap().value.as_f64(), Some(90.0));
|
||||
assert_eq!(e.param("pedestal").unwrap().value.as_f64(), Some(50.0));
|
||||
assert_eq!(e.param("choke").unwrap().value.as_f64(), Some(10.0));
|
||||
assert_eq!(e.param("contrast").unwrap().value.as_f64(), Some(10.0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an
|
||||
//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends
|
||||
//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS
|
||||
//! cannot signal the encoder delay, so a player starts it with the priming samples.
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_project::{ItemId, Project};
|
||||
use filmcraft_render::SourceProvider;
|
||||
use filmcraft_time::TimeRange;
|
||||
|
||||
use crate::audio_out::AudioOut;
|
||||
use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories};
|
||||
|
||||
/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file.
|
||||
pub(crate) fn is_adts_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac"))
|
||||
}
|
||||
|
||||
/// Where the encoded access units go.
|
||||
enum Sink {
|
||||
Mp4 { mux: Mp4Writer<Out>, track: usize, frame: u32 },
|
||||
Adts { out: Out, header: AdtsHeader },
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
fn write(&mut self, units: Vec<Vec<u8>>) -> Result<()> {
|
||||
let io = |e: std::io::Error| ExportError::Io(e.to_string());
|
||||
match self {
|
||||
Sink::Mp4 { mux, track, frame } => {
|
||||
for au in units {
|
||||
mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true })
|
||||
.map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Sink::Adts { out, header } => {
|
||||
for au in units {
|
||||
out.write_all(&header.bytes(au.len())?).map_err(io)?;
|
||||
out.write_all(&au).map_err(io)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self, settings: &ExportSettings) -> Result<u64> {
|
||||
match self {
|
||||
Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings),
|
||||
Sink::Adts { out, .. } => out.finish(settings),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed fields of an ADTS header, from the AudioSpecificConfig.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AdtsHeader {
|
||||
/// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC).
|
||||
profile: u8,
|
||||
sf_index: u8,
|
||||
channel_config: u8,
|
||||
}
|
||||
|
||||
impl AdtsHeader {
|
||||
/// Read the object type, sampling frequency index and channel configuration from an
|
||||
/// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a
|
||||
/// channel configuration of 1–7.
|
||||
pub(crate) fn from_asc(asc: &[u8]) -> Result<AdtsHeader> {
|
||||
let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else {
|
||||
return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into()));
|
||||
};
|
||||
let object_type = a >> 3;
|
||||
let sf_index = ((a & 0x07) << 1) | (b >> 7);
|
||||
let channel_config = (b >> 3) & 0x0F;
|
||||
if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) {
|
||||
return Err(ExportError::Unsupported(format!(
|
||||
"an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead"
|
||||
)));
|
||||
}
|
||||
Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config })
|
||||
}
|
||||
|
||||
/// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes.
|
||||
pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> {
|
||||
// aac_frame_length is 13 bits and counts the header
|
||||
let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS"));
|
||||
let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?;
|
||||
Ok([
|
||||
0xFF,
|
||||
0xF1, // syncword, MPEG-4, layer 0, protection absent
|
||||
(self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2),
|
||||
((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8,
|
||||
((len >> 3) & 0xFF) as u8,
|
||||
(((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate
|
||||
0xFC, // … and one raw data block
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel).
|
||||
pub(crate) fn export(
|
||||
project: &Arc<Project>,
|
||||
seq: ItemId,
|
||||
settings: &ExportSettings,
|
||||
range: TimeRange,
|
||||
sources: &dyn SourceProvider,
|
||||
progress: &Progress,
|
||||
) -> Result<(u64, u64)> {
|
||||
let cancelled = || progress.cancel.load(Ordering::Relaxed);
|
||||
let mut a = AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
let mut enc: Box<dyn AudioEncoder> = audio_factories()
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings))
|
||||
.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??;
|
||||
let entry = enc.sample_entry();
|
||||
let frames = a.remaining();
|
||||
// one second per chunk: memory stays flat however long the range is
|
||||
let chunk = i64::from(a.sr.max(1));
|
||||
if !settings.part_of_batch {
|
||||
progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed);
|
||||
progress.set_status(format!("Exporting audio ({})", settings.format.label()));
|
||||
}
|
||||
a.measure(settings, sources, &cancelled)?;
|
||||
*progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness;
|
||||
let mut sink = if is_adts_path(&settings.path) {
|
||||
let CodecConfig::Aac(cfg) = &entry.codec else {
|
||||
return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into()));
|
||||
};
|
||||
Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? }
|
||||
} else {
|
||||
let mut opts = WriterOptions::new(Brand::Mp4);
|
||||
opts.metadata = settings.metadata.udta();
|
||||
let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
let mut track = TrackConfig::new(entry, a.sr);
|
||||
track.media_start = Some(i64::from(enc.priming()));
|
||||
let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?;
|
||||
Sink::Mp4 { mux, track, frame: enc.frame_size() }
|
||||
};
|
||||
while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) {
|
||||
if cancelled() {
|
||||
return Err(ExportError::Cancelled);
|
||||
}
|
||||
sink.write(enc.encode(&planar)?)?;
|
||||
if !settings.part_of_batch {
|
||||
progress.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
sink.write(enc.flush()?)?;
|
||||
let n = sink.finish(settings)?;
|
||||
if settings.part_of_batch {
|
||||
progress.done.store(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok((n, frames))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adts_header_fields_follow_the_asc() {
|
||||
// AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000
|
||||
let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap();
|
||||
assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 });
|
||||
let b = h.bytes(371).unwrap();
|
||||
assert_eq!(&b[..2], &[0xFF, 0xF1]);
|
||||
assert_eq!(b[2] >> 6, 1);
|
||||
assert_eq!((b[2] >> 2) & 0x0F, 3);
|
||||
assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2);
|
||||
let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5);
|
||||
assert_eq!(len, 378);
|
||||
assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF);
|
||||
assert_eq!(b[6] & 0x03, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adts_refuses_what_it_cannot_signal() {
|
||||
assert!(AdtsHeader::from_asc(&[]).is_err());
|
||||
assert!(AdtsHeader::from_asc(&[0x11]).is_err());
|
||||
// explicit sampling frequency (index 15)
|
||||
assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err());
|
||||
// object type 5 (SBR) has no ADTS profile
|
||||
assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err());
|
||||
let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap();
|
||||
assert!(h.bytes(8184).is_ok());
|
||||
assert!(h.bytes(8185).is_err());
|
||||
assert!(h.bytes(usize::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_dot_aac_path_is_adts() {
|
||||
assert!(is_adts_path("/x/mix.aac"));
|
||||
assert!(is_adts_path("mix.AAC"));
|
||||
assert!(!is_adts_path("mix.m4a"));
|
||||
assert!(!is_adts_path("aac"));
|
||||
}
|
||||
}
|
||||
|
|
@ -145,6 +145,11 @@ impl AudioOut {
|
|||
Some(buf)
|
||||
}
|
||||
|
||||
/// Output samples still to come (per channel).
|
||||
pub fn remaining(&self) -> u64 {
|
||||
u64::try_from(self.end.saturating_sub(self.out)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Everything still to come (to the end of the range).
|
||||
pub fn rest(&mut self, sources: &dyn SourceProvider) -> Option<Vec<Vec<f32>>> {
|
||||
self.pull(self.end, sources)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ use rayon::prelude::*;
|
|||
use crate::audio_out::AudioOut;
|
||||
use crate::mxf_out::{MxfMux, MxfSetup};
|
||||
use crate::pipeline::Pipeline;
|
||||
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
|
||||
use crate::settings::{AudioCodec, BitrateMode};
|
||||
use crate::{
|
||||
AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder,
|
||||
audio_factories, export_range, frame_span, note_stage, timed, video_factories,
|
||||
|
|
@ -186,7 +186,10 @@ pub struct Exporter {
|
|||
|
||||
/// Whether [`Exporter`] handles a format.
|
||||
pub fn stepped(format: Format) -> bool {
|
||||
matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
|
||||
matches!(
|
||||
format,
|
||||
Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder
|
||||
|
|
@ -249,7 +252,7 @@ impl Exporter {
|
|||
progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label()));
|
||||
}
|
||||
let venc = make_venc(&settings, pipe.w, pipe.h, pipe.rate)?;
|
||||
let brand = if settings.format.is_h26x() && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov };
|
||||
let brand = if settings.format.is_mp4_with(settings.multiplexer) { Brand::Mp4 } else { Brand::Mov };
|
||||
let audio = if settings.has_audio() { Some(AudioOut::new(project.clone(), seq, &settings, range)?) } else { None };
|
||||
let aenc: Option<Box<dyn AudioEncoder>> = match (&audio, settings.audio_codec()) {
|
||||
(Some(a), AudioCodec::Aac) => {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
//! are shared atomics so the UI (Export mode, header progress) and MCP can observe/cancel jobs.
|
||||
//!
|
||||
//! Video encoders implement [`VideoEncoder`]; codec crates register theirs with
|
||||
//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP
|
||||
//! sequences, GIF, WAV and AIFF.
|
||||
//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG /
|
||||
//! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF.
|
||||
//!
|
||||
//! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio
|
||||
//! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
use std::io::Write;
|
||||
|
||||
mod aac_out;
|
||||
mod audio_out;
|
||||
mod job;
|
||||
mod mxf_out;
|
||||
|
|
@ -22,6 +23,7 @@ mod pace;
|
|||
mod pcm;
|
||||
mod pipeline;
|
||||
pub mod presets;
|
||||
mod raster;
|
||||
pub mod settings;
|
||||
pub mod still;
|
||||
pub use audio_out::LoudnessReport;
|
||||
|
|
@ -70,6 +72,10 @@ pub enum Format {
|
|||
/// ([`hdr_available`]); elsewhere they are tone-mapped to SDR.
|
||||
#[serde(rename = "hevc", alias = "Hevc")]
|
||||
Hevc,
|
||||
/// MPEG-4, AV1 Main (8-bit) video + AAC audio. Like H.265 it has no built-in encoder: offered
|
||||
/// where a hardware encoder registers one.
|
||||
#[serde(rename = "av1", alias = "Av1")]
|
||||
Av1,
|
||||
/// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM.
|
||||
#[serde(rename = "prores", alias = "ProRes")]
|
||||
ProRes,
|
||||
|
|
@ -92,12 +98,28 @@ pub enum Format {
|
|||
/// Numbered BMP stills.
|
||||
#[serde(rename = "bmp", alias = "BmpSequence")]
|
||||
BmpSequence,
|
||||
/// Numbered JPEG stills (baseline, [`ExportSettings::quality`]).
|
||||
#[serde(rename = "jpg", alias = "JpegSequence")]
|
||||
JpegSequence,
|
||||
/// Numbered Targa stills (uncompressed, 24-bit, or 32-bit with [`ExportSettings::alpha`]).
|
||||
#[serde(rename = "tga", alias = "TgaSequence")]
|
||||
TgaSequence,
|
||||
/// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709).
|
||||
#[serde(rename = "dpx", alias = "DpxSequence")]
|
||||
DpxSequence,
|
||||
/// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]).
|
||||
#[serde(rename = "exr", alias = "ExrSequence")]
|
||||
ExrSequence,
|
||||
#[serde(rename = "gif", alias = "Gif")]
|
||||
Gif,
|
||||
#[serde(rename = "wav", alias = "Wav")]
|
||||
Wav,
|
||||
#[serde(rename = "aiff", alias = "Aiff")]
|
||||
Aiff,
|
||||
/// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when
|
||||
/// the path ends in `.aac`.
|
||||
#[serde(rename = "aac", alias = "Aac")]
|
||||
Aac,
|
||||
/// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM.
|
||||
#[serde(rename = "mxf-op1a", alias = "MxfOp1a")]
|
||||
MxfOp1a,
|
||||
|
|
@ -112,6 +134,7 @@ impl Format {
|
|||
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
|
||||
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
|
||||
"hevc" | "h265" | "hvc1" | "hev1" | "x265" => Format::Hevc,
|
||||
"av1" | "av01" => Format::Av1,
|
||||
"prores" | "mov" | "appleprores" => Format::ProRes,
|
||||
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
|
||||
"apv" | "apv1" => Format::Apv,
|
||||
|
|
@ -121,9 +144,15 @@ impl Format {
|
|||
"png" | "pngsequence" => Format::PngSequence,
|
||||
"tif" | "tiff" | "tiffsequence" => Format::TiffSequence,
|
||||
"bmp" | "bmpsequence" => Format::BmpSequence,
|
||||
// "jpeg" alone stays Motion-JPEG (QuickTime), as it always was
|
||||
"jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence,
|
||||
"tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence,
|
||||
"dpx" | "dpxsequence" => Format::DpxSequence,
|
||||
"exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence,
|
||||
"gif" | "animatedgif" => Format::Gif,
|
||||
"wav" | "waveform" | "waveformaudio" => Format::Wav,
|
||||
"aif" | "aiff" | "aifc" => Format::Aiff,
|
||||
"aac" | "aacaudio" | "m4a" | "adts" => Format::Aac,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -132,6 +161,7 @@ impl Format {
|
|||
match self {
|
||||
Format::H264 => "h264",
|
||||
Format::Hevc => "hevc",
|
||||
Format::Av1 => "av1",
|
||||
Format::ProRes => "prores",
|
||||
Format::DnxHr => "dnxhr",
|
||||
Format::Apv => "apv",
|
||||
|
|
@ -139,23 +169,33 @@ impl Format {
|
|||
Format::PngSequence => "png",
|
||||
Format::TiffSequence => "tiff",
|
||||
Format::BmpSequence => "bmp",
|
||||
Format::JpegSequence => "jpg",
|
||||
Format::TgaSequence => "tga",
|
||||
Format::DpxSequence => "dpx",
|
||||
Format::ExrSequence => "exr",
|
||||
Format::Gif => "gif",
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aiff",
|
||||
Format::Aac => "aac",
|
||||
Format::MxfOp1a => "mxf-op1a",
|
||||
Format::MxfOpAtom => "mxf-opatom",
|
||||
}
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::H264 | Format::Hevc => "mp4",
|
||||
Format::H264 | Format::Hevc | Format::Av1 => "mp4",
|
||||
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov",
|
||||
Format::PngSequence => "png",
|
||||
Format::TiffSequence => "tif",
|
||||
Format::BmpSequence => "bmp",
|
||||
Format::JpegSequence => "jpg",
|
||||
Format::TgaSequence => "tga",
|
||||
Format::DpxSequence => "dpx",
|
||||
Format::ExrSequence => "exr",
|
||||
Format::Gif => "gif",
|
||||
Format::Wav => "wav",
|
||||
Format::Aiff => "aif",
|
||||
Format::Aac => "m4a",
|
||||
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
|
||||
}
|
||||
}
|
||||
|
|
@ -163,6 +203,7 @@ impl Format {
|
|||
match self {
|
||||
Format::H264 => "H.264",
|
||||
Format::Hevc => "H.265 (HEVC)",
|
||||
Format::Av1 => "AV1",
|
||||
Format::ProRes => "Apple ProRes",
|
||||
Format::DnxHr => "Avid DNxHR",
|
||||
Format::Apv => "APV",
|
||||
|
|
@ -170,13 +211,31 @@ impl Format {
|
|||
Format::PngSequence => "PNG",
|
||||
Format::TiffSequence => "TIFF",
|
||||
Format::BmpSequence => "BMP",
|
||||
Format::JpegSequence => "JPEG",
|
||||
Format::TgaSequence => "Targa",
|
||||
Format::DpxSequence => "DPX",
|
||||
Format::ExrSequence => "OpenEXR",
|
||||
Format::Gif => "Animated GIF",
|
||||
Format::Wav => "Waveform Audio",
|
||||
Format::Aiff => "AIFF",
|
||||
Format::Aac => "AAC Audio",
|
||||
Format::MxfOp1a => "MXF OP1a",
|
||||
Format::MxfOpAtom => "MXF OP-Atom",
|
||||
}
|
||||
}
|
||||
/// Numbered stills, one per frame ([`image_sequence_path`]).
|
||||
pub fn is_image_sequence(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Format::PngSequence
|
||||
| Format::TiffSequence
|
||||
| Format::BmpSequence
|
||||
| Format::JpegSequence
|
||||
| Format::TgaSequence
|
||||
| Format::DpxSequence
|
||||
| Format::ExrSequence
|
||||
)
|
||||
}
|
||||
/// An MXF container format.
|
||||
pub fn is_mxf(self) -> bool {
|
||||
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
|
||||
|
|
@ -184,15 +243,28 @@ impl Format {
|
|||
/// Whether the crate carries an encoder for the format; the others need one registered at runtime
|
||||
/// ([`register_format_probe`]).
|
||||
pub fn has_builtin_encoder(self) -> bool {
|
||||
self != Format::Hevc
|
||||
!matches!(self, Format::Hevc | Format::Av1)
|
||||
}
|
||||
/// Sound without picture: Waveform Audio, AIFF and AAC Audio.
|
||||
pub fn is_audio_only(self) -> bool {
|
||||
matches!(self, Format::Wav | Format::Aiff | Format::Aac)
|
||||
}
|
||||
/// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls.
|
||||
pub fn is_h26x(self) -> bool {
|
||||
matches!(self, Format::H264 | Format::Hevc)
|
||||
}
|
||||
pub const ALL: [Format; 14] = [
|
||||
/// A bitrate-controlled MPEG-4 video format with AAC audio: H.264, H.265 or AV1 (MPEG-4 only).
|
||||
pub fn is_mp4_video(self) -> bool {
|
||||
matches!(self, Format::H264 | Format::Hevc | Format::Av1)
|
||||
}
|
||||
/// Whether the file is MPEG-4 rather than QuickTime for `mux`.
|
||||
pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
|
||||
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
|
||||
}
|
||||
pub const ALL: [Format; 20] = [
|
||||
Format::H264,
|
||||
Format::Hevc,
|
||||
Format::Av1,
|
||||
Format::ProRes,
|
||||
Format::DnxHr,
|
||||
Format::Apv,
|
||||
|
|
@ -200,9 +272,14 @@ impl Format {
|
|||
Format::PngSequence,
|
||||
Format::TiffSequence,
|
||||
Format::BmpSequence,
|
||||
Format::JpegSequence,
|
||||
Format::TgaSequence,
|
||||
Format::DpxSequence,
|
||||
Format::ExrSequence,
|
||||
Format::Gif,
|
||||
Format::Wav,
|
||||
Format::Aiff,
|
||||
Format::Aac,
|
||||
Format::MxfOp1a,
|
||||
Format::MxfOpAtom,
|
||||
];
|
||||
|
|
@ -228,7 +305,7 @@ pub struct ExportSettings {
|
|||
/// Output scale (1.0 = sequence frame size).
|
||||
pub scale: f32,
|
||||
pub include_audio: bool,
|
||||
/// Quality 0–100 for lossy codecs.
|
||||
/// Quality 0–100 for lossy codecs (Motion-JPEG, JPEG sequences).
|
||||
pub quality: u8,
|
||||
/// Target video bitrate (kbps) for bitrate-driven encoders.
|
||||
pub bitrate_kbps: u32,
|
||||
|
|
@ -239,7 +316,7 @@ pub struct ExportSettings {
|
|||
/// `progress.total`, `finished` and the final status; this call only adds to `done`.
|
||||
#[serde(default)]
|
||||
pub part_of_batch: bool,
|
||||
/// ProRes flavour: `proxy`, `lt`, `standard` or `hq` (empty = HQ).
|
||||
/// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ).
|
||||
#[serde(default)]
|
||||
pub prores_profile: String,
|
||||
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
|
||||
|
|
@ -256,8 +333,10 @@ pub struct ExportSettings {
|
|||
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||
#[serde(default)]
|
||||
pub sdr: bool,
|
||||
/// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include
|
||||
/// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it.
|
||||
/// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ
|
||||
/// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it
|
||||
/// premultiplied, as the format defines). Off, every frame is flattened over black (#160).
|
||||
/// Other formats ignore it.
|
||||
#[serde(default)]
|
||||
pub alpha: bool,
|
||||
/// Output frame size (None = Match Source: the sequence size times `scale`).
|
||||
|
|
@ -274,6 +353,10 @@ pub struct ExportSettings {
|
|||
/// stream needs is raised.
|
||||
pub h264_level: Option<u8>,
|
||||
pub bitrate_mode: BitrateMode,
|
||||
/// Constant rate factor of [`BitrateMode::Crf`], 0 (best) to 51 (smallest); 23 by default,
|
||||
/// 18 is visually near-lossless.
|
||||
#[serde(default = "default_crf")]
|
||||
pub crf: f32,
|
||||
/// May H.264 be encoded by the system's hardware encoder (VideoToolbox on macOS, NVENC on
|
||||
/// Windows)? Off unless asked for: hardware output depends on the machine, so it is not
|
||||
/// byte-reproducible like the built-in encoder's (`determinism_tests`).
|
||||
|
|
@ -471,6 +554,7 @@ impl Default for ExportSettings {
|
|||
h264_profile: H264Profile::High,
|
||||
h264_level: None,
|
||||
bitrate_mode: BitrateMode::default(),
|
||||
crf: DEFAULT_CRF,
|
||||
hardware_encoding: HardwareEncoding::default(),
|
||||
gpu_rendering: GpuRendering::Off,
|
||||
max_bitrate_kbps: None,
|
||||
|
|
@ -490,6 +574,13 @@ impl Default for ExportSettings {
|
|||
}
|
||||
}
|
||||
|
||||
/// [`ExportSettings::crf`] of new settings and of settings saved before it existed.
|
||||
pub const DEFAULT_CRF: f32 = 23.0;
|
||||
|
||||
fn default_crf() -> f32 {
|
||||
DEFAULT_CRF
|
||||
}
|
||||
|
||||
impl ExportSettings {
|
||||
/// Reject settings the encoders cannot honour.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
|
|
@ -524,6 +615,20 @@ impl ExportSettings {
|
|||
if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass {
|
||||
return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
|
||||
}
|
||||
if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass {
|
||||
return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into()));
|
||||
}
|
||||
if self.bitrate_mode == BitrateMode::Crf {
|
||||
if self.format == Format::Hevc {
|
||||
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
|
||||
}
|
||||
if self.format == Format::Av1 {
|
||||
return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
|
||||
}
|
||||
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
|
||||
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -986,6 +1091,8 @@ fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSetti
|
|||
struct ProResEncoder {
|
||||
enc: filmcraft_prores::Encoder,
|
||||
profile: filmcraft_prores::Profile,
|
||||
/// Code the frames' alpha channel (4444 profiles with Include Alpha Channel).
|
||||
alpha: bool,
|
||||
w: u32,
|
||||
h: u32,
|
||||
rate: FrameRate,
|
||||
|
|
@ -1002,13 +1109,19 @@ impl VideoEncoder for ProResEncoder {
|
|||
self.rate.num as u32
|
||||
}
|
||||
fn encode(&mut self, f: &EncoderFrame) -> Result<Vec<EncodedPacket>> {
|
||||
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false);
|
||||
timed(Stage::Convert, || match f.hdr {
|
||||
Some(rgb) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr)
|
||||
let chroma = self.profile.chroma();
|
||||
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha);
|
||||
let (w, h) = (f.width as usize, f.height as usize);
|
||||
timed(Stage::Convert, || match (chroma, f.hdr) {
|
||||
(filmcraft_prores::ChromaFormat::Yuv444, hdr) => {
|
||||
let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) };
|
||||
to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut())
|
||||
}
|
||||
None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr),
|
||||
(_, Some(rgb)) => {
|
||||
let (kr, kb) = self.signal.kr_kb();
|
||||
rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr)
|
||||
}
|
||||
(_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr),
|
||||
});
|
||||
let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }])
|
||||
|
|
@ -1018,6 +1131,47 @@ impl VideoEncoder for ProResEncoder {
|
|||
}
|
||||
}
|
||||
|
||||
/// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B'
|
||||
/// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the
|
||||
/// RGBA8 alpha; it stays opaque for float input, which carries none.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn to_yuv444_10(
|
||||
rgba: &[u8],
|
||||
hdr: Option<&[f32]>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
kr: f32,
|
||||
kb: f32,
|
||||
y: &mut [u16],
|
||||
cb: &mut [u16],
|
||||
cr: &mut [u16],
|
||||
alpha: Option<&mut [u16]>,
|
||||
) {
|
||||
let (w, n) = (w.max(1), w.saturating_mul(h));
|
||||
let kg = 1.0 - kr - kb;
|
||||
let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr));
|
||||
let rgb_at = |i: usize| -> [f32; 3] {
|
||||
match hdr {
|
||||
Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]),
|
||||
None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]),
|
||||
}
|
||||
};
|
||||
y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| {
|
||||
for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() {
|
||||
let [r, g, b] = rgb_at(row * w + x);
|
||||
let yy = kr * r + kg * g + kb * b;
|
||||
*yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16;
|
||||
*cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16;
|
||||
*crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16;
|
||||
}
|
||||
});
|
||||
if let (Some(a), None) = (alpha, hdr) {
|
||||
for (i, dst) in a.iter_mut().enumerate().take(n) {
|
||||
*dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally).
|
||||
pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) {
|
||||
let cw = w.div_ceil(2);
|
||||
|
|
@ -1051,6 +1205,8 @@ pub fn prores_profile(name: &str) -> filmcraft_prores::Profile {
|
|||
"proxy" => Profile::Proxy,
|
||||
"lt" => Profile::Lt,
|
||||
"standard" | "422" => Profile::Standard,
|
||||
"4444" => Profile::P4444,
|
||||
"4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq,
|
||||
_ => Profile::Hq,
|
||||
}
|
||||
}
|
||||
|
|
@ -1089,7 +1245,10 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSet
|
|||
if s.signal.is_hdr() {
|
||||
cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix };
|
||||
}
|
||||
Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||
let alpha = s.keeps_alpha();
|
||||
cfg.encode_alpha = alpha;
|
||||
let enc = filmcraft_prores::Encoder::with_config(cfg);
|
||||
Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1442,6 +1601,8 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
|
|||
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| (u64::from(kbps) * 3 / 2).min(u64::from(u32::MAX)) as u32);
|
||||
cfg.rate = match s.bitrate_mode {
|
||||
BitrateMode::Cbr => filmcraft_h264enc::RateControl::Cbr { kbps },
|
||||
// the encoder clamps to 0..=51; a NaN from a hand-edited preset becomes the default
|
||||
BitrateMode::Crf => filmcraft_h264enc::RateControl::Crf(if s.crf.is_finite() { s.crf } else { DEFAULT_CRF }),
|
||||
_ => filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: max },
|
||||
};
|
||||
cfg.pass = match &s.h264_pass {
|
||||
|
|
@ -1530,11 +1691,32 @@ pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
|
|||
}
|
||||
|
||||
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
|
||||
/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB.
|
||||
/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written
|
||||
/// as RGB.
|
||||
/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another).
|
||||
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
|
||||
encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality)
|
||||
}
|
||||
|
||||
/// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats).
|
||||
pub fn encode_still_with_quality(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool, quality: u8) -> Result<Vec<u8>> {
|
||||
let enc = |e: image::ImageError| ExportError::Encode(e.to_string());
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
match format {
|
||||
Format::JpegSequence => {
|
||||
let rgb = raster::rgb(&rgba, w, h)?;
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)),
|
||||
&rgb,
|
||||
w,
|
||||
h,
|
||||
image::ExtendedColorType::Rgb8,
|
||||
)
|
||||
.map_err(enc)?
|
||||
}
|
||||
Format::TgaSequence => return raster::tga(&rgba, w, h, alpha),
|
||||
Format::DpxSequence => return raster::dpx(&rgba, w, h),
|
||||
Format::ExrSequence => return raster::exr(&rgba, w, h, alpha),
|
||||
Format::PngSequence => {
|
||||
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)?
|
||||
}
|
||||
|
|
@ -1571,23 +1753,45 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
|||
let cancelled = || progress.cancel.load(Ordering::Relaxed);
|
||||
let batch = rayon::current_num_threads().clamp(2, 16) as i64;
|
||||
let (bytes, nframes) = match settings.format {
|
||||
Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?,
|
||||
Format::Wav | Format::Aiff => {
|
||||
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
|
||||
let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff };
|
||||
let frames = a.remaining();
|
||||
// the sizes are known up front: refuse an AIFF that cannot hold them before measuring
|
||||
let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?;
|
||||
// one second per chunk: memory stays flat however long the range is
|
||||
let chunk = i64::from(sr.max(1));
|
||||
if !settings.part_of_batch {
|
||||
progress.total.store(1, Ordering::Relaxed);
|
||||
progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed);
|
||||
progress.set_status(format!("Exporting audio ({})", settings.format.label()));
|
||||
}
|
||||
let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?;
|
||||
a.measure(settings, sources, &cancelled)?;
|
||||
*progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness;
|
||||
let planar = a.rest(sources).unwrap_or_else(|| vec![Vec::new(); a.channels]);
|
||||
let inter = audio_out::interleave(&planar);
|
||||
let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits);
|
||||
let data = if settings.format == Format::Wav { pcm::write_wav(&inter, ch, sr, bits) } else { pcm::write_aiff(&inter, ch, sr, bits) };
|
||||
let n = write_output(settings, &settings.path, data)?;
|
||||
progress.done.store(1, Ordering::Relaxed);
|
||||
(n, planar.first().map_or(0, Vec::len) as u64)
|
||||
let mut out = Out::create(settings)?;
|
||||
let io = |e: std::io::Error| ExportError::Io(e.to_string());
|
||||
out.write_all(&head).map_err(io)?;
|
||||
let mut buf = Vec::new();
|
||||
while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) {
|
||||
if cancelled() {
|
||||
return Err(ExportError::Cancelled);
|
||||
}
|
||||
buf.clear();
|
||||
pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf);
|
||||
out.write_all(&buf).map_err(io)?;
|
||||
if !settings.part_of_batch {
|
||||
progress.done.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?;
|
||||
let n = out.finish(settings)?;
|
||||
if settings.part_of_batch {
|
||||
progress.done.store(1, Ordering::Relaxed);
|
||||
}
|
||||
(n, frames)
|
||||
}
|
||||
Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => {
|
||||
f if f.is_image_sequence() || f == Format::Gif => {
|
||||
let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?;
|
||||
let (f0, f1) = frame_span(pipe.rate, range);
|
||||
let count = (f1 - f0) as u64;
|
||||
|
|
@ -1622,7 +1826,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
|||
let written: Vec<Result<u64>> = (f..end)
|
||||
.into_par_iter()
|
||||
.map(|fi| {
|
||||
let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?;
|
||||
let data = encode_still_with_quality(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality)?;
|
||||
write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data)
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -1641,7 +1845,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
|||
}
|
||||
(total, count)
|
||||
}
|
||||
Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
|
||||
_ => {
|
||||
// Handled by the stepped exporter above; reaching here would be a dispatch bug.
|
||||
return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
|
|||
Profile::Proxy => 1,
|
||||
Profile::Lt => 2,
|
||||
Profile::Standard => 3,
|
||||
_ => 4,
|
||||
Profile::Hq => 4,
|
||||
Profile::P4444 => 5,
|
||||
Profile::P4444Xq => 6,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -53,9 +55,6 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc {
|
|||
_ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() },
|
||||
};
|
||||
let mut d = PictureDesc::new(coding, w, h);
|
||||
if matches!(coding, PictureCoding::ProRes { .. }) {
|
||||
d.depth = 10;
|
||||
}
|
||||
d.color = match settings.signal {
|
||||
s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq,
|
||||
s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
//! Audio-only files: WAV (RIFF, little-endian PCM) and AIFF (big-endian PCM), 16 or 24 bit, plus
|
||||
//! image-sequence file naming.
|
||||
//! Audio-only files: WAV (RIFF, little-endian PCM; RF64 past 4 GiB) and AIFF (big-endian PCM),
|
||||
//! 16 or 24 bit, plus image-sequence file naming.
|
||||
//!
|
||||
//! The header is written first from the known length ([`header`]), then the samples in chunks
|
||||
//! ([`encode`]) and the pad byte ([`pad`]), so an export streams to disk instead of building the
|
||||
//! file in memory.
|
||||
|
||||
/// The container of an audio-only export.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PcmContainer {
|
||||
Wav,
|
||||
Aiff,
|
||||
}
|
||||
|
||||
/// Quantise one sample to a signed integer of `bits` (16 or 24).
|
||||
fn quantise(s: f32, bits: u16) -> i32 {
|
||||
|
|
@ -7,41 +18,128 @@ fn quantise(s: f32, bits: u16) -> i32 {
|
|||
(s.clamp(-1.0, 1.0) * max).round() as i32
|
||||
}
|
||||
|
||||
/// The sample size written for a requested `bits`: 24, else 16.
|
||||
fn sample_bits(bits: u16) -> u16 {
|
||||
if bits >= 24 { 24 } else { 16 }
|
||||
}
|
||||
|
||||
/// Bytes of sample data for `frames` frames, or None when that does not fit 64 bits.
|
||||
fn data_len(frames: u64, channels: u16, bits: u16) -> Option<u64> {
|
||||
frames.checked_mul(u64::from(channels))?.checked_mul(u64::from(sample_bits(bits) / 8))
|
||||
}
|
||||
|
||||
/// The pad byte after an odd-sized data chunk (RIFF and AIFF chunks are word-aligned).
|
||||
pub fn pad(frames: u64, channels: u16, bits: u16) -> &'static [u8] {
|
||||
if data_len(frames, channels, bits).is_some_and(|n| n & 1 == 1) { &[0] } else { &[] }
|
||||
}
|
||||
|
||||
/// Everything before the samples of a file of `frames` frames. A WAV whose RIFF size would not
|
||||
/// fit 32 bits (4 GiB: 24-bit 5.1 at 96 kHz after about 41 minutes) is written as RF64 (EBU Tech
|
||||
/// 3306), with the 64-bit sizes in a `ds64` chunk. AIFF has no 64-bit form, so a longer AIFF is
|
||||
/// an error naming WAV instead.
|
||||
pub fn header(container: PcmContainer, channels: u16, sample_rate: u32, bits: u16, frames: u64) -> Result<Vec<u8>, String> {
|
||||
let too_long = || "the audio is too long to write".to_string();
|
||||
let bits = sample_bits(bits);
|
||||
let bps = bits / 8;
|
||||
let data = data_len(frames, channels, bits).ok_or_else(too_long)?;
|
||||
let block_align = channels.checked_mul(bps).ok_or("too many audio channels")?;
|
||||
match container {
|
||||
PcmContainer::Wav => {
|
||||
let byte_rate = u32::try_from(u64::from(sample_rate) * u64::from(block_align)).map_err(|_| "the audio sample rate is too high".to_string())?;
|
||||
let ext = channels > 2;
|
||||
let fmt_len: u32 = if ext { 40 } else { 16 };
|
||||
let padded = data.checked_add(data & 1).ok_or_else(too_long)?;
|
||||
// "WAVE" + fmt chunk + data chunk header + samples
|
||||
let riff = (4 + 8 + u64::from(fmt_len) + 8).checked_add(padded).ok_or_else(too_long)?;
|
||||
let rf64 = riff > u64::from(u32::MAX);
|
||||
let mut v = Vec::with_capacity(80);
|
||||
if rf64 {
|
||||
const DS64: u64 = 8 + 28;
|
||||
v.extend_from_slice(b"RF64");
|
||||
v.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
v.extend_from_slice(b"WAVEds64");
|
||||
v.extend_from_slice(&28u32.to_le_bytes());
|
||||
v.extend_from_slice(&riff.checked_add(DS64).ok_or_else(too_long)?.to_le_bytes());
|
||||
v.extend_from_slice(&data.to_le_bytes());
|
||||
v.extend_from_slice(&frames.to_le_bytes());
|
||||
v.extend_from_slice(&0u32.to_le_bytes()); // no table entries
|
||||
} else {
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&(riff as u32).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVE");
|
||||
}
|
||||
v.extend_from_slice(b"fmt ");
|
||||
v.extend_from_slice(&fmt_len.to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
v.extend_from_slice(&block_align.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
if ext {
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
|
||||
v.extend_from_slice(&22u16.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
|
||||
v.extend_from_slice(&mask.to_le_bytes());
|
||||
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
|
||||
}
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&(if rf64 { u32::MAX } else { data as u32 }).to_le_bytes());
|
||||
Ok(v)
|
||||
}
|
||||
PcmContainer::Aiff => {
|
||||
let aiff_too_long = || "AIFF holds at most 4 GB of audio: export WAV, which has no such limit".to_string();
|
||||
let frames32 = u32::try_from(frames).map_err(|_| aiff_too_long())?;
|
||||
// SSND: offset + block size + samples
|
||||
let ssnd = u32::try_from(data.checked_add(8).ok_or_else(aiff_too_long)?).map_err(|_| aiff_too_long())?;
|
||||
let form = (4u32 + (8 + 18)).checked_add(8).and_then(|n| n.checked_add(ssnd)).and_then(|n| n.checked_add(ssnd & 1)).ok_or_else(aiff_too_long)?;
|
||||
let mut v = Vec::with_capacity(54);
|
||||
v.extend_from_slice(b"FORM");
|
||||
v.extend_from_slice(&form.to_be_bytes());
|
||||
v.extend_from_slice(b"AIFFCOMM");
|
||||
v.extend_from_slice(&18u32.to_be_bytes());
|
||||
v.extend_from_slice(&channels.to_be_bytes());
|
||||
v.extend_from_slice(&frames32.to_be_bytes());
|
||||
v.extend_from_slice(&bits.to_be_bytes());
|
||||
v.extend_from_slice(&extended(sample_rate));
|
||||
v.extend_from_slice(b"SSND");
|
||||
v.extend_from_slice(&ssnd.to_be_bytes());
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // offset
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // block size
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append interleaved samples as `bits`-bit PCM (little-endian for WAV, big-endian for AIFF).
|
||||
pub fn encode(container: PcmContainer, interleaved: &[f32], bits: u16, out: &mut Vec<u8>) {
|
||||
let bits = sample_bits(bits);
|
||||
let bps = usize::from(bits / 8);
|
||||
out.reserve(interleaved.len().saturating_mul(bps));
|
||||
for &s in interleaved {
|
||||
let q = quantise(s, bits);
|
||||
match container {
|
||||
PcmContainer::Wav => out.extend_from_slice(q.to_le_bytes().get(..bps).unwrap_or_default()),
|
||||
PcmContainer::Aiff => out.extend_from_slice(q.to_be_bytes().get(4 - bps..).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole file in memory (short audio; exports stream with [`header`] and [`encode`]).
|
||||
fn write_whole(container: PcmContainer, interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
let frames = (interleaved.len() / usize::from(channels.max(1))) as u64;
|
||||
let mut v = header(container, channels, sample_rate, bits, frames)?;
|
||||
encode(container, interleaved, bits, &mut v);
|
||||
v.extend_from_slice(pad(frames, channels, bits));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
|
||||
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
|
||||
/// = `0x3F`).
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
|
||||
let bits: u16 = if bits >= 24 { 24 } else { 16 };
|
||||
let bps = (bits / 8) as u32;
|
||||
let data_len = interleaved.len() as u32 * bps;
|
||||
let ext = channels > 2;
|
||||
let fmt_len: u32 = if ext { 40 } else { 16 };
|
||||
let mut v = Vec::with_capacity(28 + fmt_len as usize + data_len as usize);
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&(20 + fmt_len + data_len).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVEfmt ");
|
||||
v.extend_from_slice(&fmt_len.to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&(sample_rate * channels as u32 * bps).to_le_bytes());
|
||||
v.extend_from_slice(&(channels * bps as u16).to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
if ext {
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
|
||||
v.extend_from_slice(&22u16.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
|
||||
v.extend_from_slice(&mask.to_le_bytes());
|
||||
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
|
||||
}
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&data_len.to_le_bytes());
|
||||
for &s in interleaved {
|
||||
let q = quantise(s, bits).to_le_bytes();
|
||||
v.extend_from_slice(&q[..bps as usize]);
|
||||
}
|
||||
v
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Wav, interleaved, channels, sample_rate, bits)
|
||||
}
|
||||
|
||||
/// IEEE 754 80-bit extended encoding of a sample rate (AIFF `COMM`).
|
||||
|
|
@ -59,34 +157,8 @@ fn extended(rate: u32) -> [u8; 10] {
|
|||
}
|
||||
|
||||
/// An AIFF file (`FORM`/`AIFF`, `COMM` + `SSND`) of interleaved samples.
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
|
||||
let bits: u16 = if bits >= 24 { 24 } else { 16 };
|
||||
let bps = (bits / 8) as usize;
|
||||
let frames = interleaved.len() / channels.max(1) as usize;
|
||||
let data_len = (interleaved.len() * bps) as u32;
|
||||
let ssnd_len = 8 + data_len;
|
||||
let form_len = 4 + (8 + 18) + (8 + ssnd_len) + (ssnd_len & 1);
|
||||
let mut v = Vec::with_capacity(form_len as usize + 8);
|
||||
v.extend_from_slice(b"FORM");
|
||||
v.extend_from_slice(&form_len.to_be_bytes());
|
||||
v.extend_from_slice(b"AIFFCOMM");
|
||||
v.extend_from_slice(&18u32.to_be_bytes());
|
||||
v.extend_from_slice(&channels.to_be_bytes());
|
||||
v.extend_from_slice(&(frames as u32).to_be_bytes());
|
||||
v.extend_from_slice(&bits.to_be_bytes());
|
||||
v.extend_from_slice(&extended(sample_rate));
|
||||
v.extend_from_slice(b"SSND");
|
||||
v.extend_from_slice(&ssnd_len.to_be_bytes());
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // offset
|
||||
v.extend_from_slice(&0u32.to_be_bytes()); // block size
|
||||
for &s in interleaved {
|
||||
let q = quantise(s, bits).to_be_bytes();
|
||||
v.extend_from_slice(&q[4 - bps..]);
|
||||
}
|
||||
if ssnd_len & 1 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v
|
||||
pub fn write_aiff(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Result<Vec<u8>, String> {
|
||||
write_whole(PcmContainer::Aiff, interleaved, channels, sample_rate, bits)
|
||||
}
|
||||
|
||||
/// File name of frame `index` of an image sequence of `count` frames exported to `path`:
|
||||
|
|
@ -126,12 +198,98 @@ mod tests {
|
|||
#[test]
|
||||
fn wav_and_aiff_layout() {
|
||||
let s = [0.5f32, -0.5, 1.0, -1.0];
|
||||
let w = write_wav(&s, 2, 48_000, 24);
|
||||
let w = write_wav(&s, 2, 48_000, 24).unwrap();
|
||||
assert_eq!(w.len(), 44 + 12);
|
||||
assert_eq!(&w[34..36], &24u16.to_le_bytes());
|
||||
let a = write_aiff(&s, 2, 48_000, 16);
|
||||
let a = write_aiff(&s, 2, 48_000, 16).unwrap();
|
||||
assert_eq!(&a[..4], b"FORM");
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert_eq!(&a[a.len() - 2..], &(-32767i16).to_be_bytes());
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn le64(b: &[u8], at: usize) -> u64 {
|
||||
u64::from_le_bytes(b[at..at + 8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// 24-bit 5.1 at 96 kHz passes 4 GiB after about 41 minutes; the sizes overflowed `u32`
|
||||
/// (a panic in debug builds, a corrupt header in release).
|
||||
#[test]
|
||||
fn wav_past_4_gib_is_rf64() {
|
||||
let frames = 96_000u64 * 60 * 60; // an hour: 6.2 GB
|
||||
let h = header(PcmContainer::Wav, 6, 96_000, 24, frames).unwrap();
|
||||
let data = frames * 6 * 3;
|
||||
assert_eq!(&h[..4], b"RF64");
|
||||
assert_eq!(le32(&h, 4), u32::MAX);
|
||||
assert_eq!(&h[8..16], b"WAVEds64");
|
||||
assert_eq!(le32(&h, 16), 28);
|
||||
// the 64-bit RIFF size covers everything after the first 8 bytes
|
||||
assert_eq!(le64(&h, 20), h.len() as u64 - 8 + data);
|
||||
assert_eq!((le64(&h, 28), le64(&h, 36), le32(&h, 44)), (data, frames, 0));
|
||||
assert_eq!(&h[48..52], b"fmt ");
|
||||
assert_eq!(&h[h.len() - 8..h.len() - 4], b"data");
|
||||
assert_eq!(le32(&h, h.len() - 4), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_up_to_4_gib_stays_riff() {
|
||||
// the longest stereo 16-bit RIFF: 36 bytes of header after the size field
|
||||
let max = (u64::from(u32::MAX) - 36) / 4;
|
||||
let h = header(PcmContainer::Wav, 2, 48_000, 16, max).unwrap();
|
||||
assert_eq!(&h[..4], b"RIFF");
|
||||
assert_eq!(u64::from(le32(&h, 4)), 36 + max * 4);
|
||||
assert_eq!(u64::from(le32(&h, 40)), max * 4);
|
||||
assert_eq!(&header(PcmContainer::Wav, 2, 48_000, 16, max + 1).unwrap()[..4], b"RF64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_past_4_gib_is_refused_not_wrapped() {
|
||||
let e = header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60 * 8).unwrap_err();
|
||||
assert!(e.contains("export WAV"), "{e}");
|
||||
assert!(header(PcmContainer::Aiff, 2, 48_000, 24, 48_000 * 60 * 60).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
for c in [PcmContainer::Wav, PcmContainer::Aiff] {
|
||||
assert!(header(c, 2, 48_000, 24, u64::MAX).is_err());
|
||||
assert!(header(c, u16::MAX, 48_000, 24, 1).is_err());
|
||||
}
|
||||
assert!(header(PcmContainer::Wav, 6, u32::MAX, 24, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_data_chunks_are_padded() {
|
||||
// one 24-bit mono frame: 3 bytes of data, one pad byte, counted in the RIFF size
|
||||
let w = write_wav(&[0.25], 1, 48_000, 24).unwrap();
|
||||
assert_eq!((w.len(), le32(&w, 4) as usize, le32(&w, 40)), (48, 40, 3));
|
||||
let a = write_aiff(&[0.25], 1, 48_000, 24).unwrap();
|
||||
assert_eq!(u32::from_be_bytes(a[4..8].try_into().unwrap()) as usize, a.len() - 8);
|
||||
assert!(pad(2, 1, 24).is_empty());
|
||||
}
|
||||
|
||||
/// A reader takes the RF64 sizes: ffprobe reports the duration from `ds64` (skipped without it).
|
||||
#[test]
|
||||
fn ffprobe_reads_rf64_duration() {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-rf64-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("long.wav");
|
||||
let frames = 48_000u64 * 60 * 60 * 5; // 5 hours of 24-bit stereo: 5.2 GB
|
||||
let mut f = header(PcmContainer::Wav, 2, 48_000, 24, frames).unwrap();
|
||||
encode(PcmContainer::Wav, &[0.0; 9_600], 24, &mut f);
|
||||
std::fs::write(&path, &f).unwrap();
|
||||
let out = std::process::Command::new("ffprobe")
|
||||
.args(["-v", "error", "-show_entries", "format=format_name,duration", "-of", "default=nw=1"])
|
||||
.arg(&path)
|
||||
.output();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let Ok(out) = out else { return };
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("format_name=wav"), "{text}");
|
||||
let secs: f64 = text.lines().find_map(|l| l.strip_prefix("duration=")).and_then(|d| d.parse().ok()).unwrap_or(0.0);
|
||||
assert!((secs - 18_000.0).abs() < 1.0, "{text}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use filmcraft_time::FrameRate;
|
|||
use rayon::prelude::*;
|
||||
|
||||
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
|
||||
use crate::{ExportError, ExportSettings, Format, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
|
||||
use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame};
|
||||
|
||||
/// Where the rendered picture lands in the output frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
|
@ -201,7 +201,7 @@ impl Pipeline {
|
|||
geom,
|
||||
opts,
|
||||
hdr_out,
|
||||
alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence),
|
||||
alpha: settings.keeps_alpha(),
|
||||
out_tf,
|
||||
effects,
|
||||
overlay,
|
||||
|
|
@ -213,8 +213,12 @@ impl Pipeline {
|
|||
pub fn frame(&self, f: i64, sources: &dyn SourceProvider) -> (Vec<u8>, Vec<f32>) {
|
||||
let t = self.rate.tick_of(f);
|
||||
// The registered GPU renderer goes first; `None` (no adapter, a plan the GPU cannot
|
||||
// draw, any internal error) falls back to the CPU reference renderer below.
|
||||
let gpu = match self.renderer.as_ref() {
|
||||
// draw, any internal error) falls back to the CPU reference renderer below. A frame the
|
||||
// planner hands back as one CPU image anyway (an adjustment layer, a complex transition,
|
||||
// HDR) does not take a pooled renderer: it would render on the CPU inside the pool, as
|
||||
// many frames at a time as there are renderers instead of one per export thread.
|
||||
let pool = self.renderer.as_ref().filter(|_| !filmcraft_render::plan::is_cpu_frame(&self.project, self.seq, t));
|
||||
let gpu = match pool {
|
||||
Some(pool) => {
|
||||
let asked = web_time::Instant::now();
|
||||
pool.with(|r| {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
|||
p("Apple ProRes 422", bc, "QuickTime, ProRes 422, 24-bit 48 kHz PCM", mov(Format::ProRes, "standard")),
|
||||
p("Apple ProRes 422 LT", bc, "QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM", mov(Format::ProRes, "lt")),
|
||||
p("Apple ProRes 422 Proxy", bc, "QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM", mov(Format::ProRes, "proxy")),
|
||||
p("Apple ProRes 4444", bc, "QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444")),
|
||||
p(
|
||||
"Apple ProRes 4444 with Alpha",
|
||||
bc,
|
||||
"QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM",
|
||||
ExportSettings { alpha: true, ..mov(Format::ProRes, "4444") },
|
||||
),
|
||||
p("Apple ProRes 4444 XQ", bc, "QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444xq")),
|
||||
p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")),
|
||||
p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")),
|
||||
p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")),
|
||||
|
|
@ -144,6 +152,20 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
|||
),
|
||||
p("TIFF Sequence", "Image Sequence", "Numbered TIFF stills (RGB, 8-bit)", ExportSettings { format: Format::TiffSequence, ..Default::default() }),
|
||||
p("BMP Sequence", "Image Sequence", "Numbered BMP stills (24-bit)", ExportSettings { format: Format::BmpSequence, ..Default::default() }),
|
||||
p("JPEG Sequence", "Image Sequence", "Numbered JPEG stills (quality 90)", ExportSettings { format: Format::JpegSequence, ..Default::default() }),
|
||||
p(
|
||||
"Targa Sequence",
|
||||
"Image Sequence",
|
||||
"Numbered Targa stills (24-bit, uncompressed)",
|
||||
ExportSettings { format: Format::TgaSequence, ..Default::default() },
|
||||
),
|
||||
p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }),
|
||||
p(
|
||||
"OpenEXR Sequence",
|
||||
"Image Sequence",
|
||||
"Numbered OpenEXR stills (half float, linear, uncompressed)",
|
||||
ExportSettings { format: Format::ExrSequence, ..Default::default() },
|
||||
),
|
||||
p(
|
||||
"Animated GIF 640×360",
|
||||
"Animated GIF",
|
||||
|
|
@ -153,6 +175,16 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
|
|||
p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)),
|
||||
p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)),
|
||||
p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)),
|
||||
p(
|
||||
"AAC Audio 48 kHz 320 kbps",
|
||||
"Audio Only",
|
||||
"MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps",
|
||||
ExportSettings {
|
||||
format: Format::Aac,
|
||||
audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 },
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
//! Uncompressed still formats written directly from RGBA8 frames: Targa, DPX and OpenEXR.
|
||||
//!
|
||||
//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed
|
||||
//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268
|
||||
//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit
|
||||
//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry.
|
||||
//! OpenEXR follows the public OpenEXR file layout (version 2, single-part scan-line file): half-float
|
||||
//! B, G, R (and A) channels, no compression, one scan line per chunk, scene-linear Rec.709 values
|
||||
//! with premultiplied alpha, as EXR readers expect.
|
||||
use crate::{ExportError, Result};
|
||||
|
||||
/// `w * h * 4` when `rgba` holds exactly that many bytes.
|
||||
fn checked_len(rgba: &[u8], w: u32, h: u32) -> Result<usize> {
|
||||
let n = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(4)).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
if w == 0 || h == 0 || rgba.len() != n {
|
||||
return Err(ExportError::Encode("frame size".into()));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The RGB bytes of an RGBA8 frame (alpha dropped).
|
||||
pub(crate) fn rgb(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
checked_len(rgba, w, h)?;
|
||||
Ok(rgba.as_chunks::<4>().0.iter().flat_map(|&[r, g, b, _]| [r, g, b]).collect())
|
||||
}
|
||||
|
||||
/// An uncompressed Targa file: 24-bit BGR, or 32-bit BGRA with `alpha` (straight alpha, as the
|
||||
/// frame carries it).
|
||||
pub(crate) fn tga(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
|
||||
checked_len(rgba, w, h)?;
|
||||
let (tw, th) = match (u16::try_from(w), u16::try_from(h)) {
|
||||
(Ok(tw), Ok(th)) => (tw, th),
|
||||
_ => return Err(ExportError::Unsupported(format!("Targa frames are at most 65535×65535 (got {w}×{h})"))),
|
||||
};
|
||||
let bpp: usize = if alpha { 4 } else { 3 };
|
||||
let row = w as usize * 4;
|
||||
let mut out = Vec::with_capacity(18 + w as usize * h as usize * bpp + 26);
|
||||
out.extend_from_slice(&[0, 0, 2]); // no ID, no colour map, uncompressed true-colour
|
||||
out.extend_from_slice(&[0; 5]); // colour map specification (unused)
|
||||
out.extend_from_slice(&[0, 0, 0, 0]); // x / y origin
|
||||
out.extend_from_slice(&tw.to_le_bytes());
|
||||
out.extend_from_slice(&th.to_le_bytes());
|
||||
out.push(if alpha { 32 } else { 24 });
|
||||
out.push(if alpha { 8 } else { 0 }); // attribute bits; bottom-left origin
|
||||
// bottom-left origin: the last frame row comes first
|
||||
for line in rgba.chunks_exact(row).rev() {
|
||||
for &[r, g, b, a] in line.as_chunks::<4>().0 {
|
||||
out.extend_from_slice(&[b, g, r]);
|
||||
if alpha {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TGA 2.0 footer without extension or developer areas
|
||||
out.extend_from_slice(&[0; 8]);
|
||||
out.extend_from_slice(b"TRUEVISION-XFILE.\0");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Header size of the DPX files we write (file + image + orientation + film + TV headers).
|
||||
const DPX_HEADER: usize = 2048;
|
||||
|
||||
/// A DPX file: 10-bit RGB (8-bit values widened by bit replication, so 0 → 0 and 255 → 1023).
|
||||
pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
let n = checked_len(rgba, w, h)?;
|
||||
// one 32-bit word per pixel; `n` is already `w * h * 4`
|
||||
let size = n.checked_add(DPX_HEADER).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
let file_size = u32::try_from(size).map_err(|_| ExportError::Unsupported(format!("a {w}×{h} DPX frame exceeds 4 GB")))?;
|
||||
// SMPTE ST 268: undefined numeric fields are all ones, undefined text fields are NUL
|
||||
let mut hd = vec![0xFF_u8; DPX_HEADER];
|
||||
for (a, b) in [
|
||||
(36, 660), // file name, creation time, creator, project, copyright
|
||||
(664, 768), // reserved
|
||||
(1356, 1408), // reserved
|
||||
(1432, 1620), // source file name, time, input device, serial number
|
||||
(1644, 1664), // reserved
|
||||
(1664, 1712), // film manufacturer, type, offset, prefix, count, format
|
||||
(1732, 1920), // frame id, slate, reserved
|
||||
(1972, 2048), // reserved
|
||||
] {
|
||||
hd[a..b].fill(0);
|
||||
}
|
||||
for e in 0..8 {
|
||||
let d = 780 + e * 72 + 40;
|
||||
hd[d..d + 32].fill(0); // element descriptions
|
||||
}
|
||||
let put32 = |hd: &mut Vec<u8>, at: usize, v: u32| hd[at..at + 4].copy_from_slice(&v.to_be_bytes());
|
||||
let put16 = |hd: &mut Vec<u8>, at: usize, v: u16| hd[at..at + 2].copy_from_slice(&v.to_be_bytes());
|
||||
// file information header
|
||||
hd[0..4].copy_from_slice(b"SDPX");
|
||||
put32(&mut hd, 4, DPX_HEADER as u32); // offset to the image data
|
||||
hd[8..16].copy_from_slice(b"V2.0\0\0\0\0");
|
||||
put32(&mut hd, 16, file_size);
|
||||
put32(&mut hd, 20, 1); // ditto key: new frame
|
||||
put32(&mut hd, 24, 1664); // generic header length
|
||||
put32(&mut hd, 28, 384); // industry header length
|
||||
put32(&mut hd, 32, 0); // user data length
|
||||
hd[160..169].copy_from_slice(b"FilmCraft");
|
||||
// image information header
|
||||
put16(&mut hd, 768, 0); // orientation: left to right, top to bottom
|
||||
put16(&mut hd, 770, 1); // one image element
|
||||
put32(&mut hd, 772, w);
|
||||
put32(&mut hd, 776, h);
|
||||
let el = 780;
|
||||
put32(&mut hd, el, 0); // unsigned
|
||||
put32(&mut hd, el + 4, 0); // reference low data code
|
||||
put32(&mut hd, el + 12, 1023); // reference high data code
|
||||
hd[el + 20] = 50; // descriptor: RGB
|
||||
hd[el + 21] = 6; // transfer: ITU-R BT.709
|
||||
hd[el + 22] = 6; // colorimetric: ITU-R BT.709
|
||||
hd[el + 23] = 10; // bit depth
|
||||
put16(&mut hd, el + 24, 1); // packing: filled to 32-bit words, method A
|
||||
put16(&mut hd, el + 26, 0); // no encoding (uncompressed)
|
||||
put32(&mut hd, el + 28, DPX_HEADER as u32); // offset to the element's data
|
||||
put32(&mut hd, el + 32, 0); // end-of-line padding
|
||||
put32(&mut hd, el + 36, 0); // end-of-image padding
|
||||
// image orientation header: no offset, square pixels
|
||||
put32(&mut hd, 1408, 0);
|
||||
put32(&mut hd, 1412, 0);
|
||||
put32(&mut hd, 1424, w);
|
||||
put32(&mut hd, 1428, h);
|
||||
put32(&mut hd, 1628, 1);
|
||||
put32(&mut hd, 1632, 1);
|
||||
// television header: progressive
|
||||
hd[1928] = 0;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
out.extend_from_slice(&hd);
|
||||
let ten = |v: u8| (u32::from(v) << 2) | (u32::from(v) >> 6);
|
||||
for &[r, g, b, _] in rgba.as_chunks::<4>().0 {
|
||||
out.extend_from_slice(&((ten(r) << 22) | (ten(g) << 12) | (ten(b) << 2)).to_be_bytes());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// IEEE 754 binary16 bits of `v`, rounded to nearest even (overflow → infinity, NaN → quiet NaN).
|
||||
fn half(v: f32) -> u16 {
|
||||
let b = v.to_bits();
|
||||
let sign = ((b >> 16) & 0x8000) as u16;
|
||||
let exp = ((b >> 23) & 0xFF) as i32;
|
||||
let man = b & 0x7F_FFFF;
|
||||
if exp == 0xFF {
|
||||
return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 };
|
||||
}
|
||||
// rounds `m >> shift` to nearest, ties to even (`shift` is 1..=24)
|
||||
let round = |m: u32, shift: u32| {
|
||||
let (q, rem, mid) = (m >> shift, m & ((1 << shift) - 1), 1 << (shift - 1));
|
||||
if rem > mid || (rem == mid && q & 1 == 1) { q + 1 } else { q }
|
||||
};
|
||||
let e = exp - 127 + 15;
|
||||
if e >= 0x1F {
|
||||
sign | 0x7C00
|
||||
} else if e <= 0 {
|
||||
if e < -10 {
|
||||
return sign;
|
||||
}
|
||||
// subnormal half: the 24-bit significand scaled by 2^(e - 14); rounding up may carry
|
||||
// into the smallest normal, which is the right encoding
|
||||
sign | round(man | 0x80_0000, (14 - e) as u32) as u16
|
||||
} else {
|
||||
// a carry out of the mantissa bumps the exponent (up to infinity), as it should
|
||||
sign | round(((e as u32) << 23) | man, 13) as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// An OpenEXR file: half-float scene-linear Rec.709 RGB, or RGBA with `alpha` (premultiplied, as
|
||||
/// EXR defines alpha; the frame's straight alpha is multiplied in after linearisation).
|
||||
pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
|
||||
let n = checked_len(rgba, w, h)?;
|
||||
let (Ok(x_max), Ok(y_max)) = (i32::try_from(w - 1), i32::try_from(h - 1)) else {
|
||||
return Err(ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")));
|
||||
};
|
||||
// channels in the alphabetical order the file layout requires
|
||||
let names: &[u8] = if alpha { b"ABGR" } else { b"BGR" };
|
||||
let line = (w as usize).checked_mul(names.len() * 2).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
let line_size = i32::try_from(line).map_err(|_| ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")))?;
|
||||
|
||||
let mut hd = Vec::with_capacity(512);
|
||||
hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630
|
||||
hd.extend_from_slice(&[2, 0, 0, 0]); // version 2, single-part scan-line file, short names
|
||||
let attr = |hd: &mut Vec<u8>, name: &str, ty: &str, value: &[u8]| {
|
||||
for s in [name, ty] {
|
||||
hd.extend_from_slice(s.as_bytes());
|
||||
hd.push(0);
|
||||
}
|
||||
hd.extend_from_slice(&(value.len() as i32).to_le_bytes());
|
||||
hd.extend_from_slice(value);
|
||||
};
|
||||
let le = |v: &[f32]| v.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut channels = Vec::new();
|
||||
for &c in names {
|
||||
channels.extend_from_slice(&[c, 0]);
|
||||
channels.extend_from_slice(&1_i32.to_le_bytes()); // pixel type HALF
|
||||
channels.extend_from_slice(&[0, 0, 0, 0]); // pLinear 0, reserved
|
||||
channels.extend_from_slice(&1_i32.to_le_bytes()); // x sampling
|
||||
channels.extend_from_slice(&1_i32.to_le_bytes()); // y sampling
|
||||
}
|
||||
channels.push(0);
|
||||
let window: Vec<u8> = [0, 0, x_max, y_max].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
attr(&mut hd, "channels", "chlist", &channels);
|
||||
attr(&mut hd, "chromaticities", "chromaticities", &le(&[0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290]));
|
||||
attr(&mut hd, "compression", "compression", &[0]); // NO_COMPRESSION
|
||||
attr(&mut hd, "dataWindow", "box2i", &window);
|
||||
attr(&mut hd, "displayWindow", "box2i", &window);
|
||||
attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y
|
||||
attr(&mut hd, "pixelAspectRatio", "float", &le(&[1.0]));
|
||||
attr(&mut hd, "screenWindowCenter", "v2f", &le(&[0.0, 0.0]));
|
||||
attr(&mut hd, "screenWindowWidth", "float", &le(&[1.0]));
|
||||
hd.push(0); // end of header
|
||||
|
||||
// offset table (one chunk per scan line), then the chunks: y, data size, pixel data
|
||||
let chunk = line + 8;
|
||||
let table = (h as usize).checked_mul(8);
|
||||
let body = (h as usize).checked_mul(chunk);
|
||||
let size = table.zip(body).and_then(|(t, b)| hd.len().checked_add(t)?.checked_add(b)).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
out.extend_from_slice(&hd);
|
||||
let first = (hd.len() + h as usize * 8) as u64;
|
||||
for y in 0..h as u64 {
|
||||
out.extend_from_slice(&(first + y * chunk as u64).to_le_bytes());
|
||||
}
|
||||
let lin = filmcraft_color::srgb_u8_to_linear_table();
|
||||
let opaque: [u16; 256] = std::array::from_fn(|i| half(lin[i]));
|
||||
let row = w as usize * 4;
|
||||
let mut px = vec![0_u16; w as usize * names.len()];
|
||||
for (y, src) in rgba[..n].chunks_exact(row).enumerate() {
|
||||
// planar per line: every pixel of the first channel, then the next channel …
|
||||
let wn = w as usize;
|
||||
for (x, &[r, g, b, a]) in src.as_chunks::<4>().0.iter().enumerate() {
|
||||
let rgb = [b, g, r];
|
||||
if alpha {
|
||||
let k = f32::from(a) / 255.0;
|
||||
px[x] = half(k);
|
||||
for (c, &v) in rgb.iter().enumerate() {
|
||||
px[(c + 1) * wn + x] = if a == 255 { opaque[v as usize] } else { half(lin[v as usize] * k) };
|
||||
}
|
||||
} else {
|
||||
for (c, &v) in rgb.iter().enumerate() {
|
||||
px[c * wn + x] = opaque[v as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(y as i32).to_le_bytes());
|
||||
out.extend_from_slice(&line_size.to_le_bytes());
|
||||
out.extend(px.iter().flat_map(|v| v.to_le_bytes()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_be_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dpx_header_and_packing() {
|
||||
// 2×1: pure red, then (1, 128, 255)
|
||||
let px = [255, 0, 0, 255, 1, 128, 255, 7];
|
||||
let f = dpx(&px, 2, 1).unwrap();
|
||||
assert_eq!(&f[0..4], b"SDPX");
|
||||
assert_eq!(f.len(), 2048 + 8);
|
||||
assert_eq!(be32(&f, 4), 2048);
|
||||
assert_eq!(be32(&f, 16), f.len() as u32);
|
||||
assert_eq!((be32(&f, 772), be32(&f, 776)), (2, 1));
|
||||
assert_eq!((f[800], f[803]), (50, 10));
|
||||
assert_eq!(be32(&f, 2048), 1023 << 22);
|
||||
let p = be32(&f, 2052);
|
||||
assert_eq!(((p >> 22) & 1023, (p >> 12) & 1023, (p >> 2) & 1023, p & 3), (4, 514, 1023, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_rows_are_bottom_up_bgr() {
|
||||
// 1×2: top red, bottom blue (half transparent)
|
||||
let px = [255, 0, 0, 255, 0, 0, 255, 128];
|
||||
let f = tga(&px, 1, 2, false).unwrap();
|
||||
assert_eq!(&f[..3], &[0, 0, 2]);
|
||||
assert_eq!((f[16], f[17]), (24, 0));
|
||||
assert_eq!(&f[18..24], &[255, 0, 0, 0, 0, 255]); // blue row first, as BGR
|
||||
assert!(f.ends_with(b"TRUEVISION-XFILE.\0"));
|
||||
let f = tga(&px, 1, 2, true).unwrap();
|
||||
assert_eq!((f[16], f[17]), (32, 8));
|
||||
assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_floats_round_to_nearest_even() {
|
||||
for (v, h) in [
|
||||
(0.0, 0x0000),
|
||||
(-0.0, 0x8000),
|
||||
(1.0, 0x3C00),
|
||||
(0.5, 0x3800),
|
||||
(-2.0, 0xC000),
|
||||
(65504.0, 0x7BFF),
|
||||
(65520.0, 0x7C00), // halfway past the largest half: ties to even = infinity
|
||||
(1.0e9, 0x7C00), // overflow
|
||||
(6.103_515_6e-5, 0x0400), // smallest normal
|
||||
(5.960_464_5e-8, 0x0001), // smallest subnormal, 2^-24
|
||||
(2.980_232_2e-8, 0x0000), // 2^-25: halfway to zero, ties to even
|
||||
(1.0e-10, 0x0000),
|
||||
(1.000_488_3, 0x3C00), // 1 + 2^-11: halfway, ties to even (down)
|
||||
(1.001_464_8, 0x3C02), // 1 + 3·2^-11: halfway, ties to even (up)
|
||||
(f32::INFINITY, 0x7C00),
|
||||
] {
|
||||
assert_eq!(half(v), h, "{v}");
|
||||
}
|
||||
assert_eq!(half(f32::NAN) & 0x7E00, 0x7E00);
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Where `needle` (an attribute name, NUL-terminated) starts in the header.
|
||||
fn find(b: &[u8], needle: &[u8]) -> usize {
|
||||
b.windows(needle.len()).position(|w| w == needle).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_layout() {
|
||||
// 2×1: opaque white, then (255, 0, 0) at alpha 0.5
|
||||
let px = [255, 255, 255, 255, 255, 0, 0, 128];
|
||||
for alpha in [false, true] {
|
||||
let f = exr(&px, 2, 1, alpha).unwrap();
|
||||
let nch = if alpha { 4 } else { 3 };
|
||||
assert_eq!(&f[..8], &[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]);
|
||||
let ch = find(&f, b"channels\0chlist\0") + 16;
|
||||
assert_eq!(le32(&f, ch) as usize, nch * 18 + 1);
|
||||
let names: Vec<u8> = (0..nch).map(|i| f[ch + 4 + i * 18]).collect();
|
||||
assert_eq!(names, if alpha { b"ABGR".to_vec() } else { b"BGR".to_vec() });
|
||||
let dw = find(&f, b"dataWindow\0box2i\0") + 17;
|
||||
assert_eq!((le32(&f, dw), le32(&f, dw + 4), le32(&f, dw + 8), le32(&f, dw + 12)), (16, 0, 0, 1));
|
||||
let cmp = find(&f, b"compression\0compression\0") + 24;
|
||||
assert_eq!((le32(&f, cmp), f[cmp + 4]), (1, 0));
|
||||
// header ends with a NUL; one offset (scan line 0) points at the only chunk
|
||||
let end = f.len() - (8 + 2 * nch * 2) - 8;
|
||||
assert_eq!(f[end - 1], 0);
|
||||
let off = u64::from_le_bytes(f[end..end + 8].try_into().unwrap()) as usize;
|
||||
assert_eq!(off, end + 8);
|
||||
assert_eq!((le32(&f, off), le32(&f, off + 4) as usize), (0, 2 * nch * 2));
|
||||
let hv = |c: usize, x: usize| u16::from_le_bytes(f[off + 8 + (c * 2 + x) * 2..][..2].try_into().unwrap());
|
||||
let (b, g, r) = if alpha { (1, 2, 3) } else { (0, 1, 2) };
|
||||
// white is 1.0 everywhere; red is linear 1.0 · alpha (premultiplied) or 1.0 flat
|
||||
assert_eq!((hv(b, 0), hv(g, 0), hv(r, 0)), (0x3C00, 0x3C00, 0x3C00));
|
||||
assert_eq!((hv(b, 1), hv(g, 1)), (0, 0));
|
||||
if alpha {
|
||||
assert_eq!((hv(0, 0), hv(0, 1)), (0x3C00, half(128.0 / 255.0)));
|
||||
assert_eq!(hv(r, 1), half(128.0 / 255.0));
|
||||
} else {
|
||||
assert_eq!(hv(r, 1), 0x3C00);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear
|
||||
/// light, premultiplied alpha, rows top to bottom.
|
||||
#[test]
|
||||
fn exr_decodes_in_ffmpeg() {
|
||||
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR stills") else { return };
|
||||
let (w, h) = (5_u32, 3_u32);
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i * 17) as u8, (255 - i * 13) as u8, (i * 40 % 256) as u8, [255, 128, 0, 64][i as usize % 4]]).collect();
|
||||
let dir = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("exr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let lin = filmcraft_color::srgb_u8_to_linear_table();
|
||||
for alpha in [false, true] {
|
||||
let path = dir.join(format!("f{alpha}.exr"));
|
||||
std::fs::write(&path, exr(&px, w, h, alpha).unwrap()).unwrap();
|
||||
let out = std::process::Command::new(&ffmpeg)
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let n = (w * h) as usize;
|
||||
assert_eq!(out.stdout.len(), n * 4 * 4);
|
||||
let plane = |p: usize, i: usize| f32::from_le_bytes(out.stdout[(p * n + i) * 4..][..4].try_into().unwrap());
|
||||
for (i, &[r, g, b, a]) in px.as_chunks::<4>().0.iter().enumerate() {
|
||||
let k = if alpha { f32::from(a) / 255.0 } else { 1.0 };
|
||||
// gbrap planes: G, B, R, A
|
||||
let want = [lin[g as usize] * k, lin[b as usize] * k, lin[r as usize] * k, k];
|
||||
for (p, want) in want.into_iter().enumerate() {
|
||||
let got = plane(p, i);
|
||||
assert!((got - want).abs() <= 1e-3 + want * 1e-3, "alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_errors() {
|
||||
assert!(dpx(&[0; 7], 2, 1).is_err());
|
||||
assert!(dpx(&[], 0, 0).is_err());
|
||||
assert!(tga(&[0; 4], 1, 2, false).is_err());
|
||||
assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err());
|
||||
assert!(rgb(&[0; 3], 1, 1).is_err());
|
||||
assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err());
|
||||
assert!(exr(&[0; 4], u32::MAX, u32::MAX, true).is_err());
|
||||
assert!(exr(&[0; 7], 2, 1, false).is_err());
|
||||
assert!(exr(&[], 0, 1, false).is_err());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue