mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Merge origin/main into #421; GPU export opt-in, wgpu error handler, drop panicked renderers
- GpuRendering defaults to Off (opt-in per export) until measured on Windows and macOS; docs and ROADMAP say so. - The shared export device installs an uncaptured-error handler and a device-lost callback that log once and retire GPU export for the session (frames go to the CPU) instead of wgpu's default panic. - RendererPool drops a renderer that panicked and retires the pool, so the rest of the export renders on the CPU (regression test in gpu_hook_scenarios); the duplicate hostile-size test that raced on the global registry is folded into it. - Rebased onto main's render_plan/transitions: render_export_prepared reads the linear accumulator after render_plan; transition (Composite) plans fall back to the CPU. - es/ja rows for the new Export strings; web_time::Instant in the export pipeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,10 +31,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: Linux tarball installer
|
||||
run: python3 packaging/linux/test_install.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -62,6 +64,11 @@ jobs:
|
||||
assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
# Both install the app and the CLI/MCP server (#346).
|
||||
import re
|
||||
def programs(m):
|
||||
return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)}
|
||||
assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
- name: FreeBSD package layout (stub binaries)
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
@@ -71,17 +71,16 @@ jobs:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
@@ -101,7 +100,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -129,17 +128,16 @@ jobs:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -163,7 +161,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -187,9 +185,8 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
@@ -203,8 +200,8 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
@@ -228,7 +225,7 @@ jobs:
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -251,8 +248,8 @@ jobs:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -266,7 +263,7 @@ jobs:
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/filmcraft-$FILMCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
@@ -284,16 +281,15 @@ jobs:
|
||||
FILMCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
@@ -318,7 +314,7 @@ jobs:
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -334,17 +330,16 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
@@ -362,7 +357,7 @@ jobs:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
@@ -381,7 +376,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
|
||||
@@ -36,11 +36,11 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: filmcraft-windows-arm64
|
||||
path: dist
|
||||
|
||||
@@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
|
||||
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
|
||||
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
|
||||
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
|
||||
video decoding through VideoToolbox and Media Foundation / Direct3D 11; later encoding and Linux) and does nothing else. It
|
||||
video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It
|
||||
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
|
||||
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
|
||||
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).
|
||||
|
||||
+5
-1
@@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
|
||||
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
@@ -26,6 +27,8 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
|
||||
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
|
||||
| `assets/app-icon/filmcraft.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; canonical vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: FilmCraft macOS DMG window background, derived from the FilmCraft app icon (`assets/app-icon/filmcraft.svg`); rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
| `assets/app-icon/filmcraft-small.svg` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; lighter vector | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-1024.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; 1024 px render | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `assets/app-icon/filmcraft-macos-512.png` | Brandon Thomas (FilmCraft owner) | Original work: FilmCraft app icon (owl with ear tufts), drawn by the owner in ArtCraft and vectorised; macOS runtime Dock icon (512 px) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
@@ -99,6 +102,7 @@ is traced or derived from Adobe artwork.
|
||||
| Asset | Where |
|
||||
|---|---|
|
||||
| UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid |
|
||||
| Source and Program range braces and trim hover cues | `crates/ui-egui/src/panels/monitor.rs` and `source_range.rs`: original generic vector shapes by FilmCraft contributors, MIT OR Apache-2.0; no third-party artwork |
|
||||
| Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter |
|
||||
| Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` |
|
||||
| Built-in graphics templates (Lower Third – Slab / Rule, Ticker – Crawl, Title – Centered / Boxed, End Credits – Roll, Callout – Pointer / Tag) and their placeholder text | `crates/project/src/gtemplate.rs` (`builtin_templates`): original designs defined in code with the bundled Inter font; thumbnails are rendered at runtime, no template, image or font files |
|
||||
@@ -146,7 +150,7 @@ the original copyright and permission notice in its header.
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures, function table and constants transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
| `crates/platform/src/nvenc/ffi.rs` | NVIDIA Corporation (`nvEncodeAPI.h`); FilmCraft contributors (Rust transcription) | Structures (including `NV_ENC_CONFIG_HEVC` and its bit-field masks, `NV_ENC_PIC_PARAMS_HEVC`, `NV_ENC_SEI_PAYLOAD`, `NV_ENC_TIME_CODE`), function table, codec / profile GUIDs (H.264, HEVC Main, HEVC Main 10) and constants (`NV_ENC_LEVEL_HEVC_*`, tier, CU size, `NV_ENC_BUFFER_FORMAT_YUV420_10BIT`, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE`, BT.2020 / PQ / HLG VUI codes) transcribed from `nvEncodeAPI.h`, NVIDIA Video Codec SDK, API 12.1 (https://developer.nvidia.com/video-codec-sdk) | MIT (Copyright (c) 2010-2023 NVIDIA Corporation; notice kept in the file header) |
|
||||
|
||||
## Downloaded at runtime
|
||||
|
||||
|
||||
Generated
+65
-47
@@ -1665,7 +1665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1686,13 +1686,14 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1702,7 +1703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1710,7 +1711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1720,14 +1721,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1743,7 +1744,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1754,14 +1755,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1774,11 +1775,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1799,7 +1800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
@@ -1825,6 +1826,7 @@ dependencies = [
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
@@ -1832,7 +1834,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1840,7 +1842,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1850,8 +1852,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
@@ -1862,7 +1865,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -1893,7 +1896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
@@ -1923,11 +1926,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1935,7 +1939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -1945,7 +1949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1953,7 +1957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1970,7 +1974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
@@ -1989,7 +1993,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1999,7 +2003,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2009,7 +2013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2019,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2036,7 +2040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2047,7 +2051,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
@@ -2055,7 +2059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -2068,7 +2072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2077,7 +2081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2085,39 +2089,43 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-h264",
|
||||
"filmcraft-hevc",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"libloading",
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-media",
|
||||
@@ -2128,7 +2136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2142,7 +2150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2152,7 +2160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2172,7 +2180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2181,7 +2189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2199,25 +2207,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
"skrifa",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2225,7 +2234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"eframe",
|
||||
@@ -2261,7 +2270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -2270,7 +2279,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5794,6 +5803,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
@@ -7251,7 +7269,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
- **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out.
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
@@ -137,7 +137,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
|
||||
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter, and each animated parameter opens into **value and velocity graphs** with draggable influence handles.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
|
||||
|
||||
<p align="center">
|
||||
@@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: HEVC and AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox) and Windows (Media Foundation), with Linux to follow; H.264 export can use NVIDIA's encoder on Windows (opt-in) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
<br>
|
||||
|
||||
@@ -287,9 +287,9 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows; Linux has no hardware path yet. Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only there ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only delivery-codec export; no HEVC or AV1 export yet.
|
||||
- **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.
|
||||
- **AI features.** Few so far; speech to text is optional and off by default.
|
||||
|
||||
@@ -327,6 +327,21 @@ Nothing below the front ends depends on a UI toolkit or OS API. `cargo xtask ci`
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only Vulkan and DirectX 12 by
|
||||
default, not OpenGL. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
|
||||
`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
|
||||
PowerShell, from the folder containing the executable:
|
||||
|
||||
```powershell
|
||||
$env:WGPU_BACKEND = "vulkan"
|
||||
& .\filmcraft.exe
|
||||
Remove-Item Env:WGPU_BACKEND # restore the default for later launches
|
||||
```
|
||||
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -342,7 +357,22 @@ Installers and executables are code-signed.
|
||||
| Flatpak | `filmcraft-<ver>-linux-x86_64.flatpak` | `filmcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `filmcraft-<ver>-linux-x86_64.deb` | `filmcraft-<ver>-linux-aarch64.deb` | |
|
||||
| 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 anywhere |
|
||||
| Tarball | `filmcraft-<ver>-linux-x86_64.tar.gz` | `filmcraft-<ver>-linux-aarch64.tar.gz` | Unpack and run `./install.sh` |
|
||||
|
||||
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.
|
||||
|
||||
**Gentoo (community-maintained):** the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay)
|
||||
packages the Linux release as `media-video/filmcraft-bin`. It is maintained by the community, not by the
|
||||
FilmCraft team, so report packaging problems to the overlay:
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft
|
||||
emerge --ask media-video/filmcraft-bin
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
|
||||
+14
-8
@@ -35,8 +35,8 @@ before choosing work.
|
||||
| **Feature breadth** (menus, commands, panels) | ~86–95% | **~85%** | Menu diff against Premiere 26.5; effect, transition and audio-effect catalogues complete by test. The long tail is mostly there. |
|
||||
| **Correctness in depth** | not tracked | **~60–70%** | A first-time contributor found four real bugs in core paths within hours (#5–#8: AIFF import failed, Slide left linked audio behind, transitions didn't follow ripple trims, export ignored start/end times). User reports: Color Matte always grey with no picker (#29); ALSA audio error on Linux (#23). Settings are "most wired", not all. |
|
||||
| **Codecs and media** | ~90% | **~75%** | Our decoders are bit-exact on conformance streams. Real camera and phone media (variable frame rate, damaged files, unusual containers) is far less tested. No camera RAW (RED, BRAW, ARRIRAW), no E-AC-3. |
|
||||
| **Export** | ~92% | **~70%** | Delivery: H.264 only (ProRes, DNxHR and image sequences cover mastering). No HEVC or AV1 export. AAF and OMF have never been validated in Avid Media Composer or Pro Tools. Export renders and encodes on the CPU only. |
|
||||
| **Performance and hardware** | ~62% | **~35–40%** | Premiere runs effects, decoding and encoding on the GPU and the hardware media engines. Here, hardware decode works on macOS (VideoToolbox, #33; active in the desktop app only since #41, so releases up to 0.2.1 decoded in software) and Windows (Media Foundation / Direct3D 11 DXVA, H.264, HEVC Main / Main 10, VP9 and AV1 4:2:0 8/10-bit, #30). The GPU does compositing, blend modes (#32) and 33 common effects (GPU2, GPU4, GPU5): colour adjustments, crop/flip/transform, blurs, sharpen, vignette, video limiter. Lumetri, keys, masks, the remaining effects and export rendering are CPU; H.264 encoding can use VideoToolbox on macOS (opt-in per export, no B-frames) and NVENC on Windows with an NVIDIA GPU (opt-in per export), and H.265 (HEVC) export exists there only, through the same hardware encoder. Linux has no hardware path. 8K isn't real-time (AV1 is on Windows with a hardware decoder). The effect list shows Premiere's "GPU accelerated" badge, but only 33 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. |
|
||||
| **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. |
|
||||
@@ -48,10 +48,10 @@ before choosing work.
|
||||
In priority order. Agents should prefer this work over adding more checklist items.
|
||||
|
||||
1. **Hardware acceleration** (#30), the most visible gap to users:
|
||||
- hardware decode on Linux (VA-API);
|
||||
- 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 on NVENC (Windows, opt-in); HEVC on Windows and Linux remain.
|
||||
- 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).
|
||||
@@ -63,7 +63,7 @@ In priority order. Agents should prefer this work over adding more checklist ite
|
||||
through XML / AAF.
|
||||
5. **Windows and Linux at runtime.** CI that runs the test suite and a headless playback/export
|
||||
smoke test on both, not just release builds. Fix the Linux audio path (#23).
|
||||
6. **Delivery codecs.** HEVC export is in on macOS (VideoToolbox, 8-bit, no software encoder); still to do: HEVC elsewhere, 10-bit / HDR HEVC, and AV1 (`rav1e`, BSD-2).
|
||||
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
|
||||
@@ -100,7 +100,7 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
|
||||
| Graphics and captions | 8% | text engine, shapes, per-character styles, our own graphics templates (.fcgt, 8 built-ins, export/install/edit), rolls/crawls, responsive pins + time, captions SRT/VTT/SCC/MCC/STL/TTML/DFXP, transcripts | ~85% |
|
||||
| Export | 8% | own H.264/AAC, ProRes, DNxHR, MXF OP1a/OP-Atom, image sequences, GIF, WAV, 5.1; preset library + user presets, full Export-mode settings, queue, Quick Export; AAF (Edit Protocol) import/export, OMF 2.0 export; no AAF/OMF validation against Avid/Pro Tools yet | ~92% |
|
||||
| Preferences and project management | 5% | Settings dialog with 16 categories (most settings wired), project settings, scratch disks, search bins, templates | ~80% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; no hardware path on Linux, and on Windows only decode and opt-in NVENC H.264 export (see the honest assessment: ~35–40% against Premiere) | ~62% |
|
||||
| Performance | 5% | 1080p real-time, 3×1080p; 4K H.264 real-time at load ≤140 (−23% decoder cycles, opt-in draft decoding at 1/2–1/4); HEVC 2.5× cheaper, catch-up decoding, `cargo xtask bench` + `perf.stats`; blend modes on the GPU (#32); 8K and AV1 not yet real-time on a loaded machine; 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
|
||||
@@ -154,11 +154,17 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
## Running now
|
||||
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 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). Next: VA-API (Linux) decode, zero-copy upload, Lumetri on the GPU.
|
||||
- **Hardware acceleration (#30):** landed: blend modes on the GPU (#32); VideoToolbox hardware decode + the `platform` FFI crate (#33); 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.
|
||||
|
||||
## Log
|
||||
|
||||
- **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.
|
||||
- **2026-10-08:** H.265 (HEVC Main, 8-bit 4:2:0, SDR) export on Windows through NVENC (#30): the NVENC session, ring and NV12 path of the H.264 encoder now serve both codecs (the H.264 export is byte-for-byte unchanged); the format is available where the GPU has an HEVC encoder, choosing it is the opt-in, and `hvcC` is built from the SPS the encoder wrote. ffprobe reads `hevc` / `Main` / `hvc1` / `yuv420p` / BT.709; our own HEVC decoder decodes it at 48.9 dB luma PSNR (RTX 5060). Main 10 and HDR remain.
|
||||
- **2026-10-08 (#285):** `filmcraft-cli` no longer panics with "Broken pipe" when its stdout is closed early (`filmcraft-cli commands | head`): later output is dropped, the rest of the work (script lines, `--save`, `--save-as`) still runs, and the exit status still reports failures. Other stdout write errors are reported and make the exit status 1. No change to the checklist or estimates.
|
||||
|
||||
- **2026-10-06:** 21 community PRs landed (#19, #28, #38, #39, #41–#60). Highlights: hardware decoding is now actually on in the desktop app (#41: `register()` sat inside a `log::info!` that never ran, so #33's VideoToolbox path only worked in the CLI and benchmarks); decode memory on long timelines cut sharply (frame pool, shared GOP-cache budget, positional file reads); exports are byte-identical across machines (#19); crash fix for odd track names over MCP (#44); ripple trim, ripple delete and speed changes move split-edit clips once (#56–#58); Japanese interface using an installed system font (#38; no fonts bundled); MCP annotations, resources, export progress and cancellation (#28).
|
||||
|
||||
- **2026-10-05:** honest assessment added (checklist ~87% vs ready for real work ~50–60%). Hardware acceleration started (#30): blend modes on the GPU (#32); VideoToolbox decode + `platform` crate, the one crate allowed `unsafe` (#33). Community fixes landed: AIFF import, export start/end ranges, transitions following ripple trims, Slide moving linked audio (#5–#8).
|
||||
@@ -205,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists in UI state. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian 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` and `app.language.ukrainian` 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 and Japanese 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). 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. Existing vertical text support is preserved.
|
||||
|
||||
+179
-20
@@ -4,6 +4,32 @@
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
/// `println!` that doesn't panic when stdout is closed (`filmcraft-cli commands | head`) with
|
||||
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is
|
||||
/// dropped and the work goes on: see `stdout_failed`.
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// `print!` counterpart of `outln!`.
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if !$crate::stdout_gone() {
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod args;
|
||||
mod probe;
|
||||
|
||||
@@ -11,6 +37,28 @@ use args::{Args, parse_value};
|
||||
use filmcraft_automation::BridgeClient;
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Set once a write to stdout has failed; later output is dropped.
|
||||
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
|
||||
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
|
||||
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn stdout_gone() -> bool {
|
||||
STDOUT_GONE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// stdout went away. The program doesn't stop there: the rest of `exec`/`run`/`import` (later
|
||||
/// script lines, `--save`, `--save-as`) still runs and the exit status still tells how it went, so
|
||||
/// `exec … --save | head -1` keeps the edit. A reader that stopped early (a closed pipe) is not an
|
||||
/// error, as in ripgrep; any other write error is reported and makes the exit status 1.
|
||||
fn stdout_failed(e: std::io::Error) {
|
||||
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
diag(format_args!("filmcraft-cli: can't write to stdout: {e}"));
|
||||
STDOUT_ERROR.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
const HELP: &str = "\
|
||||
filmcraft-cli: drive FilmCraft from the shell (headless engine, or the live app with --bridge)
|
||||
@@ -68,13 +116,23 @@ EXIT STATUS
|
||||
0 success · 1 a command failed · 2 usage error
|
||||
";
|
||||
|
||||
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
|
||||
/// reader is gone, which would turn a usage error, a failure or a finished export into a crash.
|
||||
fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) {
|
||||
let _ = writeln!(w, "{msg}");
|
||||
}
|
||||
|
||||
fn diag(msg: std::fmt::Arguments) {
|
||||
write_diag(&mut std::io::stderr(), msg);
|
||||
}
|
||||
|
||||
fn usage(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
|
||||
diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."));
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
eprintln!("filmcraft-cli: {msg}");
|
||||
diag(format_args!("filmcraft-cli: {msg}"));
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
@@ -135,7 +193,7 @@ impl Backend {
|
||||
|
||||
fn print(a: &Args, v: &Value) {
|
||||
let s = if a.flag("--compact") { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) };
|
||||
println!("{}", s.unwrap_or_default());
|
||||
outln!("{}", s.unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Export format from the output file's extension.
|
||||
@@ -187,8 +245,15 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
cli().await;
|
||||
if STDOUT_ERROR.load(Ordering::Relaxed) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn cli() {
|
||||
if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "-V")) {
|
||||
println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
register_hardware_decoders();
|
||||
@@ -196,8 +261,8 @@ async fn main() {
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
|
||||
match cmd {
|
||||
"help" | "--help" | "-h" => print!("{HELP}"),
|
||||
"version" => println!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"help" | "--help" | "-h" => out!("{HELP}"),
|
||||
"version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")),
|
||||
"probe" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("probe <media> [--image-sequence]"));
|
||||
match probe::probe(path, a.flag("--image-sequence")) {
|
||||
@@ -215,11 +280,11 @@ async fn main() {
|
||||
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).unwrap_or_else(|e| fail(e));
|
||||
}
|
||||
let dt = t0.elapsed().as_secs_f64();
|
||||
println!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
outln!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
|
||||
let t1 = std::time::Instant::now();
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).unwrap_or_else(|e| fail(e));
|
||||
let (w, h, _) = f.to_linear_f32_decimated(2);
|
||||
println!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
"commands" | "describe" => {
|
||||
let mut b = Backend::open(&a);
|
||||
@@ -245,7 +310,7 @@ async fn main() {
|
||||
} else {
|
||||
for c in &list {
|
||||
let s = |k: &str| c[k].as_str().unwrap_or("").to_string();
|
||||
println!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,9 +337,9 @@ async fn main() {
|
||||
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| usage(format!("line {}: {e}", n + 1)));
|
||||
let id = v["id"].as_str().unwrap_or_else(|| usage(format!("line {}: missing \"id\"", n + 1)));
|
||||
match b.exec(id, v.get("params").cloned().unwrap_or(json!({}))).await {
|
||||
Ok(r) => println!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Ok(r) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
|
||||
Err(e) => {
|
||||
println!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
|
||||
failed += 1;
|
||||
if !a.flag("--keep-going") {
|
||||
std::process::exit(1);
|
||||
@@ -344,11 +409,7 @@ async fn main() {
|
||||
if let Some(r) = a.opt("--range") {
|
||||
p["range"] = json!(r);
|
||||
}
|
||||
if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) {
|
||||
p["range"] = json!("custom");
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end"));
|
||||
if let Some(js) = a.opt("--settings") {
|
||||
p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}")));
|
||||
}
|
||||
@@ -373,7 +434,7 @@ async fn main() {
|
||||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
eprintln!("exported {out} in {:.1}s", t0.elapsed().as_secs_f64());
|
||||
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
@@ -388,11 +449,11 @@ async fn main() {
|
||||
let out = a.opt("--out").unwrap_or("frame.png");
|
||||
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
|
||||
let t0 = std::time::Instant::now();
|
||||
let Some(img) = s.render_program(scale) else { fail("no sequence") };
|
||||
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let dt = t0.elapsed();
|
||||
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
|
||||
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
|
||||
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
|
||||
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
|
||||
}
|
||||
"mcp" => {
|
||||
let server = match a.opt("--bridge") {
|
||||
@@ -410,14 +471,112 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The files an export wrote, from its result: `path` of a direct export, or each queued item's
|
||||
/// `path` (the format may have changed the extension: `out.mp4` exported as APV is `out.mp4.mov`).
|
||||
/// `requested` when the result names none.
|
||||
fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
||||
let paths: Vec<String> = match result.get("path").and_then(Value::as_str) {
|
||||
Some(p) => vec![p.to_string()],
|
||||
None => result
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| i.get("path").and_then(Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
|
||||
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
|
||||
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
|
||||
/// instead of the export silently running over the whole sequence.
|
||||
fn set_custom_bounds(p: &mut Value, start: Option<&str>, end: Option<&str>) {
|
||||
if start.is_none() && end.is_none() {
|
||||
return;
|
||||
}
|
||||
p["range"] = json!("custom");
|
||||
if let Some(s0) = start {
|
||||
p["startSeconds"] = parse_value(s0);
|
||||
}
|
||||
if let Some(s1) = end {
|
||||
p["endSeconds"] = parse_value(s1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod format_tests {
|
||||
#[test]
|
||||
fn render_reports_the_real_reason() {
|
||||
let mut s = filmcraft_engine::Session::default();
|
||||
assert!(super::render_at_playhead(&s, 0.5).unwrap_err().contains("sequence"));
|
||||
s.execute("file.newSequence", serde_json::json!({"width": 16, "height": 16})).unwrap();
|
||||
for scale in [0.0, -1.0, f32::NAN] {
|
||||
let e = super::render_at_playhead(&s, scale).unwrap_err();
|
||||
assert!(e.contains("finite and positive"), "{e}");
|
||||
}
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
|
||||
}
|
||||
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
/// decoders at start-up.
|
||||
#[test]
|
||||
fn startup_registers_the_hardware_decoders() {
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_diagnostic_stream_does_not_panic() {
|
||||
struct Closed;
|
||||
impl std::io::Write for Closed {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
}
|
||||
super::write_diag(&mut Closed, format_args!("exported {}", "a.wav"));
|
||||
let mut ok = Vec::new();
|
||||
super::write_diag(&mut ok, format_args!("hi"));
|
||||
assert_eq!(ok, b"hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_message_names_the_written_files() {
|
||||
use serde_json::json;
|
||||
let direct = json!({"job": 1, "path": "out-apv.mp4.mov", "result": {"path": "out-apv.mp4.mov"}});
|
||||
assert_eq!(super::written_paths(&direct, "out-apv.mp4"), ["out-apv.mp4.mov"]);
|
||||
let queued = json!({"added": [1, 2], "items": [{"path": "a.mp4.mov"}, {"path": "b.wav"}]});
|
||||
assert_eq!(super::written_paths(&queued, "a.mp4"), ["a.mp4.mov", "b.wav"]);
|
||||
assert_eq!(super::written_paths(&json!({}), "x.mov"), ["x.mov"]);
|
||||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_lone_bound_is_forwarded_as_custom_range() {
|
||||
use serde_json::json;
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, Some("0.25"), None);
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["startSeconds"], 0.25);
|
||||
assert!(p.get("endSeconds").is_none());
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, Some("0.25"));
|
||||
assert_eq!(p["range"], "custom");
|
||||
assert_eq!(p["endSeconds"], 0.25);
|
||||
let mut p = json!({});
|
||||
super::set_custom_bounds(&mut p, None, None);
|
||||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,7 +44,7 @@ fn probe_reports_mpeg_transport_and_program_streams() {
|
||||
assert!(st.success());
|
||||
let v = json_out(&cli(&["probe", out.to_str().unwrap()]));
|
||||
assert!(v["video"]["codec"].as_str().unwrap().starts_with("MPEG-2 Video"), "{v}");
|
||||
assert_eq!(v["audio"]["codec"], "MPEG Audio");
|
||||
assert_eq!(v["audio_streams"][0]["codec"], "MPEG Audio");
|
||||
let streams = v["mpeg"]["streams"].as_array().unwrap();
|
||||
assert_eq!(streams.len(), 2, "{v}");
|
||||
assert_eq!(streams[0]["codec"], "MPEG-2 Video");
|
||||
@@ -179,9 +179,89 @@ fn export_with_presets() {
|
||||
let v = json_out(&cli(&["--demo", "--data-dir", data_s, "export", q.to_str().unwrap(), "--preset", "Tiny WAV", "--queue", "--range", "entire"]));
|
||||
assert_eq!(v["items"][0]["status"], "done", "{v}");
|
||||
assert!(q.exists());
|
||||
// a format that changes the extension: the completion message names the file written (#341)
|
||||
let apv = dir.join("apv.mp4");
|
||||
let o =
|
||||
cli(&["--demo", "--data-dir", data_s, "export", apv.to_str().unwrap(), "--preset", "APV 422-10", "--start", "0", "--end", "0.1", "--scale", "0.125"]);
|
||||
let v = json_out(&o);
|
||||
let written = v["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("apv.mp4.mov") && std::path::Path::new(&written).exists(), "{v}");
|
||||
let msg = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(msg.contains(&format!("exported {written} in ")), "{msg}");
|
||||
let qa = dir.join("queued-apv.mp4");
|
||||
let o = cli(&[
|
||||
"--demo",
|
||||
"--data-dir",
|
||||
data_s,
|
||||
"export",
|
||||
qa.to_str().unwrap(),
|
||||
"--preset",
|
||||
"APV 422-10",
|
||||
"--queue",
|
||||
"--start",
|
||||
"0",
|
||||
"--end",
|
||||
"0.1",
|
||||
"--scale",
|
||||
"0.125",
|
||||
]);
|
||||
let v = json_out(&o);
|
||||
let written = v["items"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(written.ends_with("queued-apv.mp4.mov"), "{v}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("exported {written} in ")), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
// unknown preset: exit status 1 and a message naming it
|
||||
let bad = cli(&["--demo", "--data-dir", data_s, "export", dir.join("x.mp4").to_str().unwrap(), "--preset", "No Such Preset"]);
|
||||
assert_eq!(bad.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("No Such Preset"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `filmcraft-cli commands | head -c 10` panicked with "failed printing to stdout: Broken pipe
|
||||
/// (os error 32)" and exit status 101 (#285).
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
for args in [&["commands"][..], &["help"], &["--demo", "commands", "--json"]] {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 10];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{args:?}: {:?}: {err}", out.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// `run -` with stdout already closed: `run` reads its script to the end before it prints, so its
|
||||
/// first write fails for sure. The rest of the script and the save still happen, and the exit status
|
||||
/// still reports a failing command.
|
||||
fn run_with_closed_stdout(args: &[&str], script: &[u8]) -> Output {
|
||||
let mut child =
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
child.stdin.take().unwrap().write_all(script).unwrap();
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_stdout_still_runs_the_script_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-pipe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proj = dir.join("p.fcproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let script = b"{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped One\"}}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"Piped Two\"}}\n";
|
||||
let out = run_with_closed_stdout(&["--demo", "--save-as", p, "run", "-"], script);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let tree = json_out(&cli(&["--project", p, "inspect", "project"])).to_string();
|
||||
assert!(tree.contains("Piped One") && tree.contains("Piped Two"), "{tree}");
|
||||
|
||||
// a failing command is still a failure, whether or not anyone reads stdout
|
||||
let out = run_with_closed_stdout(&["--demo", "run", "-"], b"{\"id\":\"no.such.command\"}\n");
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let out = run_with_closed_stdout(
|
||||
&["--demo", "--keep-going", "run", "-"],
|
||||
b"{\"id\":\"no.such.command\"}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"x\"}}\n",
|
||||
);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,8 @@ fn file_name(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Register a browser `File` (or `Blob` with a name) and return its virtual path. A file with
|
||||
/// the same name and size as an existing entry replaces it (the same file picked again keeps
|
||||
/// its path, so projects referencing it come back online); otherwise a numbered name is used.
|
||||
/// Register a browser `File` (or named `Blob`) at an unused virtual path; collisions get numbered names.
|
||||
/// Name + size do not identify content: replacing an occupied path changes every project item referencing it.
|
||||
pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
||||
let size = blob.size() as u64;
|
||||
let clean: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
@@ -85,18 +84,11 @@ pub fn register_blob(name: &str, blob: web_sys::Blob) -> String {
|
||||
let mut n = 1;
|
||||
let path = loop {
|
||||
let p = if n == 1 { format!("/files/{clean}") } else { format!("/files/{stem} ({n}){ext}") };
|
||||
match fs.entries.get(&p) {
|
||||
None => break p,
|
||||
Some(Entry::Blob { size: s, .. }) if *s == size => break p,
|
||||
Some(Entry::Mem(b)) if b.len() as u64 == size => break p,
|
||||
_ => n += 1,
|
||||
if !fs.entries.contains_key(&p) {
|
||||
break p;
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
if let Some(Entry::Blob { id, .. }) = fs.entries.get(&path) {
|
||||
let id = *id;
|
||||
fs.blobs.remove(&id);
|
||||
fs.chunks.retain(|k, _| k.0 != id);
|
||||
}
|
||||
let id = fs.next_id;
|
||||
fs.next_id += 1;
|
||||
fs.blobs.insert(id, blob);
|
||||
|
||||
@@ -81,12 +81,12 @@ async fn prewarm(path: &str, size: u64) {
|
||||
}
|
||||
|
||||
/// `file.import` one path, retried while its bytes are loading.
|
||||
async fn import_path(path: String) -> Result<Value, String> {
|
||||
async fn import_path(path: String, bin: u64) -> Result<Value, String> {
|
||||
for _ in 0..2000 {
|
||||
let p = path.clone();
|
||||
let (r, pending) = on_ui(move |w, _| {
|
||||
let _ = filmcraft_media::pending::take();
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p]})).map_err(|e| e.to_string());
|
||||
let r = w.app.session.execute("file.import", json!({"paths": [p], "bin": bin})).map_err(|e| e.to_string());
|
||||
(r, filmcraft_media::pending::take())
|
||||
})
|
||||
.await;
|
||||
@@ -105,6 +105,8 @@ async fn import_path(path: String) -> Result<Value, String> {
|
||||
|
||||
/// Import browser files (no copies): returns `{items, errors, paths}`.
|
||||
pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
||||
// Capture once per batch: navigation while prewarming must not redirect pending imports.
|
||||
let bin = on_ui(|w, _| w.app.import_bin().0).await;
|
||||
let mut items = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
@@ -113,7 +115,7 @@ pub async fn import_files(files: Vec<web_sys::File>) -> Value {
|
||||
let size = f.size() as u64;
|
||||
let path = fs::register_blob(&name, f.clone().into());
|
||||
prewarm(&path, size).await;
|
||||
match import_path(path.clone()).await {
|
||||
match import_path(path.clone(), bin).await {
|
||||
Ok(v) => {
|
||||
items.extend(v.get("items").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
errors.extend(v.get("errors").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod fs;
|
||||
pub mod import;
|
||||
pub mod opfs;
|
||||
pub mod recovery;
|
||||
pub mod recovery_policy;
|
||||
pub mod webcodecs;
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -58,6 +59,13 @@ pub fn repaint() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Give a host that builds its own [`WebApp`] the egui context that [`post`] and [`repaint`] wake.
|
||||
/// [`start`] calls it; an embedding app that runs `WebApp` in its own eframe runner calls it once
|
||||
/// from its app creator, or posted actions and JS API requests wait for the next input event.
|
||||
pub fn set_context(ctx: egui::Context) {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(ctx));
|
||||
}
|
||||
|
||||
/// Environment facts reported by `filmcraft.info()` (backend, isolation, codecs…).
|
||||
pub fn set_info(key: &str, v: serde_json::Value) {
|
||||
INFO.with(|i| {
|
||||
@@ -150,7 +158,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
canvas,
|
||||
web_options,
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
set_context(cc.egui_ctx.clone());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
fs::set_on_data(move || ctx.request_repaint());
|
||||
fs::set_on_write(|path, data| {
|
||||
@@ -177,6 +185,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
import::install_hooks(&mut app);
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218): the browser's languages.
|
||||
app.hooks.system_languages =
|
||||
Some(Box::new(|| web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default()));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -10,8 +10,13 @@ use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle};
|
||||
|
||||
/// Called with whether a queued write reached OPFS.
|
||||
pub type Done = Box<dyn FnOnce(bool)>;
|
||||
|
||||
thread_local! {
|
||||
static QUEUE: RefCell<(BTreeMap<String, Vec<u8>>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
/// Pending writes per path (bytes and the callbacks of every write coalesced into them), and
|
||||
/// whether the writer task is running.
|
||||
static QUEUE: RefCell<(BTreeMap<String, (Vec<u8>, Vec<Done>)>, bool)> = const { RefCell::new((BTreeMap::new(), false)) };
|
||||
}
|
||||
|
||||
/// Whether this browser has OPFS (`navigator.storage.getDirectory`).
|
||||
@@ -60,9 +65,17 @@ async fn write_now(rel: &str, data: &[u8]) -> Result<(), JsValue> {
|
||||
|
||||
/// Queue a write of `data` to `rel` (relative to the OPFS root).
|
||||
pub fn write(rel: String, data: Vec<u8>) {
|
||||
write_then(rel, data, None);
|
||||
}
|
||||
|
||||
/// [`write`], then call `done` with whether it succeeded. A write replaced by a newer one to the
|
||||
/// same path reports the newer one's result.
|
||||
pub fn write_then(rel: String, data: Vec<u8>, done: Option<Done>) {
|
||||
let start = QUEUE.with(|q| {
|
||||
let mut q = q.borrow_mut();
|
||||
q.0.insert(rel, data);
|
||||
let e = q.0.entry(rel).or_default();
|
||||
e.0 = data;
|
||||
e.1.extend(done);
|
||||
!std::mem::replace(&mut q.1, true)
|
||||
});
|
||||
if !start {
|
||||
@@ -81,10 +94,14 @@ pub fn write(rel: String, data: Vec<u8>) {
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some((rel, data)) = next else { break };
|
||||
if let Err(e) = write_now(&rel, &data).await {
|
||||
let Some((rel, (data, done))) = next else { break };
|
||||
let r = write_now(&rel, &data).await;
|
||||
if let Err(e) = &r {
|
||||
log::warn!("OPFS write {rel}: {e:?}");
|
||||
}
|
||||
for d in done {
|
||||
d(r.is_ok());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,24 +6,32 @@
|
||||
//! - Imported media are copied to `media/<name>` (the browser streams the copy; nothing goes
|
||||
//! through wasm memory), so the snapshot's `/files/<name>` references resolve after a reload.
|
||||
//! - At start-up a dirty snapshot is reopened (`?norecover` skips it, `?fresh` also skips the media).
|
||||
//! A skipped snapshot stays dirty until this session writes its own ([`crate::recovery_policy`]).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use filmcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::opfs;
|
||||
use crate::recovery_policy::{Action, Policy};
|
||||
|
||||
const SNAPSHOT: &str = "recovery/snapshot.fcproj";
|
||||
const META: &str = "recovery/meta.json";
|
||||
/// Seconds between snapshots of a changing project.
|
||||
const INTERVAL_S: f64 = 5.0;
|
||||
/// Media larger than this are not copied to OPFS.
|
||||
const KEEP_MAX: f64 = 4.0 * 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// A finished OPFS write, reported back to the next [`Autosave::tick`].
|
||||
enum Outcome {
|
||||
Snapshot { revision: u64, ok: bool },
|
||||
Clean { ok: bool },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Autosave {
|
||||
last_revision: u64,
|
||||
last_time: f64,
|
||||
last_dirty: Option<bool>,
|
||||
policy: Policy,
|
||||
outcomes: Rc<RefCell<Vec<Outcome>>>,
|
||||
}
|
||||
|
||||
impl Autosave {
|
||||
@@ -33,22 +41,44 @@ impl Autosave {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dirty = s.revision != s.saved_revision;
|
||||
if dirty {
|
||||
if s.revision != self.last_revision && now - self.last_time >= INTERVAL_S {
|
||||
opfs::write(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false));
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
opfs::write(META.into(), meta.to_string().into_bytes());
|
||||
self.last_revision = s.revision;
|
||||
self.last_time = now;
|
||||
} else if s.revision != self.last_revision {
|
||||
// the UI may go idle: wake up when the snapshot is due
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64((INTERVAL_S - (now - self.last_time)).max(0.05)));
|
||||
for o in self.outcomes.borrow_mut().drain(..) {
|
||||
match o {
|
||||
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
|
||||
Outcome::Clean { ok } => self.policy.cleaned(ok, now),
|
||||
}
|
||||
}
|
||||
match self.policy.tick(now, s.revision, s.saved_revision) {
|
||||
Action::Nothing => {}
|
||||
Action::WakeAfter(secs) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs)),
|
||||
Action::WriteSnapshot(revision) => {
|
||||
// Snapshot and meta both have to land; either failing retries the pair.
|
||||
let failed = Rc::new(Cell::new(false));
|
||||
let left = Rc::new(Cell::new(2u8));
|
||||
let done = |out: Rc<RefCell<Vec<Outcome>>>, failed: Rc<Cell<bool>>, left: Rc<Cell<u8>>, ctx: egui::Context| -> opfs::Done {
|
||||
Box::new(move |ok| {
|
||||
failed.set(failed.get() || !ok);
|
||||
left.set(left.get().saturating_sub(1));
|
||||
if left.get() == 0 {
|
||||
out.borrow_mut().push(Outcome::Snapshot { revision, ok: !failed.get() });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
};
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
|
||||
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
|
||||
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
|
||||
opfs::write_then(META.into(), meta.to_string().into_bytes(), Some(d2));
|
||||
}
|
||||
Action::WriteClean => {
|
||||
let (out, ctx) = (self.outcomes.clone(), ctx.clone());
|
||||
let done: opfs::Done = Box::new(move |ok| {
|
||||
out.borrow_mut().push(Outcome::Clean { ok });
|
||||
ctx.request_repaint();
|
||||
});
|
||||
opfs::write_then(META.into(), json!({"dirty": false}).to_string().into_bytes(), Some(done));
|
||||
}
|
||||
} else if self.last_dirty != Some(false) {
|
||||
opfs::write(META.into(), json!({"dirty": false}).to_string().into_bytes());
|
||||
}
|
||||
self.last_dirty = Some(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
|
||||
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
|
||||
//!
|
||||
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
|
||||
//! that fails is retried after the interval, without waiting for another edit (#197).
|
||||
//! - The meta is marked clean only to retire a snapshot this session wrote. A snapshot left by an
|
||||
//! earlier session that was not reopened (`?norecover`, `?fresh`) stays dirty (#193).
|
||||
|
||||
/// Seconds between snapshots of a changing project.
|
||||
pub const INTERVAL_S: f64 = 5.0;
|
||||
|
||||
/// What the caller should do this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Nothing,
|
||||
/// Write the snapshot and a dirty meta for this revision, then report with [`Policy::written`].
|
||||
WriteSnapshot(u64),
|
||||
/// Write a clean meta, then report with [`Policy::cleaned`].
|
||||
WriteClean,
|
||||
/// Nothing now; wake up after this many seconds.
|
||||
WakeAfter(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Policy {
|
||||
/// Revision whose snapshot was last written or is being written.
|
||||
last_revision: Option<u64>,
|
||||
last_time: Option<f64>,
|
||||
/// This session wrote a dirty meta that is not yet retired.
|
||||
owns_dirty_meta: bool,
|
||||
/// A clean-meta write is in flight.
|
||||
cleaning: bool,
|
||||
/// Snapshot writes started so far, and how many had started when the clean write did (a
|
||||
/// snapshot started after it makes the meta dirty again).
|
||||
snapshots: u64,
|
||||
snapshots_at_clean: u64,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// Called every frame with the session's revisions and the time in seconds.
|
||||
pub fn tick(&mut self, now: f64, revision: u64, saved_revision: u64) -> Action {
|
||||
if revision != saved_revision {
|
||||
if self.last_revision == Some(revision) {
|
||||
return Action::Nothing;
|
||||
}
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if since >= INTERVAL_S {
|
||||
self.last_revision = Some(revision);
|
||||
self.last_time = Some(now);
|
||||
self.owns_dirty_meta = true;
|
||||
self.snapshots += 1;
|
||||
return Action::WriteSnapshot(revision);
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
if self.owns_dirty_meta && !self.cleaning {
|
||||
let since = self.last_time.map_or(f64::INFINITY, |t| now - t);
|
||||
if self.last_revision.is_some() || since >= INTERVAL_S {
|
||||
self.cleaning = true;
|
||||
self.snapshots_at_clean = self.snapshots;
|
||||
return Action::WriteClean;
|
||||
}
|
||||
return Action::WakeAfter((INTERVAL_S - since).max(0.05));
|
||||
}
|
||||
Action::Nothing
|
||||
}
|
||||
|
||||
/// The snapshot write for `revision` finished.
|
||||
pub fn written(&mut self, revision: u64, ok: bool, now: f64) {
|
||||
if !ok && self.last_revision == Some(revision) {
|
||||
// retry after the interval, even if nothing else changes
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The clean-meta write finished.
|
||||
pub fn cleaned(&mut self, ok: bool, now: f64) {
|
||||
self.cleaning = false;
|
||||
if ok {
|
||||
self.owns_dirty_meta = self.snapshots != self.snapshots_at_clean;
|
||||
} else {
|
||||
// retry after the interval
|
||||
self.last_revision = None;
|
||||
self.last_time = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,7 @@
|
||||
//! the job on a later frame, when the decoder's output callback has delivered the picture.
|
||||
//! - A session that has not delivered the frame it was started for is not restarted by other
|
||||
//! requests (thumbnails and the monitor would otherwise keep resetting each other).
|
||||
//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the
|
||||
//! browser applies the stream's colour description).
|
||||
//! - Native YUV planes feed the desktop frame/compositor contract. Unsupported browser formats or geometry use Canvas RGBA.
|
||||
//! - Any decoder error or unsupported configuration switches the source to our decoder.
|
||||
//!
|
||||
//! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`.
|
||||
@@ -34,6 +33,20 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
mod pixels;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Transfers {
|
||||
native: u64,
|
||||
canvas: u64,
|
||||
closed: u64,
|
||||
stale: u64,
|
||||
bytes: u64,
|
||||
native_ms: f64,
|
||||
canvas_ms: f64,
|
||||
reasons: BTreeMap<&'static str, u64>,
|
||||
}
|
||||
|
||||
/// Samples fed past the wanted one (decoders hold pictures for reordering).
|
||||
const LOOKAHEAD: usize = 8;
|
||||
/// Decoded frames kept per source.
|
||||
@@ -52,6 +65,7 @@ thread_local! {
|
||||
static DECODED: Cell<u64> = const { Cell::new(0) };
|
||||
static FALLBACKS: Cell<u64> = const { Cell::new(0) };
|
||||
static SOURCES: Cell<u64> = const { Cell::new(0) };
|
||||
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
@@ -160,11 +174,15 @@ pub fn stats() -> serde_json::Value {
|
||||
.iter()
|
||||
.map(|(id, s)| {
|
||||
json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(),
|
||||
"errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()})
|
||||
"errored": s.errored.get(), "queue": s.queue(), "copies": s.copying.get(), "idleMs": now_ms() - s.last_output.get()})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions})
|
||||
let transfers = TRANSFERS.with(|s| {
|
||||
let s = s.borrow();
|
||||
json!({"nativeFrames":s.native,"canvasFrames":s.canvas,"closedFrames":s.closed,"staleFrames":s.stale,"nativeBytes":s.bytes,"nativeMs":s.native_ms,"canvasMs":s.canvas_ms,"canvasReasons":s.reasons})
|
||||
});
|
||||
json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions,"transfers":transfers})
|
||||
}
|
||||
|
||||
/// A [`SharedReader`] as the demuxer's byte source.
|
||||
@@ -210,6 +228,8 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
});
|
||||
SOURCES.with(|c| c.set(c.get() + 1));
|
||||
log::info!("{name}: video decoded with WebCodecs ({codec})");
|
||||
let color = info.video.as_ref().map(|v| v.color).unwrap_or_default();
|
||||
let par = info.video.as_ref().map(|v| v.par).unwrap_or((1, 1));
|
||||
Some(Ok(Arc::new(WcSource {
|
||||
inner,
|
||||
info,
|
||||
@@ -217,11 +237,12 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
||||
file,
|
||||
track,
|
||||
id,
|
||||
config: Config { codec, description, width: w, height: h, rotation },
|
||||
config: Config { codec, description, width: w, height: h, rotation, color, par },
|
||||
state: Mutex::new(Cache::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
codec: String,
|
||||
description: Option<Vec<u8>>,
|
||||
@@ -229,6 +250,8 @@ struct Config {
|
||||
height: u32,
|
||||
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
|
||||
rotation: u8,
|
||||
color: filmcraft_color::ColorInfo,
|
||||
par: (u32, u32),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -253,6 +276,9 @@ struct Session {
|
||||
/// Highest presentation time output since the session (re)started.
|
||||
out_max: Rc<Cell<i64>>,
|
||||
out: Rc<RefCell<Vec<(i64, VideoFrame)>>>,
|
||||
copying: Rc<Cell<u32>>,
|
||||
generation: Rc<Cell<u64>>,
|
||||
alive: Rc<Cell<bool>>,
|
||||
_closures: (Closure<dyn FnMut(JsValue)>, Closure<dyn FnMut(JsValue)>),
|
||||
}
|
||||
|
||||
@@ -286,17 +312,65 @@ impl Session {
|
||||
let errored = Rc::new(Cell::new(false));
|
||||
let last_output = Rc::new(Cell::new(now_ms()));
|
||||
let out_max = Rc::new(Cell::new(i64::MIN));
|
||||
let copying = Rc::new(Cell::new(0u32));
|
||||
let generation = Rc::new(Cell::new(0u64));
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone());
|
||||
let (copies, epoch, active, config, errors) = (copying.clone(), generation.clone(), alive.clone(), cfg.clone(), errored.clone());
|
||||
let on_output = Closure::<dyn FnMut(JsValue)>::new(move |frame: JsValue| {
|
||||
let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64;
|
||||
om.set(om.get().max(ts));
|
||||
if let Some(f) = to_rgba(&frame) {
|
||||
o.borrow_mut().push((ts, f));
|
||||
DECODED.with(|c| c.set(c.get() + 1));
|
||||
}
|
||||
let _ = call(&frame, "close", &[]);
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
let expected = epoch.get();
|
||||
let (o, lo, om, copies, epoch, active, config, errors) =
|
||||
(o.clone(), lo.clone(), om.clone(), copies.clone(), epoch.clone(), active.clone(), config.clone(), errors.clone());
|
||||
copies.set(copies.get().saturating_add(1));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let begin = now_ms();
|
||||
let converted = match pixels::read(&frame, &config).await {
|
||||
Ok((f, bytes)) => {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.native = s.native.saturating_add(1);
|
||||
s.bytes = s.bytes.saturating_add(bytes as u64);
|
||||
s.native_ms += now_ms() - begin;
|
||||
});
|
||||
Some(f)
|
||||
}
|
||||
Err(reason) => {
|
||||
let begin = now_ms();
|
||||
let f = to_rgba(&frame);
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.canvas = s.canvas.saturating_add(1);
|
||||
s.canvas_ms += now_ms() - begin;
|
||||
let count = s.reasons.entry(reason).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
});
|
||||
f
|
||||
}
|
||||
};
|
||||
let _ = call(&frame, "close", &[]);
|
||||
copies.set(copies.get().saturating_sub(1));
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.closed = s.closed.saturating_add(1);
|
||||
});
|
||||
if !active.get() || epoch.get() != expected {
|
||||
TRANSFERS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.stale = s.stale.saturating_add(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(f) = converted {
|
||||
o.borrow_mut().push((ts, f));
|
||||
om.set(om.get().max(ts));
|
||||
DECODED.with(|c| c.set(c.get().saturating_add(1)));
|
||||
} else {
|
||||
errors.set(true);
|
||||
}
|
||||
lo.set(now_ms());
|
||||
crate::repaint();
|
||||
});
|
||||
});
|
||||
let e = errored.clone();
|
||||
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |err: JsValue| {
|
||||
@@ -317,6 +391,9 @@ impl Session {
|
||||
last_output,
|
||||
out_max,
|
||||
out,
|
||||
copying,
|
||||
generation,
|
||||
alive,
|
||||
_closures: (on_output, on_error),
|
||||
};
|
||||
s.configure(cfg)?;
|
||||
@@ -341,6 +418,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
self.alive.set(false);
|
||||
self.generation.set(self.generation.get().wrapping_add(1));
|
||||
let _ = call(&self.decoder, "close", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An MP4/MOV source whose video is decoded by the browser.
|
||||
pub struct WcSource {
|
||||
inner: SharedSource,
|
||||
@@ -388,12 +473,15 @@ impl WcSource {
|
||||
// already output but the cache dropped needs a restart).
|
||||
let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get());
|
||||
if !covers {
|
||||
// Don't interrupt a session that is still working towards its own frame.
|
||||
let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS;
|
||||
// Don't interrupt a session that is still working towards its own frame, unless it
|
||||
// has gone quiet for STALE_MS (a copyTo that never settles must not wedge it).
|
||||
let fresh = now_ms() - s.last_output.get() < STALE_MS;
|
||||
let busy = fresh && (s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target));
|
||||
if busy {
|
||||
return Drive::Waiting;
|
||||
}
|
||||
if !s.needs_key || s.next > 0 {
|
||||
s.generation.set(s.generation.get().wrapping_add(1));
|
||||
let _ = call(&s.decoder, "reset", &[]);
|
||||
if s.configure(&self.config).is_err() {
|
||||
return Drive::Failed;
|
||||
@@ -410,7 +498,7 @@ impl WcSource {
|
||||
s.target = want;
|
||||
}
|
||||
let upto = (i + LOOKAHEAD).min(n - 1);
|
||||
while s.next <= upto && s.queue() < MAX_QUEUE {
|
||||
while s.next <= upto && s.queue().saturating_add(s.copying.get()) < MAX_QUEUE {
|
||||
let smp = &t.samples[s.next];
|
||||
let mut data = vec![0u8; smp.size as usize];
|
||||
match self.reader.read_at(smp.offset, &mut data) {
|
||||
@@ -480,10 +568,8 @@ impl WcSource {
|
||||
impl Drop for WcSource {
|
||||
fn drop(&mut self) {
|
||||
SESSIONS.with(|ss| {
|
||||
if let Ok(mut ss) = ss.try_borrow_mut()
|
||||
&& let Some(s) = ss.remove(&self.id)
|
||||
{
|
||||
let _ = call(&s.decoder, "close", &[]);
|
||||
if let Ok(mut ss) = ss.try_borrow_mut() {
|
||||
ss.remove(&self.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -532,4 +618,7 @@ impl MediaSource for WcSource {
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
self.inner.audio(start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
self.inner.audio_stream(stream, start, frames, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Browser planes -> core frames; no color conversion or chroma reconstruction here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_time::Tick;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{Config, call, get, obj};
|
||||
|
||||
// One 8K 4:4:4 16-bit picture + alpha fits within 256 MiB; cap browser-driven allocations.
|
||||
const MAX_COPY_BYTES: u32 = 256 * 1024 * 1024;
|
||||
|
||||
fn integer(value: JsValue) -> Option<u32> {
|
||||
let n = value.as_f64()?;
|
||||
if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.fract() == 0.0 { Some(n as u32) } else { None }
|
||||
}
|
||||
|
||||
fn color(frame: &JsValue, fallback: ColorInfo) -> Option<ColorInfo> {
|
||||
let c = get(frame, "colorSpace");
|
||||
let matrix = match get(&c, "matrix").as_string().as_deref() {
|
||||
Some("bt709") => Matrix::Bt709,
|
||||
Some("bt470bg" | "smpte170m") => Matrix::Bt601,
|
||||
Some("bt2020-ncl") => Matrix::Bt2020Ncl,
|
||||
None => fallback.matrix,
|
||||
_ => return None,
|
||||
};
|
||||
let transfer = match get(&c, "transfer").as_string().as_deref() {
|
||||
Some("bt709") => Transfer::Bt709,
|
||||
Some("iec61966-2-1") => Transfer::Srgb,
|
||||
Some("smpte2084") => Transfer::Pq,
|
||||
Some("arib-std-b67") => Transfer::Hlg,
|
||||
None => fallback.transfer,
|
||||
_ => return None,
|
||||
};
|
||||
let primaries = match get(&c, "primaries").as_string().as_deref() {
|
||||
Some("bt709") => Primaries::Bt709,
|
||||
Some("bt470bg") => Primaries::Bt601_625,
|
||||
Some("smpte170m") => Primaries::Bt601_525,
|
||||
Some("bt2020") => Primaries::Bt2020,
|
||||
None => fallback.primaries,
|
||||
_ => return None,
|
||||
};
|
||||
let range = match get(&c, "fullRange").as_bool() {
|
||||
Some(true) => Range::Full,
|
||||
Some(false) => Range::Limited,
|
||||
None => fallback.range,
|
||||
};
|
||||
Some(ColorInfo { matrix, transfer, primaries, range })
|
||||
}
|
||||
|
||||
fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
|
||||
let offset = integer(get(layout, "offset")).ok_or("layout")?;
|
||||
let stride = integer(get(layout, "stride")).filter(|s| *s >= width).ok_or("layout")?;
|
||||
let end =
|
||||
height.checked_sub(1).and_then(|h| h.checked_mul(stride)).and_then(|n| offset.checked_add(n)).and_then(|n| n.checked_add(width)).ok_or("layout")?;
|
||||
if end > raw.length() {
|
||||
return Err("layout");
|
||||
}
|
||||
let len = width.checked_mul(height).filter(|n| *n <= MAX_COPY_BYTES).ok_or("allocation")? as usize;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| "memory")?;
|
||||
out.resize(len, 0);
|
||||
if stride == width {
|
||||
raw.subarray(offset, end).copy_to(&mut out);
|
||||
} else {
|
||||
for (y, row) in out.chunks_exact_mut(width as usize).enumerate() {
|
||||
// Checked for the last row above; earlier rows are smaller, but stay checked anyway.
|
||||
let start = u32::try_from(y).ok().and_then(|y| y.checked_mul(stride)).and_then(|n| offset.checked_add(n)).ok_or("layout")?;
|
||||
let row_end = start.checked_add(width).filter(|e| *e <= end).ok_or("layout")?;
|
||||
raw.subarray(start, row_end).copy_to(row);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy native 8-bit YUV planes; unsupported formats/geometry return a counted Canvas fallback reason.
|
||||
pub(super) async fn read(frame: &JsValue, cfg: &Config) -> Result<(VideoFrame, u32), &'static str> {
|
||||
let format = get(frame, "format").as_string().ok_or("format")?;
|
||||
let (chroma, alpha, interleaved) = match format.as_str() {
|
||||
"NV12" => (Chroma::C420, false, true),
|
||||
"I420" => (Chroma::C420, false, false),
|
||||
"I420A" => (Chroma::C420, true, false),
|
||||
"I422" => (Chroma::C422, false, false),
|
||||
"I422A" => (Chroma::C422, true, false),
|
||||
"I444" => (Chroma::C444, false, false),
|
||||
"I444A" => (Chroma::C444, true, false),
|
||||
_ => return Err("format"),
|
||||
};
|
||||
let rect = get(frame, "visibleRect");
|
||||
let w = integer(get(&rect, "width")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
let h = integer(get(&rect, "height")).filter(|n| *n > 0).ok_or("geometry")?;
|
||||
if integer(get(frame, "displayWidth")) != Some(w)
|
||||
|| integer(get(frame, "displayHeight")) != Some(h)
|
||||
|| get(frame, "rotation").as_f64().is_some_and(|r| r != 0.0)
|
||||
|| get(frame, "flip").as_bool() == Some(true)
|
||||
{
|
||||
return Err("geometry");
|
||||
}
|
||||
let color = color(frame, cfg.color).ok_or("color")?;
|
||||
let options = obj(&[("rect", rect)]);
|
||||
let bytes =
|
||||
integer(call(frame, "allocationSize", &[&options]).map_err(|_| "allocation")?).filter(|n| *n > 0 && *n <= MAX_COPY_BYTES).ok_or("allocation")?;
|
||||
let array: js_sys::Function = get(&js_sys::global(), "Uint8Array").dyn_into().map_err(|_| "allocation")?;
|
||||
let raw: js_sys::Uint8Array =
|
||||
js_sys::Reflect::construct(&array, &js_sys::Array::of1(&bytes.into())).and_then(|v| v.dyn_into()).map_err(|_| "allocation")?;
|
||||
let promise = call(frame, "copyTo", &[raw.as_ref(), &options]).map_err(|_| "copy")?.dyn_into::<js_sys::Promise>().map_err(|_| "copy")?;
|
||||
let layout = js_sys::Array::from(&JsFuture::from(promise).await.map_err(|_| "copy")?);
|
||||
let count = if interleaved {
|
||||
2
|
||||
} else if alpha {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
if layout.length() != count {
|
||||
return Err("layout");
|
||||
}
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
let y = plane(&raw, &layout.get(0), w, h)?;
|
||||
let (u, v) = if interleaved {
|
||||
let uv = plane(&raw, &layout.get(1), cw.checked_mul(2).ok_or("allocation")?, ch)?;
|
||||
let pairs = uv.as_chunks::<2>().0;
|
||||
let (mut u, mut v) = (Vec::new(), Vec::new());
|
||||
u.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
v.try_reserve_exact(pairs.len()).map_err(|_| "memory")?;
|
||||
u.extend(pairs.iter().map(|p| p[0]));
|
||||
v.extend(pairs.iter().map(|p| p[1]));
|
||||
(u, v)
|
||||
} else {
|
||||
(plane(&raw, &layout.get(1), cw, ch)?, plane(&raw, &layout.get(2), cw, ch)?)
|
||||
};
|
||||
let alpha = if alpha { Some(Arc::new(plane(&raw, &layout.get(3), w, h)?)) } else { None };
|
||||
Ok((
|
||||
VideoFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha },
|
||||
color,
|
||||
par: cfg.par,
|
||||
pts: Tick::ZERO,
|
||||
},
|
||||
bytes,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Web import destinations: active bin tab, browser drop + navigation during pending reads.
|
||||
// Synthetic browser drop event exercises the production handler; physical OS drag/picker UI is outside this test.
|
||||
export async function checkImportBins({ evaluate, media, timeoutMs = 120000 }) {
|
||||
const report = {gates: {}, cases: []};
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import bins"})');
|
||||
const parent = (await evaluate('filmcraft.execute("file.newBin", {name: "Footage"})')).bin;
|
||||
const child = (await evaluate(`filmcraft.execute("file.newBin", {name: "Selects", parent: ${parent}})`)).bin;
|
||||
await evaluate(`(async () => {window.__importBinFixture = new Uint8Array(await (await fetch(${JSON.stringify(media)})).arrayBuffer());})()`);
|
||||
const setBin = bin => evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${JSON.stringify(bin)}, activeTab: null}})`);
|
||||
const importExpression = name => `filmcraft.importFiles([new File([window.__importBinFixture], ${JSON.stringify(name)}, {type: "video/mp4"})])`;
|
||||
const locate = async name => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
const project = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const find = bin => {
|
||||
for (const entry of bin.children) {
|
||||
if (entry.item !== undefined && entry.name === name) return bin.id;
|
||||
if (entry.children) {const found = find(entry); if (found !== undefined) return found;}
|
||||
}
|
||||
};
|
||||
const found = find(project.root);
|
||||
if (found !== undefined) return found;
|
||||
if (Date.now() >= deadline) throw new Error(`Import never appeared: ${name}`);
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
};
|
||||
const check = async (key, expected, name) => {
|
||||
const actual = await locate(name);
|
||||
report.cases.push({key, expected, actual, name}); report.gates[key] = actual === expected;
|
||||
};
|
||||
await evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${child}, activeTab: 0, tabs: [{bin: ${parent}}]}})`);
|
||||
await evaluate(importExpression("tab.mp4")); await check("activeTab", parent, "tab.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([window.__importBinFixture], "drop.mp4", {type: "video/mp4"}));
|
||||
window.dispatchEvent(new DragEvent("drop", {dataTransfer: transfer, bubbles: true, cancelable: true}));
|
||||
})()`);
|
||||
await check("browserDrop", child, "drop.mp4");
|
||||
await setBin(child);
|
||||
await evaluate(`(() => {
|
||||
const original = Blob.prototype.arrayBuffer;
|
||||
let release;
|
||||
const pending = new Promise(resolve => {release = resolve;});
|
||||
const gate = window.__importBinGate = {hits: 0, release, original};
|
||||
Blob.prototype.arrayBuffer = function () {gate.hits++; return pending.then(() => original.call(this));};
|
||||
window.__pendingBinImport = ${importExpression("pending.mp4")};
|
||||
})()`);
|
||||
try {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const pollMs = 100;
|
||||
for (;;) {
|
||||
if (await evaluate("window.__importBinGate.hits > 0")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Read-delay probe did not intercept any Blob reads");
|
||||
await new Promise(resolve => setTimeout(resolve, pollMs));
|
||||
}
|
||||
report.delayedReadHits = await evaluate("window.__importBinGate.hits");
|
||||
await setBin(parent);
|
||||
await evaluate("window.__importBinGate.release(); window.__pendingBinImport");
|
||||
await check("destinationCapturedBeforeRead", child, "pending.mp4");
|
||||
} finally {
|
||||
await evaluate("Blob.prototype.arrayBuffer = window.__importBinGate.original; window.__importBinGate.release(); delete window.__importBinGate; delete window.__pendingBinImport; delete window.__importBinFixture");
|
||||
}
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// #194 regression: same-name/same-size imports must retain independent sources across OPFS recovery.
|
||||
// evaluate(expression) awaits a browser expression; navigate(url) waits for FilmCraft readiness. Generated PCM16 mono WAVs only; no binary fixtures.
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function tone(frequency, seconds) {
|
||||
// RIFF/WAVE PCM16 mono: 44-byte header + 2 bytes/sample; fixture matches #194 (48 kHz, amplitude 12000).
|
||||
const rate = 48000, bytesPerSample = 2, samples = rate * seconds;
|
||||
const b = Buffer.alloc(44 + samples * bytesPerSample);
|
||||
b.write("RIFF", 0); b.writeUInt32LE(b.length - 8, 4); b.write("WAVEfmt ", 8);
|
||||
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
|
||||
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * bytesPerSample, 28);
|
||||
b.writeUInt16LE(bytesPerSample, 32); b.writeUInt16LE(16, 34); b.write("data", 36); b.writeUInt32LE(samples * bytesPerSample, 40);
|
||||
for (let i = 0; i < samples; i++) b.writeInt16LE(Math.round(12000 * Math.sin(2 * Math.PI * frequency * i / rate)), 44 + bytesPerSample * i);
|
||||
return b;
|
||||
}
|
||||
|
||||
export async function checkImportCollision({ evaluate, navigate, url, timeoutMs = 120000 }) {
|
||||
const fixtures = [tone(440, 2), tone(880, 2)];
|
||||
const hashes = fixtures.map(b => createHash("sha256").update(b).digest("hex"));
|
||||
const report = { hashes, fixtureBytes: fixtures.map(b => b.length), gates: {} };
|
||||
const hash = expression => evaluate(`(async () => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", ${expression})), b => b.toString(16).padStart(2, "0")).join(""))()`);
|
||||
const readHash = path => hash(`await filmcraft.readFile(${JSON.stringify(path)})`);
|
||||
const importFile = index => evaluate(`filmcraft.importFiles([new File([Uint8Array.from(atob(${JSON.stringify(fixtures[index].toString("base64"))}), c => c.charCodeAt(0))], "collision.wav", {type: "audio/wav"})])`);
|
||||
await evaluate('filmcraft.execute("file.newProject", {name: "Import collision"})');
|
||||
const a = await importFile(0); report.a = a;
|
||||
if (a.errors?.length || !a.items?.length) throw new Error("baseline import failed: " + JSON.stringify(a));
|
||||
report.initialHash = await readHash(a.paths[0]);
|
||||
report.gates.healthyInitial = report.initialHash === hashes[0];
|
||||
await evaluate(`filmcraft.execute("file.newSequence", {name: "Original A", fromItem: ${a.items[0]}})`);
|
||||
const b = await importFile(1); report.b = b;
|
||||
if (b.errors?.length || !b.items?.length) throw new Error("second import failed: " + JSON.stringify(b));
|
||||
report.afterBHash = await readHash(a.paths[0]);
|
||||
report.gates.distinctPaths = a.paths[0] !== b.paths[0];
|
||||
report.gates.originalPreserved = report.afterBHash === hashes[0];
|
||||
report.beforeProject = await evaluate('filmcraft.execute("project.inspect")');
|
||||
const revision = report.beforeProject.revision;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const persistencePollMs = 100;
|
||||
for (;;) {
|
||||
const snapshot = await evaluate(`(async () => {
|
||||
try {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("recovery");
|
||||
const read = async name => JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text());
|
||||
return {meta: await read("meta.json"), project: await read("snapshot.fcproj")};
|
||||
} catch (e) {return {error: String(e)};}
|
||||
})()`);
|
||||
report.recoveryMeta = snapshot.meta;
|
||||
if (snapshot.meta?.dirty && snapshot.meta.revision === revision && JSON.stringify(snapshot.project).includes("Original A")) break;
|
||||
if (Date.now() >= deadline) throw new Error("Recovery persistence gate failed: " + JSON.stringify(snapshot));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
// Media writes run separately from the snapshot queue; gate their actual bytes before navigation.
|
||||
for (;;) {
|
||||
report.opfs = await evaluate(`(async () => {
|
||||
const dir = await (await navigator.storage.getDirectory()).getDirectoryHandle("media");
|
||||
const files = [];
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
const bytes = await (await handle.getFile()).arrayBuffer();
|
||||
files.push({name, hash: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), b => b.toString(16).padStart(2, "0")).join("")});
|
||||
}
|
||||
return files;
|
||||
})()`);
|
||||
// Before the fix, A and B intentionally share the same path; qualify the last write there.
|
||||
const expected = new Map([[a.paths[0], hashes[0]], [b.paths[0], hashes[1]]]);
|
||||
if ([...expected].every(([path, digest]) => report.opfs.some(f => f.name === path.slice("/files/".length) && f.hash === digest))) break;
|
||||
if (Date.now() >= deadline) throw new Error("Imported media did not reach OPFS: " + JSON.stringify(report.opfs));
|
||||
await new Promise(resolve => setTimeout(resolve, persistencePollMs));
|
||||
}
|
||||
const recoveryUrl = new URL(url);
|
||||
recoveryUrl.searchParams.delete("fresh"); recoveryUrl.searchParams.delete("norecover");
|
||||
await navigate(recoveryUrl.href);
|
||||
report.recovered = await evaluate('filmcraft.execute("project.inspect")');
|
||||
report.afterReload = {a: await readHash(a.paths[0]), b: await readHash(b.paths[0])};
|
||||
report.gates.recoveredProject = JSON.stringify(report.recovered).includes("Original A");
|
||||
report.gates.recoveredSources = report.afterReload.a === hashes[0] && report.afterReload.b === hashes[1];
|
||||
report.ok = Object.values(report.gates).every(Boolean);
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
|
||||
//! crate is wasm32-only.
|
||||
|
||||
#[path = "../src/recovery_policy.rs"]
|
||||
mod recovery_policy;
|
||||
|
||||
use recovery_policy::{Action, INTERVAL_S, Policy};
|
||||
|
||||
/// #197: a snapshot write that fails is retried after the interval without another edit.
|
||||
#[test]
|
||||
fn failed_snapshot_write_is_retried_without_another_edit() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, false, 0.1);
|
||||
// not immediately (no spinning on a failing disk)…
|
||||
assert!(matches!(p.tick(1.0, 3, 0), Action::WakeAfter(_)));
|
||||
// …but once the interval has passed, with the revision unchanged
|
||||
assert_eq!(p.tick(0.1 + INTERVAL_S, 3, 0), Action::WriteSnapshot(3));
|
||||
p.written(3, true, 5.2);
|
||||
assert_eq!(p.tick(20.0, 3, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failure reported for an older revision doesn't make the newer one be written again.
|
||||
#[test]
|
||||
fn stale_failure_is_ignored() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(INTERVAL_S, 2, 0), Action::WriteSnapshot(2));
|
||||
p.written(1, false, INTERVAL_S);
|
||||
assert_eq!(p.tick(3.0 * INTERVAL_S, 2, 0), Action::Nothing);
|
||||
}
|
||||
|
||||
/// #193: a clean session (started with `?norecover` / `?fresh`, so the snapshot left by an
|
||||
/// earlier session was not reopened) never marks that snapshot clean.
|
||||
#[test]
|
||||
fn clean_start_leaves_an_unconsumed_snapshot_dirty() {
|
||||
let mut p = Policy::default();
|
||||
for t in 0..100 {
|
||||
assert_eq!(p.tick(t as f64, 0, 0), Action::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshot this session wrote is retired once the project is saved.
|
||||
#[test]
|
||||
fn saving_marks_own_snapshot_clean() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(1.1, 1, 1), Action::Nothing);
|
||||
p.cleaned(true, 1.2);
|
||||
assert_eq!(p.tick(30.0, 1, 1), Action::Nothing);
|
||||
}
|
||||
|
||||
/// A failed clean write is retried after the interval.
|
||||
#[test]
|
||||
fn failed_clean_write_is_retried() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
p.written(1, true, 0.1);
|
||||
assert_eq!(p.tick(1.0, 1, 1), Action::WriteClean);
|
||||
p.cleaned(false, 1.1);
|
||||
assert!(matches!(p.tick(2.0, 1, 1), Action::WakeAfter(_)));
|
||||
assert_eq!(p.tick(1.1 + INTERVAL_S, 1, 1), Action::WriteClean);
|
||||
}
|
||||
|
||||
/// An edit made while the clean write is in flight keeps the meta dirty after it lands.
|
||||
#[test]
|
||||
fn edit_during_clean_write_keeps_meta_owned() {
|
||||
let mut p = Policy::default();
|
||||
assert_eq!(p.tick(0.0, 1, 0), Action::WriteSnapshot(1));
|
||||
assert_eq!(p.tick(10.0, 1, 1), Action::WriteClean);
|
||||
assert_eq!(p.tick(20.0, 2, 1), Action::WriteSnapshot(2));
|
||||
p.cleaned(true, 20.1);
|
||||
// saved again: the newer snapshot must be retired too
|
||||
assert_eq!(p.tick(30.0, 2, 2), Action::WriteClean);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { checkImportBins } from "./import-bins.mjs";
|
||||
import { checkImportCollision } from "./import-collision.mjs";
|
||||
|
||||
const arg = (k, d) => {
|
||||
const i = process.argv.indexOf(`--${k}`);
|
||||
@@ -162,6 +164,19 @@ try {
|
||||
await sleep(500);
|
||||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
report.steps.modes = { screenshot: await shot("05-modes") };
|
||||
report.steps.importBins = await checkImportBins({evaluate: js, media});
|
||||
if (!report.steps.importBins.ok) throw new Error("import bin regression: " + JSON.stringify(report.steps.importBins.gates));
|
||||
report.steps.importCollision = await checkImportCollision({
|
||||
evaluate: js,
|
||||
url,
|
||||
navigate: async (nextUrl) => {
|
||||
await send("Page.navigate", { url: nextUrl });
|
||||
await until("!!(window.filmcraftLoad && (window.filmcraftLoad.readyMs || window.filmcraftLoad.error))", 120000);
|
||||
const load = await js("window.filmcraftLoad");
|
||||
if (load.error) throw new Error(load.error);
|
||||
},
|
||||
});
|
||||
if (!report.steps.importCollision.ok) throw new Error("import collision regression: " + JSON.stringify(report.steps.importCollision.gates));
|
||||
// still alive? (a panicked app never answers)
|
||||
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
|
||||
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
|
||||
|
||||
@@ -31,6 +31,7 @@ log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave
|
||||
/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`)
|
||||
/// names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends
|
||||
// shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and
|
||||
// leaves machines without DX12 with no backend at all.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_leaves_out_gl() {
|
||||
// eframe's own default (PRIMARY | GL) would initialize OpenGL as well.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let configured = configured_backends(None);
|
||||
assert_eq!(configured, Backends::PRIMARY);
|
||||
assert!(!configured.contains(Backends::GL));
|
||||
assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The desktop app's logger: `log::` records go to standard error and to a log file.
|
||||
//!
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (GPU compositor fallbacks,
|
||||
//! audio output problems, a panicked frame job or audio mixer, container warnings) vanished. A
|
||||
//! launch from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<data dir>/Logs/filmcraft.log`, next to the crash logs (`--data-dir`, `FILMCRAFT_DATA_DIR` or
|
||||
//! `filmcraft_engine::autosave::default_data_dir`; Help ▸ Reveal Log Files opens the folder). Each
|
||||
//! start moves the previous log to `filmcraft.1.log` (and that one to `.2`), so the log of a run
|
||||
//! that crashed survives the next launch.
|
||||
//!
|
||||
//! Levels: `info` for FilmCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
//! `warn,filmcraft_codecs=trace`, `wgpu_core=info`. A directive ending in `*` matches every target
|
||||
//! that starts with it (`filmcraft*=debug`).
|
||||
//!
|
||||
//! Records logged before the data directory is known are kept (up to [`MAX_PENDING`]) and written
|
||||
//! once the file is attached. Writing never panics: a file that can't be created or written leaves
|
||||
//! standard error as the only sink.
|
||||
//!
|
||||
//! Realtime audio: the cpal callbacks (`audio.rs`, `audio_in.rs`, the play-ahead reader) do not
|
||||
//! log; they report through flags and a one-time `eprintln!`. A record takes the sink lock only for
|
||||
//! one `write_all` of an already formatted line (standard error is written outside the lock), so a
|
||||
//! logging thread such as the audio mixer never waits on more than one other line being written.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log directory.
|
||||
pub const LOG_FILE: &str = "filmcraft.log";
|
||||
/// The log directory inside the data directory (shared with the crash logs and Reveal Log Files).
|
||||
pub const LOG_DIR: &str = "Logs";
|
||||
/// How many previous logs are kept (`filmcraft.1.log` … `filmcraft.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when `RUST_LOG` is unset or empty.
|
||||
pub const DEFAULT_FILTER: &str = "warn,filmcraft*=info";
|
||||
|
||||
/// Per-target level filter parsed from env_logger-style directives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
default: LevelFilter,
|
||||
directives: Vec<(String, LevelFilter)>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no
|
||||
/// directive names log errors only.
|
||||
pub fn parse(spec: &str) -> Filter {
|
||||
let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() };
|
||||
for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match part.split_once('=') {
|
||||
Some((name, level)) => {
|
||||
let name = name.trim();
|
||||
if let (false, Some(level)) = (name.is_empty(), parse_level(level)) {
|
||||
f.directives.push((name.to_owned(), level));
|
||||
}
|
||||
}
|
||||
None => match parse_level(part) {
|
||||
Some(level) => f.default = level,
|
||||
// A bare target name: everything from it (env_logger does the same).
|
||||
None => f.directives.push((part.to_owned(), LevelFilter::Trace)),
|
||||
},
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// The level that applies to `target` (the most specific matching directive wins).
|
||||
pub fn level_for(&self, target: &str) -> LevelFilter {
|
||||
self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level)
|
||||
}
|
||||
|
||||
/// The most verbose level any target can reach (for `log::set_max_level`).
|
||||
pub fn max(&self) -> LevelFilter {
|
||||
self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_level(s: &str) -> Option<LevelFilter> {
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`);
|
||||
/// `name*` matches every target starting with `name`.
|
||||
fn matches(name: &str, target: &str) -> bool {
|
||||
match name.strip_suffix('*') {
|
||||
Some(prefix) => target.starts_with(prefix),
|
||||
None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("filmcraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
/// then appended to it until it reaches its size cap.
|
||||
pub struct Sink {
|
||||
file: Option<File>,
|
||||
pending: Vec<String>,
|
||||
dropped: usize,
|
||||
written: u64,
|
||||
max_bytes: u64,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
impl Sink {
|
||||
pub fn new(max_bytes: u64) -> Sink {
|
||||
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
|
||||
}
|
||||
|
||||
/// Append `line` to the file, or keep it until [`Sink::attach`].
|
||||
pub fn write(&mut self, line: &str) {
|
||||
if self.file.is_none() {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
if self.pending.len() < MAX_PENDING {
|
||||
self.pending.push(line.to_owned());
|
||||
} else {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.append(line);
|
||||
}
|
||||
|
||||
/// Start writing to `file`, first the lines kept so far.
|
||||
pub fn attach(&mut self, file: File) {
|
||||
self.file = Some(file);
|
||||
for line in std::mem::take(&mut self.pending) {
|
||||
self.append(&line);
|
||||
}
|
||||
if self.dropped > 0 {
|
||||
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
|
||||
self.dropped = 0;
|
||||
self.append(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// No file will come: drop the kept lines and keep none from now on.
|
||||
pub fn close(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
/// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]).
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
match rotate(dir) {
|
||||
Ok((path, file)) => {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
Ok(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.stderr_only();
|
||||
Err(format!("{}: {e}", dir.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file this run (no data directory, or it can't be used): stop keeping records for one.
|
||||
pub fn stderr_only(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).close();
|
||||
}
|
||||
}
|
||||
|
||||
impl log::Log for AppLogger {
|
||||
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
|
||||
metadata.level() <= self.filter.level_for(metadata.target())
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record<'_>) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// Format before taking the lock, so the lock covers one write only.
|
||||
let thread = std::thread::current();
|
||||
let line = format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
if self.stderr {
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] once the data directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty());
|
||||
let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
log::set_max_level(max);
|
||||
Some(logger)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use log::Log;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
std::fs::read_to_string(path).expect("read log")
|
||||
}
|
||||
|
||||
fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) {
|
||||
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_filmcraft_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("filmcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft::audio"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_ui_egui::play_ahead"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
|
||||
assert_eq!(f.max(), LevelFilter::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
|
||||
let f = Filter::parse("info,wgpu_core=error,filmcraft_codecs=trace,filmcraft_codecs::mxf=off");
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::ogg"), LevelFilter::Trace);
|
||||
assert_eq!(f.level_for("filmcraft_codecs::mxf"), LevelFilter::Off);
|
||||
assert_eq!(f.max(), LevelFilter::Trace);
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_ui_egui::frames"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,filmcraft_ui*=debug").level_for("filmcraft_engine"), LevelFilter::Warn);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive; the last bare level wins.
|
||||
assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_specs_never_panic_and_fall_back_sensibly() {
|
||||
let long = "a".repeat(100_000);
|
||||
let specs = [
|
||||
"",
|
||||
",,,",
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"filmcraft=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=debug",
|
||||
"a=b=c",
|
||||
"warn,,filmcraft=DEBUG",
|
||||
" \t ",
|
||||
"\u{0}",
|
||||
"é*=trace",
|
||||
&long,
|
||||
];
|
||||
for spec in specs {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["filmcraft", "", "🦀", "é", "a::b"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,filmcraft=DEBUG").level_for("filmcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped");
|
||||
assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target");
|
||||
assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_utc_with_milliseconds() {
|
||||
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("filmcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("filmcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "a file where the directory should be").expect("block");
|
||||
assert!(rotate(&dir).is_err());
|
||||
assert!(rotate(&dir.join("below")).is_err());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "filmcraft_ui_egui", "audio output unavailable: no output device"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] filmcraft_ui_egui: audio output unavailable: no output device\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
sink.attach(file);
|
||||
sink.write("late\n");
|
||||
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
|
||||
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
|
||||
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_max_pending_lines_drop_nothing() {
|
||||
let dir = temp_dir("exact");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
}
|
||||
sink.attach(file);
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), MAX_PENDING);
|
||||
assert!(!text.contains("dropped"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
sink.write(&format!("line {i:02} with some padding\n"));
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
|
||||
assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "filmcraft", "FilmCraft starting");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Debug, "filmcraft", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("filmcraft: FilmCraft starting")), "{text}");
|
||||
assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("filmcraft_engine::autosave").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// A second launch rotates: the first log becomes `.1`.
|
||||
let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("filmcraft.1.log")), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_from_many_threads_are_whole_lines() {
|
||||
let dir = temp_dir("threads");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
std::thread::scope(|s| {
|
||||
for _ in 0..4 {
|
||||
s.spawn(|| {
|
||||
for _ in 0..100 {
|
||||
record(&logger, log::Level::Info, "filmcraft", "from a worker");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 400);
|
||||
assert!(text.lines().all(|l| l.ends_with("filmcraft: from a worker")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
|
||||
let dir = temp_dir("unusable");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let e = logger.attach_dir(&dir).expect_err("a file is not a log directory");
|
||||
assert!(e.contains(&dir.display().to_string()), "{e}");
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
let sink = logger.sink.lock().expect("sink");
|
||||
assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come");
|
||||
drop(sink);
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_lock_does_not_stop_logging() {
|
||||
let dir = temp_dir("poison");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _guard = logger.sink.lock();
|
||||
panic!("poison the sink");
|
||||
}));
|
||||
assert!(logger.sink.is_poisoned());
|
||||
record(&logger, log::Level::Info, "filmcraft", "after the panic");
|
||||
assert!(read(&path).contains("after the panic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+71
-16
@@ -16,6 +16,9 @@
|
||||
//! with unsaved changes the app asks to recover them; `--recover` recovers the newest without
|
||||
//! asking, `--no-recover` starts without asking (the changes stay available via File ▸ Recover
|
||||
//! Unsaved Changes…).
|
||||
//!
|
||||
//! `log` records go to standard error and `<data dir>/Logs/filmcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
@@ -24,6 +27,9 @@ mod args;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod window_raise;
|
||||
@@ -66,6 +72,8 @@ fn tell(text: &str, error: bool) {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up record are logged (`logging`).
|
||||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
@@ -83,10 +91,24 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR));
|
||||
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
|
||||
// `--version` and usage errors leave no file behind. Records logged until now go in first.
|
||||
if let Some(logger) = logger {
|
||||
match log_dir.as_deref().map(|d| logger.attach_dir(d)) {
|
||||
Some(Ok(path)) => log::info!("log file {}", path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no data directory (set FILMCRAFT_DATA_DIR or --data-dir)");
|
||||
}
|
||||
}
|
||||
}
|
||||
app_nap::disable();
|
||||
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
|
||||
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
|
||||
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
|
||||
filmcraft_ui_egui::crash::install(log_dir);
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||
register_hardware_decoders();
|
||||
@@ -108,6 +130,13 @@ fn main() -> eframe::Result {
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..Default::default()
|
||||
};
|
||||
// Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
};
|
||||
let started = eframe::run_native(
|
||||
"FilmCraft",
|
||||
options,
|
||||
@@ -117,7 +146,7 @@ fn main() -> eframe::Result {
|
||||
let mut cfg = AutosaveConfig::new(dir);
|
||||
cfg.local_offset = local_offset;
|
||||
if let Err(e) = session.start_autosave(cfg) {
|
||||
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
|
||||
log::warn!("auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
@@ -125,7 +154,7 @@ fn main() -> eframe::Result {
|
||||
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("filmcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
|
||||
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
|
||||
@@ -133,7 +162,7 @@ fn main() -> eframe::Result {
|
||||
match recent {
|
||||
Some(p) => {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
eprintln!("filmcraft: {e}");
|
||||
log::error!("{e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -151,8 +180,8 @@ fn main() -> eframe::Result {
|
||||
if recover == Some(true) && !session.recovery_candidates().is_empty() {
|
||||
let id = session.recovery_candidates()[0].id.clone();
|
||||
match session.execute("file.recover", json!({"id": id})) {
|
||||
Ok(r) => eprintln!("filmcraft: recovered {r}"),
|
||||
Err(e) => eprintln!("filmcraft: recovery failed: {e}"),
|
||||
Ok(r) => log::info!("recovered {r}"),
|
||||
Err(e) => log::error!("recovery failed: {e}"),
|
||||
}
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
@@ -169,27 +198,50 @@ fn main() -> eframe::Result {
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
|
||||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), exts)
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink =
|
||||
Some(Box::new(|exts: &[&str], _hint| rfd::FileDialog::new().add_filter("Media", exts).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t("Media"), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), exts)
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
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));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
@@ -208,7 +260,7 @@ fn main() -> eframe::Result {
|
||||
// instead of exiting silently, and keep the reason in the crash log.
|
||||
if let Err(e) = &started {
|
||||
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
|
||||
eprintln!("filmcraft: {msg}");
|
||||
log::error!("{msg}");
|
||||
filmcraft_ui_egui::crash::record(&msg);
|
||||
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
|
||||
}
|
||||
@@ -302,6 +354,9 @@ mod tests {
|
||||
fn startup_registers_the_hardware_decoders_without_a_logger() {
|
||||
assert!(!log::log_enabled!(log::Level::Info));
|
||||
let hardware = super::register_hardware_decoders();
|
||||
assert_eq!(filmcraft_platform::registered(), cfg!(any(target_os = "macos", target_os = "windows")), "{hardware:?}");
|
||||
// always on macOS and Windows; on Linux when a VA-API driver is there
|
||||
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
filmcraft_ui_egui::i18n::Language::Es
|
||||
} else if checked("app.language.portuguese") {
|
||||
filmcraft_ui_egui::i18n::Language::PtBr
|
||||
} else if checked("app.language.ukrainian") {
|
||||
filmcraft_ui_egui::i18n::Language::Uk
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
|
||||
+375
-58
@@ -2,49 +2,49 @@
|
||||
"schema": 1,
|
||||
"repo": "storytold/filmcraft",
|
||||
"branch": "main",
|
||||
"head": "a4536bcfedf588cbef9ea24df149c70bf99a037d",
|
||||
"head_date": "2026-10-07T21:56:33Z",
|
||||
"commits": 425,
|
||||
"head": "7b6c134472287db4367fca80bcb498240ba36567",
|
||||
"head_date": "2026-10-09T15:02:38Z",
|
||||
"commits": 539,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 39,
|
||||
"commits": 312,
|
||||
"lines_added": 309158,
|
||||
"lines_deleted": 12778,
|
||||
"prs": 48,
|
||||
"commits": 337,
|
||||
"lines_added": 311167,
|
||||
"lines_deleted": 13200,
|
||||
"binary_added": 74,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-09-30T09:22:00Z",
|
||||
"last_commit": "2026-10-07T21:56:33Z"
|
||||
"last_commit": "2026-10-09T15:02:38Z"
|
||||
},
|
||||
{
|
||||
"login": "storybored379",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 11,
|
||||
"commits": 13,
|
||||
"lines_added": 1501,
|
||||
"lines_deleted": 87,
|
||||
"prs": 15,
|
||||
"commits": 17,
|
||||
"lines_added": 1960,
|
||||
"lines_deleted": 179,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-03T16:31:42Z",
|
||||
"last_commit": "2026-10-05T04:48:25Z"
|
||||
"last_commit": "2026-10-09T04:35:58Z"
|
||||
},
|
||||
{
|
||||
"login": "justjohn12345",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 13,
|
||||
"commits": 35,
|
||||
"lines_added": 8315,
|
||||
"lines_deleted": 839,
|
||||
"prs": 15,
|
||||
"commits": 37,
|
||||
"lines_added": 9087,
|
||||
"lines_deleted": 938,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-05T19:57:28Z",
|
||||
"last_commit": "2026-10-07T17:24:43Z"
|
||||
"last_commit": "2026-10-08T17:06:13Z"
|
||||
},
|
||||
{
|
||||
"login": "punkhop",
|
||||
@@ -63,14 +63,14 @@
|
||||
"login": "midasdf",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 480,
|
||||
"lines_deleted": 24,
|
||||
"prs": 4,
|
||||
"commits": 4,
|
||||
"lines_added": 618,
|
||||
"lines_deleted": 32,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T12:41:17Z",
|
||||
"last_commit": "2026-10-07T21:13:41Z"
|
||||
"last_commit": "2026-10-08T15:40:01Z"
|
||||
},
|
||||
{
|
||||
"login": "EthanSuquet",
|
||||
@@ -89,14 +89,14 @@
|
||||
"login": "dcarnelutti",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 3815,
|
||||
"lines_deleted": 229,
|
||||
"prs": 7,
|
||||
"commits": 7,
|
||||
"lines_added": 4069,
|
||||
"lines_deleted": 240,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-06T21:04:48Z",
|
||||
"last_commit": "2026-10-07T20:00:08Z"
|
||||
"last_commit": "2026-10-08T16:17:58Z"
|
||||
},
|
||||
{
|
||||
"login": "mweinbach",
|
||||
@@ -154,40 +154,40 @@
|
||||
"login": "carlitosteso3",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 8,
|
||||
"commits": 9,
|
||||
"lines_added": 1196,
|
||||
"lines_deleted": 141,
|
||||
"prs": 15,
|
||||
"commits": 15,
|
||||
"lines_added": 1740,
|
||||
"lines_deleted": 207,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T03:06:02Z",
|
||||
"last_commit": "2026-10-07T17:37:50Z"
|
||||
"last_commit": "2026-10-09T14:21:09Z"
|
||||
},
|
||||
{
|
||||
"login": "SaymonMaster14",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 6,
|
||||
"lines_added": 5514,
|
||||
"lines_deleted": 296,
|
||||
"prs": 8,
|
||||
"commits": 14,
|
||||
"lines_added": 10039,
|
||||
"lines_deleted": 651,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T06:59:57Z",
|
||||
"last_commit": "2026-10-07T18:42:38Z"
|
||||
"last_commit": "2026-10-09T07:32:43Z"
|
||||
},
|
||||
{
|
||||
"login": "Luna161",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 65,
|
||||
"lines_deleted": 1,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 195,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T18:20:23Z",
|
||||
"last_commit": "2026-10-07T20:21:49Z"
|
||||
"last_commit": "2026-10-08T00:35:58Z"
|
||||
},
|
||||
{
|
||||
"login": "RicSchonfelder",
|
||||
@@ -214,6 +214,305 @@
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T20:35:41Z",
|
||||
"last_commit": "2026-10-07T20:35:41Z"
|
||||
},
|
||||
{
|
||||
"login": "cirocueto2013-wq",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 3,
|
||||
"lines_added": 1127,
|
||||
"lines_deleted": 226,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:56:58Z",
|
||||
"last_commit": "2026-10-07T23:50:33Z"
|
||||
},
|
||||
{
|
||||
"login": "DWAA1660",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 209,
|
||||
"lines_deleted": 0,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T22:57:38Z",
|
||||
"last_commit": "2026-10-08T23:52:30Z"
|
||||
},
|
||||
{
|
||||
"login": "txiki739",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 8,
|
||||
"lines_added": 7976,
|
||||
"lines_deleted": 3333,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-07T23:41:48Z",
|
||||
"last_commit": "2026-10-09T09:28:24Z"
|
||||
},
|
||||
{
|
||||
"login": "wstock",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 5,
|
||||
"commits": 5,
|
||||
"lines_added": 288,
|
||||
"lines_deleted": 24,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T07:45:37Z",
|
||||
"last_commit": "2026-10-08T12:14:46Z"
|
||||
},
|
||||
{
|
||||
"login": "WavJaby",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 6,
|
||||
"commits": 6,
|
||||
"lines_added": 1090,
|
||||
"lines_deleted": 110,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T08:18:07Z",
|
||||
"last_commit": "2026-10-09T05:47:06Z"
|
||||
},
|
||||
{
|
||||
"login": "MrCloudy2",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 132,
|
||||
"lines_deleted": 4,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T08:53:54Z",
|
||||
"last_commit": "2026-10-08T08:53:54Z"
|
||||
},
|
||||
{
|
||||
"login": "switch87",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 629,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T09:25:54Z",
|
||||
"last_commit": "2026-10-08T09:37:02Z"
|
||||
},
|
||||
{
|
||||
"login": "h-a-n-a-s-h-i",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 2,
|
||||
"lines_added": 96,
|
||||
"lines_deleted": 96,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T12:12:49Z",
|
||||
"last_commit": "2026-10-08T14:01:39Z"
|
||||
},
|
||||
{
|
||||
"login": "XusBadia",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 260,
|
||||
"lines_deleted": 1,
|
||||
"binary_added": 2,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:12:32Z",
|
||||
"last_commit": "2026-10-08T13:12:32Z"
|
||||
},
|
||||
{
|
||||
"login": "MarekSpodBiedry",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 74,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:44:56Z",
|
||||
"last_commit": "2026-10-09T08:45:28Z"
|
||||
},
|
||||
{
|
||||
"login": "Tom1tk",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 47,
|
||||
"lines_deleted": 26,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T13:47:40Z",
|
||||
"last_commit": "2026-10-08T14:56:39Z"
|
||||
},
|
||||
{
|
||||
"login": "Pathos0925",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 68,
|
||||
"lines_deleted": 8,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T17:09:15Z",
|
||||
"last_commit": "2026-10-08T17:09:15Z"
|
||||
},
|
||||
{
|
||||
"login": "mjohnson7cd",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 1,
|
||||
"lines_added": 100,
|
||||
"lines_deleted": 2,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:36:28Z",
|
||||
"last_commit": "2026-10-08T20:36:28Z"
|
||||
},
|
||||
{
|
||||
"login": "Oldrich333",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 1,
|
||||
"lines_added": 119,
|
||||
"lines_deleted": 9,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T20:58:16Z",
|
||||
"last_commit": "2026-10-08T20:58:16Z"
|
||||
},
|
||||
{
|
||||
"login": "cshapiro",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 3,
|
||||
"commits": 5,
|
||||
"lines_added": 3128,
|
||||
"lines_deleted": 111,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-08T23:28:32Z",
|
||||
"last_commit": "2026-10-09T02:57:30Z"
|
||||
},
|
||||
{
|
||||
"login": "rossaai",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 112,
|
||||
"lines_deleted": 10,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T00:57:43Z",
|
||||
"last_commit": "2026-10-09T00:57:43Z"
|
||||
},
|
||||
{
|
||||
"login": "wisegamesyt-droid",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 758,
|
||||
"lines_deleted": 275,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:11:12Z",
|
||||
"last_commit": "2026-10-09T02:33:35Z"
|
||||
},
|
||||
{
|
||||
"login": "JordyUSA",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 3,
|
||||
"lines_added": 4158,
|
||||
"lines_deleted": 283,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:35:57Z",
|
||||
"last_commit": "2026-10-09T02:53:28Z"
|
||||
},
|
||||
{
|
||||
"login": "jerrykimo",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 1,
|
||||
"lines_added": 117,
|
||||
"lines_deleted": 6,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:50:09Z",
|
||||
"last_commit": "2026-10-09T01:50:09Z"
|
||||
},
|
||||
{
|
||||
"login": "swillunderscore",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 3,
|
||||
"lines_added": 1008,
|
||||
"lines_deleted": 13,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T01:58:37Z",
|
||||
"last_commit": "2026-10-09T02:19:21Z"
|
||||
},
|
||||
{
|
||||
"login": "IppSec",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 2,
|
||||
"lines_added": 486,
|
||||
"lines_deleted": 54,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T02:29:23Z",
|
||||
"last_commit": "2026-10-09T02:30:49Z"
|
||||
},
|
||||
{
|
||||
"login": "sanyamsingh001",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 2,
|
||||
"commits": 2,
|
||||
"lines_added": 323,
|
||||
"lines_deleted": 25,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T04:06:37Z",
|
||||
"last_commit": "2026-10-09T05:44:39Z"
|
||||
},
|
||||
{
|
||||
"login": "ItzBloxy",
|
||||
"people_entry": true,
|
||||
"github_name": null,
|
||||
"prs": 1,
|
||||
"commits": 4,
|
||||
"lines_added": 887,
|
||||
"lines_deleted": 167,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T09:27:48Z",
|
||||
"last_commit": "2026-10-09T10:17:08Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
@@ -222,18 +521,45 @@
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 400,
|
||||
"lines_added": 324452,
|
||||
"lines_deleted": 14207
|
||||
"commits": 474,
|
||||
"lines_added": 346706,
|
||||
"lines_deleted": 19057
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-sonnet/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Sonnet",
|
||||
"version": "5.5",
|
||||
"commits": 10,
|
||||
"lines_added": 9328,
|
||||
"lines_deleted": 524
|
||||
"commits": 18,
|
||||
"lines_added": 13935,
|
||||
"lines_deleted": 854
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 11,
|
||||
"lines_added": 2057,
|
||||
"lines_deleted": 361
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-fable/5.1",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Fable",
|
||||
"version": "5.1",
|
||||
"commits": 5,
|
||||
"lines_added": 322,
|
||||
"lines_deleted": 32
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 235,
|
||||
"lines_deleted": 22
|
||||
},
|
||||
{
|
||||
"id": "anthropic/claude-opus/4.8",
|
||||
@@ -243,15 +569,6 @@
|
||||
"commits": 1,
|
||||
"lines_added": 1632,
|
||||
"lines_deleted": 3
|
||||
},
|
||||
{
|
||||
"id": "openai/codex",
|
||||
"company": "OpenAI",
|
||||
"model": "Codex",
|
||||
"version": "",
|
||||
"commits": 1,
|
||||
"lines_added": 70,
|
||||
"lines_deleted": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,3 +21,29 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.swillunderscore]
|
||||
display_name = "Swill Software"
|
||||
|
||||
|
||||
[people.IppSec]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ItzBloxy]
|
||||
display_name = "Bloxy"
|
||||
|
||||
[people.ReReddo]
|
||||
display_name = "ReReddo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.liangforstudy]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -107,10 +107,11 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
/// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered
|
||||
/// stills of an image sequence (`<stem><digits>.<same extension>`) and the caption sidecar
|
||||
/// stills of an image sequence (`<stem><3+ digits>.<same extension>`, only when the output is a
|
||||
/// PNG/TIFF/BMP 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`, …) are never touched, even when they
|
||||
/// were saved during the export.
|
||||
/// 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.
|
||||
pub fn remove_partial(path: &str, since: SystemTime) {
|
||||
let p = Path::new(path);
|
||||
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
|
||||
@@ -139,9 +140,19 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool {
|
||||
if let Some(sidecar) = rest.strip_prefix('.') {
|
||||
return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt");
|
||||
}
|
||||
// image sequence: one or more digits, then the output's own extension
|
||||
// image sequence: the frame number (at least three digits, like `image_sequence_path`), then the
|
||||
// output's own extension; movies and audio are a single file, so numbered neighbours are not theirs
|
||||
if !is_sequence_extension(ext) {
|
||||
return false;
|
||||
}
|
||||
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
|
||||
digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
digits >= 3 && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -170,6 +181,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_files_beside_a_movie_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-movie-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// a movie export writes one file: `clip1.mp4` and `clip002.mp4` are other exports
|
||||
let ours = ["clip.mp4", "clip.srt"];
|
||||
let theirs = ["clip1.mp4", "clip002.mp4", "clip2.mov"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("clip.mp4").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_frames_have_at_least_three_digits() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-digits-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let started = SystemTime::now();
|
||||
// frames are numbered with at least three digits (`shot000.tiff`); `shot2.tiff` is not one
|
||||
let ours = ["shot.tiff", "shot000.tiff", "shot1234.tiff"];
|
||||
let theirs = ["shot2.tiff", "shot12.tiff"];
|
||||
for f in ours.iter().chain(theirs.iter()) {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
remove_partial(&dir.join("shot.tiff").to_string_lossy(), started);
|
||||
for f in ours {
|
||||
assert!(!dir.join(f).exists(), "{f} should be removed");
|
||||
}
|
||||
for f in theirs {
|
||||
assert!(dir.join(f).exists(), "{f} must not be removed");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_older_than_the_export_are_kept() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));
|
||||
|
||||
@@ -402,7 +402,7 @@ impl FilmcraftMcp {
|
||||
let t = p.seconds.map_or_else(|| g.playhead(), filmcraft_time::Tick::from_seconds_f64);
|
||||
let (w, h) =
|
||||
g.active_sequence().map(|q| (q.settings.width, q.settings.height)).ok_or_else(|| AutomationError::Other("no sequence".into()))?;
|
||||
let scale = (max as f32 / w.max(h) as f32).min(1.0);
|
||||
let scale = render_scale(max, w, h);
|
||||
let img = g.try_render_program_at(scale, t).map_err(|e| AutomationError::Other(e.to_string()))?;
|
||||
png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), max)
|
||||
})
|
||||
@@ -648,10 +648,26 @@ impl ServerHandler for FilmcraftMcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render scale that fits the longest side of a `w` x `h` sequence into `max_side` (never upscales; 0 = no limit, as in `png_rgba`).
|
||||
fn render_scale(max_side: u32, w: u32, h: u32) -> f32 {
|
||||
if max_side == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
(max_side as f32 / w.max(h).max(1) as f32).min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn render_scale_zero_means_no_limit() {
|
||||
assert_eq!(render_scale(0, 1920, 1080), 1.0);
|
||||
assert_eq!(render_scale(960, 1920, 1080), 0.5);
|
||||
assert_eq!(render_scale(960, 640, 360), 1.0);
|
||||
assert_eq!(render_scale(960, 0, 0), 1.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_commands_and_render() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -7,7 +7,7 @@ use common::*;
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn adhoc_compare() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let Some(path) = std::env::var_os("AV1_IVF") else { return };
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let pics = decode_all(&path).unwrap();
|
||||
|
||||
@@ -6,8 +6,27 @@ use filmcraft_av1::{Decoder, Picture};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// ffmpeg with libsvtav1 (fixtures) and libdav1d (reference), or `None` after reporting a skip.
|
||||
pub fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("av1 oracle")
|
||||
ffmpeg_with(&[("-encoders", "libsvtav1"), ("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libdav1d, for checks that decode existing files only.
|
||||
pub fn ffmpeg_dav1d() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
|
||||
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
|
||||
for &(list, lib) in needs {
|
||||
let out = Command::new(&ff).args(["-hide_banner", list]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();
|
||||
if !out.contains(lib) {
|
||||
filmcraft_testkit::skip("av1 oracle", &format!("ffmpeg has no {lib} (see `ffmpeg {list}`)"));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(ff)
|
||||
}
|
||||
|
||||
pub fn fixture_dir() -> PathBuf {
|
||||
|
||||
@@ -9,7 +9,7 @@ const VECTORS: &[&str] =
|
||||
|
||||
#[test]
|
||||
fn conformance_vectors() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
for name in VECTORS {
|
||||
let Some(path) = test_vector(name) else { return };
|
||||
check_file(&ff, name, &path);
|
||||
@@ -21,7 +21,7 @@ fn conformance_vectors() {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn conformance_vectors_extended() {
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let Some(ff) = ffmpeg_dav1d() else { return };
|
||||
let list: Vec<String> = match std::env::var("AV1_VECTORS") {
|
||||
Ok(v) => v.split(',').map(str::to_string).collect(),
|
||||
Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(),
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
//! - **CDP (SMPTE ST 334-2):** identifier `96 69`, length, frame-rate code, flags, sequence
|
||||
//! counter, a `cc_data` section (`72`, `E0 | cc_count`, cc_count × (marker/valid/type, two
|
||||
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
|
||||
//! checksum that makes the packet's byte sum 0 mod 256.
|
||||
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
|
||||
//! (low 8 bits of DID + SDID + data count + CDP bytes).
|
||||
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
|
||||
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
|
||||
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
|
||||
@@ -161,6 +162,9 @@ pub fn build_cdp(seq: u16, data: &FrameData) -> Vec<u8> {
|
||||
cdp.push(((256 - sum % 256) % 256) as u8);
|
||||
let mut out = vec![0x61, 0x01, cdp.len() as u8];
|
||||
out.extend(cdp);
|
||||
// SMPTE ST 291 ancillary checksum: low 8 bits of the sum of DID, SDID, data count and the user data words.
|
||||
let anc_sum: u32 = out.iter().map(|&b| b as u32).sum();
|
||||
out.push((anc_sum & 0xff) as u8);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -661,7 +665,11 @@ mod tests {
|
||||
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
|
||||
let b = build_cdp(7, &d);
|
||||
assert_eq!(&b[..3], &[0x61, 0x01, 73]);
|
||||
let cdp = &b[3..];
|
||||
// prefix (3) + CDP (73) + ancillary checksum byte (1)
|
||||
assert_eq!(b.len(), 77);
|
||||
let sum: u32 = b[..76].iter().map(|&x| x as u32).sum();
|
||||
assert_eq!(b[76], (sum & 0xff) as u8, "ancillary checksum");
|
||||
let cdp = &b[3..76];
|
||||
assert_eq!(cdp.len(), 73);
|
||||
assert_eq!(&cdp[..9], &[0x96, 0x69, 73, 0x4f, 0x43, 0, 7, 0x72, 0xf4]);
|
||||
assert_eq!(cdp.iter().map(|&x| x as u32).sum::<u32>() % 256, 0, "checksum");
|
||||
|
||||
@@ -41,3 +41,4 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aac", "m
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -114,7 +114,7 @@ impl ApvSource {
|
||||
bitrate,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: None,
|
||||
audio_streams: Vec::new(),
|
||||
container: "APV".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(len),
|
||||
|
||||
+153
-3
@@ -87,7 +87,7 @@ impl PacketDecoder {
|
||||
/// (HE-AAC, see [`aac_output_rate`]) the decoded core is upsampled to it.
|
||||
pub fn aac(asc: &[u8], sample_rate: u32) -> Result<Self> {
|
||||
if let Ok(dec) = filmcraft_aac::Decoder::new(asc) {
|
||||
let up = (dec.sample_rate().checked_mul(2) == Some(sample_rate)).then(|| Box::new(Upsample2x::new(dec.channels())));
|
||||
let up = upsamples_core(dec.sample_rate(), sample_rate).then(|| Box::new(Upsample2x::new(dec.channels())));
|
||||
return Ok(Self { inner: Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up }, channels: 0 });
|
||||
}
|
||||
Self::new(CODEC_TYPE_AAC, sample_rate, Some(asc.to_vec()))
|
||||
@@ -244,6 +244,77 @@ pub fn aac_output_rate<'a>(asc: &[u8], first_units: impl IntoIterator<Item = &'a
|
||||
Some(core)
|
||||
}
|
||||
|
||||
/// Whether [`PacketDecoder::aac`] upsamples a decoded AAC core at `core` Hz to `rate` (HE-AAC).
|
||||
fn upsamples_core(core: u32, rate: u32) -> bool {
|
||||
core.checked_mul(2) == Some(rate)
|
||||
}
|
||||
|
||||
/// A codec whose packets all decode to the same number of samples (see [`fixed_packet_samples`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FixedFrames<'a> {
|
||||
/// AAC with this AudioSpecificConfig.
|
||||
Aac(&'a [u8]),
|
||||
/// MPEG-1/2 audio, layer I, II or III.
|
||||
MpegAudio,
|
||||
/// AC-3 (and E-AC-3).
|
||||
Ac3,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
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::MpegAudio => filmcraft_mpegts::Codec::MpegAudio,
|
||||
FixedFrames::Ac3 => filmcraft_mpegts::Codec::Ac3,
|
||||
};
|
||||
let f = filmcraft_mpegts::frame_info(&mpegts, first)?;
|
||||
(f.sample_rate == rate && f.samples > 0).then_some(f.samples as i64)
|
||||
}
|
||||
|
||||
/// Where the container puts one audio packet, for [`contiguous_starts`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PacketTime {
|
||||
/// Its timestamp, in output samples.
|
||||
pub stamp: i64,
|
||||
/// Whether `stamp` is the packet's own. A Matroska frame after the first in a laced block
|
||||
/// without a duration repeats the block's timestamp.
|
||||
pub stamped: bool,
|
||||
/// Samples it decodes to, when known.
|
||||
pub samples: Option<i64>,
|
||||
}
|
||||
|
||||
/// Packet start positions in output samples: each packet starts where the previous one ends,
|
||||
/// unless its own timestamp is more than `tolerance` samples from there (a real gap or overlap in
|
||||
/// the recording) or the previous packet's length is unknown; then it starts at its timestamp.
|
||||
///
|
||||
/// Container timestamps are coarser than a sample. Matroska stores milliseconds, and a remux to MP4
|
||||
/// (OBS's) turns that rounding into uneven sample durations: a 1024-sample AAC frame at 48 kHz lasts
|
||||
/// 21.33 ms, so frames are stamped at 0, 1008, 2064, 3072… Placing each decoded packet at its
|
||||
/// stamp overwrote the end of one packet and left a hole of silence after another, a click every
|
||||
/// few packets: crackling throughout playback and export (#236).
|
||||
pub fn contiguous_starts(packets: impl IntoIterator<Item = PacketTime>, tolerance: i64) -> Vec<i64> {
|
||||
let tolerance = tolerance.max(0).unsigned_abs();
|
||||
let mut next: Option<i64> = None;
|
||||
packets
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let start = match next {
|
||||
Some(n) if !p.stamped || n.abs_diff(p.stamp) <= tolerance => n,
|
||||
_ => p.stamp,
|
||||
};
|
||||
next = p.samples.and_then(|k| start.checked_add(k));
|
||||
start
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Taps on each side of the interpolation point in [`Upsample2x`].
|
||||
const UPSAMPLE_HALF: usize = 12;
|
||||
|
||||
@@ -666,12 +737,12 @@ impl AudioFileSource {
|
||||
kind: MediaKind::AudioOnly,
|
||||
duration: Tick::from_units(frames as i64, rate as i64),
|
||||
video: None,
|
||||
audio: Some(AudioStreamInfo {
|
||||
audio_streams: vec![AudioStreamInfo {
|
||||
sample_rate: rate,
|
||||
channels: samples.len() as u32,
|
||||
codec: codec_name,
|
||||
bits_per_sample: track.codec_params.bits_per_sample,
|
||||
}),
|
||||
}],
|
||||
container: ext.to_uppercase(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.len() as u64),
|
||||
@@ -739,3 +810,82 @@ mod he_aac_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod packet_time_tests {
|
||||
use super::*;
|
||||
|
||||
/// 1024-sample frames at 48 kHz stamped to the nearest millisecond, then back in samples.
|
||||
fn ms_stamps(count: i64) -> Vec<i64> {
|
||||
(0..count).map(|k| (k * 1024 * 1000 + 24_000) / 48_000 * 48).collect()
|
||||
}
|
||||
|
||||
fn timed(stamps: &[i64], samples: Option<i64>) -> Vec<PacketTime> {
|
||||
stamps.iter().map(|&stamp| PacketTime { stamp, stamped: true, samples }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_stamps_give_back_to_back_packets() {
|
||||
let stamps = ms_stamps(200);
|
||||
assert_eq!(&stamps[..4], [0, 1008, 2064, 3072]);
|
||||
let starts = contiguous_starts(timed(&stamps, Some(1024)), 512);
|
||||
assert_eq!(starts, (0..200).map(|k| k * 1024).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_gaps_and_overlaps_resynchronise() {
|
||||
// a 100 ms gap after packet 3, then a packet stamped 600 samples early
|
||||
let mut stamps: Vec<i64> = (0..8).map(|k| k * 1024).collect();
|
||||
for s in &mut stamps[4..] {
|
||||
*s += 4800;
|
||||
}
|
||||
stamps[7] -= 600;
|
||||
let starts = contiguous_starts(timed(&stamps, Some(1024)), 512);
|
||||
assert_eq!(starts, [0, 1024, 2048, 3072, 8896, 9920, 10944, 11368]);
|
||||
// within the tolerance: runs on
|
||||
stamps[7] += 200;
|
||||
assert_eq!(contiguous_starts(timed(&stamps, Some(1024)), 512)[7], 11968);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstamped_packets_follow_on_and_unknown_lengths_resynchronise() {
|
||||
// a laced block of three frames that repeat its timestamp
|
||||
let packets = [0, 1024, 1024, 1024, 4096].map(|stamp| PacketTime { stamp, stamped: true, samples: Some(1024) });
|
||||
let mut laced = packets;
|
||||
laced[2].stamped = false;
|
||||
laced[3].stamped = false;
|
||||
assert_eq!(contiguous_starts(laced, 512), [0, 1024, 2048, 3072, 4096]);
|
||||
// a packet whose length is unknown: the next one starts at its own stamp
|
||||
let mut unknown = timed(&[0, 1024, 2100, 3124], Some(1024));
|
||||
unknown[1].samples = None;
|
||||
assert_eq!(contiguous_starts(unknown, 512), [0, 1024, 2100, 3124]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_stay_finite() {
|
||||
let packets = [
|
||||
PacketTime { stamp: i64::MAX, stamped: true, samples: Some(i64::MAX) },
|
||||
PacketTime { stamp: i64::MIN, stamped: false, samples: Some(-5) },
|
||||
PacketTime { stamp: 0, stamped: true, samples: Some(i64::MIN) },
|
||||
PacketTime { stamp: 7, stamped: true, samples: None },
|
||||
];
|
||||
assert_eq!(contiguous_starts(packets, i64::MIN).len(), 4);
|
||||
assert_eq!(contiguous_starts(packets, i64::MAX).len(), 4);
|
||||
assert!(contiguous_starts([], 512).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_frame_lengths() {
|
||||
// AAC-LC 48 kHz stereo; HE-AAC (24 kHz core) played at 48 kHz is upsampled
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[0x11, 0x90]), &[], 48_000), Some(1024));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[0x13, 0x10]), &[], 48_000), Some(2048));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::Aac(&[]), &[], 48_000), None);
|
||||
// MPEG-1 layer III 44.1 kHz, 128 kbps; MPEG-2 layer III 22.05 kHz
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 44_100), Some(1152));
|
||||
assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xF3, 0x90, 0x44], 22_050), Some(576));
|
||||
// the header's rate must be the stream's
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Audio packets placed by container timestamps (#236).
|
||||
//!
|
||||
//! An AAC access unit decodes to 1024 samples, 21.33 ms at 48 kHz. Matroska stores timestamps in
|
||||
//! whole milliseconds, and OBS's remux to MP4 carries that rounding into sample durations of 1008,
|
||||
//! 1008, 1056… Placing each decoded packet at its own timestamp overwrote the end of one packet and
|
||||
//! left a hole of silence after another: playback and export crackled throughout. The streams here
|
||||
//! are AAC from our own encoder muxed with such timestamps; read back block by block, as playback
|
||||
//! reads them, they must be exactly the access units decoded back to back.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{LacingMode, MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
use crate::audio::PacketDecoder;
|
||||
use crate::{MkvSource, Mp4Source};
|
||||
|
||||
/// A second of a stereo tone (440 Hz left, 660 Hz right) encoded with our AAC encoder: the
|
||||
/// AudioSpecificConfig and the access units.
|
||||
fn aac(rate: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let tone = |hz: f32| (0..rate as usize).map(|i| 0.5 * (std::f32::consts::TAU * hz * i as f32 / rate as f32).sin()).collect::<Vec<f32>>();
|
||||
let (l, r) = (tone(440.0), tone(660.0));
|
||||
let mut enc = filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(rate, 2, 160_000)).unwrap();
|
||||
let mut units = enc.encode(&[&l, &r]);
|
||||
units.extend(enc.flush());
|
||||
(enc.audio_specific_config(), units)
|
||||
}
|
||||
|
||||
/// The access units decoded back to back.
|
||||
fn continuous(asc: &[u8], rate: u32, units: &[Vec<u8>]) -> Vec<Vec<f32>> {
|
||||
let mut dec = PacketDecoder::aac(asc, rate).unwrap();
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
for u in units {
|
||||
for (o, c) in out.iter_mut().zip(dec.decode(u, 0).unwrap()) {
|
||||
o.extend(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Access unit `k`'s start to the nearest millisecond, as muxers stamp it.
|
||||
fn stamp_ms(rate: u32, k: usize) -> i64 {
|
||||
(k as i64 * 1024 * 2000 + rate as i64) / (2 * rate as i64)
|
||||
}
|
||||
|
||||
/// Matroska (1 ms timestamps), with the units after `gap` delayed by `gap_ms` (a real gap).
|
||||
fn mkv(asc: &[u8], rate: u32, units: &[Vec<u8>], lacing: LacingMode, gap: usize, gap_ms: i64) -> Arc<[u8]> {
|
||||
let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC");
|
||||
spec.codec_private = asc.to_vec();
|
||||
spec.audio = Some((rate as f64, 2, None));
|
||||
let opts = MuxOptions { lacing, ..MuxOptions::default() };
|
||||
assert_eq!(opts.timestamp_scale, 1_000_000, "1 ms timestamps");
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], opts).unwrap();
|
||||
for (k, u) in units.iter().enumerate() {
|
||||
let ms = stamp_ms(rate, k) + if k >= gap { gap_ms } else { 0 };
|
||||
w.write_frame(0, ms * 1_000_000, true, u, None).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// MP4 as OBS remuxes Matroska: each millisecond timestamp rescaled to the sample rate, the
|
||||
/// differences written as sample durations.
|
||||
fn mp4_remuxed(asc: &[u8], rate: u32, units: &[Vec<u8>]) -> Arc<[u8]> {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(SampleEntry::aac(asc.to_vec(), 2, rate), rate)).unwrap();
|
||||
let at = |k: usize| (stamp_ms(rate, k) * rate as i64 + 500) / 1000;
|
||||
for (k, u) in units.iter().enumerate() {
|
||||
let duration = (at(k + 1) - at(k)) as u32;
|
||||
w.write_sample(t, WriteSample { data: u, duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
w.finish().unwrap().into_inner().into()
|
||||
}
|
||||
|
||||
/// `frames` samples per channel read in 512-sample blocks at `rate`, as playback reads them.
|
||||
fn played(src: &dyn MediaSource, rate: u32, frames: usize) -> Vec<Vec<f32>> {
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0;
|
||||
while pos < frames {
|
||||
let n = 512.min(frames - pos);
|
||||
let b = src.audio(pos as i64, n, rate).unwrap();
|
||||
for (o, c) in out.iter_mut().zip(&b.channels) {
|
||||
o.extend_from_slice(c);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Samples of `got` that differ from `want[k - shift]` (silence where that is out of range), and
|
||||
/// the first few.
|
||||
fn mismatches(got: &[Vec<f32>], want: &[Vec<f32>], shift: usize) -> (usize, Vec<(usize, usize)>) {
|
||||
let mut bad = Vec::new();
|
||||
for (c, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
for (k, x) in g.iter().enumerate() {
|
||||
let expect = k.checked_sub(shift).and_then(|j| w.get(j)).copied().unwrap_or(0.0);
|
||||
if x.to_bits() != expect.to_bits() {
|
||||
bad.push((c, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
(bad.len(), bad.into_iter().take(4).collect())
|
||||
}
|
||||
|
||||
/// Runs of two or more exact zeros inside the signal: holes left between packets.
|
||||
fn holes(got: &[Vec<f32>]) -> usize {
|
||||
got.iter().map(|c| c.windows(2).filter(|w| w[0] == 0.0 && w[1] == 0.0).count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_millisecond_stamps_play_back_to_back() {
|
||||
for rate in [48_000, 44_100] {
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
let frames = units.len().saturating_sub(1) * 1024;
|
||||
for lacing in [LacingMode::None, LacingMode::Xiph] {
|
||||
let bytes = mkv(&asc, rate, &units, lacing, usize::MAX, 0);
|
||||
let src = MkvSource::open("tone.mkv", bytes.clone()).unwrap();
|
||||
let got = played(&src, rate, frames);
|
||||
let (n, first) = mismatches(&got, &want, 0);
|
||||
assert_eq!(n, 0, "{rate} Hz, {lacing:?}: {n} samples differ from the continuous decode, first (channel, sample) {first:?}");
|
||||
// random access (a fresh source, so a cold decoder primed with the packet before) gives
|
||||
// the same samples
|
||||
let src = MkvSource::open("tone.mkv", bytes).unwrap();
|
||||
for start in [30_011usize, 7_000] {
|
||||
let got = src.audio(start as i64, 4_800, rate).unwrap().channels;
|
||||
let at: Vec<Vec<f32>> = want.iter().map(|c| c[start..start + 4_800].to_vec()).collect();
|
||||
assert_eq!(mismatches(&got, &at, 0).0, 0, "{rate} Hz, {lacing:?}: seek to {start}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obs_remuxed_mp4_plays_back_to_back() {
|
||||
for rate in [48_000, 44_100] {
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
let bytes = mp4_remuxed(&asc, rate, &units);
|
||||
let src = Mp4Source::open("tone.mp4", bytes).unwrap();
|
||||
let got = played(&src, rate, units.len().saturating_sub(1) * 1024);
|
||||
let (n, first) = mismatches(&got, &want, 0);
|
||||
assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the continuous decode, first (channel, sample) {first:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_gap_stays_where_the_timestamps_put_it() {
|
||||
let rate = 48_000;
|
||||
let (asc, units) = aac(rate);
|
||||
let want = continuous(&asc, rate, &units);
|
||||
// 100 ms of the recording missing before unit 20: it plays as silence, and the units after it
|
||||
// keep their timestamps (to the millisecond)
|
||||
let src = MkvSource::open("gap.mkv", mkv(&asc, rate, &units, LacingMode::None, 20, 100)).unwrap();
|
||||
let shift = ((stamp_ms(rate, 20) + 100) * 48 - 20 * 1024) as usize;
|
||||
assert!((4_700..4_900).contains(&shift), "{shift}");
|
||||
let got = played(&src, rate, units.len().saturating_sub(1) * 1024 + shift);
|
||||
let before: Vec<Vec<f32>> = got.iter().map(|c| c[..20 * 1024].to_vec()).collect();
|
||||
assert_eq!(mismatches(&before, &want, 0).0, 0);
|
||||
assert!(got.iter().all(|c| c[20 * 1024..20 * 1024 + shift].iter().all(|x| *x == 0.0)), "the gap is silent");
|
||||
let after: Vec<Vec<f32>> = got.iter().map(|c| c[20 * 1024 + shift..].to_vec()).collect();
|
||||
let rest: Vec<Vec<f32>> = want.iter().map(|c| c[20 * 1024..].to_vec()).collect();
|
||||
let (n, first) = mismatches(&after, &rest, 0);
|
||||
assert_eq!(n, 0, "{n} samples after the gap differ, first {first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_at_another_rate_leaves_no_holes() {
|
||||
// a 44.1 kHz recording in a 48 kHz sequence (export's default rate)
|
||||
let (asc, units) = aac(44_100);
|
||||
let src = MkvSource::open("tone.mkv", mkv(&asc, 44_100, &units, LacingMode::None, usize::MAX, 0)).unwrap();
|
||||
let frames = (units.len().saturating_sub(2) * 1024) * 48_000 / 44_100;
|
||||
let got = played(&src, 48_000, frames);
|
||||
// skip the encoder's priming (the first frame decodes to near-silence)
|
||||
let tail: Vec<Vec<f32>> = got.iter().map(|c| c[2048..].to_vec()).collect();
|
||||
assert_eq!(holes(&tail), 0);
|
||||
}
|
||||
+208
-20
@@ -140,6 +140,10 @@ struct State {
|
||||
bytes: usize,
|
||||
/// When a frame was last asked of this cache.
|
||||
last_used: Instant,
|
||||
/// Only background requests (thumbnails, [`filmcraft_media::cancel::with_background`]) have
|
||||
/// used the decoder since it was made: it is given up as soon as another background request
|
||||
/// is done, whatever the caches' recency.
|
||||
background: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -212,10 +216,27 @@ impl Drop for DecodingGuard {
|
||||
/// new one when the playhead reaches them, which costs what a seek costs.
|
||||
const MAX_LIVE_DECODERS: usize = 4;
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited,
|
||||
/// the next clip being prefetched): it keeps its decoder whatever the count.
|
||||
/// A cache asked for a frame this recently may be needed again soon (the next clip being
|
||||
/// prefetched, a clip the user scrubs back to): it keeps its decoder up to [`HARD_DECODERS`], and
|
||||
/// beyond [`MAX_LIVE_DECODERS`] only until it has been idle this long. Its frames are only kept
|
||||
/// within [`FRAME_BUDGET`] once it has been idle for [`RECENT`]: going back to a clip left (or
|
||||
/// paused) more than that long ago may cost a re-decode from its keyframe when other clips have
|
||||
/// filled the budget meanwhile. Frames go least recently used cache first, earliest frames first.
|
||||
const IDLE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A cache asked for a frame this recently is in use (a layer of the frame being composited): it
|
||||
/// keeps its decoder and frames whatever the count. Caches idle for less than [`IDLE`] but longer
|
||||
/// than this are a burst of navigation (thumbnails of every clip, the playhead jumping from clip
|
||||
/// to clip) that the playhead has already left: they give up decoders beyond [`HARD_DECODERS`]
|
||||
/// and frames beyond [`FRAME_BUDGET`]. Without this, everything touched within [`IDLE`] counted
|
||||
/// as in use, so a burst held every clip's decoder (42 live hardware sessions, 6.5 GB of VRAM)
|
||||
/// and hundreds of MB of frames per clip.
|
||||
const RECENT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Most decoders caches idle for [`RECENT`] to [`IDLE`] keep together (a composite of a few
|
||||
/// layers plus the clips just left behind).
|
||||
const HARD_DECODERS: usize = 8;
|
||||
|
||||
/// Decoded frames all caches may hold together before idle ones give theirs up. Each cache also
|
||||
/// has its own budget, which alone let every clip on a timeline keep hundreds of MB of frames
|
||||
/// nobody was looking at.
|
||||
@@ -228,6 +249,10 @@ struct Pool {
|
||||
decoders: AtomicUsize,
|
||||
max_decoders: usize,
|
||||
idle: Duration,
|
||||
/// Caches idle at least this long are no longer in use (see [`RECENT`]).
|
||||
recent: Duration,
|
||||
/// Decoders the caches idle for [`Self::recent`] but not [`Self::idle`] may keep.
|
||||
hard_decoders: usize,
|
||||
/// Bytes of the frames the caches hold.
|
||||
bytes: AtomicUsize,
|
||||
budget: usize,
|
||||
@@ -258,13 +283,29 @@ fn try_state(c: &Shared) -> Option<MutexGuard<'_, State>> {
|
||||
|
||||
impl Pool {
|
||||
fn new(max_decoders: usize, idle: Duration, budget: usize) -> Self {
|
||||
Self { caches: Mutex::new(Vec::new()), decoders: AtomicUsize::new(0), max_decoders, idle, bytes: AtomicUsize::new(0), budget }
|
||||
Self {
|
||||
caches: Mutex::new(Vec::new()),
|
||||
decoders: AtomicUsize::new(0),
|
||||
max_decoders,
|
||||
idle,
|
||||
recent: idle,
|
||||
hard_decoders: max_decoders,
|
||||
bytes: AtomicUsize::new(0),
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches idle for `recent` (< the idle time) are still trimmed, down to `hard_decoders`.
|
||||
fn tiered(mut self, recent: Duration, hard_decoders: usize) -> Self {
|
||||
self.recent = recent.min(self.idle);
|
||||
self.hard_decoders = hard_decoders.max(self.max_decoders);
|
||||
self
|
||||
}
|
||||
|
||||
/// The pool of every cache in the process.
|
||||
fn global() -> Arc<Pool> {
|
||||
static POOL: std::sync::OnceLock<Arc<Pool>> = std::sync::OnceLock::new();
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET))).clone()
|
||||
POOL.get_or_init(|| Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS))).clone()
|
||||
}
|
||||
|
||||
fn register(&self, c: &Arc<Shared>) {
|
||||
@@ -273,15 +314,15 @@ impl Pool {
|
||||
g.push(Arc::downgrade(c));
|
||||
}
|
||||
|
||||
/// The idle caches other than `me` that `keep` selects, least recently used first. Other
|
||||
/// The caches other than `me` idle for at least `idle` that `keep` selects, least recently used first. Other
|
||||
/// caches are only ever try-locked, so two caches trimming each other cannot deadlock.
|
||||
fn idle_others(&self, me: &Shared, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
fn idle_others(&self, me: &Shared, idle: Duration, keep: impl Fn(&State) -> bool) -> Vec<Arc<Shared>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut idle: Vec<(Instant, Arc<Shared>)> = all
|
||||
.into_iter()
|
||||
.filter(|c| !std::ptr::eq(Arc::as_ptr(c), me))
|
||||
.filter_map(|c| {
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= self.idle).map(|st| st.last_used)?;
|
||||
let used = try_state(&c).filter(|st| keep(st) && st.last_used.elapsed() >= idle).map(|st| st.last_used)?;
|
||||
Some((used, c))
|
||||
})
|
||||
.collect();
|
||||
@@ -295,29 +336,53 @@ impl Pool {
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn trim_decoders(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let mut released = Vec::new();
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
return released;
|
||||
}
|
||||
for c in self.idle_others(me, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= self.max_decoders {
|
||||
break;
|
||||
// first the caches idle for long, down to the cap; then those a burst has left behind
|
||||
for (idle, cap) in [(self.idle, self.max_decoders), (self.recent, self.hard_decoders)] {
|
||||
if self.decoders.load(Ordering::Relaxed) <= cap {
|
||||
continue;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
for c in self.idle_others(me, idle, |st| st.decoder.is_some()) {
|
||||
if self.decoders.load(Ordering::Relaxed) <= cap {
|
||||
break;
|
||||
}
|
||||
if let Some(mut st) = try_state(&c) {
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Take the decoders of the caches other than `me` that only background requests have used
|
||||
/// (see [`State::background`]) and that are not decoding now. A burst of thumbnails (an import
|
||||
/// of many clips) touches every clip within moments, so the recency rules treat them all as
|
||||
/// in use; each software 4K decoder holds ~0.5 GB. The cache `me` just served keeps its
|
||||
/// decoder (hovering a clip asks for more of its frames). Drop the result with no cache lock
|
||||
/// held.
|
||||
#[must_use = "drop the released decoders with no cache lock held"]
|
||||
fn release_background(&self, me: &Shared) -> Vec<Box<dyn VideoDecoder>> {
|
||||
let all: Vec<Arc<Shared>> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect();
|
||||
let mut released = Vec::new();
|
||||
for c in all.iter().filter(|c| !std::ptr::eq(Arc::as_ptr(c), me)) {
|
||||
if let Some(mut st) = try_state(c)
|
||||
&& st.background
|
||||
&& st.decoder.is_some()
|
||||
{
|
||||
released.extend(st.release_decoder(self));
|
||||
}
|
||||
}
|
||||
released
|
||||
}
|
||||
|
||||
/// Evict the frames of idle caches, least recently used first, down to the budget. Caches in
|
||||
/// use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// Evict the frames of caches idle for [`RECENT`], least recently used first, down to the
|
||||
/// budget. Caches in use keep theirs (each within its own budget): a frame evicted before it is shown costs a
|
||||
/// re-decode from the keyframe.
|
||||
fn trim_frames(&self, me: &Shared) {
|
||||
let over = || self.bytes.load(Ordering::Relaxed) > self.budget;
|
||||
if !over() {
|
||||
return;
|
||||
}
|
||||
for c in self.idle_others(me, |st| !st.frames.is_empty()) {
|
||||
for c in self.idle_others(me, self.recent, |st| !st.frames.is_empty()) {
|
||||
if !over() {
|
||||
break;
|
||||
}
|
||||
@@ -361,7 +426,7 @@ const CONTINUE_THROUGH: usize = 48;
|
||||
impl GopCache {
|
||||
pub fn new(explicit_color: Option<ColorInfo>) -> Self {
|
||||
// unit tests count decoder restarts: each of their caches gets a pool of its own
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET)) } else { Pool::global() };
|
||||
let pool = if cfg!(test) { Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS)) } else { Pool::global() };
|
||||
Self::in_pool(explicit_color, pool)
|
||||
}
|
||||
|
||||
@@ -378,6 +443,7 @@ impl GopCache {
|
||||
drafts: Default::default(),
|
||||
bytes: 0,
|
||||
last_used: Instant::now(),
|
||||
background: false,
|
||||
}),
|
||||
pool: pool.clone(),
|
||||
});
|
||||
@@ -405,6 +471,7 @@ impl GopCache {
|
||||
|
||||
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
let f = self.finish(f);
|
||||
st.last_used = Instant::now();
|
||||
if draft {
|
||||
st.drafts.insert(pts);
|
||||
DRAFT.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -467,6 +534,16 @@ impl GopCache {
|
||||
/// ([`VideoDecoder::set_draft`]); its draft frames are cached for draft requests only, and an
|
||||
/// exact request for one re-decodes it.
|
||||
pub fn frame_late(&self, s: &dyn VideoSamples, target: i64, late_before: Option<i64>) -> crate::Result<Arc<VideoFrame>> {
|
||||
let r = self.frame_late_locked(s, target, late_before);
|
||||
if filmcraft_media::cancel::background() {
|
||||
// Outside the cache's lock (see `release_background`). A thumbnail leaves the decoders
|
||||
// of earlier thumbnails no reason to stay: each costs hundreds of MB at 4K.
|
||||
drop(self.shared.pool.release_background(&self.shared));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn frame_late_locked(&self, s: &dyn VideoSamples, target: i64, late_before: Option<i64>) -> crate::Result<Arc<VideoFrame>> {
|
||||
let n = s.count();
|
||||
let i = s.sample_at(target.max(0)).or_else(|| (n > 0).then(|| n - 1)).ok_or_else(|| CodecError::Decode("empty track".into()))?;
|
||||
let want_pts = s.pts(i);
|
||||
@@ -482,11 +559,23 @@ impl GopCache {
|
||||
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _decoding = DecodingGuard::enter(me);
|
||||
st.last_used = Instant::now();
|
||||
if let Some(f) = st.cached(want_pts, draft) {
|
||||
let background = filmcraft_media::cancel::background();
|
||||
if let Some(f) = st.cached(want_pts, draft).cloned() {
|
||||
HITS.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(f.clone());
|
||||
// a monitor asking for a frame of a thumbnail's clip makes its decoder one in use
|
||||
st.background &= background;
|
||||
return Ok(f);
|
||||
}
|
||||
MISSES.fetch_add(1, Ordering::Relaxed);
|
||||
if !background {
|
||||
st.background = false;
|
||||
} else if st.decoder.is_none() {
|
||||
st.background = true;
|
||||
}
|
||||
if !background && st.decoder.as_ref().is_some_and(|d| d.thread_limited()) {
|
||||
// made for a thumbnail (few threads): a monitor gets a full one
|
||||
released.extend(st.release_decoder(&self.shared.pool));
|
||||
}
|
||||
if st.decoder.is_none() {
|
||||
let d = s.make_decoder()?;
|
||||
st.intra = d.intra_only();
|
||||
@@ -683,6 +772,8 @@ mod tests {
|
||||
/// (pts, decoded as a draft picture)
|
||||
held: VecDeque<(i64, bool)>,
|
||||
draft: bool,
|
||||
/// made for background work (few threads, like the H.264 decoder's)
|
||||
limited: bool,
|
||||
resets: Arc<AtomicUsize>,
|
||||
decodes: Arc<AtomicUsize>,
|
||||
}
|
||||
@@ -717,6 +808,9 @@ mod tests {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
fn thread_limited(&self) -> bool {
|
||||
self.limited
|
||||
}
|
||||
fn intra_only(&self) -> bool {
|
||||
self.intra
|
||||
}
|
||||
@@ -749,6 +843,7 @@ mod tests {
|
||||
intra: self.intra,
|
||||
held: VecDeque::new(),
|
||||
draft: false,
|
||||
limited: filmcraft_media::cancel::background(),
|
||||
resets: self.resets.clone(),
|
||||
decodes: self.decodes.clone(),
|
||||
}))
|
||||
@@ -944,6 +1039,99 @@ mod tests {
|
||||
assert!(s.iter().all(|s| s.resets.load(Ordering::Relaxed) == 1), "no decoder restarted");
|
||||
}
|
||||
|
||||
/// A burst of navigation (every clip of a timeline asked once within moments, as when
|
||||
/// thumbnails are made or the playhead jumps clip to clip) must not keep every clip's decoder
|
||||
/// and frames: only the caches of the frame being composited are in use.
|
||||
#[test]
|
||||
fn a_burst_of_single_requests_does_not_keep_every_decoder_and_frame() {
|
||||
// the real decoder caps and idle time; RECENT scaled down from 500 ms to the test's pace
|
||||
// (3 ms per clip). With the old rule (recent = idle) all 24 decoders stay live.
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(Duration::from_millis(2), HARD_DECODERS));
|
||||
let n = 24;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for k in 0..n {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
std::thread::sleep(Duration::from_millis(3));
|
||||
}
|
||||
assert!(
|
||||
pool.decoders.load(Ordering::Relaxed) <= HARD_DECODERS + 1,
|
||||
"{} live decoders after a burst over {n} clips",
|
||||
pool.decoders.load(Ordering::Relaxed)
|
||||
);
|
||||
// the clip asked last, and a clip in use together with it, keep theirs and carry on
|
||||
let resets = s[n - 1].resets.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 11_000).expect("frame")), 11);
|
||||
assert_eq!(s[n - 1].resets.load(Ordering::Relaxed), resets);
|
||||
}
|
||||
|
||||
/// The thumbnails an import queues: every clip asked once, quickly (the real caps and recency,
|
||||
/// no sleeping: all of them are "recent"). Each 4K software decoder costs ~0.5 GB, and 42
|
||||
/// clips used to hold 6+ GB at once.
|
||||
#[test]
|
||||
fn background_requests_leave_no_decoders_behind() {
|
||||
use filmcraft_media::cancel::with_background;
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS));
|
||||
let n = 24;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 3, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
// a clip in use by a monitor
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 10_000).expect("frame")), 10);
|
||||
for k in 1..n {
|
||||
assert_eq!(index_of(&with_background(true, || c[k].frame(&s[k], 10_000)).expect("frame")), 10);
|
||||
assert!(pool.decoders.load(Ordering::Relaxed) <= 2, "{} live decoders after thumbnail {k}", pool.decoders.load(Ordering::Relaxed));
|
||||
}
|
||||
// the monitor's clip and the last thumbnail keep theirs and carry on without a seek
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(index_of(&c[0].frame(&s[0], 11_000).expect("frame")), 11);
|
||||
assert_eq!(s[0].resets.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(index_of(&with_background(true, || c[n - 1].frame(&s[n - 1], 12_000)).expect("frame")), 12);
|
||||
assert_eq!(s[n - 1].resets.load(Ordering::Relaxed), 1);
|
||||
// a monitor asking for a frame of a thumbnail's clip makes its decoder one in use
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 13_000).expect("frame")), 13);
|
||||
for k in 1..4 {
|
||||
with_background(true, || c[k].frame(&s[k], 20_000)).expect("frame");
|
||||
}
|
||||
assert_eq!(index_of(&c[n - 1].frame(&s[n - 1], 14_000).expect("frame")), 14);
|
||||
assert!(c[n - 1].shared.state.lock().unwrap().decoder.is_some(), "the thumbnails left its decoder");
|
||||
// a cache that lost its decoder still decodes (from the sync sample)
|
||||
assert_eq!(index_of(&with_background(true, || c[1].frame(&s[1], 30_000)).expect("frame")), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_monitor_replaces_a_decoder_made_for_a_thumbnail() {
|
||||
use filmcraft_media::cancel::with_background;
|
||||
let pool = Arc::new(Pool::new(MAX_LIVE_DECODERS, IDLE, FRAME_BUDGET).tiered(RECENT, HARD_DECODERS));
|
||||
let (s, c) = (samples(300, 250, 3, false), GopCache::in_pool(None, pool.clone()));
|
||||
assert_eq!(index_of(&with_background(true, || c.frame(&s, 10_000)).expect("frame")), 10);
|
||||
// another thumbnail of the same clip carries on with the thumbnail decoder
|
||||
assert_eq!(index_of(&with_background(true, || c.frame(&s, 12_000)).expect("frame")), 12);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), 1);
|
||||
// a frame a monitor waits for (not cached: far ahead) is decoded by a full-thread decoder
|
||||
assert_eq!(index_of(&c.frame(&s, 100_000).expect("frame")), 100);
|
||||
assert!(!c.shared.state.lock().unwrap().decoder.as_ref().is_some_and(|d| d.thread_limited()));
|
||||
assert_eq!(pool.decoders.load(Ordering::Relaxed), 1);
|
||||
// and keeps it for the next frames
|
||||
let resets = s.resets.load(Ordering::Relaxed);
|
||||
assert_eq!(index_of(&c.frame(&s, 101_000).expect("frame")), 101);
|
||||
assert_eq!(s.resets.load(Ordering::Relaxed), resets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burst_of_single_requests_does_not_keep_every_clips_frames() {
|
||||
// 1×1 RGBA pictures (4 bytes): a shared budget of ten of them
|
||||
let pool = Arc::new(Pool::new(usize::MAX, Duration::from_secs(3600), 40).tiered(Duration::from_millis(2), usize::MAX));
|
||||
let n = 12;
|
||||
let s: Vec<Samples> = (0..n).map(|_| samples(300, 250, 0, false)).collect();
|
||||
let c: Vec<GopCache> = (0..n).map(|_| GopCache::in_pool(None, pool.clone())).collect();
|
||||
for k in 0..n {
|
||||
assert_eq!(index_of(&c[k].frame(&s[k], 10_000).expect("frame")), 10);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let held = pool.bytes.load(Ordering::Relaxed);
|
||||
assert!(held <= 40 + 11 * 4, "{held} bytes held after a burst over {n} clips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intra_caches_give_up_their_spare_decoders_too() {
|
||||
let pool = Arc::new(Pool::new(1, Duration::ZERO, FRAME_BUDGET));
|
||||
|
||||
@@ -53,7 +53,7 @@ pub struct FrameStreamInfo {
|
||||
}
|
||||
|
||||
/// The OBUs of a low-overhead AV1 sample: `(type, payload)`, stopping at the first malformed one.
|
||||
fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
pub(crate) fn obus(data: &[u8]) -> Vec<(u8, &[u8])> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
while let Some(&h) = data.get(pos) {
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod mp4;
|
||||
pub mod mpeg;
|
||||
pub mod mxf;
|
||||
pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
@@ -173,7 +174,11 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result<filmcraft
|
||||
filmcraft_media::open_bytes(name, bytes, &openers())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_timing_tests;
|
||||
#[cfg(test)]
|
||||
mod rounded_pts_tests;
|
||||
#[cfg(test)]
|
||||
mod stream_color_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+183
-114
@@ -8,11 +8,14 @@
|
||||
//! subtracts `CodecDelay` from timestamps, so the pre-skip samples land before zero and are never
|
||||
//! read; random access decodes `SeekPreRoll` (at least [`crate::audio::OPUS_PRE_ROLL`]) of preceding
|
||||
//! packets before the target. Packet starts are accumulated from TOC durations (see [`opus_starts`]).
|
||||
//!
|
||||
//! AAC, MPEG audio and AC-3 packets each decode to a fixed number of samples, which millisecond
|
||||
//! timestamps can't express: their packets run on from one another (see [`audio_starts`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_color::{ColorInfo, Matrix, Transfer};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig};
|
||||
use filmcraft_matroska::{Codec, MkvFile, TrackKind};
|
||||
@@ -21,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::audio::{PacketDecoder, decode_pcm};
|
||||
use crate::gop::{GopCache, VideoSamples};
|
||||
use crate::stream_color::ColorCodes;
|
||||
use crate::video::VideoDecoder;
|
||||
use crate::{CodecError, make_video_decoder};
|
||||
|
||||
@@ -35,51 +39,94 @@ struct AudioState {
|
||||
last_decoded: Option<usize>,
|
||||
}
|
||||
|
||||
/// One playable audio track with its own decoder state and timing tables.
|
||||
struct MkvAudio {
|
||||
/// Index into `MkvFile::tracks`.
|
||||
track: usize,
|
||||
state: Mutex<AudioState>,
|
||||
/// Packet start positions in source sample frames.
|
||||
starts: Vec<i64>,
|
||||
/// Decoder pre-roll after a seek, in source sample frames (0: prime with one packet).
|
||||
preroll: i64,
|
||||
}
|
||||
|
||||
pub struct MkvSource {
|
||||
info: MediaInfo,
|
||||
bytes: crate::Src,
|
||||
file: MkvFile,
|
||||
vtrack: Option<usize>,
|
||||
atrack: Option<usize>,
|
||||
/// Video codec as an ISO-BMFF sample entry (for the decoder factories).
|
||||
ventry: Option<SampleEntry>,
|
||||
video: GopCache,
|
||||
audio: Mutex<AudioState>,
|
||||
/// Audio packet start positions in source sample frames.
|
||||
audio_starts: Vec<i64>,
|
||||
/// Decoder pre-roll after a seek, in source sample frames (0: prime with one packet).
|
||||
audio_preroll: i64,
|
||||
/// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`.
|
||||
audios: Vec<MkvAudio>,
|
||||
}
|
||||
|
||||
/// Audio packet start positions in source sample frames.
|
||||
///
|
||||
/// 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
|
||||
/// 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.
|
||||
fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec<i64> {
|
||||
use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples};
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
return opus_starts(file, bytes, ti, &h, rate);
|
||||
}
|
||||
let (n, d) = tb(t);
|
||||
let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64;
|
||||
let first = || file.read_sample(bytes, ti, 0).unwrap_or_default();
|
||||
let out_rate = u32::try_from(rate).unwrap_or(0);
|
||||
let frame = match &t.codec {
|
||||
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),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else {
|
||||
return t.samples.iter().map(|s| at(s.pts)).collect();
|
||||
};
|
||||
let packets = t.samples.iter().map(|s| PacketTime { stamp: at(s.pts), stamped: own_stamp(s), samples: Some(frame) });
|
||||
contiguous_starts(packets, (frame / 2).max(stamp_tolerance(t, rate)))
|
||||
}
|
||||
|
||||
/// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed).
|
||||
///
|
||||
/// Block timestamps are quantised to `TimestampScale` (usually 1 ms) and the demuxer subtracts a
|
||||
/// rounded `CodecDelay`, so they are only accurate to a tick. Starts are therefore accumulated from
|
||||
/// each packet's TOC duration, resynchronising to the timestamp only across real gaps (more than
|
||||
/// two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is absent).
|
||||
/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated
|
||||
/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more
|
||||
/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is
|
||||
/// absent).
|
||||
fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec<i64> {
|
||||
let t = &file.tracks[ti];
|
||||
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;
|
||||
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 + delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64 - skip;
|
||||
let tolerance = (2 * scale_ns * rate as i128 / 1_000_000_000) as i64 + 1;
|
||||
let mut starts = Vec::with_capacity(t.samples.len());
|
||||
let mut next: Option<i64> = None;
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
let stamped = at(s.pts);
|
||||
let start = match next {
|
||||
Some(p) if (p - stamped).abs() <= tolerance => p,
|
||||
_ => stamped,
|
||||
};
|
||||
starts.push(start);
|
||||
let dur = file.read_sample(bytes, ti, i).ok().and_then(|p| crate::audio::opus_packet_samples(&p));
|
||||
next = dur.map(|k| start + k as i64);
|
||||
}
|
||||
starts
|
||||
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 {
|
||||
stamp: at(s.pts),
|
||||
stamped: own_stamp(s),
|
||||
samples: file.read_sample(bytes, ti, i).ok().and_then(|p| crate::audio::opus_packet_samples(&p)).map(|k| k as i64),
|
||||
});
|
||||
crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let (n, d) = tb(t);
|
||||
i64::try_from(2 * n as i128 * rate as i128 / d as i128).unwrap_or(i64::MAX).saturating_add(1)
|
||||
}
|
||||
|
||||
/// Whether a sample's timestamp is its own: frames after the first in a laced block without a
|
||||
/// duration repeat the block's timestamp.
|
||||
fn own_stamp(s: &filmcraft_matroska::Sample) -> bool {
|
||||
s.lace == 0 || s.duration > 0
|
||||
}
|
||||
|
||||
/// The parsed `OpusHead` of an `A_OPUS` track.
|
||||
@@ -172,32 +219,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option<filmcraft_color::HdrMe
|
||||
})
|
||||
}
|
||||
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32) -> (ColorInfo, bool) {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
let Some(col) = &v.colour else { return (c, false) };
|
||||
let mut explicit = false;
|
||||
if let Some(m) = col.matrix_coefficients.and_then(|m| Matrix::from_code(m as u8)) {
|
||||
c.matrix = m;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(t) = col.transfer_characteristics.and_then(|t| Transfer::from_code(t as u8)) {
|
||||
c.transfer = t;
|
||||
explicit = true;
|
||||
}
|
||||
if let Some(p) = col.primaries {
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
/// Colour of a video track: the `Colour` element wins; what it leaves unspecified, or all of it
|
||||
/// when there is none, comes from the stream's own description (the SPS VUI in an `avcC` /
|
||||
/// `hvcC`, the sequence header in an `av1C`); the rest defaults by frame size. The flag says
|
||||
/// whether `Colour` signals anything that overrides the decoder.
|
||||
fn color_of(v: &filmcraft_matroska::VideoInfo, w: u32, h: u32, stream: Option<ColorCodes>) -> (ColorInfo, bool) {
|
||||
// H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here
|
||||
let code = |c: Option<u64>| c.unwrap_or(2);
|
||||
let container = v.colour.as_ref().map(|col| {
|
||||
let range = match col.range {
|
||||
Some(1) => Some(false),
|
||||
Some(2) => Some(true),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if col.full_range() {
|
||||
c.range = Range::Full;
|
||||
explicit = true;
|
||||
}
|
||||
(c, explicit)
|
||||
ColorCodes::from_wide(code(col.primaries), code(col.transfer_characteristics), code(col.matrix_coefficients), range)
|
||||
});
|
||||
let explicit = container.is_some_and(|c| Matrix::from_code(c.matrix).is_some() || Transfer::from_code(c.transfer).is_some() || c.full_range == Some(true));
|
||||
let sources: Vec<ColorCodes> = [container, stream].into_iter().flatten().collect();
|
||||
(crate::stream_color::resolve(w, h, &sources), explicit)
|
||||
}
|
||||
|
||||
fn codec_label(c: &Codec) -> String {
|
||||
@@ -236,8 +275,16 @@ impl MkvSource {
|
||||
let bytes = crate::Src(reader);
|
||||
let 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());
|
||||
let atrack = file.tracks.iter().position(|t| t.kind == TrackKind::Audio && !t.samples.is_empty());
|
||||
if vtrack.is_none() && atrack.is_none() {
|
||||
// every audio track is a stream (the track count comes from the file: capped)
|
||||
let atracks: Vec<usize> = file
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.take(filmcraft_media::MAX_AUDIO_STREAMS)
|
||||
.collect();
|
||||
if vtrack.is_none() && atracks.is_empty() {
|
||||
return Err(CodecError::Unsupported("no playable tracks".into()));
|
||||
}
|
||||
let mut explicit_color = None;
|
||||
@@ -258,11 +305,11 @@ impl MkvSource {
|
||||
FrameRate::from_f64(1.0 / step)
|
||||
}
|
||||
};
|
||||
let (color, explicit) = color_of(&v, w, h);
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec)));
|
||||
if explicit {
|
||||
explicit_color = Some(color);
|
||||
}
|
||||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
@@ -279,21 +326,24 @@ impl MkvSource {
|
||||
hdr: v.colour.as_ref().and_then(hdr_metadata),
|
||||
}
|
||||
});
|
||||
let audio = atrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let a = t.audio.clone().unwrap_or_default();
|
||||
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
|
||||
let mut channels = (a.channels as u32).max(1);
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
rate = crate::audio::OPUS_RATE;
|
||||
channels = h.channels as u32;
|
||||
}
|
||||
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
|
||||
});
|
||||
let audio_streams: Vec<AudioStreamInfo> = atracks
|
||||
.iter()
|
||||
.filter_map(|&i| file.tracks.get(i))
|
||||
.map(|t| {
|
||||
let a = t.audio.clone().unwrap_or_default();
|
||||
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
|
||||
let mut channels = (a.channels as u32).max(1);
|
||||
if let Some(h) = opus_head(&t.codec) {
|
||||
rate = crate::audio::OPUS_RATE;
|
||||
channels = h.channels as u32;
|
||||
}
|
||||
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(atrack)
|
||||
.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);
|
||||
@@ -306,48 +356,31 @@ impl MkvSource {
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
duration,
|
||||
video,
|
||||
audio,
|
||||
audio_streams,
|
||||
container: if file.is_webm() { "WebM".into() } else { "Matroska".into() },
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
};
|
||||
let audio_starts = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate) as i64;
|
||||
let (n, d) = tb(t);
|
||||
let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64;
|
||||
match opus_head(&t.codec) {
|
||||
Some(h) => opus_starts(&file, &bytes, i, &h, rate),
|
||||
None => t.samples.iter().map(|s| at(s.pts)).collect(),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let audio_preroll = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate) as i64;
|
||||
match t.codec {
|
||||
// `info.audio_streams[k]` was made from `atracks[k]`, so the two stay index-aligned
|
||||
let audios: Vec<MkvAudio> = atracks
|
||||
.iter()
|
||||
.zip(&info.audio_streams)
|
||||
.filter_map(|(&i, ainfo)| {
|
||||
let t = file.tracks.get(i)?;
|
||||
let rate = ainfo.sample_rate as i64;
|
||||
let starts = audio_starts(&file, &bytes, i, rate);
|
||||
let preroll = match t.codec {
|
||||
Codec::Opus { .. } => {
|
||||
let container = (t.seek_pre_roll_ns as u128 * rate as u128).div_ceil(1_000_000_000) as i64;
|
||||
container.max(crate::audio::OPUS_PRE_ROLL as i64 * rate / 48_000)
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let state = Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None });
|
||||
Some(MkvAudio { track: i, state, starts, preroll })
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(Self {
|
||||
info,
|
||||
bytes,
|
||||
file,
|
||||
vtrack,
|
||||
atrack,
|
||||
ventry,
|
||||
video: GopCache::new(explicit_color).with_rotation(rotation),
|
||||
audio: Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }),
|
||||
audio_starts,
|
||||
audio_preroll,
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), audios })
|
||||
}
|
||||
|
||||
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
|
||||
@@ -373,13 +406,14 @@ impl MkvSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_packet(&self, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
/// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state).
|
||||
fn audio_packet(&self, a: &MkvAudio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
if let Some(p) = st.packets.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let track = &self.file.tracks[ti];
|
||||
let ainfo = self.info.audio.as_ref().ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let ti = a.track;
|
||||
let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let pk_start = a.starts.get(i).copied().ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
|
||||
let data = self.read(ti, i)?;
|
||||
let decoded = match &track.codec {
|
||||
Codec::Pcm { float, big_endian, bits } => {
|
||||
@@ -402,8 +436,8 @@ impl MkvSource {
|
||||
if st.last_decoded.is_none_or(|l| l + 1 != i) {
|
||||
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
d.reset();
|
||||
let from = if self.audio_preroll > 0 {
|
||||
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
|
||||
let from = if a.preroll > 0 {
|
||||
a.starts.partition_point(|&x| x <= pk_start.saturating_sub(a.preroll)).saturating_sub(1)
|
||||
} else {
|
||||
i.saturating_sub(1)
|
||||
};
|
||||
@@ -413,7 +447,7 @@ impl MkvSource {
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
|
||||
st.last_decoded = Some(i);
|
||||
r.unwrap_or_default()
|
||||
}
|
||||
@@ -473,23 +507,27 @@ impl MediaSource for MkvSource {
|
||||
}
|
||||
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
let ti = self.atrack.ok_or(MediaError::NoStream("audio"))?;
|
||||
let n_packets = self.file.tracks[ti].samples.len();
|
||||
let ainfo = self.info.audio.as_ref().ok_or(MediaError::NoStream("audio"))?;
|
||||
self.audio_stream(0, start, frames, sample_rate)
|
||||
}
|
||||
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let n_packets = self.file.tracks.get(a.track).ok_or(MediaError::NoStream("audio"))?.samples.len();
|
||||
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let s0 = (start as f64 * ratio).floor() as i64;
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let mut st = self.audio.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut i = self.audio_starts.partition_point(|&x| x <= s0).saturating_sub(1);
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1);
|
||||
while i < n_packets {
|
||||
let pk_start = self.audio_starts[i];
|
||||
let Some(&pk_start) = a.starts.get(i) else { break };
|
||||
if pk_start >= s0 + need {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(&mut st, i)?;
|
||||
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
|
||||
for (c, dst) in src.iter_mut().enumerate() {
|
||||
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
|
||||
for (k, v) in chan.iter().enumerate() {
|
||||
@@ -532,3 +570,34 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
|
||||
}
|
||||
Some(MkvSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_audio_tests {
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
#[test]
|
||||
fn every_audio_track_is_inspected_and_decoded_independently() {
|
||||
let tracks: Vec<TrackSpec> = [(48_000.0, "first"), (24_000.0, "second")]
|
||||
.into_iter()
|
||||
.map(|(rate, name)| {
|
||||
let mut t = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
|
||||
t.audio = Some((rate, 1, Some(16)));
|
||||
t.name = Some(name.into());
|
||||
t
|
||||
})
|
||||
.collect();
|
||||
let mut writer = MkvWriter::new(std::io::Cursor::new(Vec::new()), tracks, MuxOptions::default()).unwrap();
|
||||
for (stream, value) in [(0, 8192i16), (1, -16384)] {
|
||||
let data: Vec<u8> = (0..100).flat_map(|_| value.to_le_bytes()).collect();
|
||||
writer.write_frame(stream, 0, true, &data, Some(4_000_000)).unwrap();
|
||||
}
|
||||
let source = super::MkvSource::open("two.mkv", writer.finish().unwrap().into_inner().into()).unwrap();
|
||||
assert_eq!(source.info().audio_streams.iter().map(|a| a.sample_rate).collect::<Vec<_>>(), vec![48_000, 24_000]);
|
||||
let primary = source.audio(0, 50, 48_000).unwrap();
|
||||
let secondary = source.audio_stream(1, 0, 50, 48_000).unwrap();
|
||||
assert!((primary.channels[0][20] - 0.25).abs() < 0.001);
|
||||
assert!((secondary.channels[0][20] + 0.5).abs() < 0.001);
|
||||
assert!(source.audio_stream(2, 0, 50, 48_000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+189
-135
@@ -7,7 +7,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_color::ColorInfo;
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind};
|
||||
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
@@ -29,26 +29,67 @@ struct AudioState {
|
||||
last_decoded: Option<usize>,
|
||||
}
|
||||
|
||||
/// One playable audio track with its own decoder state and timing tables.
|
||||
struct Mp4Audio {
|
||||
/// Index into `Mp4File::tracks`.
|
||||
track: usize,
|
||||
state: Mutex<AudioState>,
|
||||
/// Packet start positions in source sample frames (see [`audio_starts`]).
|
||||
starts: Vec<i64>,
|
||||
/// Presentation offset of the track in its timescale (edit list, or Opus pre-skip).
|
||||
offset: i64,
|
||||
/// Decoder pre-roll after a seek, in source sample frames (0: prime with one packet).
|
||||
preroll: i64,
|
||||
}
|
||||
|
||||
pub struct Mp4Source {
|
||||
info: MediaInfo,
|
||||
bytes: crate::Src,
|
||||
file: Mp4File,
|
||||
vtrack: Option<usize>,
|
||||
atrack: Option<usize>,
|
||||
video: GopCache,
|
||||
audio: Mutex<AudioState>,
|
||||
/// Cumulative sample start frames for the audio track (for packet lookup).
|
||||
audio_starts: Vec<i64>,
|
||||
/// Presentation offset of the audio track in its timescale (edit list, or Opus pre-skip).
|
||||
audio_offset: i64,
|
||||
/// Decoder pre-roll after a seek, in audio track timescale units (0: prime with one packet).
|
||||
audio_preroll: i64,
|
||||
/// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`.
|
||||
audios: Vec<Mp4Audio>,
|
||||
}
|
||||
|
||||
pub fn sniff(b: &[u8]) -> bool {
|
||||
b.len() >= 12 && matches!(&b[4..8], b"ftyp" | b"moov" | b"mdat" | b"wide" | b"free" | b"skip")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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`]).
|
||||
fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec<i64> {
|
||||
use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples};
|
||||
let Some(t) = file.tracks.get(ti) else { return Vec::new() };
|
||||
let ts = i128::from(t.timescale.max(1));
|
||||
let mut total = 0i128;
|
||||
let stamps: Vec<i64> = t
|
||||
.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let at = total * i128::from(rate) / ts;
|
||||
total += i128::from(s.duration);
|
||||
i64::try_from(at).unwrap_or(i64::MAX)
|
||||
})
|
||||
.collect();
|
||||
let first = || file.read_sample(bytes, ti, 0).unwrap_or_default();
|
||||
let frame = match t.entries.first().map(|e| &e.codec) {
|
||||
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),
|
||||
_ => None,
|
||||
};
|
||||
let Some(frame) = frame else { return stamps };
|
||||
// samples per track timescale unit, rounded up: how coarse the running total is
|
||||
let unit = i64::try_from((i128::from(rate) + ts - 1) / ts).unwrap_or(i64::MAX);
|
||||
let packets = stamps.iter().map(|&stamp| PacketTime { stamp, stamped: true, samples: Some(frame) });
|
||||
contiguous_starts(packets, (frame / 2).max(unit.saturating_mul(2).saturating_add(1)))
|
||||
}
|
||||
|
||||
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
|
||||
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
|
||||
if md.is_none() && cll.is_none() {
|
||||
@@ -63,48 +104,40 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u
|
||||
})
|
||||
}
|
||||
|
||||
/// Colour of a video sample entry. The `colr` box (`nclx` / `nclc`) wins; what it leaves
|
||||
/// unspecified, or all of it when there is none (ffmpeg writes MP4 without `colr` by default),
|
||||
/// comes from the stream's own description: `vpcC` / `apvC`, or the SPS VUI in `avcC` / `hvcC`
|
||||
/// and the sequence header in `av1C`. The rest defaults by frame size.
|
||||
fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited };
|
||||
// VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box).
|
||||
let vpc = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: v.colour_primaries as u16,
|
||||
transfer: v.transfer_characteristics as u16,
|
||||
matrix: v.matrix_coefficients as u16,
|
||||
full_range: v.full_range,
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(filmcraft_isobmff::ColorInfo::Nclx {
|
||||
primaries: a.color_primaries as u16,
|
||||
transfer: a.transfer_characteristics as u16,
|
||||
matrix: a.matrix_coefficients as u16,
|
||||
full_range: a.full_range,
|
||||
}),
|
||||
use crate::stream_color::ColorCodes;
|
||||
use filmcraft_isobmff::ColorInfo as Colr;
|
||||
let colr = match entry.video.as_ref().and_then(|v| v.color.as_ref()) {
|
||||
Some(Colr::Nclx { primaries, transfer, matrix, full_range }) => {
|
||||
Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), Some(*full_range)))
|
||||
}
|
||||
// QuickTime `nclc` has no range flag
|
||||
Some(Colr::Nclc { primaries, transfer, matrix }) => Some(ColorCodes::from_wide((*primaries).into(), (*transfer).into(), (*matrix).into(), None)),
|
||||
// an ICC profile, or no `colr`: no code points
|
||||
_ => None,
|
||||
};
|
||||
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
|
||||
let (p, t, m, full) = match col {
|
||||
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
|
||||
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
|
||||
_ => return c,
|
||||
};
|
||||
if let Some(m) = Matrix::from_code(m as u8) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = Transfer::from_code(t as u8) {
|
||||
c.transfer = t;
|
||||
}
|
||||
c.primaries = match p {
|
||||
9 => Primaries::Bt2020,
|
||||
12 => Primaries::P3D65,
|
||||
5 => Primaries::Bt601_625,
|
||||
6 => Primaries::Bt601_525,
|
||||
_ => Primaries::Bt709,
|
||||
};
|
||||
if full {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
}
|
||||
c
|
||||
let stream = match &entry.codec {
|
||||
CodecConfig::Vp9(v) => Some(ColorCodes {
|
||||
primaries: v.colour_primaries,
|
||||
transfer: v.transfer_characteristics,
|
||||
matrix: v.matrix_coefficients,
|
||||
full_range: Some(v.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(a) if a.color_description_present => Some(ColorCodes {
|
||||
primaries: a.color_primaries,
|
||||
transfer: a.transfer_characteristics,
|
||||
matrix: a.matrix_coefficients,
|
||||
full_range: Some(a.full_range),
|
||||
}),
|
||||
CodecConfig::Apv(_) => None,
|
||||
codec => crate::stream_color::from_codec_config(codec),
|
||||
};
|
||||
let sources: Vec<ColorCodes> = [colr, stream].into_iter().flatten().collect();
|
||||
crate::stream_color::resolve(w, h, &sources)
|
||||
}
|
||||
|
||||
impl Mp4Source {
|
||||
@@ -120,8 +153,10 @@ impl Mp4Source {
|
||||
// files can lack them: indexing `entries[0]` or dividing by the timescale used to panic).
|
||||
let playable = |t: &&filmcraft_isobmff::Track, kind| t.kind == kind && !t.samples.is_empty() && !t.entries.is_empty() && t.timescale > 0;
|
||||
let vtrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Video));
|
||||
let atrack = file.tracks.iter().position(|t| playable(&t, TrackKind::Audio));
|
||||
if vtrack.is_none() && atrack.is_none() {
|
||||
// every audio track is a stream (the track count comes from the file: capped)
|
||||
let atracks: Vec<usize> =
|
||||
file.tracks.iter().enumerate().filter(|(_, t)| playable(t, TrackKind::Audio)).map(|(i, _)| i).take(filmcraft_media::MAX_AUDIO_STREAMS).collect();
|
||||
if vtrack.is_none() && atracks.is_empty() {
|
||||
return Err(CodecError::Unsupported("no playable tracks".into()));
|
||||
}
|
||||
let mut color = ColorInfo::REC709;
|
||||
@@ -195,37 +230,11 @@ impl Mp4Source {
|
||||
}
|
||||
info
|
||||
});
|
||||
let audio = atrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let entry = &t.entries[0];
|
||||
let ap = entry.audio.clone().unwrap_or_default();
|
||||
let (rate, ch, bits) = match &entry.codec {
|
||||
CodecConfig::Aac(a) => (
|
||||
{
|
||||
// HE-AAC plays at twice the core rate the AudioSpecificConfig starts with
|
||||
// an AAC access unit is at most 6144 bits per channel: a hostile `stsz` can't make us read more
|
||||
let units = t
|
||||
.samples
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|x| filmcraft_media::reader::read_range(&*bytes.0, x.offset, (x.size as usize).min(MAX_AAC_PROBE_UNIT)).ok());
|
||||
let units: Vec<Vec<u8>> = units.collect();
|
||||
match crate::audio::aac_output_rate(&a.asc, units.iter().map(Vec::as_slice)) {
|
||||
Some(r) => r,
|
||||
None if a.sample_rate > 0 => a.sample_rate,
|
||||
None => ap.sample_rate as u32,
|
||||
}
|
||||
},
|
||||
if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels },
|
||||
None,
|
||||
),
|
||||
CodecConfig::Pcm(p) => (p.sample_rate as u32, p.channels, Some(p.bits as u32)),
|
||||
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),
|
||||
};
|
||||
AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits }
|
||||
});
|
||||
let duration = vtrack.or(atrack).and_then(|i| file.tracks.get(i)).map(|t| presentation_duration(&file, t)).unwrap_or_default();
|
||||
// one entry per playable audio track (`playable` guarantees a sample description); a track
|
||||
// that somehow has none is dropped so `audio_streams` and `audios` stay index-aligned
|
||||
let (atracks, audio_streams): (Vec<usize>, Vec<AudioStreamInfo>) =
|
||||
atracks.into_iter().filter_map(|i| Some((i, audio_stream_info(file.tracks.get(i)?, &bytes)?))).unzip();
|
||||
let duration = vtrack.or(atracks.first().copied()).and_then(|i| file.tracks.get(i)).map(|t| presentation_duration(&file, t)).unwrap_or_default();
|
||||
let start_timecode = file.tracks.iter().find_map(|t| match t.codec() {
|
||||
Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64),
|
||||
_ => None,
|
||||
@@ -235,50 +244,31 @@ impl Mp4Source {
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
duration,
|
||||
video,
|
||||
audio,
|
||||
audio_streams,
|
||||
container: if file.is_quicktime { "QuickTime".into() } else { "MPEG-4".into() },
|
||||
start_timecode,
|
||||
file_size: Some(bytes.0.len()),
|
||||
};
|
||||
let audio_starts = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let mut acc = 0i64;
|
||||
t.samples
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let st = acc;
|
||||
acc += s.duration as i64;
|
||||
st
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (audio_offset, audio_preroll) = atrack
|
||||
.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let audios: Vec<Mp4Audio> = atracks
|
||||
.iter()
|
||||
.zip(&info.audio_streams)
|
||||
.filter_map(|(&i, ainfo)| {
|
||||
let t = file.tracks.get(i)?;
|
||||
let starts = audio_starts(&file, &bytes, i, ainfo.sample_rate);
|
||||
let ts = t.timescale.max(1) as i64;
|
||||
match &t.entries[0].codec {
|
||||
CodecConfig::Opus(o) => {
|
||||
let (offset, preroll) = match t.entries.first().map(|e| &e.codec) {
|
||||
Some(CodecConfig::Opus(o)) => {
|
||||
let off = if t.edit_offset != 0 { t.edit_offset } else { -(o.pre_skip as i64) * ts / 48_000 };
|
||||
(off, (crate::audio::OPUS_PRE_ROLL as u64 * ts as u64).div_ceil(48_000) as i64)
|
||||
// (Opus plays at 48 kHz: the pre-roll is in its sample frames)
|
||||
(off, crate::audio::OPUS_PRE_ROLL as i64)
|
||||
}
|
||||
_ => (t.edit_offset, 0),
|
||||
}
|
||||
};
|
||||
let state = Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None });
|
||||
Some(Mp4Audio { track: i, state, starts, offset, preroll })
|
||||
})
|
||||
.unwrap_or((0, 0));
|
||||
Ok(Self {
|
||||
info,
|
||||
bytes,
|
||||
file,
|
||||
vtrack,
|
||||
atrack,
|
||||
video: GopCache::new(explicit_color).with_rotation(rotation),
|
||||
audio: Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }),
|
||||
audio_starts,
|
||||
audio_offset,
|
||||
audio_preroll,
|
||||
})
|
||||
.collect();
|
||||
Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation), audios })
|
||||
}
|
||||
|
||||
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
|
||||
@@ -294,27 +284,30 @@ impl Mp4Source {
|
||||
self.video.frame_late(&Mp4Video { src: self, track: ti }, t.to_rational_round(1, ts), late)
|
||||
}
|
||||
|
||||
fn audio_packet(&self, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
/// Decoded packet `i` of audio stream `a` (`ainfo` is its `AudioStreamInfo`; `st` its decoder state).
|
||||
fn audio_packet(&self, a: &Mp4Audio, ainfo: &AudioStreamInfo, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
|
||||
if let Some(p) = st.packets.get(&i) {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
let ti = self.atrack.ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let track = &self.file.tracks[ti];
|
||||
let ti = a.track;
|
||||
let track = self.file.tracks.get(ti).ok_or_else(|| CodecError::Unsupported("no audio track".into()))?;
|
||||
let entry = track.entries.first().ok_or_else(|| CodecError::Container("audio track without a sample description".into()))?;
|
||||
let sample = track.samples.get(i).ok_or_else(|| CodecError::Container("audio packet out of range".into()))?;
|
||||
let pk_start = a.starts.get(i).copied().unwrap_or(0);
|
||||
let data = self.read(ti, i)?;
|
||||
let decoded = match &entry.codec {
|
||||
CodecConfig::Pcm(p) => decode_pcm(&data, p),
|
||||
c => {
|
||||
if st.decoder.is_none() {
|
||||
st.decoder = Some(PacketDecoder::for_isobmff(c, self.info.audio.as_ref().map_or(48_000, |a| a.sample_rate))?);
|
||||
st.decoder = Some(PacketDecoder::for_isobmff(c, ainfo.sample_rate)?);
|
||||
}
|
||||
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
|
||||
// one packet, or `OPUS_PRE_ROLL` worth for Opus).
|
||||
if st.last_decoded.is_none_or(|l| l + 1 != i) {
|
||||
let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?;
|
||||
d.reset();
|
||||
let from = if self.audio_preroll > 0 {
|
||||
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
|
||||
let from = if a.preroll > 0 {
|
||||
a.starts.partition_point(|&x| x <= pk_start.saturating_sub(a.preroll)).saturating_sub(1)
|
||||
} else {
|
||||
i.saturating_sub(1)
|
||||
};
|
||||
@@ -324,11 +317,11 @@ impl Mp4Source {
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, self.audio_starts[i].max(0) as u64);
|
||||
let r = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?.decode(&data, pk_start.max(0) as u64);
|
||||
st.last_decoded = Some(i);
|
||||
match r {
|
||||
Ok(v) => v,
|
||||
Err(_) => vec![vec![0.0; track.samples[i].duration as usize]; self.info.audio.as_ref().map_or(2, |a| a.channels as usize)],
|
||||
Err(_) => vec![vec![0.0; sample.duration as usize]; ainfo.channels.max(1) as usize],
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -381,30 +374,33 @@ impl MediaSource for Mp4Source {
|
||||
}
|
||||
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
let ti = self.atrack.ok_or(MediaError::NoStream("audio"))?;
|
||||
let track = &self.file.tracks[ti];
|
||||
let ainfo = self.info.audio.as_ref().ok_or(MediaError::NoStream("audio"))?;
|
||||
self.audio_stream(0, start, frames, sample_rate)
|
||||
}
|
||||
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let ti = a.track;
|
||||
let track = self.file.tracks.get(ti).ok_or(MediaError::NoStream("audio"))?;
|
||||
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
|
||||
let ratio = src_rate as f64 / sample_rate as f64;
|
||||
let p0 = (start as f64 * ratio).floor() as i64;
|
||||
// (in i128: a hostile edit list can make the offset large enough to overflow i64)
|
||||
let offset = i128::from(self.audio_offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
|
||||
let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
|
||||
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
|
||||
let need = (frames as f64 * ratio).ceil() as i64 + 2;
|
||||
// Samples-per-unit: the track timescale is usually the sample rate for audio.
|
||||
let unit = src_rate as f64 / track.timescale.max(1) as f64;
|
||||
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
|
||||
let mut st = self.audio.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = self.audio_starts.partition_point(|&x| (x as f64 * unit) as i64 <= s0.max(0)).saturating_sub(1);
|
||||
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
|
||||
let mut i = first;
|
||||
while i < track.samples.len() {
|
||||
let pk_start = (self.audio_starts[i] as f64 * unit) as i64;
|
||||
let Some(&pk_start) = a.starts.get(i) else { break };
|
||||
if pk_start >= s0.saturating_add(need) {
|
||||
break;
|
||||
}
|
||||
let pk = self.audio_packet(&mut st, i)?;
|
||||
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
|
||||
for (c, dst) in src.iter_mut().enumerate() {
|
||||
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
|
||||
for (k, v) in chan.iter().enumerate() {
|
||||
@@ -443,6 +439,37 @@ impl MediaSource for Mp4Source {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the file says about one audio track (`None`: the track has no sample description).
|
||||
fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option<AudioStreamInfo> {
|
||||
let entry = t.entries.first()?;
|
||||
let ap = entry.audio.clone().unwrap_or_default();
|
||||
let (rate, ch, bits) = match &entry.codec {
|
||||
CodecConfig::Aac(a) => (
|
||||
{
|
||||
// HE-AAC plays at twice the core rate the AudioSpecificConfig starts with
|
||||
// an AAC access unit is at most 6144 bits per channel: a hostile `stsz` can't make us read more
|
||||
let units = t
|
||||
.samples
|
||||
.iter()
|
||||
.take(8)
|
||||
.filter_map(|x| filmcraft_media::reader::read_range(&*bytes.0, x.offset, (x.size as usize).min(MAX_AAC_PROBE_UNIT)).ok());
|
||||
let units: Vec<Vec<u8>> = units.collect();
|
||||
match crate::audio::aac_output_rate(&a.asc, units.iter().map(Vec::as_slice)) {
|
||||
Some(r) => r,
|
||||
None if a.sample_rate > 0 => a.sample_rate,
|
||||
None => ap.sample_rate as u32,
|
||||
}
|
||||
},
|
||||
if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels },
|
||||
None,
|
||||
),
|
||||
CodecConfig::Pcm(p) => (p.sample_rate as u32, p.channels, Some(p.bits as u32)),
|
||||
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 })
|
||||
}
|
||||
|
||||
fn codec_label(c: &CodecConfig) -> String {
|
||||
match c {
|
||||
CodecConfig::Avc(a) => format!(
|
||||
@@ -803,6 +830,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_audio_tests {
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
/// Two mono PCM tracks with different rates and samples. Stream indices follow file order;
|
||||
/// decoder caches, sample timing and resampling must not cross streams.
|
||||
#[test]
|
||||
fn every_audio_track_is_inspected_and_decoded_independently() {
|
||||
let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
for (rate, value) in [(48_000u32, 8192i16), (24_000, -16384)] {
|
||||
let entry = SampleEntry::pcm(PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 1, sample_rate: rate as f64 });
|
||||
let track = mux.add_track(TrackConfig::new(entry, rate)).unwrap();
|
||||
let data: Vec<u8> = (0..rate / 10).flat_map(|_| value.to_le_bytes()).collect();
|
||||
mux.write_sample(track, WriteSample { data: &data, duration: rate / 10, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let bytes = mux.finish().unwrap().into_inner();
|
||||
let source = super::Mp4Source::open("two.mov", bytes.into()).unwrap();
|
||||
assert_eq!(source.info().audio_streams.iter().map(|a| a.sample_rate).collect::<Vec<_>>(), vec![48_000, 24_000]);
|
||||
let primary = source.audio(0, 100, 48_000).unwrap();
|
||||
let secondary = source.audio_stream(1, 0, 100, 48_000).unwrap();
|
||||
assert!((primary.channels[0][50] - 0.25).abs() < 0.001);
|
||||
assert!((secondary.channels[0][50] + 0.5).abs() < 0.001);
|
||||
assert!(source.audio_stream(2, 0, 100, 48_000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod he_aac_tests {
|
||||
use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
@@ -829,7 +883,7 @@ mod he_aac_tests {
|
||||
}
|
||||
let b = mux.finish().unwrap().into_inner();
|
||||
let src = super::Mp4Source::open("he.mp4", b.into()).unwrap();
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().sample_rate, 44_100);
|
||||
assert_eq!(src.info().audio().unwrap().sample_rate, 44_100);
|
||||
// one second from 0.5 s at 44.1 kHz: the 1 kHz tone at its level (not silence, not shifted)
|
||||
let buf = src.audio(22_050, 44_100, 44_100).unwrap();
|
||||
let x = &buf.channels[0];
|
||||
|
||||
@@ -211,7 +211,7 @@ impl MpegSource {
|
||||
kind: MediaKind::Movie,
|
||||
duration: Tick::ZERO,
|
||||
video: None,
|
||||
audio: None,
|
||||
audio_streams: Vec::new(),
|
||||
container: file.format.name().to_string(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
@@ -316,7 +316,7 @@ impl MpegSource {
|
||||
kind: MediaKind::Movie,
|
||||
duration: Tick::ZERO,
|
||||
video: None,
|
||||
audio: None,
|
||||
audio_streams: Vec::new(),
|
||||
container: "MPEG video elementary stream".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
@@ -485,7 +485,8 @@ impl MpegSource {
|
||||
/// Mark the audio stream as not decodable (it still shows in the media info).
|
||||
fn audio_unsupported(&mut self, codec_name: &str, why: String) -> Option<AudioTrack> {
|
||||
self.unsupported_audio = Some(why);
|
||||
self.info.audio = Some(AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: format!("{codec_name} (unsupported)"), bits_per_sample: None });
|
||||
self.info.audio_streams =
|
||||
vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: format!("{codec_name} (unsupported)"), bits_per_sample: None }];
|
||||
None
|
||||
}
|
||||
|
||||
@@ -599,7 +600,8 @@ impl MpegSource {
|
||||
self.info.kind = MediaKind::AudioOnly;
|
||||
}
|
||||
}
|
||||
self.info.audio = Some(AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits });
|
||||
self.info.audio_streams =
|
||||
vec![AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }];
|
||||
Some(AudioTrack {
|
||||
stream: ai,
|
||||
codec,
|
||||
|
||||
@@ -361,7 +361,7 @@ impl MxfSource {
|
||||
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
|
||||
duration,
|
||||
video,
|
||||
audio,
|
||||
audio_streams: audio.into_iter().collect(),
|
||||
container: format!("MXF {}", file.operational_pattern.name()),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
@@ -457,7 +457,7 @@ impl MediaSource for MxfSource {
|
||||
}
|
||||
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
let ainfo = self.info.audio.as_ref().ok_or(MediaError::NoStream("audio"))?;
|
||||
let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?;
|
||||
let src_rate = ainfo.sample_rate;
|
||||
let ch = ainfo.channels.max(1) as usize;
|
||||
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
|
||||
|
||||
@@ -110,7 +110,7 @@ impl OggSource {
|
||||
kind: MediaKind::AudioOnly,
|
||||
duration: Tick::from_units(frames, rate as i64),
|
||||
video: None,
|
||||
audio: Some(AudioStreamInfo { sample_rate: rate, channels: channels.max(1), codec: codec.into(), bits_per_sample: None }),
|
||||
audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: channels.max(1), codec: codec.into(), bits_per_sample: None }],
|
||||
container: "Ogg".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(bytes.0.len()),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The colour description a video stream carries in its own bitstream, for containers that don't
|
||||
//! repeat it: the H.264 / HEVC sequence parameter set's VUI (from `avcC` / `hvcC`) and the AV1
|
||||
//! sequence header's `color_config` (from `av1C`), read with the decoders' own parsers.
|
||||
//!
|
||||
//! ffmpeg writes MP4 without a `colr` box unless asked (`-movflags +write_colr`), so for most
|
||||
//! files the bitstream is the only place their colour is described. [`resolve`] merges it with
|
||||
//! what the container signals, which wins.
|
||||
|
||||
use filmcraft_color::{ColorInfo, Matrix, Range, Transfer};
|
||||
use filmcraft_isobmff::{AvcConfig, CodecConfig, HevcConfig};
|
||||
|
||||
use crate::video::primaries_from_code;
|
||||
|
||||
/// ITU-T H.273 `Unspecified`.
|
||||
const UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// Colour code points (ITU-T H.273 / ISO/IEC 23091-2) and range as one place in a file (a
|
||||
/// container colour box, a sequence header) signals them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColorCodes {
|
||||
pub primaries: u8,
|
||||
pub transfer: u8,
|
||||
pub matrix: u8,
|
||||
/// `None`: this place doesn't say.
|
||||
pub full_range: Option<bool>,
|
||||
}
|
||||
|
||||
impl ColorCodes {
|
||||
/// From wider code points (a `colr` box or a Matroska `Colour` element): a value that doesn't
|
||||
/// fit a byte is no H.273 code point, so it counts as unspecified.
|
||||
pub fn from_wide(primaries: u64, transfer: u64, matrix: u64, full_range: Option<bool>) -> Self {
|
||||
let code = |v: u64| u8::try_from(v).unwrap_or(UNSPECIFIED);
|
||||
Self { primaries: code(primaries), transfer: code(transfer), matrix: code(matrix), full_range }
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour a video codec configuration's bitstream signals: the VUI of the first SPS in an
|
||||
/// `avcC` / `hvcC`, or the `color_config` of the sequence header in an `av1C`. `None` for other
|
||||
/// codecs, an SPS without VUI, and a configuration that is truncated or corrupt.
|
||||
pub fn from_codec_config(codec: &CodecConfig) -> Option<ColorCodes> {
|
||||
match codec {
|
||||
CodecConfig::Avc(c) => avc(c),
|
||||
CodecConfig::Hevc(c) => hevc(c),
|
||||
CodecConfig::Av1(c) => av1(&c.config_obus),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn avc(c: &AvcConfig) -> Option<ColorCodes> {
|
||||
// NAL header byte: nal_unit_type 7 is an SPS
|
||||
let nal = c.sps.iter().find(|n| n.first().is_some_and(|h| h & 0x1f == 7))?;
|
||||
let sps = filmcraft_h264::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(1..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn hevc(c: &HevcConfig) -> Option<ColorCodes> {
|
||||
// two-byte NAL header: nal_unit_type 33 is an SPS (whatever array the record files it under)
|
||||
let nal = c.arrays.iter().flat_map(|a| &a.nalus).find(|n| n.first().is_some_and(|h| (h >> 1) & 0x3f == 33))?;
|
||||
let sps = filmcraft_hevc::params::Sps::parse(&filmcraft_bitstream::unescape_rbsp(nal.get(2..)?)).ok()?;
|
||||
let v = sps.vui?;
|
||||
Some(ColorCodes { primaries: v.colour_primaries, transfer: v.transfer_characteristics, matrix: v.matrix_coefficients, full_range: Some(v.full_range) })
|
||||
}
|
||||
|
||||
fn av1(config_obus: &[u8]) -> Option<ColorCodes> {
|
||||
// OBU type 1: the sequence header
|
||||
let (_, payload) = crate::hw_frame::obus(config_obus).into_iter().find(|(t, _)| *t == 1)?;
|
||||
let c = filmcraft_av1::SequenceHeader::parse(payload).ok()?.color;
|
||||
Some(ColorCodes { primaries: c.color_primaries, transfer: c.transfer_characteristics, matrix: c.matrix_coefficients, full_range: Some(c.color_range) })
|
||||
}
|
||||
|
||||
/// Colour from the code points a file signals, most authoritative first (the container's colour
|
||||
/// box, then the bitstream's): the matrix, transfer, primaries and range each come from the first
|
||||
/// source that signals a value we know. What no source signals keeps the defaults: the matrix by
|
||||
/// frame size ([`filmcraft_frame::default_matrix`]: BT.601 for SD, BT.709 otherwise), BT.709
|
||||
/// transfer and primaries, limited range.
|
||||
pub fn resolve(width: u32, height: u32, sources: &[ColorCodes]) -> ColorInfo {
|
||||
let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..ColorInfo::REC709 };
|
||||
if let Some(m) = sources.iter().find_map(|s| Matrix::from_code(s.matrix)) {
|
||||
c.matrix = m;
|
||||
}
|
||||
if let Some(t) = sources.iter().find_map(|s| Transfer::from_code(s.transfer)) {
|
||||
c.transfer = t;
|
||||
}
|
||||
if let Some(p) = sources.iter().find_map(|s| primaries_from_code(s.primaries)) {
|
||||
c.primaries = p;
|
||||
}
|
||||
if sources.iter().find_map(|s| s.full_range) == Some(true) {
|
||||
c.range = Range::Full;
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Colour from the bitstream when the container has none (`crate::stream_color`): ffmpeg writes MP4
|
||||
//! without a `colr` box by default, and an HEVC PQ file imported as Rec. 709 SDR, a full-range
|
||||
//! H.264 file as limited range, and a BT.601 SD file with BT.709 primaries. The parameter sets
|
||||
//! are built here bit by bit; no media files.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_bitstream::{BitWriter, escape_rbsp};
|
||||
use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer};
|
||||
use filmcraft_isobmff::{Av1Config, AvcConfig, Brand, CodecConfig, HevcConfig, HevcNalArray, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
|
||||
use filmcraft_matroska::{MkvWriter, MuxOptions, TrackKind, TrackSpec};
|
||||
use filmcraft_media::MediaSource;
|
||||
|
||||
use crate::stream_color::{ColorCodes, from_codec_config, resolve};
|
||||
|
||||
/// The video signal description of a VUI: (colour_primaries, transfer_characteristics,
|
||||
/// matrix_coefficients) when `colour_description_present_flag` is set, and the range flag.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Signal {
|
||||
codes: Option<(u8, u8, u8)>,
|
||||
full_range: bool,
|
||||
}
|
||||
|
||||
const PQ_2020: Signal = Signal { codes: Some((9, 16, 9)), full_range: false };
|
||||
const SMPTE_170M: Signal = Signal { codes: Some((6, 6, 6)), full_range: false };
|
||||
const FULL_ONLY: Signal = Signal { codes: None, full_range: true };
|
||||
|
||||
fn signal(w: &mut BitWriter, s: Signal) {
|
||||
w.write_bit(true); // video_signal_type_present_flag
|
||||
w.write_bits(5, 3); // video_format: unspecified
|
||||
w.write_bit(s.full_range);
|
||||
w.write_bit(s.codes.is_some()); // colour_description_present_flag
|
||||
if let Some((p, t, m)) = s.codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Baseline H.264 SPS NAL unit (ITU-T H.264 §7.3.2.1.1, VUI per Annex E.1.1) of
|
||||
/// `mbs_w`×`mbs_h` macroblocks, with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn h264_sps(mbs_w: u32, mbs_h: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(66, 8); // profile_idc: Baseline
|
||||
w.write_bits(0, 8); // constraint flags
|
||||
w.write_bits(30, 8); // level_idc
|
||||
w.write_ue(0); // seq_parameter_set_id
|
||||
w.write_ue(0); // log2_max_frame_num_minus4
|
||||
w.write_ue(2); // pic_order_cnt_type
|
||||
w.write_ue(1); // max_num_ref_frames
|
||||
w.write_bit(false); // gaps_in_frame_num_value_allowed_flag
|
||||
w.write_ue(mbs_w - 1);
|
||||
w.write_ue(mbs_h - 1);
|
||||
w.write_bits(0b110, 3); // frame_mbs_only, direct_8x8_inference, no cropping
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // timing_info_present_flag
|
||||
w.write_bit(false); // nal_hrd_parameters_present_flag
|
||||
w.write_bit(false); // vcl_hrd_parameters_present_flag
|
||||
w.write_bit(false); // pic_struct_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![0x67];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// A Main 10 HEVC SPS NAL unit (ITU-T H.265 §7.3.2.2, VUI per Annex E.2.1) of `width`×`height`
|
||||
/// (multiples of 8), with a VUI carrying `vui` (no VUI when `None`).
|
||||
fn hevc_sps(width: u32, height: u32, vui: Option<Signal>) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 4); // sps_video_parameter_set_id
|
||||
w.write_bits(0, 3); // sps_max_sub_layers_minus1
|
||||
w.write_bit(true); // sps_temporal_id_nesting_flag
|
||||
// profile_tier_level: Main 10, level 4.1
|
||||
w.write_bits(0, 2);
|
||||
w.write_bit(false);
|
||||
w.write_bits(2, 5);
|
||||
w.write_bits(0x2000_0000, 32);
|
||||
w.write_bits(0b1001, 4);
|
||||
w.write_bits(0, 32);
|
||||
w.write_bits(0, 12);
|
||||
w.write_bits(123, 8);
|
||||
w.write_ue(0); // sps_seq_parameter_set_id
|
||||
w.write_ue(1); // chroma_format_idc 4:2:0
|
||||
w.write_ue(width);
|
||||
w.write_ue(height);
|
||||
w.write_bit(false); // conformance_window_flag
|
||||
w.write_ue(2); // bit_depth_luma_minus8
|
||||
w.write_ue(2); // bit_depth_chroma_minus8
|
||||
w.write_ue(4); // log2_max_pic_order_cnt_lsb_minus4
|
||||
w.write_bit(true); // sps_sub_layer_ordering_info_present_flag
|
||||
w.write_ue(4); // sps_max_dec_pic_buffering_minus1
|
||||
w.write_ue(0); // sps_max_num_reorder_pics
|
||||
w.write_ue(0); // sps_max_latency_increase_plus1
|
||||
w.write_ue(0); // log2_min_luma_coding_block_size_minus3 (8)
|
||||
w.write_ue(1); // log2_diff_max_min_luma_coding_block_size (CTB 16)
|
||||
w.write_ue(0); // log2_min_luma_transform_block_size_minus2 (4)
|
||||
w.write_ue(2); // log2_diff_max_min_luma_transform_block_size (16)
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_inter
|
||||
w.write_ue(0); // max_transform_hierarchy_depth_intra
|
||||
w.write_bit(false); // scaling_list_enabled_flag
|
||||
w.write_bit(false); // amp_enabled_flag
|
||||
w.write_bit(false); // sample_adaptive_offset_enabled_flag
|
||||
w.write_bit(false); // pcm_enabled_flag
|
||||
w.write_ue(0); // num_short_term_ref_pic_sets
|
||||
w.write_bit(false); // long_term_ref_pics_present_flag
|
||||
w.write_bit(false); // sps_temporal_mvp_enabled_flag
|
||||
w.write_bit(false); // strong_intra_smoothing_enabled_flag
|
||||
w.write_bit(vui.is_some()); // vui_parameters_present_flag
|
||||
if let Some(s) = vui {
|
||||
w.write_bit(false); // aspect_ratio_info_present_flag
|
||||
w.write_bit(false); // overscan_info_present_flag
|
||||
signal(&mut w, s);
|
||||
w.write_bit(false); // chroma_loc_info_present_flag
|
||||
w.write_bit(false); // neutral_chroma_indication_flag
|
||||
w.write_bit(false); // field_seq_flag
|
||||
w.write_bit(false); // frame_field_info_present_flag
|
||||
w.write_bit(false); // default_display_window_flag
|
||||
w.write_bit(false); // vui_timing_info_present_flag
|
||||
w.write_bit(false); // bitstream_restriction_flag
|
||||
}
|
||||
w.write_bit(false); // sps_extension_present_flag
|
||||
w.rbsp_trailing();
|
||||
let mut nal = vec![33 << 1, 1];
|
||||
nal.extend(escape_rbsp(&w.finish()));
|
||||
nal
|
||||
}
|
||||
|
||||
/// An AV1 sequence header OBU (AV1 bitstream spec §5.5; reduced still-picture header, profile 0,
|
||||
/// 10-bit 4:2:0) of `width`×`height` with `color_config` signalling `codes` and `full_range`.
|
||||
fn av1_sequence_header(width: u32, height: u32, codes: Option<(u8, u8, u8)>, full_range: bool) -> Vec<u8> {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_bits(0, 3); // seq_profile
|
||||
w.write_bit(true); // still_picture
|
||||
w.write_bit(true); // reduced_still_picture_header
|
||||
w.write_bits(0, 5); // seq_level_idx[0]
|
||||
w.write_bits(15, 4); // frame_width_bits_minus_1
|
||||
w.write_bits(15, 4); // frame_height_bits_minus_1
|
||||
w.write_bits(width - 1, 16);
|
||||
w.write_bits(height - 1, 16);
|
||||
w.write_bits(0, 3); // use_128x128_superblock, enable_filter_intra, enable_intra_edge_filter
|
||||
w.write_bits(0, 3); // enable_superres, enable_cdef, enable_restoration
|
||||
w.write_bit(true); // high_bitdepth
|
||||
w.write_bit(false); // mono_chrome
|
||||
w.write_bit(codes.is_some()); // color_description_present_flag
|
||||
if let Some((p, t, m)) = codes {
|
||||
w.write_bits(p.into(), 8);
|
||||
w.write_bits(t.into(), 8);
|
||||
w.write_bits(m.into(), 8);
|
||||
}
|
||||
w.write_bit(full_range); // color_range
|
||||
w.write_bits(0, 2); // chroma_sample_position
|
||||
w.write_bit(false); // separate_uv_delta_q
|
||||
w.write_bit(false); // film_grain_params_present
|
||||
w.rbsp_trailing();
|
||||
let payload = w.finish();
|
||||
// OBU header: type 1 (OBU_SEQUENCE_HEADER), obu_has_size_field; one-byte leb128 size
|
||||
let mut obu = vec![(1 << 3) | 2, u8::try_from(payload.len()).expect("short payload")];
|
||||
obu.extend(payload);
|
||||
obu
|
||||
}
|
||||
|
||||
fn avc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4))
|
||||
}
|
||||
|
||||
fn hevc(sps: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Hevc(HevcConfig {
|
||||
general_profile_idc: 2,
|
||||
chroma_format_idc: 1,
|
||||
bit_depth_luma: 10,
|
||||
bit_depth_chroma: 10,
|
||||
length_size: 4,
|
||||
arrays: vec![HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps] }],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn av1(obus: Vec<u8>) -> CodecConfig {
|
||||
CodecConfig::Av1(Av1Config { high_bitdepth: true, chroma_subsampling_x: true, chroma_subsampling_y: true, config_obus: obus, ..Default::default() })
|
||||
}
|
||||
|
||||
/// A one-sample MP4 with `codec` (the sample is never decoded) and, when given, a `colr` box.
|
||||
fn mp4(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> Arc<[u8]> {
|
||||
let mut entry = match codec {
|
||||
CodecConfig::Avc(c) => SampleEntry::avc(c, w, h),
|
||||
CodecConfig::Hevc(c) => SampleEntry::hevc(c, w, h),
|
||||
other => SampleEntry::video(filmcraft_isobmff::FourCc(*b"av01"), other, w, h),
|
||||
};
|
||||
if let Some(v) = entry.video.as_mut() {
|
||||
v.color = colr;
|
||||
}
|
||||
let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).expect("writer");
|
||||
let t = mux.add_track(TrackConfig::new(entry, 30)).expect("track");
|
||||
mux.write_sample(t, WriteSample { data: &[0, 0, 0, 1, 0x65], duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
|
||||
mux.finish().expect("finish").into_inner().into()
|
||||
}
|
||||
|
||||
fn mp4_color(codec: CodecConfig, w: u16, h: u16, colr: Option<filmcraft_isobmff::ColorInfo>) -> ColorInfo {
|
||||
let src = crate::Mp4Source::open("clip.mp4", mp4(codec, w, h, colr)).expect("open");
|
||||
src.info().video.as_ref().expect("video").color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hevc_pq_without_colr_is_rec2100_pq() {
|
||||
let c = mp4_color(hevc(hevc_sps(1920, 1080, Some(PQ_2020))), 1920, 1080, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited });
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert!(ColorSpace::from_info(&c).is_hdr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_range_h264_without_colr_is_full_range() {
|
||||
// video_full_range_flag with no colour description: full range, the rest by size
|
||||
let c = mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, None);
|
||||
assert_eq!(c, ColorInfo { range: Range::Full, ..ColorInfo::REC709 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bt601_sd_h264_without_colr_has_bt601_primaries() {
|
||||
let c = mp4_color(avc(h264_sps(45, 30, Some(SMPTE_170M))), 720, 480, None);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt601, transfer: Transfer::Bt709, primaries: Primaries::Bt601_525, range: Range::Limited });
|
||||
// PAL (BT.470 BG primaries, BT.601 matrix)
|
||||
let pal = Signal { codes: Some((5, 6, 5)), full_range: false };
|
||||
let c = mp4_color(avc(h264_sps(45, 36, Some(pal))), 720, 576, None);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt601_625, Matrix::Bt601));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_or_missing_vui_keeps_the_size_defaults() {
|
||||
let unspecified = Signal { codes: Some((2, 2, 2)), full_range: false };
|
||||
for vui in [None, Some(unspecified)] {
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, vui)), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(hevc_sps(1920, 1080, vui)), 1920, 1080, None), ColorInfo::REC709);
|
||||
// SD: the BT.601 matrix, as before
|
||||
assert_eq!(mp4_color(avc(h264_sps(45, 30, vui)), 720, 480, None), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colr_wins_over_the_bitstream() {
|
||||
use filmcraft_isobmff::ColorInfo::{Nclc, Nclx};
|
||||
let sps = || hevc(hevc_sps(1920, 1080, Some(PQ_2020)));
|
||||
// a BT.709 nclx box beats a PQ VUI, and is what the decoder's frames are tagged with
|
||||
let colr = Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(colr.clone())), ColorInfo::REC709);
|
||||
// HLG in colr, PQ in the VUI: HLG
|
||||
let hlg = Nclx { primaries: 9, transfer: 18, matrix: 9, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(hlg)).transfer, Transfer::Hlg);
|
||||
// limited range in colr beats a full-range VUI
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(colr)).range, Range::Limited);
|
||||
// what colr leaves unspecified comes from the VUI
|
||||
let partial = Nclx { primaries: 2, transfer: 16, matrix: 2, full_range: false };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(partial)), mp4_color(sps(), 1920, 1080, None));
|
||||
// QuickTime nclc has no range flag: the VUI's range stands
|
||||
let nclc = Nclc { primaries: 1, transfer: 1, matrix: 1 };
|
||||
assert_eq!(mp4_color(avc(h264_sps(80, 45, Some(FULL_ONLY))), 1280, 720, Some(nclc)).range, Range::Full);
|
||||
// an ICC profile has no code points: the bitstream decides
|
||||
let icc = filmcraft_isobmff::ColorInfo::Icc { kind: filmcraft_isobmff::FourCc(*b"prof"), profile: vec![0; 8] };
|
||||
assert_eq!(mp4_color(sps(), 1920, 1080, Some(icc)).transfer, Transfer::Pq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn av1_sequence_header_colour() {
|
||||
let obu = av1_sequence_header(1920, 1080, Some((9, 16, 9)), false);
|
||||
assert_eq!(from_codec_config(&av1(obu.clone())), Some(ColorCodes { primaries: 9, transfer: 16, matrix: 9, full_range: Some(false) }));
|
||||
let c = mp4_color(av1(obu), 1920, 1080, None);
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
// no colour description: unspecified code points, the range flag still counts
|
||||
let obu = av1_sequence_header(640, 360, None, true);
|
||||
assert_eq!(from_codec_config(&av1(obu)), Some(ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: Some(true) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_without_colour_uses_the_bitstream() {
|
||||
let CodecConfig::Hevc(hvcc) = hevc(hevc_sps(1920, 1080, Some(PQ_2020))) else { panic!("an hvcC") };
|
||||
let mut spec = TrackSpec::new(TrackKind::Video, "V_MPEGH/ISO/HEVC");
|
||||
spec.codec_private = hvcc.to_bytes();
|
||||
spec.video_size = Some((1920, 1080));
|
||||
spec.default_duration_ns = Some(33_333_333);
|
||||
let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).expect("writer");
|
||||
w.write_frame(0, 0, true, &[0, 0, 0, 1, 0x26], None).expect("frame");
|
||||
let b: Arc<[u8]> = w.finish().expect("finish").into_inner().into();
|
||||
let src = crate::MkvSource::open("clip.mkv", b).expect("open");
|
||||
let c = src.info().video.as_ref().expect("video").color;
|
||||
assert_eq!(ColorSpace::from_info(&c), ColorSpace::Rec2100Pq);
|
||||
assert_eq!((c.primaries, c.matrix), (Primaries::Bt2020, Matrix::Bt2020Ncl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_takes_each_field_from_the_first_source_that_knows_it() {
|
||||
let colr = ColorCodes { primaries: 1, transfer: 2, matrix: 0, full_range: None };
|
||||
let vui = ColorCodes { primaries: 9, transfer: 18, matrix: 9, full_range: Some(true) };
|
||||
// matrix 0 (identity) is not one we know: the VUI's BT.2020 is used
|
||||
let c = resolve(1920, 1080, &[colr, vui]);
|
||||
assert_eq!(c, ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Hlg, primaries: Primaries::Bt709, range: Range::Full });
|
||||
assert_eq!(resolve(720, 480, &[]), ColorInfo { matrix: Matrix::Bt601, ..ColorInfo::REC709 });
|
||||
// code points that don't fit a byte are unspecified, not truncated (0x109 is not 9)
|
||||
assert_eq!(ColorCodes::from_wide(0x109, 0x110, 0x109, None), ColorCodes { primaries: 2, transfer: 2, matrix: 2, full_range: None });
|
||||
}
|
||||
|
||||
/// Every truncation and many bit flips of each configuration: never a panic, and the result is
|
||||
/// either some colour or none (the size defaults).
|
||||
#[test]
|
||||
fn hostile_parameter_sets_never_panic() {
|
||||
let configs: Vec<(Vec<u8>, fn(Vec<u8>) -> CodecConfig)> = vec![
|
||||
(h264_sps(45, 30, Some(SMPTE_170M)), avc),
|
||||
(hevc_sps(1920, 1080, Some(PQ_2020)), hevc),
|
||||
(av1_sequence_header(1920, 1080, Some((9, 16, 9)), false), av1),
|
||||
];
|
||||
for (good, make) in configs {
|
||||
assert!(from_codec_config(&make(good.clone())).is_some());
|
||||
let mut variants: Vec<Vec<u8>> = (0..good.len()).map(|n| good[..n].to_vec()).collect();
|
||||
for bit in 0..good.len() * 8 {
|
||||
let mut b = good.clone();
|
||||
b[bit / 8] ^= 0x80 >> (bit % 8);
|
||||
variants.push(b);
|
||||
}
|
||||
variants.push(vec![0xff; 64]);
|
||||
variants.push(Vec::new());
|
||||
for v in variants {
|
||||
let r = std::panic::catch_unwind(|| from_codec_config(&make(v.clone())));
|
||||
assert!(r.is_ok(), "panicked on {v:02x?}");
|
||||
}
|
||||
}
|
||||
// configurations with no parameter sets at all
|
||||
assert_eq!(from_codec_config(&CodecConfig::Avc(AvcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Hevc(HevcConfig::default())), None);
|
||||
assert_eq!(from_codec_config(&CodecConfig::Av1(Av1Config::default())), None);
|
||||
// a garbage SPS imports with the size defaults
|
||||
assert_eq!(mp4_color(avc(vec![0x67, 0xff, 0xff]), 1280, 720, None), ColorInfo::REC709);
|
||||
assert_eq!(mp4_color(hevc(vec![33 << 1, 1, 0xff]), 1920, 1080, None), ColorInfo::REC709);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ fn mjpeg_mov_with_pcm() {
|
||||
fn aac_mp4_decodes() {
|
||||
let Some(b) = fixture("tone_aac.mp4", &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=3", "-c:a", "aac", "-b:a", "128k"]) else { return };
|
||||
let src = crate::open_bytes("tone_aac.mp4", b).unwrap();
|
||||
assert!(src.info().audio.as_ref().unwrap().codec.contains("AAC"));
|
||||
assert!(src.info().audio().unwrap().codec.contains("AAC"));
|
||||
// read in the middle (random access) and sequentially
|
||||
let a = src.audio(48_000, 4800, 48_000).unwrap();
|
||||
let p = a.peaks()[0];
|
||||
@@ -510,7 +510,7 @@ fn check_opus(name: &str, channels: usize, expr: &str, extra: &[&str], container
|
||||
let src = crate::open_bytes(name, b).expect("open");
|
||||
let info = src.info().clone();
|
||||
assert_eq!(info.container, container);
|
||||
let a = info.audio.as_ref().expect("audio");
|
||||
let a = info.audio().expect("audio");
|
||||
assert_eq!((a.codec.as_str(), a.sample_rate, a.channels as usize), ("Opus", 48_000, channels));
|
||||
let d = info.duration.seconds();
|
||||
assert!((d - 3.0).abs() < 0.03, "{name}: duration {d}");
|
||||
@@ -615,3 +615,32 @@ fn apv_mp4_and_raw_bitstream_decode() {
|
||||
let decoded_mp4 = mp4_src.video_frame(FrameRequest::full(Tick::ZERO)).expect("decode mp4 frame");
|
||||
assert_eq!(decoded_mp4.to_rgba8(), decoded_raw.to_rgba8());
|
||||
}
|
||||
|
||||
/// ffmpeg writes MP4 without a `colr` box unless asked: the colour comes from the x265 / x264
|
||||
/// SPS VUI, and matches the same stream remuxed with `colr`.
|
||||
#[test]
|
||||
fn mp4_colour_comes_from_the_sps_without_colr() {
|
||||
use filmcraft_color::{ColorSpace, Primaries, Range};
|
||||
let src = |name: &str, args: &[&str]| fixture(name, args).map(|b| crate::open_bytes(name, b).expect("open"));
|
||||
let color = |s: &filmcraft_media::SharedSource| s.info().video.as_ref().expect("video").color;
|
||||
let lavfi = |size: &'static str| ["-f", "lavfi", "-i", size, "-t", "0.2"];
|
||||
let x265 = "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:log-level=error";
|
||||
let mut pq = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
pq.extend(["-c:v", "libx265", "-tag:v", "hvc1", "-pix_fmt", "yuv420p10le", "-x265-params", x265]);
|
||||
let Some(plain) = src("hevc_pq_no_colr.mp4", &pq) else { return };
|
||||
assert_eq!(ColorSpace::from_info(&color(&plain)), ColorSpace::Rec2100Pq);
|
||||
let colr_path = fixture_path("hevc_pq_no_colr.mp4", &pq).expect("fixture");
|
||||
if let Some(with_colr) = src("hevc_pq_colr.mp4", &["-i", colr_path.to_str().expect("path"), "-c", "copy", "-movflags", "+write_colr"]) {
|
||||
assert_eq!(color(&with_colr), color(&plain));
|
||||
}
|
||||
let mut full = lavfi("testsrc2=s=640x360:r=25").to_vec();
|
||||
full.extend(["-c:v", "libx264", "-pix_fmt", "yuvj420p", "-color_range", "pc"]);
|
||||
if let Some(s) = src("h264_full_no_colr.mp4", &full) {
|
||||
assert_eq!(color(&s).range, Range::Full);
|
||||
}
|
||||
let mut sd = lavfi("testsrc2=s=720x480:r=30000/1001").to_vec();
|
||||
sd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", "colorprim=smpte170m:transfer=smpte170m:colormatrix=smpte170m"]);
|
||||
if let Some(s) = src("h264_sd601_no_colr.mp4", &sd) {
|
||||
assert_eq!(color(&s).primaries, Primaries::Bt601_525);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,19 @@ pub trait VideoDecoder: Send {
|
||||
/// shortcuts that only change pictures nothing references, which come out flagged
|
||||
/// [`DecodedFrame::draft`]. Decoders without such a mode ignore it.
|
||||
fn set_draft(&mut self, _on: bool) {}
|
||||
/// Made with fewer worker threads than a decoder for playback gets, because it was made for
|
||||
/// background work ([`filmcraft_media::cancel::with_background`]). A request for a monitor
|
||||
/// replaces such a decoder.
|
||||
fn thread_limited(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker threads of the H.264 decoders made for background work (thumbnails): a frame-threaded
|
||||
/// 4K decoder holds `threads + 2` pictures in flight (~0.5 GB with 16 threads), and the frame
|
||||
/// workers already decode different files in parallel.
|
||||
const BACKGROUND_H264_THREADS: usize = 2;
|
||||
|
||||
/// NAL unit headers of a length-prefixed (avcC / hvcC) sample: the first two bytes of each unit.
|
||||
fn nal_headers(sample: &[u8], length_size: usize) -> impl Iterator<Item = (u8, u8)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
@@ -200,19 +211,36 @@ pub struct H264Decoder {
|
||||
dec: filmcraft_h264::Decoder,
|
||||
length_size: usize,
|
||||
draft: bool,
|
||||
/// Worker threads (a background decoder gets fewer).
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
impl H264Decoder {
|
||||
/// The worker threads for a decoder made now.
|
||||
fn threads_now() -> usize {
|
||||
if filmcraft_media::cancel::background() { BACKGROUND_H264_THREADS.min(h264_threads()) } else { h264_threads() }
|
||||
}
|
||||
|
||||
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()))?;
|
||||
}
|
||||
Ok(dec)
|
||||
}
|
||||
|
||||
pub fn new(avcc: Vec<u8>) -> Result<Self> {
|
||||
recycle_h264_planes();
|
||||
let dec = filmcraft_h264::Decoder::from_avcc(&avcc).map_err(|e| CodecError::Decode(e.to_string()))?;
|
||||
let threads = Self::threads_now();
|
||||
let dec = Self::make_inner(&avcc, threads)?;
|
||||
let length_size = avcc_length_size(&avcc);
|
||||
Ok(Self { avcc, dec, length_size, draft: false })
|
||||
Ok(Self { avcc, dec, length_size, draft: false, threads })
|
||||
}
|
||||
/// A decoder for Annex B byte-stream samples (start codes, in-band parameter sets: MXF, TS).
|
||||
pub fn annexb() -> Self {
|
||||
recycle_h264_planes();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::new(), length_size: 0, draft: false }
|
||||
let threads = Self::threads_now();
|
||||
Self { avcc: Vec::new(), dec: filmcraft_h264::Decoder::with_threads(threads), length_size: 0, draft: false, threads }
|
||||
}
|
||||
fn convert(p: filmcraft_h264::Picture) -> DecodedFrame {
|
||||
use std::sync::Arc;
|
||||
@@ -245,9 +273,7 @@ impl VideoDecoder for H264Decoder {
|
||||
self.dec.flush().into_iter().map(Self::convert).collect()
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
if self.avcc.is_empty() {
|
||||
self.dec = filmcraft_h264::Decoder::new();
|
||||
} else if let Ok(d) = filmcraft_h264::Decoder::from_avcc(&self.avcc) {
|
||||
if let Ok(d) = Self::make_inner(&self.avcc, self.threads) {
|
||||
self.dec = d;
|
||||
}
|
||||
self.dec.set_draft(self.draft);
|
||||
@@ -255,6 +281,9 @@ impl VideoDecoder for H264Decoder {
|
||||
fn name(&self) -> &str {
|
||||
"FilmCraft H.264"
|
||||
}
|
||||
fn thread_limited(&self) -> bool {
|
||||
self.threads < h264_threads()
|
||||
}
|
||||
fn set_draft(&mut self, on: bool) {
|
||||
self.draft = on;
|
||||
self.dec.set_draft(on);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Matroska audio made by ffmpeg, against ffmpeg's decode (#236). ffmpeg stamps blocks to the
|
||||
//! millisecond, so at 44.1 kHz AAC (1024-sample), MP3 (1152) and AC-3 (1536) packets are each
|
||||
//! stamped up to half a millisecond off their exact start. Read back block by block, as playback
|
||||
//! reads it, a tone must stay as smooth as ffmpeg's decode of it: no holes or overlaps between
|
||||
//! packets.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
/// Largest |second difference| of `x` from `from` on: a click shows up as a spike.
|
||||
fn roughness(x: &[f32], from: usize) -> f32 {
|
||||
x.windows(3).skip(from).map(|w| (w[0] - 2.0 * w[1] + w[2]).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matroska_audio_plays_without_holes() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let cases: [(&str, u32, &[&str]); 5] = [
|
||||
("tone_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "160k"]),
|
||||
("tone_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "160k"]),
|
||||
("tone_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "192k"]),
|
||||
("tone_mp2_44k.mkv", 44_100, &["-c:a", "mp2", "-b:a", "192k"]),
|
||||
("tone_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "192k"]),
|
||||
];
|
||||
for (name, rate, codec) in cases {
|
||||
let input = format!("sine=frequency=440:sample_rate={rate}:duration=3");
|
||||
let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-af", "volume=4"].iter().copied().chain(codec.iter().copied()).collect();
|
||||
let Some(path) = fixture(&ff, name, &args) else {
|
||||
eprintln!("SKIPPED ({name}): this ffmpeg can't encode it");
|
||||
continue;
|
||||
};
|
||||
let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap();
|
||||
assert_eq!(src.info().audio().map(|a| a.sample_rate), Some(rate), "{name}");
|
||||
let frames = rate as usize * 29 / 10;
|
||||
let mut ours = Vec::with_capacity(frames);
|
||||
while ours.len() < frames {
|
||||
let n = 512.min(frames - ours.len());
|
||||
ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]);
|
||||
}
|
||||
let theirs = ffmpeg_audio_f32(&ff, &path, &[]);
|
||||
// past the codecs' start-up (priming, MP3's first frame)
|
||||
let skip = rate as usize / 10;
|
||||
let (r_ours, r_theirs) = (roughness(&ours, skip), roughness(&theirs[..theirs.len().min(frames)], skip));
|
||||
let peak = ours.iter().skip(skip).fold(0f32, |m, x| m.max(x.abs()));
|
||||
println!("{name}: peak {peak:.3}, max |second difference| ours {r_ours:.5}, ffmpeg {r_theirs:.5}");
|
||||
assert!(peak > 0.4, "{name}: the tone is missing (peak {peak})");
|
||||
assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})");
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,64 @@ fn open(p: &Path) -> MpegSource {
|
||||
MpegSource::open(p.file_name().unwrap().to_str().unwrap(), bytes(p)).unwrap()
|
||||
}
|
||||
|
||||
/// ffprobe's video frames in display order: pts (90 kHz).
|
||||
/// ffprobe's video frames in display order: PTS (90 kHz). A program-stream picture
|
||||
/// need not carry its own PTS; use the decoder's presentation timestamp for that picture.
|
||||
fn ffprobe_frame_pts(file: &Path) -> Option<Vec<i64>> {
|
||||
let fp = filmcraft_testkit::ffprobe()?;
|
||||
let fp = filmcraft_testkit::ffprobe_or_skip("MPEG video frame presentation timestamps")?;
|
||||
let o = std::process::Command::new(fp)
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts", "-of", "csv=p=0"])
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts,best_effort_timestamp", "-of", "json"])
|
||||
.arg(file)
|
||||
.output()
|
||||
.ok()?;
|
||||
Some(String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.trim().trim_end_matches(',').parse().ok()).collect())
|
||||
.unwrap_or_else(|error| panic!("{}: cannot run ffprobe: {error}", file.display()));
|
||||
assert!(o.status.success(), "{}: ffprobe failed: {}", file.display(), String::from_utf8_lossy(&o.stderr));
|
||||
Some(parse_ffprobe_frame_pts(&o.stdout).unwrap_or_else(|error| panic!("{}: {error}", file.display())))
|
||||
}
|
||||
|
||||
fn parse_ffprobe_frame_pts(bytes: &[u8]) -> Result<Vec<i64>, String> {
|
||||
let data: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| format!("invalid ffprobe JSON: {error}"))?;
|
||||
let frames = data.get("frames").and_then(serde_json::Value::as_array).ok_or("ffprobe output is missing its frames array")?;
|
||||
if frames.is_empty() {
|
||||
return Err("ffprobe returned no video frames".into());
|
||||
}
|
||||
frames
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, frame)| {
|
||||
let field = if frame.get("pts").is_some() { "pts" } else { "best_effort_timestamp" };
|
||||
frame.get(field).and_then(serde_json::Value::as_i64).ok_or_else(|| format!("ffprobe video frame {index} is missing an integer {field}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffprobe_pts_preserve_missing_raw_timestamps_and_side_data() {
|
||||
let output = br#"{"frames":[
|
||||
{"pts":131400,"best_effort_timestamp":131401,"side_data_list":[{},{}]},
|
||||
{"best_effort_timestamp":135000,"side_data_list":[{}]},
|
||||
{"pts":138600,"best_effort_timestamp":138600}
|
||||
]}"#;
|
||||
assert_eq!(parse_ffprobe_frame_pts(output).unwrap(), [131400, 135000, 138600], "keep every display frame, with raw PTS preferred when present");
|
||||
assert_eq!(parse_ffprobe_frame_pts(br#"{"frames":[{"pts":-3600},{"pts":0},{"pts":9223372036854775807}]}"#).unwrap(), [-3600, 0, i64::MAX]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ffprobe_pts_fail_instead_of_dropping_frames() {
|
||||
for output in [
|
||||
"not JSON",
|
||||
"{}",
|
||||
r#"{"frames":{}}"#,
|
||||
r#"{"frames":[]}"#,
|
||||
r#"{"frames":[{}]}"#,
|
||||
r#"{"frames":[null]}"#,
|
||||
r#"{"frames":[{"pts":"N/A","best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":null,"best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":1.5}]}"#,
|
||||
r#"{"frames":[{"pts":9223372036854775808}]}"#,
|
||||
r#"{"frames":[{"best_effort_timestamp":"135000"}]}"#,
|
||||
r#"{"frames":[{"pts":131400},{"best_effort_timestamp":null},{"pts":138600}]}"#,
|
||||
] {
|
||||
assert!(parse_ffprobe_frame_pts(output.as_bytes()).is_err(), "malformed oracle output must fail: {output}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode every frame in order and `seeks` random frames; compare with ffmpeg within `tol`.
|
||||
@@ -245,7 +294,7 @@ fn check_video(ff: &Path, file: &Path, tol: u16, seeks: usize) -> (u16, f64) {
|
||||
/// Our decoded audio from the first audio frame against ffmpeg's decode. Returns the max error.
|
||||
fn check_audio(ff: &Path, file: &Path, tol: f32) -> f32 {
|
||||
let src = open(file);
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
let (start, len) = src.audio_extent().unwrap();
|
||||
let want = ffmpeg_audio_f32(ff, file, &[]);
|
||||
let ch = a.channels as usize;
|
||||
@@ -314,7 +363,7 @@ fn transport_stream_mpeg2_and_mp2() {
|
||||
let (worst, psnr) = check_video(&ff, &f, 4, 12);
|
||||
assert!(psnr >= 58.0, "PSNR {psnr}");
|
||||
println!("ts_mpeg2_mp2: video max diff {worst}, PSNR {psnr:.2} dB");
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.codec.as_str(), a.sample_rate, a.channels), ("MPEG Audio", 48_000, 2));
|
||||
let e = check_audio(&ff, &f, 2e-4);
|
||||
println!("ts_mpeg2_mp2: MP2 audio max error {e:.2e}");
|
||||
@@ -337,7 +386,7 @@ fn bdav_h264_with_lpcm_sample_exact() {
|
||||
let f = make(&ff, "m2ts_h264_lpcm.m2ts");
|
||||
let src = open(&f);
|
||||
assert_eq!(src.info().container, "MPEG-2 TS (BDAV/AVCHD)");
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.codec.as_str(), a.channels, a.bits_per_sample), ("LPCM (Blu-ray)", 2, Some(24)));
|
||||
check_video(&ff, &f, 0, 10);
|
||||
assert_eq!(check_audio(&ff, &f, 0.0), 0.0);
|
||||
@@ -349,7 +398,7 @@ fn avchd_ac3_audio() {
|
||||
let f = make(&ff, "m2ts_h264_ac3.mts");
|
||||
let src = open(&f);
|
||||
check_video(&ff, &f, 0, 6);
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
if filmcraft_codecs::audio::AC3_DECODER {
|
||||
assert_eq!((a.codec.as_str(), a.channels), ("AC-3", 2));
|
||||
// AC-3 zero-bit mantissas carry decoder-specific dither (A/52 §7.3.4)
|
||||
@@ -370,7 +419,7 @@ fn transport_stream_hevc_and_latm() {
|
||||
let f = make(&ff, "ts_hevc_latm.ts");
|
||||
let src = open(&f);
|
||||
assert_eq!(src.info().video.as_ref().unwrap().codec, "HEVC");
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().codec, "AAC (LATM)");
|
||||
assert_eq!(src.info().audio().unwrap().codec, "AAC (LATM)");
|
||||
check_video(&ff, &f, 0, 8);
|
||||
let e = check_audio(&ff, &f, 1e-4);
|
||||
println!("ts_hevc_latm: AAC (LATM) max error {e:.2e}");
|
||||
@@ -389,17 +438,17 @@ fn program_streams() {
|
||||
// DVD LPCM: sample-exact
|
||||
let f = make(&ff, "ps_mpeg2_lpcm.vob");
|
||||
let src = open(&f);
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().codec, "LPCM (DVD)");
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().bits_per_sample, Some(24));
|
||||
assert_eq!(src.info().audio().unwrap().codec, "LPCM (DVD)");
|
||||
assert_eq!(src.info().audio().unwrap().bits_per_sample, Some(24));
|
||||
assert_eq!(check_audio(&ff, &f, 0.0), 0.0);
|
||||
check_video(&ff, &f, 4, 4);
|
||||
let f = make(&ff, "ps_mpeg2_lpcm16.vob");
|
||||
assert_eq!(open(&f).info().audio.as_ref().unwrap().bits_per_sample, Some(16));
|
||||
assert_eq!(open(&f).info().audio().unwrap().bits_per_sample, Some(16));
|
||||
assert_eq!(check_audio(&ff, &f, 0.0), 0.0);
|
||||
// AC-3 in private stream 1
|
||||
let f = make(&ff, "ps_mpeg2_ac3.vob");
|
||||
let src = open(&f);
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().codec, "AC-3");
|
||||
assert_eq!(src.info().audio().unwrap().codec, "AC-3");
|
||||
let e = check_audio(&ff, &f, 5e-3);
|
||||
println!("ps_mpeg2_ac3: AC-3 max error {e:.2e}");
|
||||
// MPEG-1 system stream
|
||||
@@ -457,7 +506,7 @@ fn standalone_mp2_file() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let f = make(&ff, "audio.mp2");
|
||||
let src = filmcraft_codecs::open_bytes("audio.mp2", bytes(&f)).unwrap();
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((a.sample_rate, a.channels), (48_000, 2));
|
||||
let want = ffmpeg_audio_f32(&ff, &f, &[]);
|
||||
let n = want.len() / 2;
|
||||
@@ -483,7 +532,7 @@ fn truncated_and_corrupt_files_never_panic() {
|
||||
let _ = s.video_frame(FrameRequest::full(v.frame_rate.tick_of(i)));
|
||||
}
|
||||
}
|
||||
if let Some(a) = &info.audio {
|
||||
if let Some(a) = info.audio() {
|
||||
let _ = s.audio(rng.below(48_000) as i64, 2048, a.sample_rate);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +179,7 @@ fn check_video(ff: &Path, file: &Path, pix_fmt: &str, chroma: (usize, usize), bp
|
||||
/// Our audio, as interleaved f32, equals ffmpeg's decode exactly.
|
||||
fn check_audio_exact(ff: &Path, file: &Path, extra: &[&str]) {
|
||||
let src = open(file);
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
let ch = a.channels as usize;
|
||||
let want = ffmpeg_audio_f32(ff, file, extra);
|
||||
let n = want.len() / ch;
|
||||
@@ -236,7 +236,7 @@ fn h264_2997_drop_frame_timecode_and_24_bit_audio() {
|
||||
assert!(tc.drop_frame);
|
||||
assert_eq!(tc.format(), "01:00:00;00");
|
||||
assert_eq!(info.start_timecode, Some(107_892));
|
||||
assert_eq!(info.audio.as_ref().unwrap().bits_per_sample, Some(24));
|
||||
assert_eq!(info.audio().unwrap().bits_per_sample, Some(24));
|
||||
check_video(&ff, &f, "yuv420p", (2, 2), 1, 0, 10);
|
||||
check_audio_exact(&ff, &f, &[]);
|
||||
}
|
||||
@@ -299,14 +299,14 @@ fn op_atom_video_and_audio_files() {
|
||||
let v = make(&ff, "mxf_atom_dnxhr.mxf");
|
||||
let src = open(&v);
|
||||
assert_eq!(src.info().container, "MXF OP-Atom");
|
||||
assert!(src.info().audio.is_none());
|
||||
assert!(!src.info().has_audio());
|
||||
assert_eq!(src.file().tracks[0].wrapping, filmcraft_mxf::Wrapping::Clip);
|
||||
check_video(&ff, &v, "yuv422p", (2, 1), 1, 2, 10);
|
||||
let a = make(&ff, "mxf_atom_pcm.mxf");
|
||||
let src = open(&a);
|
||||
assert_eq!(src.info().kind, MediaKind::AudioOnly);
|
||||
assert_eq!(src.info().container, "MXF OP-Atom");
|
||||
let ai = src.info().audio.clone().unwrap();
|
||||
let ai = src.info().audio().cloned().unwrap();
|
||||
assert_eq!((ai.sample_rate, ai.channels, ai.bits_per_sample), (48_000, 1, Some(24)));
|
||||
assert_eq!(src.info().duration, filmcraft_time::Tick::from_units(48_000, 48_000));
|
||||
check_audio_exact(&ff, &a, &[]);
|
||||
@@ -317,7 +317,7 @@ fn d10_aes3_elements_sample_exact() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let f = make(&ff, "mxf_d10.mxf");
|
||||
let src = open(&f);
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
assert_eq!(a.codec, "AES3 PCM");
|
||||
assert!(a.channels >= 4, "{}", a.channels);
|
||||
let v = src.info().video.as_ref().unwrap();
|
||||
@@ -347,7 +347,7 @@ fn truncated_and_corrupt_files_never_panic() {
|
||||
let _ = s.video_frame(FrameRequest::full(t));
|
||||
}
|
||||
}
|
||||
if let Some(a) = &info.audio {
|
||||
if let Some(a) = info.audio() {
|
||||
let _ = s.audio(rng.below(96_000) as i64, 4000, a.sample_rate);
|
||||
}
|
||||
}
|
||||
@@ -364,7 +364,7 @@ fn truncated_and_corrupt_files_never_panic() {
|
||||
let _ = s.video_frame(FrameRequest::full(s.info().frame_rate().tick_of(i)));
|
||||
}
|
||||
}
|
||||
if s.info().audio.is_some() {
|
||||
if s.info().has_audio() {
|
||||
let _ = s.audio(0, 9600, 48_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fn snr_db(reference: &[f32], ours: &[f32]) -> f64 {
|
||||
/// (our continuous decode interleaved, ffmpeg's decode interleaved, channels, rate)
|
||||
fn decode_both(ff: &Path, file: &Path, decoder: &[&str]) -> (OggSource, Vec<f32>, Vec<f32>, usize, u32) {
|
||||
let src = OggSource::open(file.file_name().unwrap().to_str().unwrap(), bytes(file)).unwrap();
|
||||
let a = src.info().audio.clone().unwrap();
|
||||
let a = src.info().audio().cloned().unwrap();
|
||||
let ch = a.channels as usize;
|
||||
let mut args: Vec<&str> = decoder.to_vec();
|
||||
args.extend_from_slice(&["-i", file.to_str().unwrap(), "-map", "0:a:0", "-f", "f32le", "-"]);
|
||||
@@ -134,7 +134,7 @@ fn opus_celt_music_matches_libopus() {
|
||||
assert_eq!(src.info().container, "Ogg");
|
||||
assert_eq!(src.info().kind, MediaKind::AudioOnly);
|
||||
assert_eq!((rate, ch), (48_000, 2));
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().codec, "Opus");
|
||||
assert_eq!(src.info().audio().unwrap().codec, "Opus");
|
||||
let snr = snr_db(&want, &ours);
|
||||
eprintln!("{}: {} samples, SNR {snr:.1} dB vs libopus", f.display(), want.len() / ch);
|
||||
assert!(snr >= 50.0, "SNR {snr:.1} dB");
|
||||
@@ -173,7 +173,7 @@ fn vorbis_matches_ffmpeg() {
|
||||
let ff = filmcraft_testkit::require_ffmpeg!();
|
||||
let f = make(&ff, "ogg_vorbis.ogg");
|
||||
let (src, ours, want, ch, rate) = decode_both(&ff, &f, &[]);
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().codec, "Vorbis");
|
||||
assert_eq!(src.info().audio().unwrap().codec, "Vorbis");
|
||||
assert_eq!((rate, ch), (44_100, 2));
|
||||
let snr = snr_db(&want, &ours);
|
||||
eprintln!("{}: SNR {snr:.1} dB vs ffmpeg's Vorbis decoder", f.display());
|
||||
|
||||
@@ -16,5 +16,6 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
filmcraft-geom = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Sequence Settings ▸ "Scale motion effects proportionally when changing frame size".
|
||||
//!
|
||||
//! Motion's Position is in sequence pixels and its Scale is relative to the source, so a new frame
|
||||
//! size leaves every clip where it was in pixels: smaller and off-centre in a larger frame, cropped
|
||||
//! in a smaller one. With the option on, the whole picture scales instead, by one factor about the
|
||||
//! frame centre. On an aspect change (16:9 → 9:16) that factor is the one that fits the old frame
|
||||
//! inside the new one (`min(W1/W0, H1/H0)`), so nothing is cropped and the old composition sits
|
||||
//! centred, letterboxed or pillarboxed, rather than filling the new frame.
|
||||
|
||||
use filmcraft_project::{ParamValue, Sequence, TrackKind};
|
||||
|
||||
/// Motion Scale's upper bound (the parameter's range, in %).
|
||||
const MAX_SCALE: f64 = 10_000.0;
|
||||
|
||||
/// The uniform factor `k` and the old and new frame centres for `from` → `to`, or `None` for an
|
||||
/// empty frame on either side.
|
||||
fn fit(from: (u32, u32), to: (u32, u32)) -> Option<(f64, (f64, f64), (f64, f64))> {
|
||||
if from.0 == 0 || from.1 == 0 || to.0 == 0 || to.1 == 0 {
|
||||
return None;
|
||||
}
|
||||
let (w0, h0, w1, h1) = (f64::from(from.0), f64::from(from.1), f64::from(to.0), f64::from(to.1));
|
||||
let k = (w1 / w0).min(h1 / h0);
|
||||
(k.is_finite() && k > 0.0).then_some((k, (w0 / 2.0, h0 / 2.0), (w1 / 2.0, h1 / 2.0)))
|
||||
}
|
||||
|
||||
/// Rescale the Motion of every video clip in `seq` for a frame-size change from `from` to `to`:
|
||||
/// static values and every keyframe. Returns how many clips were rescaled (0 when the size does not
|
||||
/// change).
|
||||
///
|
||||
/// - Position maps through `p' = c1 + k·(p − c0)`. An automatic (NaN) position stays automatic: it
|
||||
/// follows the frame centre on its own.
|
||||
/// - Scale and Scale Width are multiplied by `k`, up to the parameter's 10 000 %. A clip with Scale to
|
||||
/// Frame Size keeps its Scale, because the renderer refits it to the new frame anyway.
|
||||
/// - Anchor Point (source pixels) and Rotation do not depend on the frame size and stay as they are.
|
||||
/// - Only the intrinsic Motion effect changes, as the option's name says: the points of standard
|
||||
/// effects (a Mirror centre, the Transform effect's Position) keep their pixel values.
|
||||
pub fn scale_motion(seq: &mut Sequence, from: (u32, u32), to: (u32, u32)) -> usize {
|
||||
if from == to {
|
||||
return 0;
|
||||
}
|
||||
let Some((k, c0, c1)) = fit(from, to) else { return 0 };
|
||||
let mut n = 0;
|
||||
for track in seq.all_tracks_mut().filter(|t| t.kind == TrackKind::Video) {
|
||||
for item in &mut track.items {
|
||||
let refits = item.scale_to_frame;
|
||||
let Some(motion) = item.effect_mut("motion") else { continue };
|
||||
if let Some(p) = motion.param_mut("position") {
|
||||
p.map_values(|v| match v {
|
||||
ParamValue::Vec2(mut v) => {
|
||||
v.x = c1.0 + k * (v.x - c0.0);
|
||||
v.y = c1.1 + k * (v.y - c0.1);
|
||||
ParamValue::Vec2(v)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
if !refits {
|
||||
for id in ["scale", "scale_width"] {
|
||||
if let Some(p) = motion.param_mut(id) {
|
||||
p.map_values(|v| match v {
|
||||
ParamValue::Float(s) => ParamValue::Float((s * k).clamp(0.0, MAX_SCALE)),
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_project::{ClipId, ItemId, Keyframe, Label, Project, SequenceSettings, TrackItem, find_effect};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
fn seq_with(items: Vec<TrackItem>) -> Sequence {
|
||||
let mut p = Project::new("t");
|
||||
let s = p.new_sequence("s", SequenceSettings::default(), 2, 1, None);
|
||||
let mut seq = p.sequence(s).unwrap().clone();
|
||||
seq.video_tracks[0].items = items;
|
||||
seq
|
||||
}
|
||||
|
||||
fn clip(id: u64, pos: (f64, f64), scale: f64) -> TrackItem {
|
||||
let mut motion = find_effect("motion").unwrap().instance();
|
||||
motion.param_mut("position").unwrap().value = ParamValue::Vec2(filmcraft_geom::Vec2::new(pos.0, pos.1));
|
||||
motion.param_mut("scale").unwrap().value = ParamValue::Float(scale);
|
||||
TrackItem {
|
||||
id: ClipId(id),
|
||||
item: ItemId(1),
|
||||
name: format!("c{id}"),
|
||||
label: Label::Iris,
|
||||
start: Tick(id as i64 * 1_000_000_000_000),
|
||||
duration: Tick(1_000_000_000_000),
|
||||
source_in: Tick::ZERO,
|
||||
speed: 1.0,
|
||||
reverse: false,
|
||||
enabled: true,
|
||||
link: None,
|
||||
group: None,
|
||||
effects: vec![motion],
|
||||
markers: vec![],
|
||||
gain_db: 0.0,
|
||||
frame_hold: None,
|
||||
scale_to_frame: false,
|
||||
essential: None,
|
||||
multicam: None,
|
||||
time_interpolation: Default::default(),
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pos(seq: &Sequence, i: usize) -> (f64, f64) {
|
||||
match seq.video_tracks[0].items[i].effect("motion").unwrap().param("position").unwrap().value {
|
||||
ParamValue::Vec2(v) => (v.x, v.y),
|
||||
_ => panic!("position is a point"),
|
||||
}
|
||||
}
|
||||
|
||||
fn scale(seq: &Sequence, i: usize) -> f64 {
|
||||
match seq.video_tracks[0].items[i].effect("motion").unwrap().param("scale").unwrap().value {
|
||||
ParamValue::Float(s) => s,
|
||||
_ => panic!("scale is a number"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
(a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_aspect_scales_position_and_scale_together() {
|
||||
let mut seq = seq_with(vec![clip(1, (960.0, 540.0), 100.0), clip(2, (300.0, 90.0), 50.0)]);
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1280, 720)), 2);
|
||||
assert!(close(pos(&seq, 0), (640.0, 360.0)), "{:?}", pos(&seq, 0));
|
||||
assert!(close(pos(&seq, 1), (200.0, 60.0)), "{:?}", pos(&seq, 1));
|
||||
assert!((scale(&seq, 0) - 100.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
assert!((scale(&seq, 1) - 50.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_change_fits_the_old_frame_inside_the_new_one() {
|
||||
// 1920x1080 -> 1080x1920: k = min(1080/1920, 1920/1080) = 0.5625, centred
|
||||
let mut seq = seq_with(vec![clip(1, (960.0, 540.0), 100.0), clip(2, (0.0, 0.0), 100.0)]);
|
||||
scale_motion(&mut seq, (1920, 1080), (1080, 1920));
|
||||
assert!(close(pos(&seq, 0), (540.0, 960.0)), "{:?}", pos(&seq, 0));
|
||||
// the old top-left corner lands on the new frame's left edge, letterboxed vertically
|
||||
assert!(close(pos(&seq, 1), (0.0, 960.0 - 0.5625 * 540.0)), "{:?}", pos(&seq, 1));
|
||||
assert!((scale(&seq, 0) - 56.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_scale_too() {
|
||||
let mut c = clip(1, (960.0, 540.0), 100.0);
|
||||
let m = c.effect_mut("motion").unwrap();
|
||||
m.param_mut("position").unwrap().keyframes = vec![
|
||||
Keyframe::new(Tick::ZERO, ParamValue::Vec2(filmcraft_geom::Vec2::new(0.0, 0.0))),
|
||||
Keyframe::new(Tick(10), ParamValue::Vec2(filmcraft_geom::Vec2::new(1920.0, 1080.0))),
|
||||
];
|
||||
m.param_mut("scale").unwrap().keyframes = vec![Keyframe::new(Tick::ZERO, ParamValue::Float(100.0)), Keyframe::new(Tick(10), ParamValue::Float(200.0))];
|
||||
let mut seq = seq_with(vec![c]);
|
||||
scale_motion(&mut seq, (1920, 1080), (3840, 2160));
|
||||
let m = seq.video_tracks[0].items[0].effect("motion").unwrap();
|
||||
let kp: Vec<_> = m.param("position").unwrap().keyframes.iter().map(|k| k.value.clone()).collect();
|
||||
assert_eq!(kp, vec![ParamValue::Vec2(filmcraft_geom::Vec2::new(0.0, 0.0)), ParamValue::Vec2(filmcraft_geom::Vec2::new(3840.0, 2160.0))]);
|
||||
let ks: Vec<_> = m.param("scale").unwrap().keyframes.iter().map(|k| k.value.clone()).collect();
|
||||
assert_eq!(ks, vec![ParamValue::Float(200.0), ParamValue::Float(400.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_position_stays_automatic() {
|
||||
let mut seq = seq_with(vec![clip(1, (f64::NAN, f64::NAN), 100.0)]);
|
||||
scale_motion(&mut seq, (1920, 1080), (1280, 720));
|
||||
let (x, y) = pos(&seq, 0);
|
||||
assert!(x.is_nan() && y.is_nan());
|
||||
assert!((scale(&seq, 0) - 100.0 * 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_to_frame_clips_keep_their_scale_but_move() {
|
||||
let mut c = clip(1, (480.0, 270.0), 100.0);
|
||||
c.scale_to_frame = true;
|
||||
let mut seq = seq_with(vec![c]);
|
||||
scale_motion(&mut seq, (1920, 1080), (3840, 2160));
|
||||
assert!(close(pos(&seq, 0), (960.0, 540.0)), "{:?}", pos(&seq, 0));
|
||||
assert_eq!(scale(&seq, 0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_is_capped_at_the_parameter_range() {
|
||||
let mut seq = seq_with(vec![clip(1, (1.0, 1.0), 5000.0)]);
|
||||
scale_motion(&mut seq, (2, 2), (8, 8));
|
||||
assert_eq!(scale(&seq, 0), MAX_SCALE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_size_or_empty_frame_changes_nothing() {
|
||||
let mut seq = seq_with(vec![clip(1, (100.0, 100.0), 100.0)]);
|
||||
let before = seq.clone();
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1920, 1080)), 0);
|
||||
assert_eq!(scale_motion(&mut seq, (0, 1080), (1280, 720)), 0);
|
||||
assert_eq!(scale_motion(&mut seq, (1920, 1080), (1280, 0)), 0);
|
||||
// Debug, not ==: the automatic anchor is NaN, which never equals itself
|
||||
assert_eq!(format!("{:?}", seq.video_tracks[0].items[0].effects), format!("{:?}", before.video_tracks[0].items[0].effects));
|
||||
}
|
||||
}
|
||||
+54
-10
@@ -18,6 +18,7 @@
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub mod captions;
|
||||
pub mod frame_size;
|
||||
pub mod multicam;
|
||||
pub mod through;
|
||||
pub mod transcript;
|
||||
@@ -534,15 +535,30 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt
|
||||
match edge {
|
||||
Edge::In => {
|
||||
// extending left (d<0) needs media before source_in; shortening needs min duration
|
||||
let max_ext = Tick((head(ctx, it).0 as f64 / speed).floor() as i64);
|
||||
// a reversed clip plays source_out down to source_in, so its In edge consumes media after source_out
|
||||
let handle = match (it.reverse, media) {
|
||||
(true, Some(m)) => (m - it.source_out()).max(Tick::ZERO),
|
||||
(true, None) => Tick::MAX,
|
||||
(false, _) => head(ctx, it),
|
||||
};
|
||||
let max_ext = if handle == Tick::MAX { Tick::MAX } else { Tick((handle.0 as f64 / speed).floor() as i64) };
|
||||
let lo = if mode == TrimMode::Regular { (-(it.start - prev_end)).max(-max_ext) } else { -max_ext };
|
||||
let hi = it.duration - ctx.min_duration;
|
||||
d = d.clamp(if it.frame_hold.is_some() { Tick::MIN } else { lo }, hi);
|
||||
// a frame hold needs no source head, but a Regular trim still stops at the previous clip
|
||||
let lo = match (it.frame_hold.is_some(), mode) {
|
||||
(true, TrimMode::Regular) => -(it.start - prev_end),
|
||||
(true, _) => Tick::MIN,
|
||||
(false, _) => lo,
|
||||
};
|
||||
d = d.clamp(lo, hi);
|
||||
}
|
||||
Edge::Out => {
|
||||
let lo = -(it.duration - ctx.min_duration);
|
||||
let mut hi = if mode == TrimMode::Regular { next_start - it.end() } else { Tick::MAX };
|
||||
if let Some(m) = media {
|
||||
if it.reverse && it.frame_hold.is_none() {
|
||||
// a reversed clip's Out edge consumes media before source_in
|
||||
hi = hi.min(Tick((head(ctx, it).0 as f64 / speed).floor() as i64));
|
||||
} else if let Some(m) = media {
|
||||
let remain = Tick(((m - it.source_out()).0 as f64 / speed).floor() as i64);
|
||||
hi = hi.min(remain);
|
||||
}
|
||||
@@ -750,10 +766,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
let mut d = delta;
|
||||
// left out-point limits
|
||||
d = d.max(-(l.duration - ctx.min_duration)).min(r.duration - ctx.min_duration);
|
||||
if let Some(m) = media_len(ctx, l) {
|
||||
// a reversed clip consumes media from the opposite end, so its media limit is the other one
|
||||
if l.reverse {
|
||||
d = d.min(Tick((head(ctx, l).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
} else if let Some(m) = media_len(ctx, l) {
|
||||
d = d.min(Tick(((m - l.source_out()).0 as f64 / l.speed.abs()).floor() as i64));
|
||||
}
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
if r.reverse {
|
||||
if let Some(m) = media_len(ctx, r) {
|
||||
d = d.max(-Tick(((m - r.source_out()).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.max(-Tick((head(ctx, r).0 as f64 / r.speed.abs()).floor() as i64));
|
||||
}
|
||||
if d == Tick::ZERO {
|
||||
return Ok(d);
|
||||
}
|
||||
@@ -761,15 +786,19 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &
|
||||
let before = seq.clone();
|
||||
{
|
||||
let (_, l) = seq.find_item_mut(left).ok_or(EditError::NoItem(left))?;
|
||||
if l.reverse {
|
||||
l.source_in -= src_of(d, ls);
|
||||
}
|
||||
l.duration += d;
|
||||
}
|
||||
{
|
||||
let (_, r) = seq.find_item_mut(right).ok_or(EditError::NoItem(right))?;
|
||||
r.start += d;
|
||||
r.duration -= d;
|
||||
r.source_in += src_of(d, rs);
|
||||
if !r.reverse {
|
||||
r.source_in += src_of(d, rs);
|
||||
}
|
||||
}
|
||||
let _ = ls;
|
||||
transitions_follow_cuts(&before, seq);
|
||||
Ok(d)
|
||||
}
|
||||
@@ -798,7 +827,10 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
let mut d = delta;
|
||||
if let Some(p) = &prev {
|
||||
d = d.max(-(p.duration - ctx.min_duration));
|
||||
if let Some(m) = media_len(ctx, p) {
|
||||
if p.reverse {
|
||||
// a reversed neighbour grows by pulling earlier media in front of its source In
|
||||
d = d.min(Tick((head(ctx, p).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
} else if let Some(m) = media_len(ctx, p) {
|
||||
d = d.min(Tick(((m - p.source_out()).0 as f64 / p.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
@@ -806,7 +838,14 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
}
|
||||
if let Some(n) = &next {
|
||||
d = d.min(n.duration - ctx.min_duration);
|
||||
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
if n.reverse {
|
||||
// a reversed neighbour grows leftwards by pulling later media behind its source Out
|
||||
if let Some(m) = media_len(ctx, n) {
|
||||
d = d.max(-Tick(((m - n.source_out()).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.max(-Tick((head(ctx, n).0 as f64 / n.speed.abs()).floor() as i64));
|
||||
}
|
||||
} else {
|
||||
d = d.min(neighbours(tr, clip).1 - it.end());
|
||||
}
|
||||
@@ -818,12 +857,17 @@ pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -
|
||||
if let Some(p) = prev {
|
||||
let pi = t.item_mut(p.id).ok_or(EditError::NoItem(p.id))?;
|
||||
pi.duration += d;
|
||||
if p.reverse {
|
||||
pi.source_in -= src_of(d, p.speed);
|
||||
}
|
||||
}
|
||||
if let Some(n) = next {
|
||||
let ni = t.item_mut(n.id).ok_or(EditError::NoItem(n.id))?;
|
||||
ni.start += d;
|
||||
ni.duration -= d;
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
if !n.reverse {
|
||||
ni.source_in += src_of(d, n.speed);
|
||||
}
|
||||
}
|
||||
let me = t.item_mut(clip).ok_or(EditError::NoItem(clip))?;
|
||||
me.start += d;
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Fx {
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
@@ -233,6 +234,27 @@ fn close_gap_works() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_trim_uses_the_handle_its_playback_direction_consumes() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
// source frames [5, 15) played backwards; the media is 1000 frames long
|
||||
let a = fx.put(v1, 30, 10, 5);
|
||||
fx.seq.find_item_mut(a).unwrap().1.reverse = true;
|
||||
let mut n = fx.next;
|
||||
// out extends into the 5 frames before source_in, not into the 985 after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, f(5));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(15)));
|
||||
// in extends into the media after source_out
|
||||
let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(10));
|
||||
let it = fx.seq.find_item(a).unwrap().1;
|
||||
assert_eq!((it.source_in, it.source_out()), (f(0), f(25)));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_trim_respects_neighbours_and_handles() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -252,6 +274,24 @@ fn regular_trim_respects_neighbours_and_handles() {
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_regular_in_trim_stops_at_previous_clip() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
fx.put(v1, 0, 10, 0);
|
||||
let b = fx.put(v1, 15, 10, 5);
|
||||
fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5));
|
||||
let mut n = fx.next;
|
||||
// 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, -f(5));
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]);
|
||||
// no space left: nothing to do, and no error
|
||||
let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(d, Tick::ZERO);
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ripple_trim_shifts_following() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -358,6 +398,25 @@ fn roll_slip_slide() {
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_keeps_reversed_neighbours_content() {
|
||||
let mut fx = Fx::new();
|
||||
let v1 = fx.v(0);
|
||||
let a = fx.put(v1, 0, 30, 20);
|
||||
let b = fx.put(v1, 30, 30, 50);
|
||||
let c = fx.put(v1, 60, 30, 70);
|
||||
for id in [a, c] {
|
||||
fx.seq.find_item_mut(id).unwrap().1.reverse = true;
|
||||
}
|
||||
let mut n = fx.next;
|
||||
slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]);
|
||||
assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10));
|
||||
assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50));
|
||||
assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70));
|
||||
fx.seq.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_stay_on_their_cuts() {
|
||||
let mut fx = Fx::new();
|
||||
@@ -672,3 +731,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() {
|
||||
assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_reversed_clips_keeps_media_mapping() {
|
||||
let mut fx = Fx::new();
|
||||
let v = fx.v(0);
|
||||
let mut a = fx.item(0, 30, 20);
|
||||
a.reverse = true;
|
||||
let mut b = fx.item(30, 30, 50);
|
||||
b.reverse = true;
|
||||
let (ia, ib) = (a.id, b.id);
|
||||
fx.seq.track_mut(v).unwrap().items.extend([a, b]);
|
||||
let mut n = 0;
|
||||
roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap();
|
||||
let (_, l) = fx.seq.find_item(ia).unwrap();
|
||||
assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10)));
|
||||
let (_, r) = fx.seq.find_item(ib).unwrap();
|
||||
assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50)));
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec<ThroughEdit> {
|
||||
/// piece is listed. The left piece absorbs the right one (keeping its own effects and attributes);
|
||||
/// a transition sitting on the cut is removed. Returns the number of edits joined.
|
||||
pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
||||
join_through_edits_where(seq, |a, b| only.is_empty() || only.contains(&a) || only.contains(&b))
|
||||
}
|
||||
|
||||
/// Join the through edits on unlocked tracks whose (left, right) pieces `wanted` accepts.
|
||||
pub fn join_through_edits_where(seq: &mut Sequence, wanted: impl Fn(ClipId, ClipId) -> bool) -> usize {
|
||||
let mut n = 0;
|
||||
for tr in seq.all_tracks_mut() {
|
||||
if tr.locked {
|
||||
@@ -67,8 +72,7 @@ pub fn join_through_edits(seq: &mut Sequence, only: &[ClipId]) -> usize {
|
||||
let mut i = 0;
|
||||
while i + 1 < tr.items.len() {
|
||||
let (a, b) = (&tr.items[i], &tr.items[i + 1]);
|
||||
let wanted = only.is_empty() || only.contains(&a.id) || only.contains(&b.id);
|
||||
if !(wanted && is_through_edit(a, b)) {
|
||||
if !(wanted(a.id, b.id) && is_through_edit(a, b)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -180,6 +184,7 @@ mod tests {
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ fn clip(id: u64, start: f64, dur: f64, src_in: f64) -> TrackItem {
|
||||
hold_filters: false,
|
||||
field_options: None,
|
||||
source_channels: Vec::new(),
|
||||
audio_stream: 0,
|
||||
graphic: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result<Vec<Vec<f32
|
||||
let mut done = 0usize;
|
||||
while done < frames {
|
||||
let n = chunk.min(frames - done);
|
||||
let b = src.audio(start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let b = src.audio_stream(need.audio_stream, start + done as i64, n, sample_rate).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
for (c, o) in out.iter_mut().enumerate() {
|
||||
match b.channels.get(c).or(b.channels.first()) {
|
||||
Some(x) => o.extend(x.iter().copied().chain(std::iter::repeat(0.0)).take(n)),
|
||||
@@ -230,8 +230,19 @@ fn ensure_dir(s: &Session, dir: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_linked_streams(s: &Session, seq: ItemId, plan: &AudioPlan) -> Result<()> {
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
let needs = audio_needs(&s.project, seq, &NeedOptions { nests: plan.nests, ..Default::default() });
|
||||
if needs.iter().any(|need| need.audio_stream > 0) {
|
||||
return Err(EngineError::Other("linked AAF media cannot select container audio streams; choose embedded or separate audio".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare the audio essence of an export; returns the essence and the files written.
|
||||
pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str) -> Result<(Vec<AudioEssence>, Vec<String>)> {
|
||||
check_linked_streams(s, seq, plan)?;
|
||||
if plan.mode == AudioMode::Linked && !plan.trim && !plan.render_effects {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
@@ -255,7 +266,7 @@ pub fn prepare_audio(s: &Session, seq: ItemId, plan: &AudioPlan, media_dir: &str
|
||||
let name = audio_clip(&s.project, seq, c).map(|(_, _, ti)| ti.name.clone()).unwrap_or_default();
|
||||
format!("{} {}", safe_name(&name), c.0)
|
||||
}
|
||||
EssenceKey::Media(_) => safe_name(
|
||||
EssenceKey::Media(_) | EssenceKey::MediaStream { .. } => safe_name(
|
||||
need.path
|
||||
.as_deref()
|
||||
.map(|p| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default())
|
||||
@@ -380,6 +391,7 @@ pub fn export_aaf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "need `path`"))?.to_string();
|
||||
let seq = crate::export_tools::sequence_param(s, p, cmd)?;
|
||||
let plan = plan_from(s, p, seq, cmd, false)?;
|
||||
check_linked_streams(s, seq, &plan)?;
|
||||
let (dir, stem) = split(&path);
|
||||
let media_dir = join(&dir, &format!("{stem} Media"));
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -69,7 +69,7 @@ fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
|
||||
assert_eq!(a, b, "{kind:?}");
|
||||
}
|
||||
// video links to the original movies, audio to WAV files extracted next to the AAF
|
||||
assert_eq!(path_of(&s, q.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, q.video_tracks[0].items[0].item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
for (c, oc) in q.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
@@ -138,7 +138,7 @@ fn aaf_separate_aiff_breakout_and_linked() {
|
||||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let a = &q.audio_tracks[0].items[0];
|
||||
assert_eq!(path_of(&s, a.item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, a.item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
assert_eq!(a.source_in, FrameRate::FPS_24.tick_of(12));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
@@ -159,7 +159,7 @@ fn aaf_video_mixdown() {
|
||||
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), 1);
|
||||
let v = &q.video_tracks[0].items[0];
|
||||
assert_eq!(v.duration, FrameRate::FPS_24.tick_of(48));
|
||||
assert_eq!(path_of(&s, v.item), mov);
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, v.item)), std::path::Path::new(mov));
|
||||
let m = s.project.item(v.item).unwrap().as_media().unwrap();
|
||||
assert!(!m.offline);
|
||||
assert_eq!(m.info.video.as_ref().map(|v| (v.width, v.height)), Some((64, 36)));
|
||||
@@ -284,7 +284,7 @@ fn aaf_keeps_a_nested_sequence_as_a_sequence() {
|
||||
let want: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
assert_eq!(got, want, "{kind:?}");
|
||||
}
|
||||
assert_eq!(path_of(&s, n.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, n.video_tracks[0].items[0].item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
for (c, oc) in n.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
@@ -311,3 +311,192 @@ fn commands_are_registered_with_menus_and_validate_params() {
|
||||
assert!(s.execute("file.importAaf", json!({"path": dir.join("a.mov").to_string_lossy()})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
// Selected-stream essence uses only PR361 schema13 placement and decoder fields.
|
||||
mod selected_streams {
|
||||
use crate::{Services, Session};
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource};
|
||||
use filmcraft_project::{ClipId, ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type Calls = Arc<Mutex<Vec<(usize, i64, usize, u32)>>>;
|
||||
|
||||
struct Tones {
|
||||
info: MediaInfo,
|
||||
calls: Calls,
|
||||
}
|
||||
|
||||
impl Tones {
|
||||
fn new(streams: usize) -> Self {
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(
|
||||
filmcraft_media::Generator::BarsAndTone,
|
||||
32,
|
||||
18,
|
||||
FrameRate::FPS_24,
|
||||
Tick::from_seconds_f64(2.0),
|
||||
);
|
||||
let mut info = source.info().clone();
|
||||
info.kind = MediaKind::AudioOnly;
|
||||
info.video = None;
|
||||
info.audio_streams = vec![info.audio_streams[0].clone(); streams];
|
||||
Self { info, calls: Arc::default() }
|
||||
}
|
||||
|
||||
fn sample(stream: usize, channel: usize, frame: i64, rate: u32) -> f32 {
|
||||
let frequency = (stream * 2 + channel + 1) as f64 * 240.0;
|
||||
let amplitude = 0.05 * (stream + 1) as f64;
|
||||
(amplitude * (std::f64::consts::TAU * frequency * frame as f64 / rate as f64).sin()) as f32
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for Tones {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
Err(MediaError::NoStream("video"))
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.audio_stream(0, start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
if stream >= self.info.audio_streams.len() {
|
||||
return Err(MediaError::NoStream("audio"));
|
||||
}
|
||||
self.calls.lock().unwrap().push((stream, start, frames, sample_rate));
|
||||
Ok(AudioBuffer {
|
||||
sample_rate,
|
||||
channels: (0..2).map(|channel| (0..frames).map(|i| Self::sample(stream, channel, start + i as i64, sample_rate)).collect()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryFiles {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
}
|
||||
impl Services for MemoryFiles {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
self.writes.lock().unwrap().push((path.to_owned(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
fn export_in_memory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn add_source(s: &mut Session, streams: usize) -> (ItemId, Calls) {
|
||||
let source = Arc::new(Tones::new(streams));
|
||||
let calls = source.calls.clone();
|
||||
let mut project = (*s.project).clone();
|
||||
let item = project.add_item(
|
||||
"distinct tones",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::File { path: format!("tone-source-{streams}.wav") },
|
||||
info: source.info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
s.project = Arc::new(project);
|
||||
s.media.insert(item, source);
|
||||
(item, calls)
|
||||
}
|
||||
|
||||
fn fixture() -> (Session, ItemId, ItemId, Calls, Arc<MemoryFiles>) {
|
||||
let files = Arc::new(MemoryFiles::default());
|
||||
let mut session = Session::new(files.clone());
|
||||
let mut project = (*session.project).clone();
|
||||
let sequence =
|
||||
project.new_sequence("streams", SequenceSettings { width: 32, height: 18, frame_rate: FrameRate::FPS_24, ..Default::default() }, 0, 1, None);
|
||||
session.project = Arc::new(project);
|
||||
session.state.active_sequence = Some(sequence);
|
||||
session.state.open_sequences = vec![sequence];
|
||||
let (item, calls) = add_source(&mut session, 3);
|
||||
session.state.project_selection = vec![item];
|
||||
session.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
(session, item, sequence, calls, files)
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, item: ItemId, seconds: f64) -> Vec<ClipId> {
|
||||
let result = s.execute("timeline.place", json!({"item": item.0, "track": "A1", "seconds": seconds})).unwrap();
|
||||
result["clips"].as_array().unwrap().iter().map(|id| ClipId(id.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aaf_essence_distinguishes_streams_and_linked_refusal_precedes_writes() {
|
||||
use crate::aaf_omf::{AudioMode, AudioPlan};
|
||||
use filmcraft_interchange::essence::{EssenceData, EssenceKey, NestNeeds};
|
||||
let (mut s, item, sequence, calls, files) = fixture();
|
||||
place(&mut s, item, 0.0);
|
||||
let plan = AudioPlan {
|
||||
mode: AudioMode::Embedded,
|
||||
aiff: false,
|
||||
mxf: false,
|
||||
sample_rate: 48_000,
|
||||
bits: 16,
|
||||
trim: true,
|
||||
handles: Tick::ZERO,
|
||||
render_effects: false,
|
||||
breakout: false,
|
||||
nests: NestNeeds::Inside,
|
||||
};
|
||||
let (essences, paths) = crate::aaf_omf::prepare_audio(&s, sequence, &plan, "ignored").unwrap();
|
||||
assert!(paths.is_empty());
|
||||
assert_eq!(essences.len(), 3);
|
||||
for (stream, essence) in essences.iter().enumerate() {
|
||||
assert_eq!(essence.key, EssenceKey::media(item, stream));
|
||||
let EssenceData::Embedded(bytes) = &essence.data else { panic!("expected embedded PCM") };
|
||||
for channel in 0..2 {
|
||||
let offset = (25 * 2 + channel) * 2;
|
||||
let actual = i16::from_le_bytes([bytes[offset], bytes[offset + 1]]);
|
||||
let expected = (Tones::sample(stream, channel, 25, 48_000) * 32767.0).round() as i16;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
let options = filmcraft_interchange::aaf::AafOptions {
|
||||
media: filmcraft_interchange::essence::MediaOptions { essence: essences, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let (aaf, _) = filmcraft_interchange::aaf::export(&s.project, sequence, &options).unwrap();
|
||||
let omf_options = filmcraft_interchange::omf::OmfOptions { media: options.media.clone(), ..Default::default() };
|
||||
let (omf, _) = filmcraft_interchange::omf::export(&s.project, sequence, &omf_options).unwrap();
|
||||
for (kind, document) in [("AAF", aaf), ("OMF", omf)] {
|
||||
let (imported, extracted, _) = if kind == "AAF" {
|
||||
filmcraft_interchange::aaf::import(&document, &Default::default()).unwrap()
|
||||
} else {
|
||||
filmcraft_interchange::omf::import(&document, &Default::default()).unwrap()
|
||||
};
|
||||
assert_eq!(extracted.len(), 3, "{kind}: composition memo must retain distinct selected-stream essence");
|
||||
let tracks = &imported.project.sequence(imported.sequences[0]).unwrap().audio_tracks;
|
||||
assert_eq!(tracks.len(), 3, "{kind}");
|
||||
let mut source_ids: Vec<_> = tracks.iter().map(|track| track.items[0].item).collect();
|
||||
source_ids.sort();
|
||||
source_ids.dedup();
|
||||
assert_eq!(source_ids.len(), 3, "{kind}: each stream remains a distinct imported source");
|
||||
let mut waveforms: Vec<_> = extracted.iter().map(|media| media.wav.clone()).collect();
|
||||
waveforms.sort();
|
||||
waveforms.dedup();
|
||||
assert_eq!(waveforms.len(), 3, "{kind}");
|
||||
}
|
||||
assert!(calls.lock().unwrap().iter().any(|call| call.0 == 2));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
let error =
|
||||
s.execute("file.exportAaf", json!({"path": "must-not-be-written.aaf", "sequence": sequence.0, "audio": "linked", "trimAudio": false})).unwrap_err();
|
||||
assert!(error.to_string().contains("embedded") || error.to_string().contains("separate"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Multi-clip audio placement: `audio_placement_specs` and dynamic audio track allocation.
|
||||
|
||||
use filmcraft_media::MediaSource;
|
||||
use filmcraft_project::{AudioChannels, ItemId, TrackKind};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::{AudioPlacementSpec, audio_placement_specs, ensure_audio_tracks};
|
||||
|
||||
fn demo() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> ItemId {
|
||||
s.state.active_sequence.expect("demo session has an active sequence")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specs_always_place_one_clip_on_the_destination() {
|
||||
assert_eq!(audio_placement_specs(&[], 1), vec![AudioPlacementSpec { track_offset: 0, audio_stream: 0, source_channels: None }]);
|
||||
assert_eq!(audio_placement_specs(&[vec![0, 1]], 1).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specs_put_further_clips_on_the_tracks_below() {
|
||||
let specs = audio_placement_specs(&[vec![0], vec![1], vec![2, 3]], 1);
|
||||
let got: Vec<(usize, Option<Vec<u16>>)> = specs.into_iter().map(|s| (s.track_offset, s.source_channels)).collect();
|
||||
assert_eq!(got, vec![(0, None), (1, Some(vec![1])), (2, Some(vec![2, 3]))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specs_are_capped_for_hostile_channel_maps() {
|
||||
let clips = vec![vec![0u16]; 100_000];
|
||||
assert!(audio_placement_specs(&clips, usize::MAX).len() <= crate::sequence_tools::MAX_TRACKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specs_append_container_streams_after_channel_map_clips() {
|
||||
let specs = audio_placement_specs(&[vec![1], vec![0]], 3);
|
||||
assert_eq!(
|
||||
specs.iter().map(|s| (s.track_offset, s.audio_stream, s.source_channels.clone())).collect::<Vec<_>>(),
|
||||
vec![(0, 0, None), (1, 0, Some(vec![0])), (2, 1, None), (3, 2, None)]
|
||||
);
|
||||
assert_eq!(audio_placement_specs(&[], 0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() {
|
||||
let mut s = Session::default();
|
||||
let mut p = (*s.project).clone();
|
||||
let rate = FrameRate::FPS_24;
|
||||
let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None);
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24));
|
||||
let mut info = source.info().clone();
|
||||
info.audio_streams.push(info.audio_streams[0].clone());
|
||||
let item = p.add_item(
|
||||
"two streams",
|
||||
filmcraft_project::Label::Iris,
|
||||
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
|
||||
media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (vdest, adest) = {
|
||||
let q = p.sequence(seq).unwrap();
|
||||
(q.video_tracks[0].id, q.audio_tracks[0].id)
|
||||
};
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.state.active_sequence = Some(seq);
|
||||
let ids =
|
||||
crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None)
|
||||
.unwrap();
|
||||
assert_eq!(ids.len(), 3);
|
||||
let q = s.project.sequence(seq).unwrap();
|
||||
assert_eq!(q.audio_tracks.len(), 2);
|
||||
let (a, b) = (&q.audio_tracks[0].items[0], &q.audio_tracks[1].items[0]);
|
||||
assert_eq!((a.audio_stream, b.audio_stream), (0, 1));
|
||||
assert_eq!(a.link, b.link);
|
||||
assert!(a.link.is_some());
|
||||
assert_ne!(a.id, b.id);
|
||||
assert_eq!(a.source_in, b.source_in);
|
||||
assert_eq!(a.start, b.start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_peaks_measure_the_stream_the_clip_plays() {
|
||||
let mut s = Session::default();
|
||||
let mut p = (*s.project).clone();
|
||||
let rate = FrameRate::FPS_24;
|
||||
let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None);
|
||||
let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24));
|
||||
let mut info = source.info().clone();
|
||||
info.audio_streams.push(info.audio_streams[0].clone());
|
||||
let item = p.add_item(
|
||||
"two streams",
|
||||
filmcraft_project::Label::Iris,
|
||||
filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip {
|
||||
media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone),
|
||||
info,
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (vdest, adest) = {
|
||||
let q = p.sequence(seq).unwrap();
|
||||
(q.video_tracks[0].id, q.audio_tracks[0].id)
|
||||
};
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.state.active_sequence = Some(seq);
|
||||
let ids =
|
||||
crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None)
|
||||
.unwrap();
|
||||
let q = s.project.sequence(seq).unwrap();
|
||||
let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id);
|
||||
assert!(ids.contains(&a) && ids.contains(&b));
|
||||
let peaks = crate::mixer::clip_peaks(&s, &[a, b]);
|
||||
let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap();
|
||||
// The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0.
|
||||
assert!(db(a) > -30.0, "{peaks:?}");
|
||||
assert!(db(b) < -100.0, "{peaks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_audio_tracks_are_cloned_from_the_destination() {
|
||||
let s = demo();
|
||||
let seq = active(&s);
|
||||
let mut p = (*s.project).clone();
|
||||
let have = p.sequence(seq).map(|q| q.audio_tracks.len()).unwrap_or(0);
|
||||
assert!(have >= 1);
|
||||
if let Some(t) = p.sequence_mut(seq).and_then(|q| q.audio_tracks.get_mut(0)) {
|
||||
t.channels = AudioChannels::Mono;
|
||||
t.volume_db = -3.0;
|
||||
t.locked = true;
|
||||
}
|
||||
let ids = ensure_audio_tracks(&mut p, seq, 0, have + 3, "test").unwrap();
|
||||
assert_eq!(ids.len(), have + 3);
|
||||
let q = p.sequence(seq).unwrap();
|
||||
for t in &q.audio_tracks[have..] {
|
||||
assert_eq!(t.kind, TrackKind::Audio);
|
||||
assert_eq!(t.channels, AudioChannels::Mono);
|
||||
assert_eq!(t.volume_db, -3.0);
|
||||
assert!(t.items.is_empty() && !t.locked);
|
||||
}
|
||||
let mut unique = ids.clone();
|
||||
unique.sort_by_key(|t| t.0);
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), ids.len(), "track ids are unique");
|
||||
// enough tracks already: nothing is added
|
||||
let again = ensure_audio_tracks(&mut p, seq, 0, have + 3, "test").unwrap();
|
||||
assert_eq!(again, ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_track_counts_are_an_error_not_a_crash() {
|
||||
let s = demo();
|
||||
let seq = active(&s);
|
||||
let mut p = (*s.project).clone();
|
||||
assert!(ensure_audio_tracks(&mut p, seq, usize::MAX, usize::MAX, "test").is_err());
|
||||
assert!(ensure_audio_tracks(&mut p, seq, 0, 1_000_000, "test").is_err());
|
||||
assert!(ensure_audio_tracks(&mut p, ItemId(u64::MAX), 0, 1, "test").is_err());
|
||||
}
|
||||
@@ -22,12 +22,13 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::Duration;
|
||||
|
||||
use filmcraft_format::autosave as names;
|
||||
use filmcraft_project::Project;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use web_time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::settings;
|
||||
|
||||
@@ -470,7 +471,17 @@ impl Preferences {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user data directory (`FILMCRAFT_DATA_DIR` overrides):
|
||||
/// A file with this name next to the executable makes the install portable: its data directory is
|
||||
/// `<exe dir>/data` instead of the per-user one (the Windows portable zip ships it).
|
||||
pub const PORTABLE_MARKER: &str = "portable.txt";
|
||||
|
||||
/// `<exe_dir>/data` when `exe_dir` holds [`PORTABLE_MARKER`], else `None`.
|
||||
pub fn portable_data_dir(exe_dir: &Path) -> Option<PathBuf> {
|
||||
exe_dir.join(PORTABLE_MARKER).is_file().then(|| exe_dir.join("data"))
|
||||
}
|
||||
|
||||
/// The data directory. `FILMCRAFT_DATA_DIR` overrides; a portable install ([`PORTABLE_MARKER`] next
|
||||
/// to the executable) uses `<exe dir>/data`; otherwise the per-user directory:
|
||||
/// macOS `~/Library/Application Support/FilmCraft`, Windows `%APPDATA%\FilmCraft`,
|
||||
/// elsewhere `$XDG_DATA_HOME/filmcraft` or `~/.local/share/filmcraft`.
|
||||
pub fn default_data_dir() -> Option<PathBuf> {
|
||||
@@ -478,6 +489,9 @@ pub fn default_data_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = env("FILMCRAFT_DATA_DIR") {
|
||||
return Some(d);
|
||||
}
|
||||
if let Some(d) = std::env::current_exe().ok().and_then(|exe| exe.parent().and_then(portable_data_dir)) {
|
||||
return Some(d);
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
return env("HOME").map(|h| h.join("Library/Application Support/FilmCraft"));
|
||||
}
|
||||
|
||||
@@ -176,6 +176,19 @@ fn auto_save_ring_rotates_and_can_save_the_project() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_marker_keeps_data_next_to_the_executable() {
|
||||
// #139: a portable install must not write to %APPDATA%.
|
||||
let d = temp_dir("portable");
|
||||
assert_eq!(autosave::portable_data_dir(&d), None);
|
||||
std::fs::create_dir_all(d.join(autosave::PORTABLE_MARKER)).unwrap();
|
||||
assert_eq!(autosave::portable_data_dir(&d), None, "a directory is not the marker");
|
||||
std::fs::remove_dir_all(d.join(autosave::PORTABLE_MARKER)).unwrap();
|
||||
std::fs::write(d.join(autosave::PORTABLE_MARKER), "").unwrap();
|
||||
assert_eq!(autosave::portable_data_dir(&d), Some(d.join("data")));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_costs_the_ui_thread_only_a_handoff() {
|
||||
// Per change the UI thread pays an Arc clone and a channel send; encoding happens on the worker.
|
||||
|
||||
@@ -513,7 +513,11 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
enabled: always,
|
||||
run: |s, p| {
|
||||
let Some(seq) = s.active_sequence() else { return Ok(json!({"tracks": []})) };
|
||||
let only = p.get("track").and_then(|_| track_param(s, p));
|
||||
// a `track` that names no caption track is an error, never every track
|
||||
let only = match p.get("track") {
|
||||
Some(_) => Some(track_param(s, p).ok_or_else(|| bad("captions.list", "no such caption track"))?),
|
||||
None => None,
|
||||
};
|
||||
let tracks: Vec<Value> =
|
||||
seq.caption_tracks.iter().enumerate().filter(|(_, t)| only.is_none_or(|o| o == t.id)).map(|(i, t)| track_json(s, t, i)).collect();
|
||||
Ok(json!({"tracks": tracks, "selection": s.state.caption_selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
|
||||
|
||||
@@ -137,3 +137,24 @@ fn caption_commands_need_tracks_and_navigate() {
|
||||
s.execute("captions.deleteTrack", json!({"track": "C1"})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().caption_tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_list_track_filter_never_falls_back_to_every_track() {
|
||||
let mut s = demo();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
s.execute("captions.newTrack", json!({})).unwrap();
|
||||
let all = s.execute("captions.list", json!({})).unwrap();
|
||||
let ids: Vec<u64> = all["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(ids.len(), 2, "{all}");
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
for sel in [json!(id), json!(format!("C{}", i + 1))] {
|
||||
let r = s.execute("captions.list", json!({"track": sel})).unwrap();
|
||||
let got: Vec<u64> = r["tracks"].as_array().unwrap().iter().map(|t| t["id"].as_u64().unwrap()).collect();
|
||||
assert_eq!(got, vec![*id], "{sel}");
|
||||
}
|
||||
}
|
||||
let missing = ids.iter().max().unwrap() + 999;
|
||||
for sel in [json!(missing), json!("C3"), json!("C0"), json!("nope")] {
|
||||
assert!(s.execute("captions.list", json!({"track": sel})).is_err(), "{sel} names no caption track");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,16 @@ pub struct SourceView {
|
||||
}
|
||||
|
||||
impl SourceView {
|
||||
/// The marked Source span. Unset marks mean the beginning/end of the source; Out is inclusive.
|
||||
pub fn selected_range(&self) -> TimeRange {
|
||||
if self.start.0 < 0 || self.end <= self.start {
|
||||
return TimeRange::new(self.start, Tick::ZERO);
|
||||
}
|
||||
let start = self.mark_in.unwrap_or(self.start).clamp(self.start, self.end);
|
||||
let end = self.mark_out.map(|o| Tick(o.0.saturating_add(self.rate.frame_duration().0))).unwrap_or(self.end).clamp(start, self.end);
|
||||
TimeRange::from_bounds(start, end)
|
||||
}
|
||||
|
||||
pub fn to_json(&self, playhead: Tick) -> Value {
|
||||
json!({
|
||||
"item": self.item.0, "media": self.media.0, "start": self.start.0, "end": self.end.0, "fps": self.rate.as_f64(),
|
||||
@@ -636,8 +646,12 @@ fn sequence_from_clip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
settings.height = *height;
|
||||
settings.frame_rate = *rate;
|
||||
}
|
||||
let channels = media_root(&s.project, first).and_then(|(_, m, _)| m.info.audio.as_ref().map(|a| a.channels)).unwrap_or(2);
|
||||
let n_audio = media_root(&s.project, first).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref().map(|a| a.clips.len())).unwrap_or(1).max(1);
|
||||
let channels = media_root(&s.project, first).and_then(|(_, m, _)| m.info.audio().map(|a| a.channels)).unwrap_or(2);
|
||||
let n_audio = media_root(&s.project, first)
|
||||
.map(|(_, m, _)| {
|
||||
crate::commands::audio_placement_specs(m.interpret.audio_channels.as_ref().map_or(&[], |a| a.clips.as_slice()), m.info.audio_streams.len()).len()
|
||||
})
|
||||
.unwrap_or(1);
|
||||
let n0 = s.history.undo.len();
|
||||
let name = pi.name.clone();
|
||||
let bin = s.project.root.parent_of(first).filter(|b| *b != s.project.root.id);
|
||||
@@ -761,12 +775,15 @@ fn offline_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bitrate: None,
|
||||
hdr: None,
|
||||
}),
|
||||
audio: has_a.then(|| filmcraft_media::AudioStreamInfo {
|
||||
sample_rate: u64_p(p, "sampleRate").unwrap_or(48_000) as u32,
|
||||
channels: u64_p(p, "channels").unwrap_or(2).max(1) as u32,
|
||||
codec: String::new(),
|
||||
bits_per_sample: None,
|
||||
}),
|
||||
audio_streams: has_a
|
||||
.then(|| filmcraft_media::AudioStreamInfo {
|
||||
sample_rate: u64_p(p, "sampleRate").unwrap_or(48_000) as u32,
|
||||
channels: u64_p(p, "channels").unwrap_or(2).max(1) as u32,
|
||||
codec: String::new(),
|
||||
bits_per_sample: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
container: String::new(),
|
||||
start_timecode: start_tc,
|
||||
file_size: None,
|
||||
@@ -913,7 +930,7 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
for it in s.project.items.values() {
|
||||
if let ItemKind::Media(m) = &it.kind {
|
||||
// same file (or generator), interpretation and streams
|
||||
let shape = (m.info.video.is_some(), m.info.audio.is_some(), m.info.duration);
|
||||
let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration);
|
||||
let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default();
|
||||
groups.entry(key).or_default().push(it.id);
|
||||
}
|
||||
@@ -1249,7 +1266,7 @@ fn audio_channels(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut maps = Vec::new();
|
||||
for i in &items {
|
||||
let Some(m) = s.project.item(*i).and_then(|it| it.as_media()) else { continue };
|
||||
let n = m.info.audio.as_ref().map_or(2, |a| a.channels) as u16;
|
||||
let n = m.info.audio().map_or(2, |a| a.channels) as u16;
|
||||
let fmt = format.unwrap_or_else(|| m.interpret.audio_channels.as_ref().map_or(AudioChannels::Stereo, |a| a.format));
|
||||
let map = match &custom {
|
||||
Some(c) => {
|
||||
@@ -1554,7 +1571,7 @@ fn breakout_to_mono(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in &items {
|
||||
let Some(it) = pr.item(*i).cloned() else { continue };
|
||||
let Some(m) = it.as_media() else { continue };
|
||||
let n = m.info.audio.as_ref().map_or(1, |a| a.channels).max(1) as u16;
|
||||
let n = m.info.audio().map_or(1, |a| a.channels).max(1) as u16;
|
||||
let bin = pr.root.parent_of(*i).filter(|b| *b != pr.root.id);
|
||||
for c in 0..n {
|
||||
let suffix = match (n, c) {
|
||||
@@ -1590,7 +1607,7 @@ fn extract_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
let (root, m, sub) = media_root(&s.project, item).ok_or_else(|| bad("clip.extractAudio", "not a media clip"))?;
|
||||
let a = m.info.audio.clone().ok_or_else(|| bad("clip.extractAudio", "the clip has no audio"))?;
|
||||
let a = m.info.audio().cloned().ok_or_else(|| bad("clip.extractAudio", "the clip has no audio"))?;
|
||||
let range = sub.unwrap_or(TimeRange::new(Tick::ZERO, m.info.duration));
|
||||
let dir = match str_p(p, "dir") {
|
||||
Some(d) => d.to_string(),
|
||||
|
||||
@@ -261,7 +261,7 @@ fn new_sequence_from_clip_bin_from_selection_and_offline_file() {
|
||||
.unwrap();
|
||||
let it = s.project.item(ItemId(r["item"].as_u64().unwrap())).unwrap();
|
||||
let m = it.as_media().unwrap();
|
||||
assert!(m.offline && m.info.audio.is_none() && m.info.video.is_some());
|
||||
assert!(m.offline && !m.info.has_audio() && m.info.video.is_some());
|
||||
assert_eq!(m.info.duration, s.sequence_rate().snap_nearest(Tick(4 * TICKS_PER_SECOND)));
|
||||
assert_eq!(m.info.start_timecode, Some(3600 * 24));
|
||||
assert_eq!(it.metadata["Tape Name"], "A001");
|
||||
@@ -372,7 +372,7 @@ fn modify_audio_channels_and_breakout_to_mono() {
|
||||
s.project = std::sync::Arc::new({
|
||||
let mut p = (*s.project).clone();
|
||||
if let Some(m) = p.item_mut(item_named(&s, "Bars and Tone")).and_then(|i| i.as_media_mut()) {
|
||||
m.info.audio = None;
|
||||
m.info.audio_streams.clear();
|
||||
}
|
||||
p
|
||||
});
|
||||
@@ -557,7 +557,7 @@ fn extract_audio_writes_a_wav_and_imports_it() {
|
||||
let item = s.project.item(ItemId(e["item"].as_u64().unwrap())).unwrap();
|
||||
let m = item.as_media().unwrap();
|
||||
assert!(m.info.video.is_none());
|
||||
assert_eq!(m.info.audio.as_ref().unwrap().channels, 2);
|
||||
assert_eq!(m.info.audio().unwrap().channels, 2);
|
||||
let src_dur = s.project.item(forest).unwrap().duration();
|
||||
assert!((m.info.duration - src_dur).0.abs() < TICKS_PER_SECOND / 100, "same length");
|
||||
assert_eq!(s.project.root.parent_of(item.id), s.project.root.parent_of(forest));
|
||||
@@ -901,3 +901,25 @@ fn add_edit_cuts_only_the_selected_clips() {
|
||||
let r = s.execute("sequence.addEdit", json!({"time": t.0})).unwrap();
|
||||
assert!(r["cuts"].as_u64().unwrap() > 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slip_keeps_picture_and_sound_aligned_at_unequal_media_limits() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newProject", json!({"name": "Slip"})).unwrap();
|
||||
s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 1, "width": 16, "height": 16})).unwrap();
|
||||
let r = s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": true})).unwrap();
|
||||
let item = r["item"].as_u64().unwrap();
|
||||
let fr = s.sequence_rate().tick_of(1);
|
||||
let r =
|
||||
s.execute("timeline.place", json!({"item": item, "track": "V1", "audioTrack": "A1", "time": 0, "sourceIn": fr.0 * 20, "duration": fr.0 * 60})).unwrap();
|
||||
let (v, a) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap());
|
||||
// lengthen only the picture so the pair has unequal tail handles
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
s.execute("timeline.trim", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap();
|
||||
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": 30})).unwrap();
|
||||
assert_eq!(clip(&s, v).source_in, Tick(fr.0 * 20));
|
||||
assert_eq!(clip(&s, a).source_in, Tick(fr.0 * 20), "sound must not slip alone");
|
||||
s.execute("timeline.slip", json!({"clip": v, "deltaFrames": -10})).unwrap();
|
||||
assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 10), Tick(fr.0 * 10)));
|
||||
}
|
||||
|
||||
+269
-59
@@ -8,8 +8,8 @@ use filmcraft_edit::{Edge, TrimMode};
|
||||
use filmcraft_media::Generator;
|
||||
use filmcraft_media::generators::GeneratorSource;
|
||||
use filmcraft_project::{
|
||||
ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, MediaClip, MediaRef, ParamValue, SequenceSettings, TrackId, TrackKind, Transition,
|
||||
TransitionId, resolve_auto_points,
|
||||
ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, Track, TrackId, TrackKind,
|
||||
Transition, TransitionId, resolve_auto_points,
|
||||
};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange, parse_timecode};
|
||||
use serde_json::{Value, json};
|
||||
@@ -131,9 +131,16 @@ 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)
|
||||
}
|
||||
/// Project items named explicitly in `items` / `item`, likewise.
|
||||
/// 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).
|
||||
pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
|
||||
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(|i| s.project.item(*i).is_some()).collect();
|
||||
let root = s.project.root.id;
|
||||
let exists = |i: &ItemId| {
|
||||
let b = filmcraft_project::BinId(i.0);
|
||||
s.project.item(*i).is_some() || (b != root && s.project.root.find_bin(b).is_some())
|
||||
};
|
||||
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(exists).collect();
|
||||
(!items.is_empty()).then_some(items)
|
||||
}
|
||||
|
||||
@@ -358,7 +365,7 @@ pub(crate) fn default_seq_settings_for(info: &filmcraft_media::MediaInfo) -> Seq
|
||||
st.frame_rate = v.frame_rate;
|
||||
st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
|
||||
}
|
||||
if let Some(a) = &info.audio {
|
||||
if let Some(a) = info.audio() {
|
||||
st.sample_rate = a.sample_rate.max(8000);
|
||||
}
|
||||
st
|
||||
@@ -485,6 +492,70 @@ fn set_matte_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"item": item.0, "color": filmcraft_color::to_hex(color)}))
|
||||
}
|
||||
|
||||
/// One audio clip of a placement: how many tracks below the destination it goes, and the source
|
||||
/// channels it plays (`None` = the clip's own default, used for the first one).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AudioPlacementSpec {
|
||||
pub track_offset: usize,
|
||||
pub audio_stream: usize,
|
||||
pub source_channels: Option<Vec<u16>>,
|
||||
}
|
||||
|
||||
/// The audio clips to place for a source whose Modify ▸ Audio Channels map has `clips` (one entry
|
||||
/// per clip): always at least one (on the destination track), then one per further entry on the
|
||||
/// tracks below. The count is capped at [`crate::sequence_tools::MAX_TRACKS`] (the map comes from
|
||||
/// project files and scripts: never trusted).
|
||||
pub(crate) fn audio_placement_specs(clips: &[Vec<u16>], streams: usize) -> Vec<AudioPlacementSpec> {
|
||||
let mut specs = vec![AudioPlacementSpec { track_offset: 0, audio_stream: 0, source_channels: None }];
|
||||
let extra = clips.iter().skip(1).take(crate::sequence_tools::MAX_TRACKS.saturating_sub(1));
|
||||
specs.extend(extra.enumerate().map(|(k, chans)| AudioPlacementSpec {
|
||||
track_offset: k.saturating_add(1),
|
||||
audio_stream: 0,
|
||||
source_channels: Some(chans.clone()),
|
||||
}));
|
||||
// Interpret Footage's channel map describes stream 0; further container streams keep their
|
||||
// own channel layouts and get one clip each, after any channel-map clips.
|
||||
for stream in 1..streams.min(filmcraft_media::MAX_AUDIO_STREAMS) {
|
||||
if specs.len() >= crate::sequence_tools::MAX_TRACKS {
|
||||
break;
|
||||
}
|
||||
specs.push(AudioPlacementSpec { track_offset: specs.len(), audio_stream: stream, source_channels: None });
|
||||
}
|
||||
specs
|
||||
}
|
||||
|
||||
/// Make sure the sequence has audio tracks for `count` clips starting at track index `first`,
|
||||
/// adding tracks at the bottom when it has fewer. A new track is a copy of the destination track
|
||||
/// (channel format, volume, pan, inserts) without its clips, transitions, lock / mute / solo
|
||||
/// state or mixer routing. Returns the ids of all the sequence's audio tracks, in order.
|
||||
pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize, count: usize, label: &str) -> Result<Vec<TrackId>> {
|
||||
let (have, template) = {
|
||||
let q = p.sequence(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
(q.audio_tracks.len(), q.audio_tracks.get(first).or(q.audio_tracks.last()).cloned())
|
||||
};
|
||||
let need = first.saturating_add(count);
|
||||
if need > crate::sequence_tools::MAX_TRACKS {
|
||||
return Err(bad(label, format!("a sequence has at most {} audio tracks", crate::sequence_tools::MAX_TRACKS)));
|
||||
}
|
||||
if need > have {
|
||||
let ids: Vec<(usize, TrackId)> = (have..need).map(|k| (k, TrackId(p.alloc_id()))).collect();
|
||||
let q = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||||
for (k, id) in ids {
|
||||
let mut t = template.clone().unwrap_or_else(|| Track::new(id, TrackKind::Audio, String::new()));
|
||||
t.id = id;
|
||||
t.name = format!("Audio {}", k + 1);
|
||||
t.items.clear();
|
||||
t.transitions.clear();
|
||||
t.locked = false;
|
||||
t.muted = false;
|
||||
t.solo = false;
|
||||
t.mixer = Default::default();
|
||||
q.audio_tracks.push(t);
|
||||
}
|
||||
}
|
||||
Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
|
||||
pub(crate) fn place_item(
|
||||
s: &mut Session,
|
||||
@@ -539,21 +610,33 @@ pub(crate) fn place_item(
|
||||
let (arange, aat) = audio_split.unwrap_or((range, at));
|
||||
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
|
||||
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
|
||||
let extra: Vec<Vec<u16>> = p
|
||||
.item(item)
|
||||
.and_then(|i| i.as_media())
|
||||
.and_then(|m| m.interpret.audio_channels.as_ref())
|
||||
.map(|m| m.clips.iter().skip(1).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let tracks: Vec<TrackId> = p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default();
|
||||
let first = tracks.iter().position(|t| *t == adest).unwrap_or(0);
|
||||
a.link = if link.is_none() && !extra.is_empty() { Some(p.alloc_id()) } else { link };
|
||||
placements.push((adest, a.clone()));
|
||||
for (k, chans) in extra.into_iter().enumerate() {
|
||||
let Some(tid) = tracks.get(first + k + 1) else { break };
|
||||
let clips: Vec<Vec<u16>> =
|
||||
p.item(item).and_then(|i| i.as_media()).and_then(|m| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default();
|
||||
let streams = p.resolve_media(item).map_or(1, |(_, m, _)| m.info.audio_streams.len());
|
||||
let specs = audio_placement_specs(&clips, streams);
|
||||
let first = p.sequence(seq_id).and_then(|q| q.audio_tracks.iter().position(|t| t.id == adest)).unwrap_or(0);
|
||||
// too few audio tracks below the destination: grow the sequence from the destination track
|
||||
let tracks = ensure_audio_tracks(p, seq_id, first, specs.len(), label)?;
|
||||
a.link = if link.is_none() && specs.len() > 1 { Some(p.alloc_id()) } else { link };
|
||||
for spec in specs {
|
||||
let tid = match spec.track_offset {
|
||||
0 => &adest,
|
||||
off => match first.checked_add(off).and_then(|i| tracks.get(i)) {
|
||||
Some(t) => t,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.id = ClipId(p.alloc_id());
|
||||
b.source_channels = chans;
|
||||
b.audio_stream = spec.audio_stream;
|
||||
if spec.track_offset > 0 {
|
||||
b.id = ClipId(p.alloc_id());
|
||||
}
|
||||
if spec.audio_stream > 0 {
|
||||
b.source_channels.clear();
|
||||
}
|
||||
if let Some(chans) = spec.source_channels {
|
||||
b.source_channels = chans;
|
||||
}
|
||||
placements.push((*tid, b));
|
||||
}
|
||||
}
|
||||
@@ -1071,7 +1154,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,"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|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}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
@@ -1242,13 +1325,17 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
|
||||
// selected, Enable swaps them rather than first making both the same.
|
||||
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
|
||||
let sel = with_links(s, &clips_p(s, p));
|
||||
let mut sel = with_links(s, &clips_p(s, p));
|
||||
// a clip named twice must still flip once
|
||||
sel.sort();
|
||||
sel.dedup();
|
||||
s.edit_sequence("Enable", |q, _, _| {
|
||||
let target = !sel.iter().all(|c| q.find_item(*c).is_some_and(|(_, i)| i.enabled));
|
||||
for c in &sel {
|
||||
if let Some((_, i)) = q.find_item_mut(*c) {
|
||||
i.enabled = target;
|
||||
i.enabled = !i.enabled;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1513,41 +1600,70 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Sequence Settings…",
|
||||
["Sequence"],
|
||||
None,
|
||||
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?}"#,
|
||||
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?,"dropFrame":bool?,"maxRenderQuality":bool?,"workingSpace":"rec709"|"rec2100-pq"|"rec2100-hlg"?,"wideGamut":bool?,"autoToneMap":bool?,"scaleMotion":bool=false}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
const CMD: &str = "sequence.settings";
|
||||
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let fps = fps_p(p, "sequence.settings")?;
|
||||
let fps = fps_p(p, CMD)?;
|
||||
let mix = match str_p(p, "mix") {
|
||||
Some(m) => Some(
|
||||
crate::mixer::channels_from(m).ok_or_else(|| bad("sequence.settings", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?,
|
||||
),
|
||||
Some(m) => Some(crate::mixer::channels_from(m).ok_or_else(|| bad(CMD, format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?),
|
||||
None => None,
|
||||
};
|
||||
let working = match str_p(p, "workingSpace") {
|
||||
Some(w) => Some(filmcraft_color::WorkingSpace::parse(w).ok_or_else(|| bad(CMD, format!("unknown working space `{w}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let drop_frame = bool_p(p, "dropFrame");
|
||||
let rate = fps.or_else(|| s.active_sequence().map(|q| q.settings.frame_rate)).unwrap_or(FrameRate::FPS_23_976);
|
||||
if drop_frame == Some(true) && !rate.supports_drop_frame() {
|
||||
return Err(bad(CMD, format!("drop-frame timecode needs a 29.97, 59.94 or 119.88 fps timebase, not {rate}")));
|
||||
}
|
||||
let scale_motion = bool_p(p, "scaleMotion").unwrap_or(false);
|
||||
let p = p.clone();
|
||||
s.edit("Sequence Settings", |pr, _| {
|
||||
let scaled = s.edit("Sequence Settings", |pr, _| {
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
pr.item_mut(id).ok_or(EngineError::NoSequence)?.name = n.to_string();
|
||||
}
|
||||
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
|
||||
if let Some(w) = checked_u32_p(&p, "width", "sequence.settings")? {
|
||||
let old_size = (q.settings.width, q.settings.height);
|
||||
if let Some(w) = checked_u32_p(&p, "width", CMD)? {
|
||||
q.settings.width = w;
|
||||
}
|
||||
if let Some(h) = checked_u32_p(&p, "height", "sequence.settings")? {
|
||||
if let Some(h) = checked_u32_p(&p, "height", CMD)? {
|
||||
q.settings.height = h;
|
||||
}
|
||||
if let Some(f) = fps {
|
||||
q.settings.frame_rate = f;
|
||||
}
|
||||
if let Some(sr) = checked_u32_p(&p, "sampleRate", "sequence.settings")? {
|
||||
if let Some(sr) = checked_u32_p(&p, "sampleRate", CMD)? {
|
||||
q.settings.sample_rate = sr;
|
||||
}
|
||||
if let Some(m) = mix {
|
||||
q.settings.audio_master = m;
|
||||
}
|
||||
q.settings.validate().map_err(|e| bad("sequence.settings", e))
|
||||
if let Some(df) = drop_frame {
|
||||
q.settings.drop_frame = df;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "maxRenderQuality") {
|
||||
q.settings.max_render_quality = v;
|
||||
}
|
||||
let c = &mut q.settings.color;
|
||||
if let Some(w) = working {
|
||||
c.working = w;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "wideGamut") {
|
||||
c.wide_gamut = v;
|
||||
}
|
||||
if let Some(v) = bool_p(&p, "autoToneMap") {
|
||||
c.auto_tone_map = v;
|
||||
}
|
||||
q.settings.working_space = q.settings.color.working.label().into();
|
||||
q.settings.validate().map_err(|e| bad(CMD, e))?;
|
||||
let new_size = (q.settings.width, q.settings.height);
|
||||
Ok(if scale_motion { edit::frame_size::scale_motion(q, old_size, new_size) } else { 0 })
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
Ok(json!({"scaledClips": scaled}))
|
||||
}
|
||||
),
|
||||
cmd!("sequence.renderEffectsInToOut", "Render Effects In to Out", ["Sequence"], Some("Enter"), r#"{"wait":bool=false}"#, has_seq, |s, p| {
|
||||
@@ -1609,19 +1725,31 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), "{}", has_seq, |s, _| {
|
||||
if let Some(i) = s.active_sequence().and_then(|q| q.mark_in) {
|
||||
// With no In / Out mark, like Premiere: the start / end of the sequence (#208).
|
||||
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.goToIn", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(i) = s.active_sequence().map(|q| q.mark_in.unwrap_or(Tick::ZERO)) {
|
||||
s.set_playhead(i);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.goToOut", "Go to Out", ["Markers"], Some("Shift+O"), "{}", has_seq, |s, _| {
|
||||
if let Some(o) = s.active_sequence().and_then(|q| q.mark_out) {
|
||||
cmd!("markers.goToOut", "Go to Out", ["Markers"], Some("Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.goToOut", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(o) = s.active_sequence().map(|q| q.mark_out.unwrap_or_else(|| q.duration())) {
|
||||
s.set_playhead(o);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.clearIn", "Clear In", ["Markers"], Some("Cmd+Shift+I"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.clearIn", "Clear In", ["Markers"], Some("Cmd+Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearIn", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.edit_sequence("Clear In", |q, _, _| {
|
||||
q.mark_in = None;
|
||||
q.split.video_in = None;
|
||||
@@ -1630,7 +1758,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.clearOut", "Clear Out", ["Markers"], Some("Cmd+Shift+O"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.clearOut", "Clear Out", ["Markers"], Some("Cmd+Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearOut", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.edit_sequence("Clear Out", |q, _, _| {
|
||||
q.mark_out = None;
|
||||
q.split.video_out = None;
|
||||
@@ -1639,7 +1770,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.clearInOut", "Clear In and Out", ["Markers"], Some("Cmd+Shift+X"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.clearInOut", "Clear In and Out", ["Markers"], Some("Cmd+Shift+X"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearInOut", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.edit_sequence("Clear In and Out", |q, _, _| {
|
||||
q.mark_in = None;
|
||||
q.mark_out = None;
|
||||
@@ -1653,9 +1787,12 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Add Marker",
|
||||
["Markers"],
|
||||
Some("M"),
|
||||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
|
||||
has_seq,
|
||||
r#"{"target":"program|source","time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.add", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let (_seq, ph) = marker_list_mut(s)?;
|
||||
let t = time_p(s, p, "").unwrap_or(ph);
|
||||
let name = str_p(p, "name").unwrap_or("").to_string();
|
||||
@@ -1671,21 +1808,32 @@ fn build() -> Vec<CommandSpec> {
|
||||
Ok(json!({"marker": id.0}))
|
||||
}
|
||||
),
|
||||
cmd!("markers.goNext", "Go to Next Marker", ["Markers"], Some("Shift+M"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.goNext", "Go to Next Marker", ["Markers"], Some("Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.goNext", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let t = s.playhead();
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().map(|m| m.start).find(|m| *m > t)) {
|
||||
s.set_playhead(m);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.goPrev", "Go to Previous Marker", ["Markers"], Some("Cmd+Shift+M"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.goPrev", "Go to Previous Marker", ["Markers"], Some("Cmd+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.goPrev", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let t = s.playhead();
|
||||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().rev().map(|m| m.start).find(|m| *m < t)) {
|
||||
s.set_playhead(m);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.clearCurrent", "Clear Selected Marker", ["Markers"], Some("Cmd+Alt+M"), "{}", has_seq, |s, _| {
|
||||
cmd!("markers.clearCurrent", "Clear Selected Marker", ["Markers"], Some("Cmd+Alt+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearCurrent", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let t = s.playhead();
|
||||
s.edit_sequence("Clear Marker", |q, _, _| {
|
||||
q.markers.retain(|m| m.start != t);
|
||||
@@ -1693,7 +1841,19 @@ fn build() -> Vec<CommandSpec> {
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), "{}", has_seq, |s, _| {
|
||||
cmd!(
|
||||
"markers.exportCsv",
|
||||
"Export Markers as CSV…",
|
||||
["Markers"],
|
||||
None,
|
||||
r#"{"path":str} (all markers in the active sequence)"#,
|
||||
has_seq,
|
||||
crate::marker_export::export
|
||||
),
|
||||
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
s.edit_sequence("Clear All Markers", |q, _, _| {
|
||||
q.markers.clear();
|
||||
Ok(())
|
||||
@@ -1705,9 +1865,12 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Edit Marker…",
|
||||
[],
|
||||
None,
|
||||
r#"{"marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
|
||||
has_seq,
|
||||
r#"{"target":"program|source","marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
|
||||
always,
|
||||
|s, p| {
|
||||
if let Some(v) = crate::source_monitor::route(s, "markers.edit", p)? {
|
||||
return Ok(v);
|
||||
}
|
||||
let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?;
|
||||
let rate = s.sequence_rate();
|
||||
let p = p.clone();
|
||||
@@ -1739,8 +1902,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
|
||||
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
|
||||
let r = s.sequence_rate();
|
||||
let f = r.frame_at(s.playhead()) + n;
|
||||
s.set_playhead(r.tick_of(f.max(0)));
|
||||
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
|
||||
if f > r.frame_at(Tick::MAX) {
|
||||
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
|
||||
}
|
||||
s.set_playhead(r.tick_of(f));
|
||||
Ok(json!({"frame": r.frame_at(s.playhead())}))
|
||||
}),
|
||||
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
|
||||
@@ -1807,9 +1973,18 @@ 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?}"#,
|
||||
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}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let stream_flag = |key: &str| match p.get(key) {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
_ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))),
|
||||
};
|
||||
let (video, audio) = (stream_flag("video")?, stream_flag("audio")?);
|
||||
if !video && !audio {
|
||||
return Err(bad("timeline.place", "enable video, audio, or both"));
|
||||
}
|
||||
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
|
||||
let at = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let tg = s.targeting();
|
||||
@@ -1831,6 +2006,14 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
|
||||
};
|
||||
let v = v.filter(|_| video);
|
||||
let a = a.filter(|_| audio);
|
||||
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
|
||||
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
|
||||
}
|
||||
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)
|
||||
@@ -2036,8 +2219,16 @@ fn build() -> Vec<CommandSpec> {
|
||||
.unwrap_or_default();
|
||||
let clips = with_links(s, &[c]);
|
||||
s.edit_sequence("Slip", |q, ctx, _| {
|
||||
// clamp across all linked partners, then slip them by the common delta
|
||||
let mut dd = d;
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, d, ctx)?;
|
||||
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
|
||||
if x.abs() < dd.abs() {
|
||||
dd = x;
|
||||
}
|
||||
}
|
||||
for c in &clips {
|
||||
edit::slip(q, *c, dd, ctx)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -2330,7 +2521,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Move Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "move")
|
||||
),
|
||||
@@ -2348,7 +2539,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Edit Keyframe",
|
||||
[],
|
||||
None,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?}"#,
|
||||
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#,
|
||||
has_seq,
|
||||
|s, p| keyframe_op(s, p, "set")
|
||||
),
|
||||
@@ -2537,7 +2728,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
"set" => "Edit Keyframe",
|
||||
_ => "Keyframe Interpolation",
|
||||
};
|
||||
s.edit_sequence(label, |q, _, _| {
|
||||
// a keyframe dragged on the timeline is one undo step, like a dragged value
|
||||
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
|
||||
if bool_p(p, "begin").unwrap_or(false) {
|
||||
s.history.merge_key = None;
|
||||
}
|
||||
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
|
||||
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
|
||||
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
|
||||
let e = match &eff {
|
||||
@@ -2563,6 +2759,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) {
|
||||
let mut k = prm.keyframes.remove(i);
|
||||
k.time = to;
|
||||
if let Some(v) = p.get("value") {
|
||||
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
|
||||
}
|
||||
prm.keyframes.retain(|x| x.time != to);
|
||||
let at = prm.keyframes.partition_point(|x| x.time < to);
|
||||
prm.keyframes.insert(at, k);
|
||||
@@ -3016,6 +3215,16 @@ fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
|
||||
let (track, tr) = preview_transition(s, p, kind)?;
|
||||
let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name));
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
}
|
||||
|
||||
/// Plan an applied transition without editing the project or allocating an id.
|
||||
/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application,
|
||||
/// so a drag preview shows the exact range that release will commit.
|
||||
pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> {
|
||||
let eff_id = str_p(p, "effect")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() });
|
||||
@@ -3066,6 +3275,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
||||
}
|
||||
}
|
||||
let (track, from, to, cut) = found.ok_or_else(|| EngineError::Other("no edit point at the playhead on targeted tracks".into()))?;
|
||||
if q.track(track).is_some_and(|tr| tr.locked) {
|
||||
return Err(edit::EditError::Locked.into());
|
||||
}
|
||||
let start = if from.is_some() && to.is_some() {
|
||||
cut - dur.mul_ratio(1, 2)
|
||||
} else if to.is_some() {
|
||||
@@ -3074,9 +3286,7 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value
|
||||
cut - dur
|
||||
};
|
||||
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
|
||||
let label = format!("Apply {}", def.name);
|
||||
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
|
||||
Ok(json!({"transition": id.0}))
|
||||
Ok((track, tr))
|
||||
}
|
||||
|
||||
/// JSON description of the project (bins, items) for agents.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! | `essentialSound.setType` / `clearType` | assign / clear the audio type (clearing removes its effects and gain) |
|
||||
//! | `essentialSound.set` | one setting by dotted key (`repair.noise.amount`), or several (`values`) |
|
||||
//! | `essentialSound.applyPreset` / `savePreset` / `deletePreset` | built-in and user presets |
|
||||
//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770) and set the match gain to the target |
|
||||
//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770, mono clips as one channel) and set the match gain to the target |
|
||||
//! | `essentialSound.generateDucking` | Volume keyframes that duck Music / Ambience under the trigger types |
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -262,15 +262,26 @@ fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Integrated loudness (LUFS) of a clip's own signal (clip gain + clip effects) over its whole
|
||||
/// timeline range, or `None` when it is silent / has no audio.
|
||||
///
|
||||
/// The clip is measured with the channels it has: a stereo (or wider) source as the two channels
|
||||
/// it plays, a mono source or a mono pick as one channel (its two identical sides folded to one),
|
||||
/// the way BS.1770 and ffmpeg's `ebur128` measure a mono programme. Metering the dual-mono pair the
|
||||
/// clip plays would read 3 LU louder than the file and match it 3 dB too quietly (#296).
|
||||
pub fn clip_loudness(item: &TrackItem, sr: u32, sources: &dyn SourceProvider) -> Option<f64> {
|
||||
let a0 = item.start.to_units_floor(sr as i64);
|
||||
let a1 = item.end().to_units_floor(sr as i64);
|
||||
let mut meter = LoudnessMeter::new(sr as f64, 2);
|
||||
let mono = filmcraft_render::audio::clip_is_mono(item, sources)?;
|
||||
let mut meter = LoudnessMeter::new(sr as f64, if mono { 1 } else { 2 });
|
||||
let mut pos = a0;
|
||||
while pos < a1 {
|
||||
let n = (a1 - pos).min(sr as i64) as usize;
|
||||
let [l, r] = filmcraft_render::audio::clip_signal(item, pos, n, sr, sources)?;
|
||||
meter.process(&[&l, &r]);
|
||||
if mono {
|
||||
let m: Vec<f32> = l.iter().zip(&r).map(|(a, b)| 0.5 * (a + b)).collect();
|
||||
meter.process(&[&m]);
|
||||
} else {
|
||||
meter.process(&[&l, &r]);
|
||||
}
|
||||
pos += n as i64;
|
||||
}
|
||||
Some(meter.integrated()).filter(|v| v.is_finite())
|
||||
|
||||
@@ -265,6 +265,41 @@ fn auto_match_hits_the_target_loudness() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #296: a mono file measured ~3 LU too loud. The same speech imported as a 1-channel WAV and as
|
||||
/// a dual-mono stereo WAV must measure 3.01 LU apart (the stereo pair carries twice the energy),
|
||||
/// the mono clip must agree with a one-channel BS.1770 meter over the file (what ffmpeg's
|
||||
/// `ebur128` reports), and a mono pick (Modify ▸ Audio Channels) of the stereo file counts as mono.
|
||||
#[test]
|
||||
fn auto_match_measures_a_mono_clip_as_one_channel() {
|
||||
let sig = speech(12.0, 0.3, |t| (t % 3.0) < 2.2);
|
||||
let (mut s, c) = session_with(&[(sig.clone(), 0.0)]);
|
||||
let stereo = c[0];
|
||||
let mono_wav: Arc<[u8]> = filmcraft_media::wav::write_wav16(&sig, 1, SR).into();
|
||||
let mono_item = crate::commands::import_bytes(&mut s, "/mono.wav", mono_wav, None).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": mono_item.0, "audioTrack": "A2", "seconds": 0.0})).unwrap();
|
||||
let mono = ClipId(r["clips"][0].as_u64().unwrap());
|
||||
for c in [stereo, mono] {
|
||||
s.execute("essentialSound.setType", json!({"clips": [c.0], "type": "dialogue"})).unwrap();
|
||||
}
|
||||
let measured = |s: &mut Session, c: ClipId| {
|
||||
let r = s.execute("essentialSound.autoMatch", json!({"clips": [c.0], "target": -23.0})).unwrap();
|
||||
r["clips"][0]["measuredLufs"].as_f64().unwrap()
|
||||
};
|
||||
let (two, one) = (measured(&mut s, stereo), measured(&mut s, mono));
|
||||
let mut file = LoudnessMeter::new(SR as f64, 1);
|
||||
file.process(&[&sig]);
|
||||
let want = file.integrated();
|
||||
println!("stereo (dual mono) {two:.3} LUFS, mono {one:.3} LUFS, mono file {want:.3} LUFS");
|
||||
assert!((one - want).abs() <= 0.1, "mono clip {one} LUFS, the file reads {want} LUFS");
|
||||
assert!((two - one - 3.0103).abs() <= 0.1, "dual mono {two} LUFS should read 3.01 LU above mono {one} LUFS");
|
||||
let st = item(&s, mono).essential.unwrap();
|
||||
assert!((st.loudness.gain_db - (-23.0 - want)).abs() <= 0.1, "match gain {} dB for {want} LUFS", st.loudness.gain_db);
|
||||
// one picked channel of the stereo file plays on both sides: measured as mono too
|
||||
s.execute("clip.audioChannels", json!({"clips": [stereo.0], "channels": [0]})).unwrap();
|
||||
let picked = measured(&mut s, stereo);
|
||||
assert!((picked - want).abs() <= 0.1, "mono pick {picked} LUFS, the file reads {want} LUFS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ducking_generates_volume_keyframes_under_dialogue() {
|
||||
// dialogue in [2, 4] and [6, 7.5] s; music for 10 s
|
||||
|
||||
@@ -175,3 +175,39 @@ fn naming_targets_lifts_only_the_selection_condition() {
|
||||
let _ = s.execute("edit.clear", json!({"clips": [a.id.0]}));
|
||||
assert!(clip(&s, a.id).is_some(), "a clip on a locked track is not removed");
|
||||
}
|
||||
|
||||
/// `project.delete` with explicit ids needs no Project-panel selection, for bins as much as for
|
||||
/// items: the selection can hold both, so a named bin stands in for it (#244).
|
||||
#[test]
|
||||
fn project_delete_takes_explicit_bins_and_items_without_a_selection() {
|
||||
let mut s = demo();
|
||||
let before = s.project.items.len();
|
||||
let empty = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let outer = s.execute("file.newBin", json!({"name": "Old footage"})).unwrap()["bin"].as_u64().unwrap();
|
||||
let ocean = item_named(&s, "Ocean_Sunset.mp4");
|
||||
s.execute("project.moveToBin", json!({"items": [ocean.0], "bin": outer})).unwrap();
|
||||
s.state.project_selection.clear();
|
||||
assert!(!s.is_enabled("project.delete"));
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel");
|
||||
// an empty bin, by its id alone
|
||||
let r = s.execute("project.delete", json!({"items": [empty]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 0, "bins": 1}));
|
||||
assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none());
|
||||
// a bin with a clip in it, and an item, each by id alone
|
||||
let r = s.execute("project.delete", json!({"items": [outer]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 1}));
|
||||
assert!(s.project.item(ocean).is_none());
|
||||
let dunes = item_named(&s, "Desert_Dunes.mp4");
|
||||
let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap();
|
||||
assert_eq!(r, json!({"items": 1, "bins": 0}));
|
||||
assert_eq!(s.project.items.len(), before - 2);
|
||||
assert!(s.state.project_selection.is_empty(), "the selection is left as it was");
|
||||
assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection");
|
||||
// ids of nothing, or of the project's own top bin, do not stand in for a selection
|
||||
let root = s.project.root.id.0;
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({"items": [root]}))), "select an item in the Project panel");
|
||||
assert_eq!(disabled(s.execute("project.delete", json!({"items": [999_999]}))), "select an item in the Project panel");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), before);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ fn export_integer_parameters_cannot_wrap_or_overflow() {
|
||||
assert!(s.execute("export.resolve", json!({"bitrateKbps":8000.0, "keyframeDistance":48.0})).is_ok(), "integer-valued floats are integers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_camel_case_settings_are_honoured() {
|
||||
let mut s = demo();
|
||||
let camel = json!({"audio":{"sampleRate":96000}, "effects":{"loudness":{"enabled":true, "targetLufs":-16}}});
|
||||
let snake = json!({"audio":{"sample_rate":96000}, "effects":{"loudness":{"enabled":true, "target_lufs":-16}}});
|
||||
let camel = s.execute("export.resolve", json!({"format":"wav", "settings":camel})).unwrap();
|
||||
let snake = s.execute("export.resolve", json!({"format":"wav", "settings":snake})).unwrap();
|
||||
assert_eq!(camel["output"]["sampleRate"], 96000);
|
||||
assert_eq!(camel["settings"]["audio"]["sample_rate"], 96000);
|
||||
assert_eq!(camel["settings"]["effects"]["loudness"]["target_lufs"], -16.0);
|
||||
assert_eq!(camel["settings"], snake["settings"]);
|
||||
}
|
||||
|
||||
fn probe(path: &str) -> Option<Value> {
|
||||
let ffprobe = filmcraft_testkit::ffprobe_or_skip("export presets")?;
|
||||
let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-show_format", "-show_streams", path]).output().unwrap();
|
||||
@@ -359,6 +372,22 @@ fn queue_orders_cancels_and_retries() {
|
||||
assert!(queue(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_move_by_extreme_offsets_clamps_instead_of_overflowing() {
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = (0..3)
|
||||
.map(|_| {
|
||||
let r = s.execute("export.queue.add", json!({"preset": "Waveform Audio 48 kHz 16-bit", "path": "queued-output/"})).unwrap();
|
||||
r["added"][0].as_u64().unwrap()
|
||||
})
|
||||
.collect();
|
||||
let order = |s: &mut Session| -> Vec<u64> { queue(s).iter().map(|i| i["id"].as_u64().unwrap()).collect() };
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MAX})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[0], ids[2], ids[1]]);
|
||||
s.execute("export.queue.move", json!({"id": ids[1], "by": i64::MIN})).unwrap();
|
||||
assert_eq!(order(&mut s), [ids[1], ids[0], ids[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_cancels_a_running_export_and_retries_a_failed_one() {
|
||||
let mut s = demo();
|
||||
@@ -484,6 +513,23 @@ fn queue_exports_several_sequences_and_ranges() {
|
||||
assert_eq!(len("parts_2.wav"), 44 + 12_000 * 4, "0.25 s of 16-bit stereo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_folder_paths_expand_home_and_take_either_separator() {
|
||||
let mut s = demo();
|
||||
let first = s.state.active_sequence.unwrap();
|
||||
let second = s.execute("file.newSequence", json!({"name": "Second Cut", "width": 640, "height": 360, "fps": 25})).unwrap()["sequence"].as_u64().unwrap();
|
||||
let preset = "Waveform Audio 48 kHz 16-bit";
|
||||
// a folder that does not exist yet, named by a trailing `\`: each sequence keeps its own name
|
||||
s.execute("export.queue.add", json!({"preset": preset, "path": "not-yet-made\\", "sequences": [first.0, second]})).unwrap();
|
||||
let paths: Vec<String> = queue(&mut s).iter().map(|i| i["path"].as_str().unwrap().to_string()).collect();
|
||||
assert!(paths.iter().any(|p| p.ends_with("Second Cut.wav")), "{paths:?}");
|
||||
if let Some(home) = crate::media_browser::std_home_dir() {
|
||||
s.execute("export.queue.add", json!({"preset": preset, "path": "~/Exports/", "sequence": first.0})).unwrap();
|
||||
let last = queue(&mut s).last().unwrap()["path"].as_str().unwrap().to_string();
|
||||
assert!(last.starts_with(&home), "{last}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_export_uses_and_remembers_a_preset() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result<PresetFile, String> {
|
||||
// settings
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `sampleRate` → `sample_rate`.
|
||||
fn camel_to_snake(k: &str) -> String {
|
||||
let mut out = String::with_capacity(k.len() + 2);
|
||||
for c in k.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deep-merge `patch` into `base` (objects merge, everything else replaces).
|
||||
fn merge(base: &mut Value, patch: &Value) {
|
||||
match (base, patch) {
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
merge(b.entry(k.clone()).or_insert(Value::Null), v);
|
||||
// Some nested settings structs serialize snake_case (`audio.sample_rate`); accept the documented camelCase
|
||||
// spelling for them too instead of adding a second, ignored key next to the default.
|
||||
let snake = camel_to_snake(k);
|
||||
let key = if !b.contains_key(k) && b.contains_key(&snake) { snake } else { k.clone() };
|
||||
merge(b.entry(key).or_insert(Value::Null), v);
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
@@ -242,6 +260,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
||||
if let Some(v) = bool_p(p, "sdr") {
|
||||
settings.sdr = v;
|
||||
}
|
||||
if let Some(v) = bool_p(p, "alpha") {
|
||||
settings.alpha = v;
|
||||
}
|
||||
match (crate::commands::checked_u32_p(p, "width", cmd)?, crate::commands::checked_u32_p(p, "height", cmd)?) {
|
||||
(Some(w), Some(h)) => settings.frame_size = Some((w, h)),
|
||||
(None, None) => {}
|
||||
@@ -316,7 +337,7 @@ pub fn default_export_dir(s: &Session) -> PathBuf {
|
||||
// the web build's virtual file table: written files are offered as downloads
|
||||
return PathBuf::from("/exports");
|
||||
}
|
||||
if let Ok(h) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
|
||||
if let Some(h) = crate::media_browser::std_home_dir() {
|
||||
let movies = Path::new(&h).join("Movies");
|
||||
return if movies.is_dir() { movies } else { PathBuf::from(h) };
|
||||
}
|
||||
@@ -339,11 +360,19 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(p: &str) -> String {
|
||||
match (p.strip_prefix("~/"), std::env::var("HOME")) {
|
||||
(Some(rest), Ok(h)) => format!("{h}/{rest}"),
|
||||
/// `~/` expanded to the home directory (`USERPROFILE` on Windows). On Windows `/` becomes `\`:
|
||||
/// after a `\\?\` prefix (what `canonicalize` returns) `/` is not a separator.
|
||||
pub fn expand_home(p: &str) -> String {
|
||||
let p = match (p.strip_prefix("~/"), crate::media_browser::std_home_dir()) {
|
||||
(Some(rest), Some(h)) => format!("{h}/{rest}"),
|
||||
_ => p.to_string(),
|
||||
}
|
||||
};
|
||||
if cfg!(windows) { p.replace('/', "\\") } else { p }
|
||||
}
|
||||
|
||||
/// `x` names a folder: it ends in a separator or already exists as one.
|
||||
fn is_folder(x: &str) -> bool {
|
||||
x.ends_with(['/', '\\']) || Path::new(x).is_dir()
|
||||
}
|
||||
|
||||
/// Output path for `seq`: the `path` param (a file, or a directory ending in `/` or existing), else
|
||||
@@ -351,7 +380,7 @@ fn expand_home(p: &str) -> String {
|
||||
fn output_path(s: &Session, p: &Value, seq: ItemId, settings: &ExportSettings, suffix: Option<usize>) -> String {
|
||||
let name = s.project.item(seq).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
|
||||
let base = match str_p(p, "path").map(expand_home) {
|
||||
Some(x) if x.ends_with('/') || x.ends_with('\\') || Path::new(&x).is_dir() => Path::new(&x).join(file_safe(&name)).to_string_lossy().to_string(),
|
||||
Some(x) if is_folder(&x) => Path::new(&x).join(file_safe(&name)).to_string_lossy().to_string(),
|
||||
Some(x) => x,
|
||||
None => default_export_dir(s).join(file_safe(&name)).to_string_lossy().to_string(),
|
||||
};
|
||||
@@ -645,11 +674,7 @@ fn queue_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
st.range = range_param(s, &project, seq, *r, times, cmd)?;
|
||||
// several ranges of one sequence get numbered names; several sequences their own names
|
||||
let suffix = if many && (ranges.len() > 1 || str_p(p, "path").is_some_and(|x| !x.ends_with('/') && !Path::new(&expand_home(x)).is_dir())) {
|
||||
Some(n)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let suffix = if many && (ranges.len() > 1 || str_p(p, "path").is_some_and(|x| !is_folder(&expand_home(x)))) { Some(n) } else { None };
|
||||
st.path = output_path(s, p, seq, &st, suffix);
|
||||
n += 1;
|
||||
items.push((seq, st));
|
||||
@@ -774,7 +799,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.export_queue.items.len() as i64;
|
||||
let to = match (p.get("to").and_then(Value::as_i64), p.get("by").and_then(Value::as_i64)) {
|
||||
(Some(t), _) => t,
|
||||
(None, Some(b)) => i as i64 + b,
|
||||
(None, Some(b)) => (i as i64).saturating_add(b),
|
||||
_ => return Err(bad(cmd, "need `to` (index) or `by` (±n)")),
|
||||
}
|
||||
.clamp(0, n - 1) as usize;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::{Session, keyboard};
|
||||
use filmcraft_media::{DemoScene, Generator, generators::GeneratorSource};
|
||||
use filmcraft_project::{ItemId, Label};
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn session() -> (Session, ItemId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"width":128,"height":72,"fps":24})).unwrap();
|
||||
let item = crate::demo::add_generator(
|
||||
Arc::make_mut(&mut s.project),
|
||||
&s.media,
|
||||
GeneratorSource::new(Generator::Demo(DemoScene::Aurora), 128, 72, FrameRate::FPS_24, Tick(5 * TICKS_PER_SECOND)),
|
||||
"Source clip",
|
||||
Label::Iris,
|
||||
None,
|
||||
);
|
||||
s.execute("timeline.place", json!({"item":item.0,"track":"V1"})).unwrap();
|
||||
s.execute("source.open", json!({"item":item.0})).unwrap();
|
||||
(s, item)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_export_selects_the_correct_monitor_time_and_original_media() {
|
||||
let (mut s, item) = session();
|
||||
let dir = std::env::temp_dir().join(format!("fc-frame-original-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let proxy = dir.join("proxy.png");
|
||||
image::RgbaImage::from_pixel(16, 9, image::Rgba([255, 0, 0, 255])).save(&proxy).unwrap();
|
||||
Arc::make_mut(&mut s.project).item_mut(item).unwrap().as_media_mut().unwrap().proxy =
|
||||
Some(filmcraft_project::MediaRef::File { path: proxy.to_string_lossy().into_owned() });
|
||||
s.media.set_use_proxies(true);
|
||||
s.state.source_playhead = FrameRate::FPS_24.tick_of(20);
|
||||
s.set_playhead(FrameRate::FPS_24.tick_of(35));
|
||||
let before = s.project.to_json();
|
||||
let revision = s.revision;
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
let expected_source = filmcraft_render::render_item(&s.project, item, s.state.source_playhead, 1.0, &provider).unwrap().to_rgba8();
|
||||
let preview_provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
let preview = filmcraft_render::render_item(&s.project, item, s.state.source_playhead, 1.0, &preview_provider).unwrap().to_rgba8();
|
||||
assert_ne!(preview, expected_source, "the preview really uses the red proxy");
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let expected_program =
|
||||
filmcraft_render::render_sequence(&s.project, seq, s.playhead(), filmcraft_render::RenderOptions { captions: true, ..Default::default() }, &provider)
|
||||
.to_rgba8();
|
||||
assert_ne!(expected_source, expected_program);
|
||||
for (target, time, expected) in [("source", s.state.source_playhead, expected_source), ("program", s.playhead(), expected_program)] {
|
||||
let path = dir.join(format!("{target}.png"));
|
||||
let result = s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(result["time"], json!(time.0));
|
||||
let image = image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!(image.dimensions(), (128, 72));
|
||||
assert_eq!(image.into_raw(), expected);
|
||||
}
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert!(s.media.use_proxies());
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_frame_export_works_without_an_active_sequence_and_rejects_bad_params() {
|
||||
let (mut s, item) = session();
|
||||
s.state.active_sequence = None;
|
||||
let path = std::env::temp_dir().join(format!("fc-source-frame-{}.png", std::process::id()));
|
||||
let r = s.execute("file.exportFrame", json!({"target":"source","path":path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["width"], 128);
|
||||
assert!(s.execute("file.exportFrame", json!({"path":path.to_string_lossy()})).is_err());
|
||||
for params in [
|
||||
json!({"target":"wrong"}),
|
||||
json!({"target":3}),
|
||||
json!({"target":"source","time":-1}),
|
||||
json!({"target":"source","time":"bad"}),
|
||||
json!({"target":"source","item":0}),
|
||||
json!({"target":"source","format":false}),
|
||||
json!({"target":"source","import":"yes"}),
|
||||
json!({"target":"source","path":""}),
|
||||
] {
|
||||
assert!(s.execute("file.exportFrame", params.clone()).is_err(), "{params}");
|
||||
}
|
||||
Arc::make_mut(&mut s.project).item_mut(item).unwrap().as_media_mut().unwrap().info.video = None;
|
||||
assert!(s.execute("file.exportFrame", json!({"target":"source","path":path.to_string_lossy()})).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_export_snapshots_are_independent_of_later_monitor_selection() {
|
||||
let (mut s, item) = session();
|
||||
s.state.source_playhead = FrameRate::FPS_24.tick_of(19);
|
||||
let target = keyboard::frame_export_target(&s, &json!({"target":"source"})).unwrap();
|
||||
s.state.source_item = None;
|
||||
s.state.source_playhead = Tick::ZERO;
|
||||
let path = std::env::temp_dir().join(format!("fc-captured-frame-{}.bmp", std::process::id()));
|
||||
let r = s.execute("file.exportFrame", json!({"target":"source","item":item.0,"time":target.time.0,"format":"bmp","path":path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["time"], json!(target.time.0));
|
||||
assert_eq!(&std::fs::read(&path).unwrap()[..2], b"BM");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
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());
|
||||
}
|
||||
@@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (layers, meta) = t.instantiate((w, h));
|
||||
let before = s.history.undo.len();
|
||||
let dur = t.duration;
|
||||
let clip = place_video_clip(s, &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.template.apply", &t.name, p, "Apply Graphics Template", layers, move |pr, (w, h, rate)| {
|
||||
let src = crate::graphics::graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(dur))
|
||||
})?;
|
||||
@@ -911,7 +911,7 @@ fn upgrade_caption(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::<String>();
|
||||
let dur = c.duration;
|
||||
let q = json!({"time": c.start.0});
|
||||
let clip = place_video_clip(s, &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let clip = place_video_clip(s, "graphics.upgradeCaption", &name, &q, "Upgrade Caption to Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
(crate::graphics::graphic_source(pr, w, h, rate), dur)
|
||||
})?;
|
||||
clips.push(clip.0);
|
||||
|
||||
@@ -96,18 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32,
|
||||
|
||||
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
|
||||
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
|
||||
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
|
||||
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
|
||||
place_video_clip(s, name, p, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
// Here `seconds` is duration, not the generic time parser's placement alias.
|
||||
let mut placement = p.clone();
|
||||
if let Some(params) = placement.as_object_mut() {
|
||||
params.remove("seconds");
|
||||
}
|
||||
place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
|
||||
let src = graphic_source(pr, w, h, rate);
|
||||
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
|
||||
/// `time`), above the clips there, as one undo step; selects it.
|
||||
/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked,
|
||||
/// for its errors.
|
||||
pub(crate) fn place_video_clip(
|
||||
s: &mut Session,
|
||||
cmd: &str,
|
||||
name: &str,
|
||||
p: &Value,
|
||||
label: &str,
|
||||
@@ -128,7 +135,7 @@ pub(crate) fn place_video_clip(
|
||||
let src_size = pr.source_size(src).unwrap_or((w, h));
|
||||
let t = rate.snap(t);
|
||||
let dur = dur.max(rate.frame_duration());
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
|
||||
ti.name = name;
|
||||
ti.effects.extend(extra);
|
||||
for e in &mut ti.effects {
|
||||
@@ -141,7 +148,7 @@ pub(crate) fn place_video_clip(
|
||||
let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range));
|
||||
let idx = match want_track {
|
||||
Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i,
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))),
|
||||
Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))),
|
||||
_ => {
|
||||
let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1);
|
||||
match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) {
|
||||
@@ -185,6 +192,10 @@ fn param_id(k: &str) -> &str {
|
||||
"fontSize" => "size",
|
||||
"fontStyle" | "style" => "font_style",
|
||||
"fillColor" | "color" => "fill_color",
|
||||
"fillKind" => "fill_kind",
|
||||
"gradientStart" => "gradient_start",
|
||||
"gradientEnd" => "gradient_end",
|
||||
"gradientAngle" => "gradient_angle",
|
||||
"strokeColor" => "stroke_color",
|
||||
"strokeWidth" => "stroke_width",
|
||||
"backgroundColor" => "background_color",
|
||||
@@ -207,6 +218,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
|
||||
"align" => graphic::ALIGN_OPTS,
|
||||
"caps" => graphic::CAPS_OPTS,
|
||||
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
|
||||
"fill_kind" => graphic::FILL_KIND_OPTS,
|
||||
"shape" => SHAPE_OPTS,
|
||||
_ => &[],
|
||||
};
|
||||
@@ -698,7 +710,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+R"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "rectangle"),
|
||||
),
|
||||
@@ -707,7 +719,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
"Ellipse",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
Some("Cmd+Alt+E"),
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| new_shape_cmd(s, p, "ellipse"),
|
||||
),
|
||||
@@ -716,7 +728,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
"Polygon",
|
||||
&["Graphics and Titles", "New Layer"],
|
||||
None,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
|
||||
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
@@ -761,7 +773,7 @@ fn menu_commands() -> Vec<CommandSpec> {
|
||||
return Err(bad("graphics.newFromFile", "the file has no picture"));
|
||||
}
|
||||
let (name, dur) = (pi.name.clone(), pi.duration());
|
||||
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
let clip = place_video_clip(s, "graphics.newFromFile", &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
|
||||
s.state.graphic_layers.clear();
|
||||
Ok(json!({"clip": clip.0, "item": item.0}))
|
||||
},
|
||||
@@ -897,7 +909,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => {
|
||||
let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string();
|
||||
(new_graphic_clip(s, layer, &name, p)?, 0)
|
||||
(new_graphic_clip(s, "graphics.newText", layer, &name, p)?, 0)
|
||||
}
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
@@ -908,7 +920,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
"Shape",
|
||||
&[],
|
||||
None,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
|
||||
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]? (the shape's centre),"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id? (add the shape to this graphic),"seconds":f64=5,"track":index?,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase();
|
||||
@@ -931,7 +943,7 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c)));
|
||||
let (clip, layer_i) = match into {
|
||||
Some(c) => (c, add_layer(s, c, layer)?),
|
||||
None => (new_graphic_clip(s, layer, "Shape", p)?, 0),
|
||||
None => (new_graphic_clip(s, "graphics.newShape", layer, "Shape", p)?, 0),
|
||||
};
|
||||
Ok(json!({"clip": clip.0, "layer": layer_i}))
|
||||
},
|
||||
|
||||
@@ -24,6 +24,77 @@ fn text_of(l: &filmcraft_project::graphic::LayerSpec) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_duration_does_not_set_its_timeline_position() {
|
||||
for command in ["graphics.newText", "graphics.newShape"] {
|
||||
for playhead_frame in [0, 72] {
|
||||
for seconds in [1, 2] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 1, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": playhead_frame})).unwrap();
|
||||
let start = s.playhead();
|
||||
let r = s.execute(command, json!({"seconds": seconds})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (_, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(it.start, start, "{command}, duration {seconds}, playhead {playhead_frame}");
|
||||
assert_eq!(it.duration, Tick::from_seconds_f64(f64::from(seconds)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(clip).is_none());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.start, start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() {
|
||||
for command in ["graphics.newText", "graphics.newShape"] {
|
||||
for mut params in [json!({"time": 0}), json!({"frame": 0}), json!({"timecode": "00:00:00:00"})] {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 1, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 72})).unwrap();
|
||||
params["seconds"] = json!(2);
|
||||
let r = s.execute(command, params).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let (_, it) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
assert_eq!(it.start, Tick::ZERO, "{command}");
|
||||
assert_eq!(it.duration, Tick::from_seconds_f64(2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track
|
||||
/// that is not free there is an error named after the command that asked.
|
||||
#[test]
|
||||
fn graphic_placement_takes_a_track_and_a_time() {
|
||||
let commands =
|
||||
[("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))];
|
||||
for (command, mut params) in commands {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap();
|
||||
s.execute("playhead.set", json!({"frame": 72})).unwrap();
|
||||
params["track"] = json!(2);
|
||||
params["frame"] = json!(24);
|
||||
params["seconds"] = json!(1);
|
||||
let r = s.execute(command, params.clone()).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let (track, it) = q.find_item(clip).unwrap();
|
||||
assert_eq!(track, q.video_tracks[2].id, "{command}");
|
||||
assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}");
|
||||
// the same place again is taken
|
||||
let e = s.execute(command, params).unwrap_err().to_string();
|
||||
assert!(e.contains("V3 is not free here"), "{command}: {e}");
|
||||
let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" };
|
||||
assert!(e.contains(named), "{command}: {e}");
|
||||
assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_makes_a_graphic_clip_above_the_footage() {
|
||||
let mut s = demo();
|
||||
@@ -108,6 +179,24 @@ fn align_and_distribute_layers() {
|
||||
let _ = w;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradient_fill_sets_and_evaluates() {
|
||||
let mut s = demo();
|
||||
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap();
|
||||
let clip = ClipId(r["clip"].as_u64().unwrap());
|
||||
s.execute(
|
||||
"graphics.set",
|
||||
json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill");
|
||||
assert_eq!(g.start[0], 1.0);
|
||||
assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}");
|
||||
assert_eq!(g.angle, 0.0);
|
||||
s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap();
|
||||
assert!(layers(&s, clip)[0].appearance.gradient.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_renders_in_the_program_and_survives_save() {
|
||||
let mut s = demo();
|
||||
|
||||
+118
-30
@@ -34,12 +34,13 @@
|
||||
//! changes the project is one undo step.
|
||||
|
||||
use filmcraft_edit as edit;
|
||||
pub use filmcraft_export::still::StillFormat;
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKind};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, str_p, time_p, with_links};
|
||||
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, time_p, with_links};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
@@ -530,39 +531,119 @@ fn sanitize(name: &str) -> String {
|
||||
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Export Frame: render the Program monitor frame at the playhead to a still image.
|
||||
fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fmt = str_p(p, "format").unwrap_or("png").to_ascii_lowercase();
|
||||
let format = match fmt.as_str() {
|
||||
"png" => filmcraft_export::Format::PngSequence,
|
||||
"tif" | "tiff" => filmcraft_export::Format::TiffSequence,
|
||||
"bmp" => filmcraft_export::Format::BmpSequence,
|
||||
_ => return Err(bad("file.exportFrame", "format must be png, tiff or bmp")),
|
||||
/// A monitor frame captured when Export Frame opens. Export uses original media, never proxies.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FrameExportTarget {
|
||||
pub source: bool,
|
||||
pub item: filmcraft_project::ItemId,
|
||||
pub time: Tick,
|
||||
pub name: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Resolve the requested monitor, item and exact frame without changing either playhead.
|
||||
pub fn frame_export_target(s: &Session, p: &Value) -> Result<FrameExportTarget> {
|
||||
const CMD: &str = "file.exportFrame";
|
||||
let source = match p.get("target") {
|
||||
None => false,
|
||||
Some(Value::String(t)) if t == "program" => false,
|
||||
Some(Value::String(t)) if t == "source" => true,
|
||||
_ => return Err(bad(CMD, "target must be source or program")),
|
||||
};
|
||||
let ext = if fmt == "tif" { "tiff" } else { fmt.as_str() };
|
||||
let seq_name = s.state.active_sequence.and_then(|i| s.project.item(i)).map(|i| i.name.clone()).unwrap_or_else(|| "Sequence".into());
|
||||
let path = match str_p(p, "path") {
|
||||
Some(path) => path.to_string(),
|
||||
let key = if source { "item" } else { "sequence" };
|
||||
let item = match p.get(key) {
|
||||
Some(v) => filmcraft_project::ItemId(v.as_u64().ok_or_else(|| bad(CMD, format!("{key} must be an item id")))?),
|
||||
None => if source { s.state.source_item } else { s.state.active_sequence }
|
||||
.ok_or_else(|| bad(CMD, "open a video clip or sequence in the requested monitor"))?,
|
||||
};
|
||||
let pi = s.project.item(item).ok_or_else(|| bad(CMD, "the requested item is unavailable"))?;
|
||||
let (width, height) = s.project.source_size(item).ok_or_else(|| bad(CMD, "the requested Source clip has no video frame"))?;
|
||||
filmcraft_project::validate_frame_size(width, height).map_err(|e| bad(CMD, e.to_string()))?;
|
||||
let (rate, current, bounds) = if source {
|
||||
let v = crate::clip_ops::source_view(s, item).ok_or_else(|| bad(CMD, "Source clip is unavailable"))?;
|
||||
if v.start.0 < 0 || v.end <= v.start {
|
||||
return Err(bad(CMD, "Source clip has invalid bounds"));
|
||||
}
|
||||
(v.rate, s.state.source_playhead, Some((v.start, v.end)))
|
||||
} else {
|
||||
let q = s.project.sequence(item).ok_or(EngineError::NoSequence)?;
|
||||
(q.settings.frame_rate, s.state.playheads.get(&item).copied().unwrap_or_default(), None)
|
||||
};
|
||||
let requested = match p.get("time") {
|
||||
None => current,
|
||||
Some(v) => Tick(v.as_i64().ok_or_else(|| bad(CMD, "time must be integer ticks"))?),
|
||||
};
|
||||
if rate.num <= 0 || rate.den <= 0 || requested.0 < 0 {
|
||||
return Err(bad(CMD, "frame rate must be positive and time non-negative"));
|
||||
}
|
||||
let invalid = || bad(CMD, "frame time exceeds the supported range");
|
||||
let unit = i128::from(filmcraft_time::TICKS_PER_SECOND).checked_mul(i128::from(rate.den)).ok_or_else(invalid)?;
|
||||
let fd = unit / i128::from(rate.num);
|
||||
if fd <= 0 || fd > i128::from(i64::MAX) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let t = bounds.map(|(a, b)| requested.clamp(a, Tick(b.0.saturating_sub(1)).max(a))).unwrap_or(requested);
|
||||
let frame = i128::from(t.0).checked_mul(i128::from(rate.num)).ok_or_else(invalid)? / unit;
|
||||
let snapped = frame.checked_mul(unit).ok_or_else(invalid)? / i128::from(rate.num);
|
||||
let mut time = Tick(i64::try_from(snapped).map_err(|_| invalid())?);
|
||||
if let Some((a, _)) = bounds {
|
||||
time = time.max(a);
|
||||
}
|
||||
Ok(FrameExportTarget { source, item, time, name: pi.name.clone(), width, height })
|
||||
}
|
||||
|
||||
/// Export the captured Source or Program frame as a full-resolution SDR still.
|
||||
fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = frame_export_target(s, p)?;
|
||||
let fmt = match p.get("format") {
|
||||
None => "png".to_string(),
|
||||
Some(v) => v.as_str().ok_or_else(|| bad("file.exportFrame", "format must be a string"))?.to_ascii_lowercase(),
|
||||
};
|
||||
let format = filmcraft_export::still::StillFormat::parse(&fmt).map_err(|e| bad("file.exportFrame", e.to_string()))?;
|
||||
let depth = match p.get("depth") {
|
||||
None => 8,
|
||||
Some(v) => u8::try_from(v.as_u64().ok_or_else(|| bad("file.exportFrame", "depth must be 8 or 16"))?)
|
||||
.map_err(|_| bad("file.exportFrame", "depth must be 8 or 16"))?,
|
||||
};
|
||||
if !matches!(depth, 8 | 16) || (depth == 16 && !format.supports_16()) {
|
||||
return Err(bad("file.exportFrame", "16-bit output requires PNG or TIFF; JPEG and BMP require 8-bit output"));
|
||||
}
|
||||
let import = match p.get("import") {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("file.exportFrame", "import must be true or false"))?,
|
||||
};
|
||||
let ext = format.extension();
|
||||
let path = match p.get("path") {
|
||||
Some(v) => {
|
||||
let path = v.as_str().filter(|v| !v.trim().is_empty()).ok_or_else(|| bad("file.exportFrame", "path must be a non-empty string"))?;
|
||||
crate::export_tools::expand_home(path)
|
||||
}
|
||||
None => {
|
||||
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let base = sanitize(&seq_name);
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = format!("{base}.Still{n:03}.{ext}");
|
||||
let cand = if dir.is_empty() { name } else { format!("{dir}/{name}") };
|
||||
if s.services.file_size(&cand).is_err() || n > 9999 {
|
||||
break cand;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).unwrap_or_else(|| std::path::Path::new(""));
|
||||
let base = sanitize(&target.name);
|
||||
let candidate =
|
||||
(1..=9999).map(|n| dir.join(format!("{base}.Still{n:03}.{ext}")).to_string_lossy().into_owned()).find(|p| s.services.file_size(p).is_err());
|
||||
candidate.ok_or_else(|| bad("file.exportFrame", "choose a path: all default still names already exist"))?
|
||||
}
|
||||
};
|
||||
let img = s.render_program(1.0).ok_or(EngineError::NoSequence)?;
|
||||
let (w, h) = (img.w as u32, img.h as u32);
|
||||
let bytes = filmcraft_export::encode_still(format, img.to_rgba8(), w, h).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
|
||||
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)
|
||||
};
|
||||
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"))?,
|
||||
);
|
||||
let bytes = filmcraft_export::still::encode(&img, format, depth).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut out = json!({"path": path, "width": w, "height": h});
|
||||
if p.get("import").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let mut out =
|
||||
json!({"path": path, "width": w, "height": h, "depth": depth, "target": if target.source { "source" } else { "program" }, "time": target.time.0});
|
||||
if import {
|
||||
out["import"] = s.execute("file.import", json!({"paths": [path]}))?;
|
||||
}
|
||||
s.toast(format!("Exported frame to {path}"));
|
||||
@@ -704,7 +785,14 @@ pub fn commands() -> Vec<CommandSpec> {
|
||||
spec("graphics.nudgeDown5", "Nudge Selected Object down by five", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 0.0, 5.0)),
|
||||
// ---- export frame, poster frame
|
||||
// Shift+E is Clip > Enable in FilmCraft Default; the Premiere preset moves it here
|
||||
spec("file.exportFrame", "Export Frame", None, r#"{"path":str?,"format":"png|tiff|bmp"?,"import":bool?}"#, has_seq, export_frame),
|
||||
spec(
|
||||
"file.exportFrame",
|
||||
"Export Frame",
|
||||
None,
|
||||
r#"{"path":str?,"format":"png|jpeg|tiff|bmp"?,"depth":8|16?,"import":bool?,"target":"source|program"?,"item":id?,"sequence":id?,"time":ticks?}"#,
|
||||
always,
|
||||
export_frame,
|
||||
),
|
||||
spec("clip.setPosterFrame", "Set Poster Frame", Some("Cmd+P"), r#"{"item":id?,"time":ticks?}"#, has_poster_target, |s, p| set_poster(s, p, false)),
|
||||
spec("clip.clearPosterFrame", "Clear Poster Frame", Some("Alt+P"), r#"{"item":id?}"#, has_poster_target, |s, p| set_poster(s, p, true)),
|
||||
];
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod graphic_templates;
|
||||
pub mod graphics;
|
||||
pub mod interchange;
|
||||
pub mod keyboard;
|
||||
mod marker_export;
|
||||
pub mod masks;
|
||||
pub mod media_browser;
|
||||
pub mod media_pool;
|
||||
@@ -45,6 +46,7 @@ pub mod sequence_tools;
|
||||
pub mod settings;
|
||||
pub mod shortcut_presets;
|
||||
pub mod shortcuts;
|
||||
pub mod source_monitor;
|
||||
pub mod sync;
|
||||
pub mod transcript;
|
||||
pub mod trim;
|
||||
@@ -553,10 +555,10 @@ impl Session {
|
||||
let Some(p) = self.persistence.as_mut() else { return };
|
||||
for ev in p.drain_events() {
|
||||
match ev {
|
||||
autosave::WorkerEvent::SavedProject { path, revision } => {
|
||||
if self.path.as_deref() == Some(path.as_str()) && revision > self.saved_revision && revision <= self.revision {
|
||||
self.saved_revision = revision;
|
||||
}
|
||||
autosave::WorkerEvent::SavedProject { path, revision }
|
||||
if self.path.as_deref() == Some(path.as_str()) && revision > self.saved_revision && revision <= self.revision =>
|
||||
{
|
||||
self.saved_revision = revision;
|
||||
}
|
||||
autosave::WorkerEvent::Error(m) => {
|
||||
self.log.push(panels::Level::Error, "autosave", m.clone());
|
||||
@@ -706,14 +708,15 @@ impl Session {
|
||||
open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(),
|
||||
active_sequence: self.state.active_sequence.filter(is_seq),
|
||||
sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(),
|
||||
playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are
|
||||
/// not sequences of this project are dropped (also a second mention of the same sequence),
|
||||
/// and numbers are brought into range. A view without any open sequence leaves the project
|
||||
/// on its first sequence: a project saved by a session that never showed one (a script, the
|
||||
/// CLI) should not open on an empty Timeline.
|
||||
/// and numbers are brought into range (a playhead also lands on a frame of its sequence). A
|
||||
/// view without any open sequence leaves the project on its first sequence: a project saved
|
||||
/// by a session that never showed one (a script, the CLI) should not open on an empty Timeline.
|
||||
pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) {
|
||||
let mut open: Vec<ItemId> = Vec::new();
|
||||
for id in view.open_sequences {
|
||||
@@ -727,6 +730,12 @@ impl Session {
|
||||
}
|
||||
self.state.timeline_views =
|
||||
view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect();
|
||||
for (id, t) in view.playheads {
|
||||
if let Some(seq) = self.project.sequence(id) {
|
||||
let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD);
|
||||
self.state.playheads.insert(id, seq.settings.frame_rate.snap(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every edit passes through here: one that would put a sequence inside itself (directly or
|
||||
@@ -1022,6 +1031,8 @@ mod aaf_omf_tests;
|
||||
#[cfg(test)]
|
||||
mod audio_effects_tests;
|
||||
#[cfg(test)]
|
||||
mod audio_placement_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod clip_ops_tests;
|
||||
@@ -1105,3 +1116,11 @@ mod trim_tests;
|
||||
mod vfx_tests;
|
||||
#[cfg(test)]
|
||||
mod voiceover_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_placement_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_export_tests;
|
||||
#[cfg(test)]
|
||||
mod wasm_clock_tests;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service.
|
||||
|
||||
use filmcraft_project::Label;
|
||||
use filmcraft_time::{Tick, format_timecode_frames};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::bad;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "markers.exportCsv";
|
||||
const MAX_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAX_MARKERS: usize = 100_000;
|
||||
|
||||
fn label_name(s: &Session, color: Label) -> &str {
|
||||
s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name())
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty() && !v.contains('\0'))
|
||||
.ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
seq.settings.validate().map_err(|e| bad(CMD, e))?;
|
||||
let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence");
|
||||
if seq.markers.len() > MAX_MARKERS {
|
||||
return Err(bad(CMD, "too many markers (maximum 100000)"));
|
||||
}
|
||||
// Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric,
|
||||
// timecode and enum fields; arbitrary text may double in size when every character is a quote.
|
||||
let mut bound = 512usize;
|
||||
for m in &seq.markers {
|
||||
let row = name
|
||||
.len()
|
||||
.checked_add(m.name.len())
|
||||
.and_then(|n| n.checked_add(m.comment.len()))
|
||||
.and_then(|n| n.checked_add(label_name(s, m.color).len()))
|
||||
.and_then(|n| n.checked_mul(2))
|
||||
.and_then(|n| n.checked_add(1024));
|
||||
bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?;
|
||||
}
|
||||
let rate = seq.settings.frame_rate;
|
||||
let mut markers: Vec<_> = seq.markers.iter().collect();
|
||||
markers.sort_by_key(|m| (m.start, m.id));
|
||||
let mut csv =
|
||||
String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n");
|
||||
for m in markers {
|
||||
if m.start.0 < 0 || m.duration.0 < 0 {
|
||||
return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0)));
|
||||
}
|
||||
let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?;
|
||||
let tc = |t: Tick| -> Result<String> {
|
||||
let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?;
|
||||
Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame))
|
||||
};
|
||||
let fields = [
|
||||
name.to_string(),
|
||||
m.id.0.to_string(),
|
||||
m.name.clone(),
|
||||
m.comment.clone(),
|
||||
format!("{:?}", m.kind),
|
||||
label_name(s, m.color).to_string(),
|
||||
tc(m.start)?,
|
||||
tc(Tick(end))?,
|
||||
rate.frame_at(m.duration).to_string(),
|
||||
m.start.0.to_string(),
|
||||
m.duration.0.to_string(),
|
||||
format!("{}/{}", rate.num, rate.den),
|
||||
];
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
csv.push(',');
|
||||
}
|
||||
csv.push('"');
|
||||
// Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text,
|
||||
// including formulas preceded by whitespace; numeric/timecode fields stay numeric.
|
||||
if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) {
|
||||
csv.push('\'');
|
||||
}
|
||||
for ch in field.chars() {
|
||||
if ch == '"' {
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push(ch);
|
||||
}
|
||||
csv.push('"');
|
||||
}
|
||||
csv.push_str("\r\n");
|
||||
}
|
||||
s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use filmcraft_project::{Label, Marker, MarkerId, MarkerKind};
|
||||
use filmcraft_time::FrameRate;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Files {
|
||||
writes: Mutex<Vec<(String, Vec<u8>)>>,
|
||||
fail: bool,
|
||||
}
|
||||
impl Services for Files {
|
||||
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
if self.fail {
|
||||
return Err(std::io::ErrorKind::PermissionDenied.into());
|
||||
}
|
||||
self.writes.lock().unwrap().push((path.into(), data.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn session(files: Arc<Files>) -> Session {
|
||||
let mut s = Session { services: files, ..Default::default() };
|
||||
s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap();
|
||||
s
|
||||
}
|
||||
fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker {
|
||||
Marker {
|
||||
id: MarkerId(id),
|
||||
start,
|
||||
duration,
|
||||
name: name.into(),
|
||||
comment: "First line\r\nSecond, \"quoted\"".into(),
|
||||
kind: MarkerKind::Chapter,
|
||||
color: Label::Iris,
|
||||
}
|
||||
}
|
||||
fn set_markers(s: &mut Session, markers: Vec<Marker>) {
|
||||
s.edit_sequence("Review markers", |q, _, _| {
|
||||
q.markers = markers;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
fn written(files: &Files) -> String {
|
||||
String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_24;
|
||||
set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]);
|
||||
s.execute("markers.markIn", json!({"frame":12})).unwrap();
|
||||
s.state.hidden_marker_colors.push(Label::Iris);
|
||||
let before = s.project.clone();
|
||||
let state = serde_json::to_value(&s.state).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap();
|
||||
let csv = written(&files);
|
||||
assert_eq!(result["markers"], 2);
|
||||
assert_eq!(result["bytes"], csv.len());
|
||||
assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\""));
|
||||
assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap());
|
||||
assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert_eq!(serde_json::to_value(&s.state).unwrap(), state);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
s.undo();
|
||||
s.redo();
|
||||
assert_eq!(*s.project, *before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_29_97;
|
||||
s.edit_sequence("Timing", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
q.settings.drop_frame = true;
|
||||
q.start_timecode = 107892;
|
||||
q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"df.csv"})).unwrap();
|
||||
assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\""));
|
||||
assert!(written(&files).contains("\"30000/1001\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] {
|
||||
let mut m = marker(100, Tick::ZERO, Tick::ZERO, text);
|
||||
m.comment = text.into();
|
||||
set_markers(&mut s, vec![m]);
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = text.into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute(CMD, json!({"path":"safe.csv"})).unwrap();
|
||||
assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3);
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into();
|
||||
s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap();
|
||||
assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim())));
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let spec = crate::find_command(CMD).unwrap();
|
||||
assert_eq!(spec.menu, ["Markers"]);
|
||||
assert!(spec.params.contains("path"));
|
||||
assert!(spec.shortcut.is_none());
|
||||
assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0);
|
||||
assert_eq!(written(&files).lines().count(), 1);
|
||||
assert!((spec.enabled)(&Session::default()).is_err());
|
||||
assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_parameters_and_damaged_timing_fail_before_writing() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] {
|
||||
assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err());
|
||||
}
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] {
|
||||
Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")];
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
}
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")];
|
||||
q.settings.frame_rate.num = i64::MAX;
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.settings.frame_rate = FrameRate::FPS_24;
|
||||
q.start_timecode = i64::MAX;
|
||||
q.markers[0].start = FrameRate::FPS_24.tick_of(1);
|
||||
assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err());
|
||||
q_reset(&mut s, id);
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.execute(CMD, json!({"path":"recovered.csv"})).unwrap();
|
||||
}
|
||||
fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) {
|
||||
let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap();
|
||||
q.start_timecode = 0;
|
||||
q.markers.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reports_and_write_errors_are_actionable() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]);
|
||||
assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB"));
|
||||
assert!(files.writes.lock().unwrap().is_empty());
|
||||
s.services = Arc::new(Files { fail: true, ..Default::default() });
|
||||
set_markers(&mut s, vec![]);
|
||||
s.saved_revision = s.revision;
|
||||
assert!(!s.is_dirty());
|
||||
let before = s.project.clone();
|
||||
assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv"));
|
||||
assert_eq!(*s.project, *before);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() {
|
||||
let files = Arc::new(Files::default());
|
||||
let mut s = session(files.clone());
|
||||
let rate = FrameRate::FPS_23_976;
|
||||
s.edit_sequence("Locked review", |q, _, _| {
|
||||
q.settings.frame_rate = rate;
|
||||
for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) {
|
||||
t.locked = true;
|
||||
}
|
||||
q.markers =
|
||||
vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into();
|
||||
let id = s.state.active_sequence.unwrap();
|
||||
s.edit("Review name", |p, _| {
|
||||
p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.saved_revision = s.revision;
|
||||
let path = "review, \"notes\"\n日本語.csv";
|
||||
s.execute(CMD, json!({"path":path})).unwrap();
|
||||
assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path);
|
||||
let csv = written(&files);
|
||||
assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\""));
|
||||
assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\""));
|
||||
assert!(csv.contains("\"A, \"\"B\"\"\n字幕\""));
|
||||
assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap());
|
||||
assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1)));
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
@@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let after = u64_p(p, "after").ok_or_else(|| bad("masks.addVertex", "need `after`"))? as usize;
|
||||
let at = p.get("at").and_then(pt).ok_or_else(|| bad("masks.addVertex", "need `at`"))?;
|
||||
edit_mask(s, p, "Add Mask Vertex", move |m, t| {
|
||||
let n = m.path_at(t).vertices.len();
|
||||
// clamp against the length before any insertion, so the index stays valid in the grown path
|
||||
let i = (after + 1).min(n);
|
||||
if m.path.is_animated() && m.path.keyframes.len() > 1 {
|
||||
if n == 0 {
|
||||
return Err(bad("masks.addVertex", "the animated mask path has no vertices to insert after"));
|
||||
}
|
||||
// keep every keyframe interpolable: insert the vertex into all of them
|
||||
for k in &mut m.path.keyframes {
|
||||
if let ParamValue::Path(path) = &mut k.value {
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
let a = path.vertices[after.min(path.vertices.len() - 1)].p;
|
||||
let b = path.vertices[i % path.vertices.len()].p;
|
||||
path.vertices.insert(i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
let len = path.vertices.len();
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let at_i = i.min(len);
|
||||
let a = path.vertices[after.min(len - 1)].p;
|
||||
let b = path.vertices[at_i % len].p;
|
||||
path.vertices.insert(at_i, MaskVertex::corner(a.lerp(b, 0.5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut path = m.path_at(t);
|
||||
let i = (after + 1).min(path.vertices.len());
|
||||
if m.path.keyframes.len() > 1 {
|
||||
path.vertices[i] = MaskVertex::corner(at);
|
||||
if let Some(v) = path.vertices.get_mut(i) {
|
||||
*v = MaskVertex::corner(at);
|
||||
}
|
||||
} else {
|
||||
path.vertices.insert(i, MaskVertex::corner(at));
|
||||
path.vertices.insert(i.min(path.vertices.len()), MaskVertex::corner(at));
|
||||
}
|
||||
set_path_at(m, t, path);
|
||||
Ok(())
|
||||
@@ -574,7 +585,7 @@ fn track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let start_path = mask.path_at(mt0);
|
||||
let run = move || {
|
||||
use std::sync::atomic::Ordering;
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let gray = |t: Tick| -> Option<(filmcraft_render::track::Prepared, f64)> {
|
||||
let f = src.video_frame(filmcraft_media::FrameRequest { time: t, scale }).ok()?;
|
||||
let g = filmcraft_render::track::Gray::from_rgba8(f.width as usize, f.height as usize, &f.to_rgba8());
|
||||
|
||||
@@ -106,6 +106,39 @@ fn keyframed_mask_path_interpolates() {
|
||||
assert_eq!(a["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_vertex_on_animated_path_clamps_oversized_index_and_rejects_empty_path() {
|
||||
let (mut s, clip) = demo();
|
||||
let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone();
|
||||
let rate = s.active_sequence().unwrap().settings.frame_rate;
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"delta": [5, 0]})).unwrap();
|
||||
// an index past the last vertex appends instead of panicking
|
||||
s.execute("masks.addVertex", json!({"after": 100, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 5);
|
||||
|
||||
// an empty animated path is a parameter error and leaves the mask alone
|
||||
s.set_playhead(it.start);
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 1, "param": "path"})).unwrap();
|
||||
s.set_playhead(it.start + rate.tick_of(10));
|
||||
s.execute("masks.translate", json!({"mask": 1, "delta": [5, 0]})).unwrap();
|
||||
let e = s.execute("masks.addVertex", json!({"mask": 1, "after": 0, "at": [1, 1]})).unwrap_err().to_string();
|
||||
assert!(e.contains("no vertices"), "{e}");
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][1]["path"]["vertices"].as_array().unwrap().len(), 0);
|
||||
|
||||
// a static empty path still takes its first vertex
|
||||
s.execute("masks.add", json!({"effect": "opacity", "path": {"vertices": [], "closed": false}})).unwrap();
|
||||
s.execute("masks.addVertex", json!({"mask": 2, "after": 0, "at": [1, 1]})).unwrap();
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
assert_eq!(l["masks"][2]["path"]["vertices"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_drag_is_one_undo_step_and_project_round_trips() {
|
||||
let (mut s, _) = demo();
|
||||
|
||||
@@ -516,9 +516,9 @@ pub fn column_text(e: &Entry, column: &str, probe: Option<&MediaInfo>) -> String
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
"Video Info" => probe.and_then(|i| i.video.as_ref()).map(|v| format!("{} x {}", v.width, v.height)).unwrap_or_default(),
|
||||
"Audio Info" => probe.and_then(|i| i.audio.as_ref()).map(|a| format!("{} Hz - {} ch", a.sample_rate, a.channels)).unwrap_or_default(),
|
||||
"Audio Info" => probe.and_then(|i| i.audio()).map(|a| format!("{} Hz - {} ch", a.sample_rate, a.channels)).unwrap_or_default(),
|
||||
"Video Codec" => probe.and_then(|i| i.video.as_ref()).map(|v| v.codec.clone()).unwrap_or_default(),
|
||||
"Audio Codec" => probe.and_then(|i| i.audio.as_ref()).map(|a| a.codec.clone()).unwrap_or_default(),
|
||||
"Audio Codec" => probe.and_then(|i| i.audio()).map(|a| a.codec.clone()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -663,7 +663,9 @@ pub fn std_volumes() -> Vec<Volume> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The desktop user's home directory.
|
||||
/// The desktop user's home directory. Windows prefers `USERPROFILE`: shells like Git Bash set
|
||||
/// `HOME` to a Unix-style path (`/c/Users/…`) that native file APIs cannot open.
|
||||
pub fn std_home_dir() -> Option<String> {
|
||||
std::env::var("HOME").ok().or_else(|| std::env::var("USERPROFILE").ok()).filter(|h| !h.is_empty())
|
||||
let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
|
||||
vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty()))
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ fn probing_large_unstreamable_files_does_not_read_them_whole() {
|
||||
crate::media_test_util::make_movie(&mov, filmcraft_media::DemoScene::OceanSunset, 64, 36, 24);
|
||||
let mut s = Session::default();
|
||||
assert!(media_browser::probe(&mut s, &avi.to_string_lossy()).is_none());
|
||||
assert_eq!(media_browser::probe(&mut s, &wav.to_string_lossy()).unwrap().audio.unwrap().channels, 2);
|
||||
assert_eq!(media_browser::probe(&mut s, &wav.to_string_lossy()).unwrap().audio().unwrap().channels, 2);
|
||||
assert!(media_browser::probe(&mut s, &mov.to_string_lossy()).unwrap().video.is_some());
|
||||
// importing is unchanged: it still opens files through the full path
|
||||
let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
|
||||
|
||||
@@ -280,7 +280,19 @@ impl MediaPool {
|
||||
}
|
||||
match self.open_file(path, services) {
|
||||
Ok(s) => {
|
||||
let src: SharedSource = Arc::new(ProxySource { proxy: s, info: m.info.clone() });
|
||||
// a proxy with fewer audio streams than the original (say one track of an OBS
|
||||
// recording's seven) still stands in for the picture; the audio comes from the
|
||||
// original, so every clip keeps the stream it plays
|
||||
let audio_from = if s.info().audio_streams.len() < m.info.audio_streams.len() {
|
||||
log::warn!("proxy has fewer audio streams than the original: {path}; audio from full-resolution media");
|
||||
match &m.media {
|
||||
MediaRef::File { path: original } if !m.offline => self.open_file(original, services).ok(),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let src: SharedSource = Arc::new(ProxySource { proxy: s, info: m.info.clone(), audio_from });
|
||||
self.proxies.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone()));
|
||||
Some(src)
|
||||
}
|
||||
@@ -357,7 +369,7 @@ impl MediaSource for SlateSource {
|
||||
Ok(Arc::new((*f).clone().with_pts(req.time)))
|
||||
}
|
||||
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
Ok(AudioBuffer::silence(sample_rate, self.info.audio.as_ref().map_or(2, |a| a.channels as usize), frames))
|
||||
Ok(AudioBuffer::silence(sample_rate, self.info.audio().map_or(2, |a| a.channels as usize), frames))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +379,8 @@ impl MediaSource for SlateSource {
|
||||
pub struct ProxySource {
|
||||
pub proxy: SharedSource,
|
||||
pub info: MediaInfo,
|
||||
/// Where audio comes from when the proxy has fewer audio streams than the original.
|
||||
pub audio_from: Option<SharedSource>,
|
||||
}
|
||||
|
||||
impl MediaSource for ProxySource {
|
||||
@@ -395,7 +409,10 @@ impl MediaSource for ProxySource {
|
||||
Ok(Arc::new(VideoFrame::rgba8(w as u32, h as u32, out).with_pts(f.pts)))
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.proxy.audio(start, frames, sample_rate)
|
||||
self.audio_from.as_ref().unwrap_or(&self.proxy).audio(start, frames, sample_rate)
|
||||
}
|
||||
fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
self.audio_from.as_ref().unwrap_or(&self.proxy).audio_stream(stream, start, frames, sample_rate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ------------------------------------------------------------------------------------- audio gain
|
||||
|
||||
/// Peak (dBFS, before clip gain) of each selected audio clip's source range.
|
||||
fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
pub(crate) fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
||||
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
||||
let sr = seq.settings.sample_rate.max(1);
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
@@ -1065,7 +1065,7 @@ fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraf
|
||||
let mut pos = s0;
|
||||
while pos < s0 + len {
|
||||
let n = (s0 + len - pos).min(sr as i64) as usize;
|
||||
if let Ok(b) = src.audio(pos, n, sr) {
|
||||
if let Ok(b) = src.audio_stream(it.audio_stream, pos, n, sr) {
|
||||
peak = b.peaks().into_iter().fold(peak, f32::max);
|
||||
}
|
||||
pos += n as i64;
|
||||
@@ -1129,19 +1129,19 @@ pub fn audio_gain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
m => return Err(bad("clip.audioGain", format!("unknown mode `{m}` (set, adjust, normalizeMax, normalizeAll)"))),
|
||||
};
|
||||
let n = new_gain.len();
|
||||
let applied = new_gain.clone();
|
||||
s.edit_sequence("Audio Gain", move |q, _, _| {
|
||||
let applied = s.edit_sequence("Audio Gain", move |q, _, _| {
|
||||
let mut applied = Vec::new();
|
||||
for t in q.audio_tracks.iter_mut() {
|
||||
for i in t.items.iter_mut() {
|
||||
if let Some((_, g)) = new_gain.iter().find(|(c, _)| *c == i.id) {
|
||||
i.gain_db = g.clamp(-96.0, 96.0);
|
||||
applied.push((i.id, i.gain_db));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(applied)
|
||||
})?;
|
||||
Ok(json!({"mode": mode, "clips": n, "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::<Vec<_>>()}))
|
||||
Ok(json!({"mode": mode, "clips": applied.len(), "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind).
|
||||
|
||||
@@ -312,6 +312,45 @@ fn audio_gain_modes_and_peak() {
|
||||
assert!(s.execute("clip.audioGain", json!({"mode": "loud"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_gain_reports_only_audio_items_it_updates() {
|
||||
for mode in ["set", "adjust"] {
|
||||
for target in [29, 30] {
|
||||
for linked in [true, false] {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
|
||||
let r = s.execute("clip.audioGain", json!({"clips": [target], "mode": mode, "db": -6})).unwrap();
|
||||
let applied = linked || target == 30;
|
||||
assert_eq!(r["clips"], usize::from(applied), "{mode}, target {target}, linked {linked}: {r}");
|
||||
assert_eq!(r["gainDb"], if applied { json!([{"clip": 30, "gainDb": -6.0}]) } else { json!([]) });
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.find_item(ClipId(29)).unwrap().1.gain_db, 0.0);
|
||||
assert_eq!(q.find_item(ClipId(30)).unwrap().1.gain_db, if applied { -6.0 } else { 0.0 });
|
||||
if applied {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db, 0.0);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db, -6.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_gain_reports_the_clamped_stored_gain() {
|
||||
for mode in ["set", "adjust"] {
|
||||
for db in [-200.0_f64, 200.0] {
|
||||
let mut s = demo();
|
||||
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
|
||||
let r = s.execute("clip.audioGain", json!({"clips": [30], "mode": mode, "db": db})).unwrap();
|
||||
let stored = s.active_sequence().unwrap().find_item(ClipId(30)).unwrap().1.gain_db;
|
||||
assert_eq!(stored, db.clamp(-96.0, 96.0));
|
||||
assert_eq!(r["gainDb"], json!([{"clip": 30, "gainDb": stored}]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_audio_transition_is_used_by_apply() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -609,7 +609,11 @@ fn camera_keys_cut_and_select() {
|
||||
let keys: Vec<(String, String)> =
|
||||
s.shortcuts.bindings.iter().filter(|b| b.command.starts_with("multicam.")).map(|b| (b.command.clone(), b.keys.clone())).collect();
|
||||
assert!(keys.contains(&("multicam.selectCamera1".into(), "1".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera2".into(), "Ctrl+2".into())), "{keys:?}");
|
||||
// ⌃9 is Premiere's macOS key; off macOS Ctrl+9 is Toggle All Audio Targets (⌘9 there), which
|
||||
// wins, so Cut to Camera 9 has no default key on Windows, Linux and FreeBSD
|
||||
let nine = keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into()));
|
||||
assert_eq!(nine, cfg!(target_os = "macos"), "{keys:?}");
|
||||
}
|
||||
|
||||
/// Colour of camera `k` (0-based) in the many-angle tests: distinct reds and greens.
|
||||
|
||||
@@ -224,7 +224,7 @@ pub fn metadata_fields(p: &Project, id: ItemId) -> Vec<Field> {
|
||||
let cs = media.and_then(|m| m.interpret.color_space).unwrap_or(detected);
|
||||
add("Clip", "Color Space", cs.label().to_string(), false);
|
||||
}
|
||||
if let Some(a) = media.and_then(|m| m.info.audio.as_ref()) {
|
||||
if let Some(a) = media.and_then(|m| m.info.audio()) {
|
||||
add("Clip", "Audio Info", format!("{} Hz - {} ch", a.sample_rate, a.channels), false);
|
||||
add("Clip", "Audio Codec", a.codec.clone(), false);
|
||||
}
|
||||
|
||||
@@ -48,10 +48,16 @@ pub fn decode_json() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Export counters: pictures encoded by hardware encoders, sessions, declined requests.
|
||||
/// Export counters: pictures encoded by hardware encoders, sessions, declined requests; and the wall
|
||||
/// time of each export stage in milliseconds (`stages`: setup, loudness, render, encode with its
|
||||
/// `convert` part, audio, mux, finish, wait), summed over the exports of this process (diff two readings).
|
||||
/// Render and encode overlap (the next batch renders while this one is encoded), so their sum can exceed
|
||||
/// the export's wall time; `waitMs` is the encoding side's idle wait for the render.
|
||||
pub fn export_json() -> Value {
|
||||
let hw = filmcraft_export::hw_encode_stats();
|
||||
json!({"hardware": {"frames": hw.frames, "sessions": hw.sessions, "declined": hw.declined}})
|
||||
let stages: serde_json::Map<String, Value> =
|
||||
filmcraft_export::stage_times().into_iter().map(|(s, ns)| (format!("{}Ms", s.name()), json!(ns as f64 / 1e6))).collect();
|
||||
json!({"hardware": {"frames": hw.frames, "sessions": hw.sessions, "declined": hw.declined}, "stages": stages})
|
||||
}
|
||||
|
||||
/// The engine's `perf.stats`.
|
||||
@@ -105,6 +111,9 @@ mod tests {
|
||||
for k in ["frames", "sessions", "declined"] {
|
||||
assert!(v["export"]["hardware"][k].is_number(), "export.hardware.{k} in {v}");
|
||||
}
|
||||
for k in ["setupMs", "loudnessMs", "renderMs", "encodeMs", "convertMs", "audioMs", "muxMs", "finishMs", "waitMs"] {
|
||||
assert!(v["export"]["stages"][k].is_number(), "export.stages.{k} in {v}");
|
||||
}
|
||||
assert!(v["media"]["openSources"].is_number());
|
||||
assert_eq!(v["jobs"]["running"], json!(0));
|
||||
assert_eq!(s.history.undo.len(), undo, "a query adds no undo step");
|
||||
|
||||
@@ -550,13 +550,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if chosen.is_empty() {
|
||||
return Err(bad("presets.export", "no presets to export"));
|
||||
}
|
||||
filmcraft_format::atomic_write(Path::new(path), &file_bytes(&chosen)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// through the host: atomic on the desktop, a download on the web (no filesystem there)
|
||||
s.services.write_file(path, &file_bytes(&chosen)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "count": chosen.len()}))
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("presets.import", "need `path`"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let presets = parse_file(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let names: Vec<String> = presets.iter().map(|x| x.name.clone()).collect();
|
||||
for pr in presets {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::presets::{KeyframeMode, retime};
|
||||
use crate::{Services, Session};
|
||||
use filmcraft_project::{ClipId, ParamValue, TrackItem};
|
||||
use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
@@ -203,3 +206,35 @@ fn mask_geometry_scales_to_the_target_frame_size() {
|
||||
assert!((c.x - 480.0).abs() < 1e-6 && (c.y - 270.0).abs() < 1e-6, "{c:?}");
|
||||
assert_eq!(m.feather.value, ParamValue::Float(80.0));
|
||||
}
|
||||
|
||||
/// A host without a filesystem (the web): files exist only through `Services`.
|
||||
#[derive(Default)]
|
||||
struct MemFs(Mutex<BTreeMap<String, Vec<u8>>>);
|
||||
|
||||
impl Services for MemFs {
|
||||
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
|
||||
self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.lock().unwrap().insert(path.into(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// #379: export and import go through the host's services, not `std::fs` (which traps on the web).
|
||||
#[test]
|
||||
fn export_and_import_go_through_host_services() {
|
||||
let fs = Arc::new(MemFs::default());
|
||||
let mut s = Session::new(fs.clone());
|
||||
let path = "/exports/owned-preset.prfpset";
|
||||
let r = s.execute("presets.export", json!({"path": path, "names": ["Fade In"]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let bytes = fs.read_file(path).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("filmcraft.effect-presets"));
|
||||
// a second session on the same host imports it from there
|
||||
let mut s2 = Session::new(fs);
|
||||
let r = s2.execute("presets.import", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Fade In"]));
|
||||
assert_eq!(s2.presets.user.len(), 1);
|
||||
assert!(s2.execute("presets.import", json!({"path": "/exports/missing.prfpset"})).is_err());
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result<Value> {
|
||||
let nseg = todo.len();
|
||||
let pool = s.media.clone();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut bytes = 0u64;
|
||||
let mut done_frames = 0u64;
|
||||
let mut outcome: std::result::Result<(), String> = Ok(());
|
||||
@@ -581,7 +581,7 @@ pub fn render_audio(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (prog, res) = (job.progress.clone(), job.result.clone());
|
||||
let nseg = todo.len();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut bytes = 0u64;
|
||||
let mut outcome: std::result::Result<(), String> = Ok(());
|
||||
let Some(q) = project.sequence(seq) else { return };
|
||||
|
||||
@@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap<ItemId, (Tick, Tic
|
||||
Some(ItemKind::Subclip { parent, .. }) => {
|
||||
used.insert(ti.item);
|
||||
target = *parent;
|
||||
// Loaded projects can nest subclips; keep every link up to the media (bounded like `resolve_media`).
|
||||
let mut up = *parent;
|
||||
for _ in 0..16 {
|
||||
used.insert(up);
|
||||
match p.item(up).map(|i| &i.kind) {
|
||||
Some(ItemKind::Subclip { parent, .. }) => up = *parent,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => continue,
|
||||
@@ -208,7 +217,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let prev_dst = crate::previews::dir_for_project(&project_path);
|
||||
let services = s.services.clone();
|
||||
let run = move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = web_time::Instant::now();
|
||||
let r = execute(proj, work, sources, previews.map(|d| (d, prev_dst)), &project_path, pr, &prog, &*services);
|
||||
let secs = t0.elapsed().as_secs_f64();
|
||||
let r = r.map(|bytes| filmcraft_export::Report {
|
||||
@@ -311,7 +320,7 @@ fn execute(
|
||||
|
||||
fn estimate_bytes(m: &filmcraft_project::MediaClip, dur: Tick, pr: &Preset) -> u64 {
|
||||
let secs = dur.seconds().max(0.0);
|
||||
let audio = m.info.audio.as_ref().map_or(0.0, |a| a.sample_rate as f64 * 2.0 * 2.0 * secs);
|
||||
let audio = m.info.audio().map_or(0.0, |a| a.sample_rate as f64 * 2.0 * 2.0 * secs);
|
||||
let Some(v) = &m.info.video else { return audio as u64 };
|
||||
let fps = v.frame_rate.num as f64 / v.frame_rate.den.max(1) as f64;
|
||||
let px = (v.width * v.height) as f64 * (pr.scale * pr.scale) as f64;
|
||||
|
||||
@@ -59,11 +59,12 @@ fn collect_files_copies_used_media_and_opens() {
|
||||
let jobs = s.execute("jobs.list", json!({})).unwrap();
|
||||
assert!(jobs[0]["result"].get("error").is_none(), "{jobs}");
|
||||
// the copy opens with its media next to it, and plays the same frames
|
||||
drop(s); // Windows does not allow renaming a directory while decoder files are open.
|
||||
std::fs::rename(root.join("Media"), root.join("Media-gone")).unwrap();
|
||||
let mut t = opened(r["project"].as_str().unwrap());
|
||||
for it in t.project.items.values().filter_map(|i| i.as_media()) {
|
||||
let MediaRef::File { path } = &it.media else { panic!() };
|
||||
assert!(path.starts_with(&*dest.to_string_lossy()), "{path}");
|
||||
assert!(std::path::Path::new(path).starts_with(&dest), "{path}");
|
||||
assert!(it.identity.is_some());
|
||||
}
|
||||
let after: Vec<Vec<u8>> = (0..36).step_by(5).map(|f| frame_rgba(&mut t, f, 1.0).2).collect();
|
||||
@@ -100,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subclip_keeps_its_whole_parent_chain_used() {
|
||||
use filmcraft_project::{ItemKind, Label};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
let root = tmp_dir("pm-nested-subclip");
|
||||
let (s, items, _) = project(&root);
|
||||
let seq = s.state.active_sequence.unwrap();
|
||||
let mut p = (*s.project).clone();
|
||||
let range = TimeRange::new(Tick::ZERO, Tick(1000));
|
||||
let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None);
|
||||
let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None);
|
||||
for t in p.sequence_mut(seq).unwrap().all_tracks_mut() {
|
||||
for x in t.items.iter_mut().filter(|i| i.item == items[0]) {
|
||||
x.item = outer;
|
||||
}
|
||||
}
|
||||
let (_, used) = super::project_manager::used_ranges(&p, &[seq]);
|
||||
assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user