diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index a7446cac9..a3fb4af4d 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 650b1dc83..960f1ee38 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a65db7bfd..08eba22b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 22a3a54bc..ccb475e58 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index bea60585b..684860456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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)). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b28b9539..3e94fa45b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | @@ -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 diff --git a/Cargo.lock b/Cargo.lock index c2d388112..c7ae334fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "cpal", @@ -1681,13 +1681,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", @@ -1697,7 +1698,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1705,7 +1706,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1715,14 +1716,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", @@ -1738,7 +1739,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1749,14 +1750,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", @@ -1769,11 +1770,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", @@ -1790,7 +1791,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1816,6 +1817,7 @@ dependencies = [ "filmcraft-vp9", "image", "log", + "serde_json", "symphonia", "thiserror 2.0.21", "web-time", @@ -1823,7 +1825,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -1831,7 +1833,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1841,8 +1843,9 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.4.0" +version = "0.5.0" dependencies = [ + "filmcraft-geom", "filmcraft-media", "filmcraft-project", "filmcraft-time", @@ -1853,7 +1856,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1885,7 +1888,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1915,11 +1918,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", @@ -1927,7 +1931,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1937,7 +1941,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -1945,7 +1949,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1962,7 +1966,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1979,7 +1983,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1989,7 +1993,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1999,7 +2003,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2009,7 +2013,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2026,7 +2030,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2037,7 +2041,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2045,7 +2049,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2058,7 +2062,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2067,7 +2071,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2075,39 +2079,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", @@ -2118,7 +2126,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2132,7 +2140,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2142,7 +2150,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2162,7 +2170,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "serde", @@ -2171,7 +2179,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.4.0" +version = "0.5.0" dependencies = [ "candle-core", "candle-nn", @@ -2189,25 +2197,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", @@ -2215,7 +2224,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.4.0" +version = "0.5.0" dependencies = [ "candle-core", "candle-nn", @@ -2229,7 +2238,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2237,7 +2246,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "cpu-time", "eframe", @@ -2273,7 +2282,7 @@ dependencies = [ [[package]] name = "filmcraft-vp9" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2282,7 +2291,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -5806,6 +5815,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" @@ -7263,7 +7281,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 458ee1fd2..1d306cffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 0c91ed07b..a4381a8d1 100644 --- a/README.md +++ b/README.md @@ -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.

@@ -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)).
@@ -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--linux-x86_64.flatpak` | `filmcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `filmcraft--linux-x86_64.deb` | `filmcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `filmcraft--linux-x86_64.rpm` | `filmcraft--linux-aarch64.rpm` | | -| Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack anywhere | +| Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--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 diff --git a/ROADMAP.md b/ROADMAP.md index 29c0409fb..7a32f5498 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. 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 31 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 31 common effects (GPU2): colour adjustments, crop/flip/transform, blurs, sharpen. 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 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 31 effect ids run on the GPU here; the rest, Lumetri included, run on the CPU. | | **Stability** | not tracked | **improving, unproven** | The never-crash pass (no panics in product code, last-resort guards) landed on 2026-10-04. There's no field record from real users' projects yet. | | **Plugins and pro ecosystem** | not tracked | **~0–10%** | No audio plugin hosting (VST3 / Audio Units) and no third-party video effects (OpenFX). For many professional editors this alone rules FilmCraft out. Team Projects and Productions are out of scope by design. | | **AI features** | partial | **~25–35%** | Speech to text exists but is off in default builds (the `whisper` feature). Enhance Speech is a DSP chain, not a model. Auto Reframe is approximate. No Generative Extend, and no media-intelligence search or auto colour. | @@ -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, Vignette, Video Limiter, masks (31 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); 31 common effects on the GPU (GPU2). 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); 31 common effects on the GPU (GPU2). 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/.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. diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 13bebb85d..be104b30c 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -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) @@ -67,13 +115,23 @@ EXIT STATUS 0 success · 1 a command failed · 2 usage error "; +/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's +/// reader is gone, which would turn a usage error, a failure or a finished export into a crash. +fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) { + let _ = writeln!(w, "{msg}"); +} + +fn diag(msg: std::fmt::Arguments) { + write_diag(&mut std::io::stderr(), msg); +} + fn usage(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."); + diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.")); std::process::exit(2) } fn fail(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}"); + diag(format_args!("filmcraft-cli: {msg}")); std::process::exit(1) } @@ -134,7 +192,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. @@ -162,16 +220,23 @@ 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(); 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 [--image-sequence]")); match probe::probe(path, a.flag("--image-sequence")) { @@ -189,11 +254,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); @@ -219,7 +284,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")); } } } @@ -246,9 +311,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); @@ -318,11 +383,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}"))); } @@ -344,7 +405,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}")), } @@ -359,11 +420,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") { @@ -381,14 +442,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 { + let paths: Vec = 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 { + 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 { + 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] diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index b7fedfc2c..c3d3c979f 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -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); +} diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index b959bdcb7..adf417ca7 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -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); diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs index e837a3710..6b41a2cdd 100644 --- a/apps/filmcraft-web/src/import.rs +++ b/apps/filmcraft-web/src/import.rs @@ -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 { +async fn import_path(path: String, bin: u64) -> Result { 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 { /// Import browser files (no copies): returns `{items, errors, paths}`. pub async fn import_files(files: Vec) -> 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) -> 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()); diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs index 469d2107a..d769ea4be 100644 --- a/apps/filmcraft-web/src/lib.rs +++ b/apps/filmcraft-web/src/lib.rs @@ -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); diff --git a/apps/filmcraft-web/src/opfs.rs b/apps/filmcraft-web/src/opfs.rs index b19071082..a246a52d2 100644 --- a/apps/filmcraft-web/src/opfs.rs +++ b/apps/filmcraft-web/src/opfs.rs @@ -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; + thread_local! { - static QUEUE: RefCell<(BTreeMap>, 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, Vec)>, 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) { + 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, done: Option) { 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) { } } }); - 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()); + } } }); } diff --git a/apps/filmcraft-web/src/recovery.rs b/apps/filmcraft-web/src/recovery.rs index 939559a46..6001531e8 100644 --- a/apps/filmcraft-web/src/recovery.rs +++ b/apps/filmcraft-web/src/recovery.rs @@ -6,24 +6,32 @@ //! - Imported media are copied to `media/` (the browser streams the copy; nothing goes //! through wasm memory), so the snapshot's `/files/` 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, + policy: Policy, + outcomes: Rc>>, } 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)); + 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>>, failed: Rc>, left: Rc>, 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()}); - 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))); + 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); } } diff --git a/apps/filmcraft-web/src/recovery_policy.rs b/apps/filmcraft-web/src/recovery_policy.rs new file mode 100644 index 000000000..8426d3c39 --- /dev/null +++ b/apps/filmcraft-web/src/recovery_policy.rs @@ -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, + last_time: Option, + /// 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); + } + } +} diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 612a782b2..48fa7a1c8 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -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 = const { Cell::new(0) }; static FALLBACKS: Cell = const { Cell::new(0) }; static SOURCES: Cell = const { Cell::new(0) }; + static TRANSFERS: RefCell = 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 Option>, @@ -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>, out: Rc>>, + copying: Rc>, + generation: Rc>, + alive: Rc>, _closures: (Closure, Closure), } @@ -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::::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::::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 { self.inner.audio(start, frames, sample_rate) } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + self.inner.audio_stream(stream, start, frames, sample_rate) + } } diff --git a/apps/filmcraft-web/src/webcodecs/pixels.rs b/apps/filmcraft-web/src/webcodecs/pixels.rs new file mode 100644 index 000000000..dc99e7711 --- /dev/null +++ b/apps/filmcraft-web/src/webcodecs/pixels.rs @@ -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 { + 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 { + 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, &'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::().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, + )) +} diff --git a/apps/filmcraft-web/tests/import-bins.mjs b/apps/filmcraft-web/tests/import-bins.mjs new file mode 100644 index 000000000..6e1811716 --- /dev/null +++ b/apps/filmcraft-web/tests/import-bins.mjs @@ -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; +} diff --git a/apps/filmcraft-web/tests/import-collision.mjs b/apps/filmcraft-web/tests/import-collision.mjs new file mode 100644 index 000000000..dfde3b664 --- /dev/null +++ b/apps/filmcraft-web/tests/import-collision.mjs @@ -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; +} diff --git a/apps/filmcraft-web/tests/recovery_policy.rs b/apps/filmcraft-web/tests/recovery_policy.rs new file mode 100644 index 000000000..c60b34d5b --- /dev/null +++ b/apps/filmcraft-web/tests/recovery_policy.rs @@ -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); +} diff --git a/apps/filmcraft-web/tests/smoke.mjs b/apps/filmcraft-web/tests/smoke.mjs index f43ceec0c..71951edf5 100644 --- a/apps/filmcraft-web/tests/smoke.mjs +++ b/apps/filmcraft-web/tests/smoke.mjs @@ -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)); diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index bcc127ce9..a7e3d6dab 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -11,7 +11,7 @@ build = "build.rs" workspace = true [features] -default = ["neural-voices"] +default = [] # Speech-to-text (Text panel ▸ Transcribe) with Whisper on the CPU, and model downloads. whisper = ["filmcraft-engine/whisper", "filmcraft-engine/speech-download"] # Natural Text to Speech voices (Kokoro-82M on the CPU, downloaded on first use). @@ -28,6 +28,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" diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs new file mode 100644 index 000000000..b2be99cbf --- /dev/null +++ b/apps/filmcraft/src/graphics.rs @@ -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) { + 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 { + 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); + } + } +} diff --git a/apps/filmcraft/src/logging.rs b/apps/filmcraft/src/logging.rs new file mode 100644 index 000000000..3d7c713fb --- /dev/null +++ b/apps/filmcraft/src/logging.rs @@ -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: +//! `/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..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 { + 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` → … → `..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: ` [] : \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, + pending: Vec, + 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, +} + +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 { + 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 = 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); + } +} diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index b9b279a12..18fb4d7b0 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -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 `/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 /Logs/crash-.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(); @@ -107,6 +129,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, @@ -116,7 +145,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 @@ -124,7 +153,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 @@ -132,7 +161,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 => { @@ -150,8 +179,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); @@ -168,27 +197,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()); @@ -207,7 +259,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(); } @@ -277,6 +329,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:?}"); } } diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 93601b7d0..c63f8295b 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -99,6 +99,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, 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 }; diff --git a/contributors/contributors.json b/contributors/contributors.json index 798be44a9..29b65c4c4 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -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 } ] } diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db208..9762e2ac7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index d03c40673..8d4035ab3 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -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 (`.`) and the caption sidecar +/// stills of an image sequence (`<3+ digits>.`, only when the output is a +/// PNG/TIFF/BMP sequence) and the caption sidecar /// (`.srt` / `.vtt`), each only if it was written since `since`. Other files that -/// merely share the name's stem (`.aep`, `2.psd`, …) are never touched, even when they -/// were saved during the export. +/// merely share the name's stem (`.aep`, `2.psd`, `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())); diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 4a43cd2cc..4c705c3c2 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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(); diff --git a/crates/av1/tests/adhoc.rs b/crates/av1/tests/adhoc.rs index d72a220e8..e140b9768 100644 --- a/crates/av1/tests/adhoc.rs +++ b/crates/av1/tests/adhoc.rs @@ -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(); diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/common/mod.rs index 7998dbd13..0892fd697 100644 --- a/crates/av1/tests/common/mod.rs +++ b/crates/av1/tests/common/mod.rs @@ -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 { - 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 { + 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 { + 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 { diff --git a/crates/av1/tests/conformance.rs b/crates/av1/tests/conformance.rs index d309bac6f..033f590e2 100644 --- a/crates/av1/tests/conformance.rs +++ b/crates/av1/tests/conformance.rs @@ -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 = match std::env::var("AV1_VECTORS") { Ok(v) => v.split(',').map(str::to_string).collect(), Err(_) => EXTENDED.iter().map(|s| s.to_string()).collect(), diff --git a/crates/captions/src/mcc.rs b/crates/captions/src/mcc.rs index b5da4e295..3004872bb 100644 --- a/crates/captions/src/mcc.rs +++ b/crates/captions/src/mcc.rs @@ -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 { 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::() % 256, 0, "checksum"); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 8765f3eb4..3a490e2c4 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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 } diff --git a/crates/codecs/src/apv.rs b/crates/codecs/src/apv.rs index 0001e2a30..c7068ccb4 100644 --- a/crates/codecs/src/apv.rs +++ b/crates/codecs/src/apv.rs @@ -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), diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 4942f6a6c..5f7c02f4a 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -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 { 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 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 { + 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, +} + +/// 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, tolerance: i64) -> Vec { + let tolerance = tolerance.max(0).unsigned_abs(); + let mut next: Option = 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 { + (0..count).map(|k| (k * 1024 * 1000 + 24_000) / 48_000 * 48).collect() + } + + fn timed(stamps: &[i64], samples: Option) -> Vec { + 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::>()); + } + + #[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 = (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); + } +} diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs new file mode 100644 index 000000000..40e43a2d8 --- /dev/null +++ b/crates/codecs/src/audio_timing_tests.rs @@ -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, Vec>) { + 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::>(); + 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]) -> Vec> { + 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], 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]) -> 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> { + 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], want: &[Vec], 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]) -> 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> = 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> = 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> = got.iter().map(|c| c[20 * 1024 + shift..].to_vec()).collect(); + let rest: Vec> = 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> = got.iter().map(|c| c[2048..].to_vec()).collect(); + assert_eq!(holes(&tail), 0); +} diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index b1ddd9a2e..3972998ef 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -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> { 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 { static POOL: std::sync::OnceLock> = 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) { @@ -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> { + fn idle_others(&self, me: &Shared, idle: Duration, keep: impl Fn(&State) -> bool) -> Vec> { let all: Vec> = self.caches.lock().unwrap_or_else(PoisonError::into_inner).iter().filter_map(Weak::upgrade).collect(); let mut idle: Vec<(Instant, Arc)> = 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> { 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> { + let all: Vec> = 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) -> 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) -> crate::Result> { + 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) -> crate::Result> { 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, decodes: Arc, } @@ -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 = (0..n).map(|_| samples(300, 250, 3, false)).collect(); + let c: Vec = (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 = (0..n).map(|_| samples(300, 250, 3, false)).collect(); + let c: Vec = (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 = (0..n).map(|_| samples(300, 250, 0, false)).collect(); + let c: Vec = (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)); diff --git a/crates/codecs/src/hw_frame.rs b/crates/codecs/src/hw_frame.rs index 59537d808..b37fc4d01 100644 --- a/crates/codecs/src/hw_frame.rs +++ b/crates/codecs/src/hw_frame.rs @@ -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) { diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index a14f74d3d..a31da3a7e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -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, } +/// One playable audio track with its own decoder state and timing tables. +struct MkvAudio { + /// Index into `MkvFile::tracks`. + track: usize, + state: Mutex, + /// Packet start positions in source sample frames. + starts: Vec, + /// 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, - atrack: Option, /// Video codec as an ISO-BMFF sample entry (for the decoder factories). ventry: Option, video: GopCache, - audio: Mutex, - /// Audio packet start positions in source sample frames. - audio_starts: Vec, - /// 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, +} + +/// 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 { + 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 { - 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 = 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 (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) -> (ColorInfo, bool) { + // H.273 code points; `Range`: 1 broadcast (limited), 2 full, 0 / 3 not signalled here + let code = |c: Option| 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 = [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 = 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::() 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 = 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 = 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> { @@ -373,13 +406,14 @@ impl MkvSource { } } - fn audio_packet(&self, st: &mut AudioState, i: usize) -> crate::Result>>> { + /// 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>>> { 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 { - 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 { + 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![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 = [(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 = (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![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()); + } +} diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 2fedf3be1..9ef2792be 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -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, } +/// One playable audio track with its own decoder state and timing tables. +struct Mp4Audio { + /// Index into `Mp4File::tracks`. + track: usize, + state: Mutex, + /// Packet start positions in source sample frames (see [`audio_starts`]). + starts: Vec, + /// 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, - atrack: Option, video: GopCache, - audio: Mutex, - /// Cumulative sample start frames for the audio track (for packet lookup). - audio_starts: Vec, - /// 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, } 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 { + 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 = 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 { 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 = [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 = + 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> = 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, Vec) = + 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 = 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> { @@ -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>>> { + /// 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>>> { 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 { - 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 { + 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![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 { + 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> = 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 = (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![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]; diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index c2d0ba67e..ef2a69994 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -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 { 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, diff --git a/crates/codecs/src/mxf.rs b/crates/codecs/src/mxf.rs index 5980906bc..1b230a8dc 100644 --- a/crates/codecs/src/mxf.rs +++ b/crates/codecs/src/mxf.rs @@ -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 { - 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); diff --git a/crates/codecs/src/ogg.rs b/crates/codecs/src/ogg.rs index 18092667d..1e729837a 100644 --- a/crates/codecs/src/ogg.rs +++ b/crates/codecs/src/ogg.rs @@ -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()), diff --git a/crates/codecs/src/stream_color.rs b/crates/codecs/src/stream_color.rs new file mode 100644 index 000000000..d01d6a640 --- /dev/null +++ b/crates/codecs/src/stream_color.rs @@ -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, +} + +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) -> 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 { + 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 { + // 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 { + // 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 { + // 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 +} diff --git a/crates/codecs/src/stream_color_tests.rs b/crates/codecs/src/stream_color_tests.rs new file mode 100644 index 000000000..1c6526fee --- /dev/null +++ b/crates/codecs/src/stream_color_tests.rs @@ -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) -> Vec { + 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) -> Vec { + 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 { + 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) -> CodecConfig { + CodecConfig::Avc(AvcConfig::new(vec![sps], vec![vec![0x68, 0xCE, 0x38, 0x80]], 4)) +} + +fn hevc(sps: Vec) -> 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) -> 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) -> 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) -> 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, fn(Vec) -> 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> = (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); +} diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 819a41f90..3f1f5047d 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -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); + } +} diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 8aada02e6..86b21b7d9 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -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 + '_ { 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 { + 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) -> Result { 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); diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs new file mode 100644 index 000000000..1298ccd48 --- /dev/null +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -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})"); + } +} diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index 5fdbc0bfc..d6a7042a0 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -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> { - 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, 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); } }; diff --git a/crates/codecs/tests/mxf_oracle.rs b/crates/codecs/tests/mxf_oracle.rs index 874b6e9a7..5a965b814 100644 --- a/crates/codecs/tests/mxf_oracle.rs +++ b/crates/codecs/tests/mxf_oracle.rs @@ -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); } } diff --git a/crates/codecs/tests/ogg_oracle.rs b/crates/codecs/tests/ogg_oracle.rs index 043715a12..939407756 100644 --- a/crates/codecs/tests/ogg_oracle.rs +++ b/crates/codecs/tests/ogg_oracle.rs @@ -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, Vec, 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()); diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index fcecc05a3..bcf354768 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -16,5 +16,6 @@ thiserror = { workspace = true } [dev-dependencies] proptest = { workspace = true } +filmcraft-geom = { workspace = true } filmcraft-media = { workspace = true } serde_json = { workspace = true } diff --git a/crates/edit/src/frame_size.rs b/crates/edit/src/frame_size.rs new file mode 100644 index 000000000..90585c9cd --- /dev/null +++ b/crates/edit/src/frame_size.rs @@ -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) -> 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)); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 67937f854..dc228522a 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4f49f1fad..ae258cb0f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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))); +} diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index 0905149a7..9b662abf9 100644 --- a/crates/edit/src/through.rs +++ b/crates/edit/src/through.rs @@ -59,6 +59,11 @@ pub fn through_edits(seq: &Sequence) -> Vec { /// 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, } } diff --git a/crates/edit/src/transcript/tests.rs b/crates/edit/src/transcript/tests.rs index 2fd8ed171..afd8c52bf 100644 --- a/crates/edit/src/transcript/tests.rs +++ b/crates/edit/src/transcript/tests.rs @@ -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, } } diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs index fd0d01965..823b895dc 100644 --- a/crates/engine/src/aaf_omf.rs +++ b/crates/engine/src/aaf_omf.rs @@ -143,7 +143,7 @@ fn decode(s: &Session, need: &AudioNeed, sample_rate: u32) -> Result 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, Vec)> { + 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 { 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(); diff --git a/crates/engine/src/aaf_omf_tests.rs b/crates/engine/src/aaf_omf_tests.rs index 1bd52a8c6..83bdd05de 100644 --- a/crates/engine/src/aaf_omf_tests.rs +++ b/crates/engine/src/aaf_omf_tests.rs @@ -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::(), 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>>; + + 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> { + Err(MediaError::NoStream("video")) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.audio_stream(0, start, frames, sample_rate) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + 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)>>, + } + impl Services for MemoryFiles { + fn read_file(&self, _: &str) -> std::io::Result> { + 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) { + 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 { + 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()); + } +} diff --git a/crates/engine/src/audio_placement_tests.rs b/crates/engine/src/audio_placement_tests.rs new file mode 100644 index 000000000..f765e055c --- /dev/null +++ b/crates/engine/src/audio_placement_tests.rs @@ -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>)> = 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![(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()); +} diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 5b87a9585..733384873 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -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 +/// `/data` instead of the per-user one (the Windows portable zip ships it). +pub const PORTABLE_MARKER: &str = "portable.txt"; + +/// `/data` when `exe_dir` holds [`PORTABLE_MARKER`], else `None`. +pub fn portable_data_dir(exe_dir: &Path) -> Option { + 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 `/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 { @@ -478,6 +489,9 @@ pub fn default_data_dir() -> Option { 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")); } diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 0e113e412..5ac8281e9 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -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. diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index 644282761..1566a596d 100644 --- a/crates/engine/src/captions.rs +++ b/crates/engine/src/captions.rs @@ -513,7 +513,11 @@ pub fn commands() -> Vec { 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 = 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::>()})) diff --git a/crates/engine/src/captions_tests.rs b/crates/engine/src/captions_tests.rs index 2167d4b77..feb65c175 100644 --- a/crates/engine/src/captions_tests.rs +++ b/crates/engine/src/captions_tests.rs @@ -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 = 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 = 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"); + } +} diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index a1910bfb7..27e6fff36 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -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 { 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 { 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 { 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 { 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 { 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 { 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(), diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 1df15fc5f..b6fcfe86d 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -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))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7355b94b6..ce1cc4dd1 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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 = 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> { - let items: Vec = 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 = 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 { 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>, +} + +/// 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], streams: usize) -> Vec { + 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> { + 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> = 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 = 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> = + 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 { "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 { 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 { "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 { })?; 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 { })?; 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 { })?; 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 { "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 { 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 { })?; 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 { "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 { 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 { "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 { } (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 { .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 { "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 { "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") ), @@ -2538,7 +2729,12 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { "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 { @@ -2564,6 +2760,9 @@ fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result { 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); @@ -3017,6 +3216,16 @@ fn set_transition(s: &mut Session, p: &Value) -> Result { } fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result { + 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() }); @@ -3067,6 +3276,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result Result Result { /// 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 { 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 = 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()) diff --git a/crates/engine/src/essential_sound_tests.rs b/crates/engine/src/essential_sound_tests.rs index 2474972ad..e7b5374ee 100644 --- a/crates/engine/src/essential_sound_tests.rs +++ b/crates/engine/src/essential_sound_tests.rs @@ -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 diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index 164d03983..1f2eca77f 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -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); +} diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 474b466a3..2757ebba4 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -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 { 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 = (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 { 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 = 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(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index b36566c7f..a2ef4ab41 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -129,12 +129,30 @@ fn parse_file(bytes: &[u8]) -> std::result::Result { // 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(), @@ -230,6 +248,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) => {} @@ -304,7 +325,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) }; } @@ -327,11 +348,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 @@ -339,7 +368,7 @@ fn expand_home(p: &str) -> String { fn output_path(s: &Session, p: &Value, seq: ItemId, settings: &ExportSettings, suffix: Option) -> 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(), }; @@ -633,11 +662,7 @@ fn queue_add(s: &mut Session, p: &Value) -> Result { }; 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)); @@ -762,7 +787,7 @@ fn queue_move(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/frame_export_tests.rs b/crates/engine/src/frame_export_tests.rs new file mode 100644 index 000000000..12702ba6b --- /dev/null +++ b/crates/engine/src/frame_export_tests.rs @@ -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()); +} diff --git a/crates/engine/src/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index db00d6ee2..0b9941e29 100644 --- a/crates/engine/src/graphic_templates.rs +++ b/crates/engine/src/graphic_templates.rs @@ -316,7 +316,7 @@ fn apply_template(s: &mut Session, p: &Value) -> Result { 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 { let name = text.lines().next().unwrap_or("Caption").chars().take(40).collect::(); 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); diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index 2e19a349b..a222ce2b9 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -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 { +fn new_graphic_clip(s: &mut Session, cmd: &str, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result { 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 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 { "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 { "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 { "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 { 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 { 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 { "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 { 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})) }, diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index 803cb7b36..70ede0ced 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -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(); diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index d6af66dc3..d8487251c 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -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; @@ -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 { - 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 { + 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 { + 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 { 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)), ]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 14d1720f3..e31f30147 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; @@ -46,6 +47,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; @@ -560,10 +562,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()); @@ -713,14 +715,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 = Vec::new(); for id in view.open_sequences { @@ -734,6 +737,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 @@ -1029,6 +1038,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; @@ -1114,3 +1125,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; diff --git a/crates/engine/src/marker_export.rs b/crates/engine/src/marker_export.rs new file mode 100644 index 000000000..86a6da6b5 --- /dev/null +++ b/crates/engine/src/marker_export.rs @@ -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 { + 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 { + 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)>>, + fail: bool, + } + impl Services for Files { + fn read_file(&self, _: &str) -> std::io::Result> { + 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) -> 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) { + 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()); + } +} diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index 40ab2e458..2a53cfdf4 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -366,23 +366,34 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { 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 { 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()); diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index cce70f7f6..549a64fc6 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -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(); diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 60cc6c244..515a10d42 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -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 { 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 { - 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())) } diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index df956ea00..972220b3a 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -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(); diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index cf468bd22..f82aa78bd 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -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 { - 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, } 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 { - 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 { + self.audio_from.as_ref().unwrap_or(&self.proxy).audio_stream(stream, start, frames, sample_rate) } } diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs index 83e48aff4..514007514 100644 --- a/crates/engine/src/mixer.rs +++ b/crates/engine/src/mixer.rs @@ -1048,7 +1048,7 @@ fn clip_set(s: &mut Session, p: &Value) -> Result { // ------------------------------------------------------------------------------------- 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 { } 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::>()})) + Ok(json!({"mode": mode, "clips": applied.len(), "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::>()})) } /// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind). diff --git a/crates/engine/src/mixer_tests.rs b/crates/engine/src/mixer_tests.rs index 3843d0180..faf9243ae 100644 --- a/crates/engine/src/mixer_tests.rs +++ b/crates/engine/src/mixer_tests.rs @@ -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(); diff --git a/crates/engine/src/multicam_tests.rs b/crates/engine/src/multicam_tests.rs index 67fef4600..486d33730 100644 --- a/crates/engine/src/multicam_tests.rs +++ b/crates/engine/src/multicam_tests.rs @@ -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. diff --git a/crates/engine/src/narration.rs b/crates/engine/src/narration.rs index d5e74b456..cc9f1fdf8 100644 --- a/crates/engine/src/narration.rs +++ b/crates/engine/src/narration.rs @@ -189,7 +189,7 @@ fn write_and_import(s: &mut Session, p: &Value, samples: &[f32], rate: u32, near _ => None, }); let dir = beside.unwrap_or_else(|| crate::voiceover::media_dir(s, p, "Narrations")); - let path = crate::voiceover::unique_wav_path(s, &dir, BASE_NAME); + let path = crate::voiceover::unique_wav_path(s, &dir, BASE_NAME)?; if !cfg!(target_arch = "wasm32") { std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; } @@ -395,7 +395,7 @@ fn spawn(s: &mut Session, job: crate::Job, wait: bool, name: &str, run: impl FnO type JobResult = std::sync::Mutex>>; -fn finish(prog: &filmcraft_export::Progress, res: &JobResult, r: std::result::Result, t0: std::time::Instant) { +fn finish(prog: &filmcraft_export::Progress, res: &JobResult, r: std::result::Result, t0: web_time::Instant) { use std::sync::atomic::Ordering; let secs = t0.elapsed().as_secs_f64(); let r = match r { @@ -431,7 +431,7 @@ fn render_job(s: &mut Session, p: &Value) -> Result { let (prog, res, cache) = (job.progress.clone(), job.result.clone(), s.tts_cache.clone()); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); let id = spawn(s, job, wait, "filmcraft-tts", move || { - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); *prog.status.lock().unwrap_or_else(PoisonError::into_inner) = "Synthesizing…".into(); let r = match render(&n, crate::transcript::models_dir().as_deref()) { Ok(a) => { @@ -462,7 +462,7 @@ fn download_voices(s: &mut Session, p: &Value) -> Result { job.progress.total.store(tts::catalog::missing_bytes(&dir), std::sync::atomic::Ordering::Relaxed); let (prog, res) = (job.progress.clone(), job.result.clone()); let id = spawn(s, job, wait, "filmcraft-tts-download", move || { - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); let r = download_package(&dir, &prog); finish(&prog, &res, r.map(|()| "Natural voices installed".to_string()), t0); })?; diff --git a/crates/engine/src/narration_tests.rs b/crates/engine/src/narration_tests.rs index 055634823..21896b5e8 100644 --- a/crates/engine/src/narration_tests.rs +++ b/crates/engine/src/narration_tests.rs @@ -396,3 +396,30 @@ fn natural_voices_are_listed_and_explain_what_is_missing() { assert!(e.contains("not available in this build"), "{e}"); } } + +#[test] +fn a_project_subset_keeps_only_the_narrations_of_kept_items() { + let mut s = demo(); + let dir = tmp("subset"); + let r = s.execute("tts.create", json!({"text": "Hello.", "voice": "basic-male", "dir": dir})).unwrap(); + let item = ItemId(r["item"].as_u64().unwrap()); + let others: std::collections::BTreeSet = s.project.items.keys().copied().filter(|i| *i != item).collect(); + assert!(crate::project_tools::project_subset(&s.project, &others).narrations.is_empty()); + let with: std::collections::BTreeSet = [item].into(); + assert!(crate::project_tools::project_subset(&s.project, &with).narrations.contains_key(&item)); +} + +#[test] +fn unique_wav_path_gives_up_instead_of_looping_forever() { + let mut s = demo(); + let first = crate::voiceover::unique_wav_path(&s, "/nonexistent-dir", "Narration").unwrap(); + assert!(first.ends_with("Narration 1.wav"), "{first}"); + // every candidate name taken by a project item: an error, not an endless loop + let template = s.project.items.values().next().cloned().unwrap(); + for k in 1..=99_999u32 { + let mut it = template.clone(); + it.name = format!("Narration {k}.wav"); + std::sync::Arc::make_mut(&mut s.project).items.insert(ItemId(1_000_000 + u64::from(k)), it); + } + assert!(crate::voiceover::unique_wav_path(&s, "/nonexistent-dir", "Narration").is_err()); +} diff --git a/crates/engine/src/panels.rs b/crates/engine/src/panels.rs index 0c1b994f5..45da2e219 100644 --- a/crates/engine/src/panels.rs +++ b/crates/engine/src/panels.rs @@ -224,7 +224,7 @@ pub fn metadata_fields(p: &Project, id: ItemId) -> Vec { 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); } diff --git a/crates/engine/src/perf.rs b/crates/engine/src/perf.rs index c909b50ea..17c0e7937 100644 --- a/crates/engine/src/perf.rs +++ b/crates/engine/src/perf.rs @@ -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 = + 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"); diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 07de7812a..75f711447 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -550,13 +550,14 @@ fn export(s: &mut Session, p: &Value) -> Result { 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 { 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 = presets.iter().map(|x| x.name.clone()).collect(); for pr in presets { diff --git a/crates/engine/src/presets_tests.rs b/crates/engine/src/presets_tests.rs index 57c642e7d..db8065b37 100644 --- a/crates/engine/src/presets_tests.rs +++ b/crates/engine/src/presets_tests.rs @@ -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>>); + +impl Services for MemFs { + fn read_file(&self, path: &str) -> std::io::Result> { + 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()); +} diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index bb1bd78df..8533f6555 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -480,7 +480,7 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { 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 { 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 }; diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index 756c3b4e8..3a78fff18 100644 --- a/crates/engine/src/project_manager.rs +++ b/crates/engine/src/project_manager.rs @@ -44,6 +44,15 @@ pub fn used_ranges(p: &Project, seqs: &[ItemId]) -> (BTreeMap { 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 { 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; diff --git a/crates/engine/src/project_manager_tests.rs b/crates/engine/src/project_manager_tests.rs index 82c4a7d43..3f7ce4fec 100644 --- a/crates/engine/src/project_manager_tests.rs +++ b/crates/engine/src/project_manager_tests.rs @@ -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> = (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); +} diff --git a/crates/engine/src/project_panel.rs b/crates/engine/src/project_panel.rs index ea18581dd..846592968 100644 --- a/crates/engine/src/project_panel.rs +++ b/crates/engine/src/project_panel.rs @@ -384,7 +384,7 @@ pub fn cell(p: &Project, it: &ProjectItem, column: &str) -> (String, SortKey) { "Video Codec" => text(media.and_then(|m| m.info.video.as_ref()).map(|v| v.codec.clone()).unwrap_or_default()), "Audio Info" => text( media - .and_then(|m| m.info.audio.as_ref()) + .and_then(|m| m.info.audio()) .map(|a| { format!( "{} Hz - {}", @@ -400,7 +400,7 @@ pub fn cell(p: &Project, it: &ProjectItem, column: &str) -> (String, SortKey) { }) .unwrap_or_default(), ), - "Audio Codec" => text(media.and_then(|m| m.info.audio.as_ref()).map(|a| a.codec.clone()).unwrap_or_default()), + "Audio Codec" => text(media.and_then(|m| m.info.audio()).map(|a| a.codec.clone()).unwrap_or_default()), "Color Space" => text(match (media.and_then(|m| m.info.video.as_ref().map(|v| (m, v))), &it.kind) { (Some((m, v)), _) => m.interpret.color_space.unwrap_or_else(|| filmcraft_color::ColorSpace::from_info(&v.color)).label().to_string(), (None, ItemKind::Sequence(q)) => q.settings.color.working.label().to_string(), diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 4bda2d666..a23e7530c 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -24,7 +24,7 @@ use std::collections::BTreeSet; -use filmcraft_project::{BinEntry, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; +use filmcraft_project::{BinEntry, BinId, FindOp, FindQuery, FindRow, ItemId, ItemKind, MarkerKind, MediaRef, Project, SearchBin, TrackKind}; use filmcraft_time::{Tick, TimeDisplay, TimeRange}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -699,14 +699,30 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { /// The selected items plus everything they need: sequence contents (recursively), subclip /// parents, multi-camera / merged-clip sources and graphic canvases. +/// +/// Bins and items share one id space, so a named bin contributes every item it holds (and +/// everything those items need), as `project.delete` does. The project's own top bin does not, +/// since no command acts on it (#244), and only ids that name a real item end up in the result. pub fn closure(p: &Project, roots: &[ItemId]) -> BTreeSet { let mut keep: BTreeSet = BTreeSet::new(); let mut todo: Vec = roots.to_vec(); + let mut seen_bins: BTreeSet = BTreeSet::new(); while let Some(id) = todo.pop() { - if !keep.insert(id) { + let bin = BinId(id.0); + if bin != p.root.id && p.root.find_bin(bin).is_some() { + if seen_bins.insert(id.0) + && let Some(b) = p.root.find_bin(bin) + { + let mut items = Vec::new(); + b.all_items(&mut items); + todo.extend(items); + } continue; } let Some(it) = p.item(id) else { continue }; + if !keep.insert(id) { + continue; + } match &it.kind { ItemKind::Subclip { parent, .. } => todo.push(*parent), ItemKind::Sequence(q) => { @@ -740,6 +756,7 @@ pub fn project_subset(p: &Project, keep: &BTreeSet) -> Project { out.items.retain(|id, _| keep.contains(id)); prune(&mut out.root, keep); out.transcripts.retain(|id, _| keep.contains(id)); + out.narrations.retain(|id, _| keep.contains(id)); out.search_bins.clear(); out } @@ -754,6 +771,9 @@ fn export_selection(s: &mut Session, p: &Value) -> Result { return Err(bad("file.exportSelectionProject", "select items in the Project panel")); } let keep = closure(&s.project, &roots); + if keep.is_empty() { + return Err(bad("file.exportSelectionProject", "the selection has no items to export")); + } let mut sub = project_subset(&s.project, &keep); let name = std::path::Path::new(&path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| sub.name.clone()); sub.name = name.clone(); @@ -805,7 +825,7 @@ pub fn info_json(name: &str, path: Option<&str>, info: &filmcraft_media::MediaIn "bitrate": vs.bitrate, "color": format!("{:?} / {:?} / {:?}", vs.color.primaries, vs.color.transfer, vs.color.matrix), }); } - if let Some(a) = &info.audio { + if let Some(a) = info.audio() { v["audio"] = json!({"codec": a.codec, "sampleRate": a.sample_rate, "channels": a.channels, "bitsPerSample": a.bits_per_sample}); } v @@ -1002,7 +1022,7 @@ fn edit_offline(s: &mut Session, p: &Value) -> Result { fn source_settings(s: &mut Session, p: &Value) -> Result { let id = targets(s, p).into_iter().find(|i| media_of(&s.project, *i).is_some()).ok_or_else(|| EngineError::Other("select a clip".into()))?; let (_, m) = media_of(&s.project, id).ok_or_else(|| EngineError::Other("select a clip".into()))?; - let codec = m.info.video.as_ref().map(|v| v.codec.clone()).or_else(|| m.info.audio.as_ref().map(|a| a.codec.clone())).unwrap_or_default(); + let codec = m.info.video.as_ref().map(|v| v.codec.clone()).or_else(|| m.info.audio().map(|a| a.codec.clone())).unwrap_or_default(); Ok(json!({ "item": id.0, "codec": codec, diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 11b7bd497..e00af7326 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -220,6 +220,59 @@ fn export_selection_project_and_ale() { assert!(doc.rows.iter().any(|r| r[0] == "Ambient_Score.wav" && r[1] == "A1A2")); } +#[test] +fn export_selection_project_of_a_bin_exports_its_contents() { + let mut s = demo(); + let (footage_bin, bin_items) = s + .project + .root + .children + .iter() + .find_map(|c| match c { + filmcraft_project::BinEntry::Bin(b) if b.name == "Footage" => { + let mut items = Vec::new(); + b.all_items(&mut items); + Some((b.id.0, items)) + } + _ => None, + }) + .expect("Footage bin"); + assert_eq!(bin_items.len(), 6); + + // Selecting the bin (its id, not its items) must export the bin's contents, not an empty project. + s.execute("project.select", json!({"items": [footage_bin]})).unwrap(); + let dir = tmp_dir("export-bin"); + let path = dir.join("Footage.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.exportSelectionProject", json!({"path": path})).unwrap(); + let p = filmcraft_format::decode(&std::fs::read(&path).unwrap()).unwrap().project; + assert_eq!(r["items"].as_array().unwrap().len(), p.items.len(), "reported items match the saved project"); + let reported: std::collections::BTreeSet = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect(); + assert_eq!(reported, bin_items.iter().copied().collect(), "the bin's items are the exported items"); + for n in ["Ocean_Sunset.mp4", "Neon_Loop.mov", "Misty_Forest.mp4"] { + assert!(p.items.values().any(|i| i.name == n), "{n}"); + } + assert!(!p.items.values().any(|i| i.name == "Ambient_Score.wav"), "the Audio bin stayed behind"); + let mut listed = Vec::new(); + p.root.all_items(&mut listed); + assert!(p.items.keys().all(|k| listed.contains(k) || matches!(p.item(*k).unwrap().kind, ItemKind::Graphic { .. }))); + + // A mixed bin + item request must not report the bin id, which names no item in the output. + let explicit = dir.join("Mixed.fcproj").to_string_lossy().to_string(); + let r = s.execute("file.exportSelectionProject", json!({"items": [footage_bin, bin_items[0].0], "path": explicit})).unwrap(); + let p = filmcraft_format::decode(&std::fs::read(&explicit).unwrap()).unwrap().project; + for v in r["items"].as_array().unwrap() { + let id = ItemId(v.as_u64().unwrap()); + assert!(p.item(id).is_some(), "reported item {id:?} is in the output"); + } + + // An empty bin has nothing to export: an actionable error, no misleading empty file. + let empty_bin = s.execute("file.newBin", json!({"name": "Empty"})).unwrap()["bin"].as_u64().unwrap(); + s.execute("project.select", json!({"items": [empty_bin]})).unwrap(); + let empty_path = dir.join("Empty.fcproj").to_string_lossy().to_string(); + assert!(s.execute("file.exportSelectionProject", json!({"path": empty_path})).is_err()); + assert!(!std::path::Path::new(&empty_path).exists(), "no empty project written"); +} + #[test] fn media_properties_settings_and_scratch_disks() { let dir = tmp_dir("props"); diff --git a/crates/engine/src/proxies.rs b/crates/engine/src/proxies.rs index 55d131ee4..de5bdf576 100644 --- a/crates/engine/src/proxies.rs +++ b/crates/engine/src/proxies.rs @@ -105,14 +105,14 @@ pub fn transcode( st.height = v.height; } st.frame_rate = rate; - st.sample_rate = info.audio.as_ref().map_or(48_000, |a| a.sample_rate.max(8000)); + st.sample_rate = info.audio().map_or(48_000, |a| a.sample_rate.max(8000)); let mut p = Project::new("transcode"); let mut c = clip.clone(); c.offline = false; c.proxy = None; let item = p.add_item(name, filmcraft_project::Label::Iris, ItemKind::Media(c), None); let has_v = info.video.is_some(); - let has_a = info.audio.is_some(); + let has_a = info.has_audio(); let seq = p.new_sequence("transcode", st, usize::from(has_v), usize::from(has_a), None); let range = range.unwrap_or(TimeRange { start: Tick::ZERO, duration: info.duration }); let mut placed = Vec::new(); @@ -200,7 +200,7 @@ pub fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, String, &'st let outputs: Arc>> = Arc::default(); let (prog, res, outs) = (job.progress.clone(), job.result.clone(), outputs.clone()); let run = move || { - let t0 = std::time::Instant::now(); + let t0 = web_time::Instant::now(); let mut bytes = 0; let mut err = None; let n = tasks.len(); diff --git a/crates/engine/src/proxies_tests.rs b/crates/engine/src/proxies_tests.rs index cb1016142..6cff74e35 100644 --- a/crates/engine/src/proxies_tests.rs +++ b/crates/engine/src/proxies_tests.rs @@ -40,7 +40,7 @@ fn create_attach_toggle_renders_the_same_picture() { let full_half = frame_rgba(&mut s, 5, 0.5); let r = s.execute("media.createProxies", json!({"items": [items[0].0], "preset": "prores_proxy_half", "wait": true})).unwrap(); let out = r["outputs"][0]["path"].as_str().unwrap().to_string(); - assert!(out.ends_with("Proxies/a_Proxy.mov"), "{out}"); + assert!(std::path::Path::new(&out).ends_with(std::path::Path::new("Proxies").join("a_Proxy.mov")), "{out}"); let job = s.execute("jobs.list", json!({})).unwrap(); assert_eq!(job[0]["finished"], json!(true), "{job}"); let m = s.project.item(items[0]).unwrap().as_media().unwrap().clone(); diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 7adf81c66..7037404ab 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -263,7 +263,7 @@ pub fn check_candidate(s: &Session, item: ItemId, path: &str, o: &MatchOptions) if info.video.is_some() != m.info.video.is_some() { c.problems.push("video stream presence differs".into()); } - if let (Some(a), Some(b)) = (&info.audio, &m.info.audio) + if let (Some(a), Some(b)) = (info.audio(), m.info.audio()) && a.channels != b.channels { c.problems.push(format!("audio channels differ ({} vs {})", a.channels, b.channels)); @@ -303,8 +303,9 @@ pub fn derive_remap(old: &str, new: &str) -> Option<(String, String)> { /// Apply a remap to a path (None when it doesn't start with the old prefix). pub fn apply_remap(path: &str, from: &str, to: &str) -> Option { let p = path.replace('\\', "/"); - let from = from.trim_end_matches('/'); - let rest = p.strip_prefix(from)?; + let from = from.replace('\\', "/"); + let to = to.replace('\\', "/"); + let rest = p.strip_prefix(from.trim_end_matches('/'))?; if !(rest.is_empty() || rest.starts_with('/')) { return None; } @@ -406,12 +407,18 @@ pub fn relink(s: &mut Session, p: &Value) -> Result { Some(MediaRef::File { path }) => path, _ => return Err(bad("media.relink", format!("item {} is not file media", item.0))), }; - let c = check_candidate(s, item, &path, &o)?; + let mut c = check_candidate(s, item, &path, &o)?; if !c.ok && !force { return Err(EngineError::Other(format!("{} doesn't match {}: {}", file_name(&path), file_name(&old), c.problems.join("; ")))); } - if c.source.is_none() && s.media.open_file(&path, &*s.services).is_err() { - return Err(EngineError::Other(format!("{path} can't be opened"))); + if c.source.is_none() { + // Forced past a failed check: the file was never opened for its properties, so do it now + // and let the item take the new file's own info instead of keeping the old file's. + let Ok(src) = s.media.open_file(&path, &*s.services) else { + return Err(EngineError::Other(format!("{path} can't be opened"))); + }; + c.info = Some(src.info().clone()); + c.source = Some(src); } let mut plans = vec![Plan { item, cand: c }]; if o.relink_others { diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index b3039d970..b6475c0d4 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -76,7 +76,8 @@ fn moved_media_opens_offline_and_relinks_others_by_folder() { assert!(!s.offline.prompt); assert_eq!(frame_rgba(&mut s, 3, 1.0).2, online, "relinked media renders as before the move"); let b_path = s.project.item(items[1]).unwrap().as_media().unwrap().media.clone(); - assert_eq!(b_path, filmcraft_project::MediaRef::File { path: moved.join("b.mov").to_string_lossy().into_owned() }); + let filmcraft_project::MediaRef::File { path: b_path } = b_path else { panic!("relinked media must remain file-based") }; + assert_eq!(std::path::Path::new(&b_path), moved.join("b.mov")); // undo brings the old (missing) paths back, and the slate with them s.execute("edit.undo", json!({})).unwrap(); assert!(psnr(&frame_rgba(&mut s, 3, 1.0).2, &slate) > 40.0); @@ -127,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn forced_relink_takes_the_new_files_properties() { + let root = tmp_dir("relink-forced-info"); + let (path, items) = saved_project(&root); + std::fs::remove_dir_all(root.join("Media")).unwrap(); + // same name, different pictures and a different size + let other = root.join("Other"); + std::fs::create_dir_all(&other).unwrap(); + make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12); + let mut s = open(&path); + s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap(); + let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width)); + assert_eq!(width, Some(W * 2), "the item describes the replacement file"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn make_offline_shows_the_slate_until_relinked() { let root = tmp_dir("relink-offline"); @@ -177,3 +194,10 @@ fn reimport_clears_offline_list_after_path_resolves() { let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() { + assert_eq!(apply_remap(r"C:\shoot\Media\a.mov", r"C:\shoot\Media\", r"D:\archive\Media\"), Some("D:/archive/Media/a.mov".into())); + assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into())); + assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None); +} diff --git a/crates/engine/src/sequence_tabs_tests.rs b/crates/engine/src/sequence_tabs_tests.rs index d8d6cd487..f3cb5f369 100644 --- a/crates/engine/src/sequence_tabs_tests.rs +++ b/crates/engine/src/sequence_tabs_tests.rs @@ -1,9 +1,10 @@ //! The Timeline's sequence tabs: closing the others, reordering, and what a project file keeps of -//! them (the open tabs in order, the active one, and each sequence's zoom, scroll and track -//! heights). Premiere's behaviour was observed in Premiere Pro 26.5.2. +//! them (the open tabs in order, the active one, each sequence's zoom, scroll and track heights, +//! and each sequence's playhead). Premiere's tab behaviour was observed in Premiere Pro 26.5.2. use super::*; use filmcraft_project::SequenceView; +use filmcraft_time::TICKS_PER_SECOND; use serde_json::json; /// The demo project with two more sequences open: tabs [main, b, c], `c` active. @@ -106,6 +107,52 @@ fn a_saved_project_reopens_with_its_tabs_and_their_views() { let _ = std::fs::remove_dir_all(&dir); } +/// Each sequence reopens with its playhead where it was left: the project file keeps one playhead +/// per sequence beside the tabs and views. +#[test] +fn each_sequence_reopens_with_its_playhead_where_it_was_left() { + let (mut s, [main, b, c]) = three_tabs(); + let at = |s: &Session, seq: ItemId, secs: i64| s.project.sequence(seq).unwrap().settings.frame_rate.snap(Tick(secs * TICKS_PER_SECOND)); + s.execute("sequence.open", json!({"item": main.0})).unwrap(); + s.execute("playhead.set", json!({"seconds": 7})).unwrap(); + s.execute("sequence.open", json!({"item": b.0})).unwrap(); + s.execute("playhead.set", json!({"seconds": 2})).unwrap(); + let (main_at, b_at) = (at(&s, main, 7), at(&s, b, 2)); + assert_eq!(s.playhead(), b_at); + assert_eq!(s.project_view().playheads.get(&c), None, "a playhead that never moved is not written"); + let dir = temp_dir("playheads"); + let path = dir.join("tabs.fcproj").to_string_lossy().to_string(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(t.state.active_sequence, Some(b)); + assert_eq!(t.playhead(), b_at, "the sequence that was shown is back where it was"); + t.execute("sequence.open", json!({"item": main.0})).unwrap(); + assert_eq!(t.playhead(), main_at, "and so is every other sequence"); + t.execute("sequence.open", json!({"item": c.0})).unwrap(); + assert_eq!(t.playhead(), Tick::ZERO); + assert!(!t.is_dirty(), "a restored playhead is not an edit"); + + // with the preference off, nothing of the view comes back, playheads included + let mut u = Session::default(); + u.prefs.timeline.restore_open_sequences = false; + u.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(u.playhead(), Tick::ZERO); + + // a file written before playheads were kept opens as it did then: every playhead at zero + let mut doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(doc["view"]["playheads"].is_object()); + doc["view"].as_object_mut().unwrap().remove("playheads"); + std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); + let mut w = Session::default(); + w.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(w.state.active_sequence, Some(b)); + assert_eq!(w.playhead(), Tick::ZERO); + assert!(w.state.playheads.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + /// The view in a project file is not trusted: ids of things that are not sequences, repeats and /// numbers out of range are dropped or brought into range, and a view that cannot be read at all /// does not stop the project from opening. @@ -134,6 +181,13 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() { "999999": {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0}, footage.0.to_string(): {"pps": 40.0, "scroll": 0.0, "video_track_h": 60.0, "audio_track_h": 56.0}, }, + "playheads": { + c.0.to_string(): -5, + b.0.to_string(): i64::MAX, + main.0.to_string(): TICKS_PER_SECOND + 1, + "999999": TICKS_PER_SECOND, + footage.0.to_string(): TICKS_PER_SECOND, + }, })); assert_eq!(t.state.open_sequences, [c, b]); assert_eq!(t.state.active_sequence, Some(c), "an active tab that is not open: the first tab"); @@ -141,6 +195,16 @@ fn a_damaged_view_in_a_project_file_is_cleaned_up_or_ignored() { let v = t.state.timeline_views[&b]; assert!(v.pps <= 1e5 && v.scroll == 0.0 && v.v_scroll <= 1e6 && v.a_scroll == 0.0); assert!(v.video_track_h >= 8.0 && v.audio_track_h <= 600.0); + // playheads: only sequences keep one, never before zero or past the bound, always on a frame + let rate = |seq: ItemId| t.project.sequence(seq).unwrap().settings.frame_rate; + assert_eq!(t.state.playheads.keys().copied().collect::>(), { + let mut ids = vec![main, b, c]; + ids.sort(); + ids + }); + assert_eq!(t.state.playheads[&c], Tick::ZERO); + assert_eq!(t.state.playheads[&b], rate(b).snap(filmcraft_project::ProjectView::MAX_PLAYHEAD)); + assert_eq!(t.state.playheads[&main], rate(main).snap(Tick(TICKS_PER_SECOND + 1))); // not a view at all: the project opens as it does without one for junk in [json!("tabs"), json!([1, 2, 3]), json!({"open_sequences": "all"}), json!({"sequences": {"x": 1}}), json!(null)] { @@ -159,6 +223,7 @@ fn the_view_of_a_deleted_sequence_is_forgotten() { s.execute("project.delete", json!({"items": [b.0]})).unwrap(); assert_eq!(s.state.timeline_views.keys().copied().collect::>(), [main, c]); assert_eq!(s.state.open_sequences, [main, c]); - // the saved view never names it either + // the saved view never names it either, nor keeps its playhead assert_eq!(s.project_view().open_sequences, [main, c]); + assert!(!s.project_view().playheads.contains_key(&b)); } diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index 5183c10e4..504090998 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -8,7 +8,7 @@ //! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | | //! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | | //! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | | -//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | | +//! | `sequence.joinThroughEdits` | (clip and edit point context menus) Join Through Edits | | //! | `sequence.throughEdits` | query: the through edits of the active sequence | | //! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U | //! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | | @@ -103,7 +103,15 @@ fn groups() -> Vec<(&'static str, Vec)> { s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits); Ok(json!({"showThroughEdits": s.state.show_through_edits})) }), - spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits), + spec( + "sequence.joinThroughEdits", + "Join Through Edits", + &[], + None, + r#"{"clips":[id]?,"cut":[id,id]?,"all":bool?}"#, + has_seq, + join_through_edits, + ), query("sequence.throughEdits", "List Through Edits", "{}", |s, _| { let q = s.active_sequence().ok_or(EngineError::NoSequence)?; Ok(Value::Array( @@ -326,6 +334,9 @@ fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result Result { + if let Some(cut) = p.get("cut") { + return join_through_edit_at(s, cut); + } let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty()); let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) }; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -343,6 +354,26 @@ fn join_through_edits(s: &mut Session, p: &Value) -> Result { Ok(json!({"joined": n})) } +/// `sequence.joinThroughEdits {cut: [left, right]}`: join just that one cut (the edit point menu, +/// #219), with the cuts between the same pieces' linked partners when linked selection is on. +fn join_through_edit_at(s: &mut Session, cut: &Value) -> Result { + let ids: Vec = cut.as_array().map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); + let [left, right] = ids[..] else { + return Err(EngineError::Other("sequence.joinThroughEdits: `cut` needs two clip ids, [left, right]".into())); + }; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + if !edit::through::through_edits(q).iter().any(|e| e.left == left && e.right == right) { + return Err(EngineError::Other("that cut is not a through edit".into())); + } + let (lefts, rights) = (with_links(s, &[left]), with_links(s, &[right])); + let n = s.edit_sequence("Join Through Edits", |q, _, st| { + let n = edit::through::join_through_edits_where(q, |a, b| lefts.contains(&a) && rights.contains(&b)); + st.selection.retain(|c| q.find_item(*c).is_some()); + Ok(n) + })?; + Ok(json!({"joined": n})) +} + // ---------- subsequence ---------- /// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the @@ -587,7 +618,7 @@ pub(crate) fn place_sequence_clips( /// The most tracks one `sequence.addTracks` adds of a kind, and the most a sequence may then /// have of it. (Amounts come from dialogs, scripts and the control channel: never trusted.) const MAX_ADDED_TRACKS: u64 = 99; -const MAX_TRACKS: usize = 999; +pub(crate) const MAX_TRACKS: usize = 999; /// Where `sequence.addTracks` puts new tracks among the `len` tracks of their kind: the number of /// tracks before them. `key` holds `"first"` (Before First Track), a track of that kind by name diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index d9df881f4..01b55b432 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -142,6 +142,41 @@ fn through_edits_show_and_join() { assert_eq!(s.execute("sequence.throughEdits", json!({})).unwrap().as_array().unwrap().len(), n - 2); } +/// `cut: [left, right]` (the timeline's edit point menu, #219) joins exactly that cut and its +/// linked partner, even when one of its pieces is also part of another through edit. +#[test] +fn join_one_through_edit_by_its_cut() { + let mut s = demo(); + let v1 = s.active_sequence().unwrap().video_tracks[0].id.0; + for frame in [30, 40] { + s.execute("playhead.set", json!({"frame": frame})).unwrap(); + s.execute("sequence.addEditAllTracks", json!({})).unwrap(); + } + let te = s.execute("sequence.throughEdits", json!({})).unwrap(); + let on_v1: Vec<&Value> = te.as_array().unwrap().iter().filter(|e| e["track"] == json!(v1)).collect(); + assert_eq!(on_v1.len(), 2); + // the second cut: its left piece is also the right piece of the first one + let (second, first) = (on_v1[1], on_v1[0]); + assert_eq!(second["left"], first["right"]); + let n = te.as_array().unwrap().len(); + let r = s.execute("sequence.joinThroughEdits", json!({"cut": [second["left"], second["right"]]})).unwrap(); + assert_eq!(r["joined"], json!(2), "V1 + linked A1"); + let te = s.execute("sequence.throughEdits", json!({})).unwrap(); + assert_eq!(te.as_array().unwrap().len(), n - 2); + assert!(te.as_array().unwrap().iter().any(|e| e["left"] == first["left"] && e["right"] == first["right"]), "the first cut stays"); +} + +#[test] +fn join_by_cut_rejects_hostile_parameters() { + let mut s = demo(); + let items = s.active_sequence().unwrap().video_tracks[0].items.clone(); + let before = s.active_sequence().unwrap().clone(); + for cut in [json!([]), json!([1]), json!([1, 2, 3]), json!("x"), json!([-1, 2]), json!([u64::MAX, 0]), json!([items[0].id.0, items[1].id.0])] { + assert!(s.execute("sequence.joinThroughEdits", json!({"cut": cut})).is_err(), "{cut}"); + } + assert_eq!(s.active_sequence().unwrap(), &before, "nothing changed"); +} + #[test] fn make_subsequence_from_in_out_and_from_selection() { let mut s = demo(); @@ -483,3 +518,24 @@ fn selection_follows_playhead() { s.execute("playhead.set", json!({"frame": 10})).unwrap(); assert_eq!(s.state.selection, sel, "off again"); } + +/// #208: with no In / Out mark, Go to In / Go to Out move to the start / end of the sequence +/// (they did nothing); with marks they go to them. +#[test] +fn go_to_in_out_without_marks_go_to_the_sequence_ends() { + let mut s = demo(); + let end = s.active_sequence().unwrap().duration(); + let mid = filmcraft_time::Tick(end.0 / 2); + s.execute("playhead.set", json!({"time": mid.0})).unwrap(); + s.execute("markers.goToIn", json!({})).unwrap(); + assert_eq!(s.playhead(), filmcraft_time::Tick::ZERO, "the start"); + s.execute("markers.goToOut", json!({})).unwrap(); + assert_eq!(s.playhead(), end, "the end"); + // with marks, the marks + let at = filmcraft_time::Tick(end.0 / 4); + s.execute("playhead.set", json!({"time": at.0})).unwrap(); + s.execute("markers.markIn", json!({})).unwrap(); + s.execute("playhead.set", json!({"time": mid.0})).unwrap(); + s.execute("markers.goToIn", json!({})).unwrap(); + assert_eq!(s.playhead(), s.active_sequence().unwrap().mark_in.unwrap()); +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index f4216067b..9243483fd 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -33,6 +33,10 @@ pub const PREFS_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { + /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or + /// `system` (the default: the operating system's preferred language when the interface has it, + /// otherwise English). + pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. pub at_startup: String, /// "When Opening a Project": `showOpenDialog` | `showHome`. @@ -59,6 +63,7 @@ pub struct GeneralPrefs { impl Default for GeneralPrefs { fn default() -> Self { Self { + interface_language: "system".into(), at_startup: "showHome".into(), when_opening_project: "showOpenDialog".into(), bins_double_click: "openInPlace".into(), @@ -672,6 +677,19 @@ static CATEGORIES: &[Category] = &[ id: "general", title: "General", rows: &[ + f( + "general.interfaceLanguage", + "Interface Language", + Kind::Choice(&[ + ("system", "System Language"), + ("en", "English"), + ("ja", "日本語"), + ("es", "Español"), + ("pt-br", "Português (Brasil)"), + ("uk", "Українська"), + ]), + true, + ), f("general.atStartup", "At Startup", Kind::Choice(STARTUP), true), f("general.whenOpeningProject", "When Opening a Project", Kind::Choice(OPENING), false), Row::Group( @@ -955,7 +973,7 @@ static CATEGORIES: &[Category] = &[ b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true), f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true), Row::Note( - "Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS) for the H.264 and HEVC streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.", + "Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.", ), ], }, @@ -1084,10 +1102,8 @@ pub fn sanitize(v: &mut Value, defaults: &Value) { let Some(slot) = path.iter().try_fold(&mut *v, |v, k| v.get_mut(*k)) else { continue }; let def = path.iter().try_fold(defaults, |v, k| v.get(*k)).cloned().unwrap_or(Value::Null); match f.kind { - Kind::Choice(_) | Kind::Color => { - if validate(f.key, slot).is_err() { - *slot = def; - } + Kind::Choice(_) | Kind::Color if validate(f.key, slot).is_err() => { + *slot = def; } Kind::Int { min, max, .. } => match slot.as_f64().filter(|x| x.is_finite()) { Some(x) => *slot = json!(x.round().clamp(min, max) as u64), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 4bc762c55..d706e4a19 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -22,6 +22,36 @@ fn set(s: &mut Session, key: &str, value: Value) { s.execute("prefs.set", json!({"key": key, "value": value})).unwrap_or_else(|e| panic!("{key}: {e}")); } +#[test] +fn interface_language_persists_and_rejects_hostile_values() { + let dir = tmp_dir("interface-language"); + let path = dir.join("preferences.json"); + let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; + set(&mut s, "general.interfaceLanguage", json!("es")); + assert_eq!(Preferences::load(&path).general.interface_language, "es"); + for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); + assert_eq!(s.prefs.general.interface_language, "es"); + } + assert_eq!(Preferences::load(&path).general.interface_language, "es"); + // back to following the operating system (#218) + set(&mut s, "general.interfaceLanguage", json!("system")); + assert_eq!(Preferences::load(&path).general.interface_language, "system"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A new installation follows the operating system's language (#218); a saved choice is kept. +#[test] +fn interface_language_defaults_to_the_system_language() { + assert_eq!(Preferences::default().general.interface_language, "system"); + let dir = tmp_dir("interface-language-default"); + let path = dir.join("preferences.json"); + assert_eq!(Preferences::load(&path).general.interface_language, "system", "no preferences file yet"); + std::fs::write(&path, r#"{"general":{"interfaceLanguage":"en"}}"#).unwrap(); + assert_eq!(Preferences::load(&path).general.interface_language, "en"); + std::fs::remove_dir_all(dir).unwrap(); +} + fn write_png(path: &Path, w: u32, h: u32) { image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])).save(path).unwrap(); } diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs index 118f2991e..7e105e509 100644 --- a/crates/engine/src/shortcut_presets.rs +++ b/crates/engine/src/shortcut_presets.rs @@ -11,6 +11,9 @@ pub type Entry = (&'static str, &'static str, &'static str); /// FilmCraft additions to the per-command defaults: panel-specific shortcuts. pub const FILMCRAFT_PANEL: &[Entry] = &[ ("project.delete", "Backspace", "Project"), + // the forward-delete key (Delete on Windows and Linux keyboards) clears timeline clips too; + // application-wide it is Project ▸ Clear (#243) + ("edit.clear", "Delete", "Timeline"), ("file.newBin", "Cmd+B", "Project"), ("edit.undo", "Left", "History"), ("edit.redo", "Right", "History"), diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 4165cde88..0c9e47718 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -363,6 +363,22 @@ pub struct Binding { pub panel: Option, } +/// Off macOS the Control and Command keys are the same key. The built-in tables are written for +/// macOS, so a binding on the Mac Control key (`⌃T`) can land on the same key as a Command binding +/// (`⌘T`) in the same context: the Command binding wins and the Control one is left out there +/// (that command has no default key on that platform). macOS keeps both. +fn drop_control_collisions(b: &mut Vec, p: Platform) { + if p.is_mac() { + return; + } + let primary: Vec<(String, Option)> = + b.iter().filter(|x| x.chord().is_some_and(|c| !c.mods.ctrl)).map(|x| (x.context().to_string(), x.chord().map(|c| c.effective(p)))).collect(); + b.retain(|x| { + let Some(c) = x.chord() else { return true }; + !(c.mods.ctrl && primary.iter().any(|(ctx, k)| ctx == x.context() && *k == Some(c.effective(p)))) + }); +} + impl Binding { fn new(command: &str, keys: &str, panel: Option<&str>) -> Self { Binding { command: command.to_string(), keys: normalize(keys).unwrap_or_else(|_| keys.to_string()), panel: panel.map(str::to_string) } @@ -410,6 +426,7 @@ fn category_of(id: &str, menu: &[&str]) -> String { "view" => "View".into(), "app" | "help" | "mode" => "Application".into(), "captions" => "Captions".into(), + "tts" => "Text to Speech".into(), _ => menu.first().map(|s| s.to_string()).unwrap_or_else(|| { let mut c = prefix.chars(); c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() @@ -564,8 +581,15 @@ impl Shortcuts { } /// Premiere-default entries FilmCraft Default adopts: commands with no shortcut of their own - /// whose Premiere key is free in that context. + /// whose Premiere key is free in that context on this platform. pub fn audit(&self) -> Vec { + self.audit_for(Platform::current()) + } + + /// [`Self::audit`] for platform `p`. The Premiere table is Premiere's macOS keyboard: off macOS + /// the Control and Command keys are the same key, so an entry whose key would collide there + /// with one already taken (`⌃T` vs `⌘T`) is left out, and the command keeps no default key. + pub(crate) fn audit_for(&self, platform: Platform) -> Vec { let base = self.base_defaults(); let mut added: Vec = Vec::new(); for (c, k, p) in presets::PREMIERE { @@ -573,7 +597,8 @@ impl Shortcuts { continue; } let b = Binding::new(c, k, panel_opt(p)); - let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord() == b.chord()); + let key = b.chord().map(|c| c.effective(platform)); + let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord().map(|c| c.effective(platform)) == key); if !taken { added.push(b); } @@ -583,8 +608,13 @@ impl Shortcuts { /// The bindings of a built-in preset. pub fn builtin(&self, name: &str) -> Option> { + self.builtin_for(name, Platform::current()) + } + + /// [`Self::builtin`] as it is built on platform `p`. + pub(crate) fn builtin_for(&self, name: &str, p: Platform) -> Option> { let mut b = self.base_defaults(); - b.extend(self.audit()); + b.extend(self.audit_for(p)); let table: Vec = match name { DEFAULT_PRESET => Vec::new(), PREMIERE_PRESET => presets::premiere(), @@ -593,6 +623,7 @@ impl Shortcuts { _ => return None, }; self.apply_table(&mut b, &table); + drop_control_collisions(&mut b, p); Some(b) } diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 0bf0b63b9..9ffd35f4d 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -74,6 +74,9 @@ fn defaults_follow_the_registry_and_the_premiere_audit() { #[test] fn assign_reassigns_conflicts_and_undoes() { let mut s = Session::default(); + // macOS Control folds into the primary modifier on Linux/Windows, so compare changes + // against the platform's original conflicts rather than assuming the set starts empty. + let baseline = s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].clone(); let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap(); assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}"); assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none()); @@ -85,12 +88,12 @@ fn assign_reassigns_conflicts_and_undoes() { s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap(); assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2); let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); - assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(r["conflicts"].as_array().unwrap().len(), baseline.as_array().unwrap().len() + 1, "{r}"); let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap(); assert_eq!(c["conflicts"][0]["keys"], json!("M")); // undo / redo within the editor s.execute("shortcuts.undo", json!({})).unwrap(); - assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty()); + assert_eq!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"], baseline); s.execute("shortcuts.undo", json!({})).unwrap(); s.execute("shortcuts.undo", json!({})).unwrap(); assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); @@ -132,6 +135,19 @@ fn panel_shortcuts_override_application_ones_with_focus() { assert!(k.as_array().unwrap().iter().any(|b| b["command"] == "edit.copy" && b["keys"] == "Ctrl+C"), "{k}"); } +#[test] +fn delete_clears_timeline_clips_and_project_items() { + // #243: with the Timeline focused, Delete (the forward-delete key, labelled Delete on Windows + // and Linux keyboards) resolved to Project ▸ Clear and left the selected clips in place. + let s = Session::default(); + let del = Chord::parse("Delete").unwrap(); + for p in [Platform::Mac, Platform::Windows] { + assert_eq!(s.shortcuts.resolve(&del, Some("Timeline"), p).unwrap().command, "edit.clear", "{p:?}"); + assert_eq!(s.shortcuts.resolve(&del, Some("Project"), p).unwrap().command, "project.delete", "{p:?}"); + assert_eq!(s.shortcuts.resolve(&del, None, p).unwrap().command, "project.delete", "{p:?}"); + } +} + #[test] fn compat_presets_map_other_editors() { let mut s = Session::default(); @@ -218,3 +234,21 @@ fn list_searches_labels_and_keys() { let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); } + +#[test] +fn default_keymaps_have_no_conflicts_on_any_platform() { + // The Premiere audit used to check keys on macOS only, so off macOS `⌃9`/`⌘9`, `⌃⇧M`/`⌘⇧M` and + // `⌃T`/`⌘T` ended up on the same Ctrl key; CI only ran on macOS, so nobody saw it. + use crate::shortcuts::Platform; + let s = Session::default(); + for p in [Platform::Mac, Platform::Windows, Platform::Linux] { + let mut sc = s.shortcuts.clone(); + sc.bindings = sc.builtin_for(crate::shortcuts::DEFAULT_PRESET, p).unwrap(); + assert!(sc.conflicts(p).is_empty(), "{p:?}: {:?}", sc.conflicts(p)); + } + // macOS keeps every Premiere key it had + let mac = s.shortcuts.builtin_for(crate::shortcuts::DEFAULT_PRESET, Platform::Mac).unwrap(); + for (cmd, keys) in [("multicam.cutToCamera9", "Ctrl+9"), ("markers.addRange", "Ctrl+Shift+M"), ("graphics.newText", "Cmd+T")] { + assert!(mac.iter().any(|b| b.command == cmd && b.keys == keys), "{cmd} {keys} on macOS"); + } +} diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs new file mode 100644 index 000000000..836fff49e --- /dev/null +++ b/crates/engine/src/source_monitor.rs @@ -0,0 +1,309 @@ +//! Source range arithmetic and Source-targeted marker commands. Edits use the session undo path. + +use crate::clip_ops::source_view; +use crate::commands::bad; +use crate::{EngineError, Result, Session}; +use filmcraft_project::{ItemId, Label, Marker, MarkerId, MarkerKind, Project}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RangeEdge { + In, + Out, + Move, +} + +/// Adjust one boundary or translate the span, constrained to the source and source frame grid. +pub fn adjust_range(bounds: TimeRange, range: TimeRange, rate: FrameRate, edge: RangeEdge, delta: Tick) -> Result { + let error = || EngineError::Other("invalid Source range or frame rate".into()); + let end = bounds.start.0.checked_add(bounds.duration.0).ok_or_else(error)?; + let out = range.start.0.checked_add(range.duration.0).ok_or_else(error)?; + if bounds.start.0 < 0 || bounds.duration.0 <= 0 || range.duration.0 <= 0 || range.start < bounds.start || out > end || rate.num <= 0 || rate.den <= 0 { + return Err(error()); + } + let scale = i128::from(TICKS_PER_SECOND).checked_mul(i128::from(rate.den)).ok_or_else(error)?; + let fd = scale / i128::from(rate.num); + if fd <= 0 || fd > i128::from(i64::MAX) { + return Err(error()); + } + let floor = |t: i128| -> Result { + let f = t.checked_mul(i128::from(rate.num)).ok_or_else(error)?.div_euclid(scale); + Ok(f.checked_mul(scale).ok_or_else(error)?.div_euclid(i128::from(rate.num))) + }; + let nearest = |t: i128| -> Result { + let a = floor(t)?; + let f = t.checked_mul(i128::from(rate.num)).ok_or_else(error)?.div_euclid(scale); + let b = f.checked_add(1).and_then(|f| f.checked_mul(scale)).ok_or_else(error)?.div_euclid(i128::from(rate.num)); + Ok(if t - a <= b - t { a } else { b }) + }; + let (lo, hi, start, stop, delta) = (i128::from(bounds.start.0), i128::from(end), i128::from(range.start.0), i128::from(out), i128::from(delta.0)); + let (a, b) = match edge { + RangeEdge::In => { + let max = floor((stop - fd.min(hi - lo)).max(lo))?.max(lo); + if max < lo { + return Err(error()); + } + (nearest((start + delta).clamp(lo, max))?.clamp(lo, max), stop) + } + RangeEdge::Out => { + let last = floor((hi - fd).max(lo))?.max(lo); + if start > last { + return Err(error()); + } + let o = nearest((stop - fd + delta).clamp(start, last))?.clamp(start, last); + (start, (o + fd).min(hi)) + } + RangeEdge::Move => { + let min = -floor(-(lo - start))?; + let max = floor(hi - stop)?; + if min > max { + return Err(error()); + } + let d = nearest(delta.clamp(min, max))?.clamp(min, max); + (start + d, stop + d) + } + }; + let a = i64::try_from(a).map_err(|_| error())?; + let b = i64::try_from(b).map_err(|_| error())?; + Ok(TimeRange::from_bounds(Tick(a), Tick(b))) +} + +pub fn source_command(id: &str) -> bool { + id.starts_with("markers.markSplit") + || id.starts_with("markers.goToSplit") + || matches!( + id, + "markers.markIn" + | "markers.markOut" + | "markers.goToIn" + | "markers.goToOut" + | "markers.clearIn" + | "markers.clearOut" + | "markers.clearInOut" + | "markers.add" + | "markers.goNext" + | "markers.goPrev" + | "markers.clearCurrent" + | "markers.clearAll" + | "markers.edit" + ) +} + +fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec> { + let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?; + match &mut it.kind { + filmcraft_project::ItemKind::Media(m) => Ok(&mut m.markers), + filmcraft_project::ItemKind::Sequence(q) => Ok(&mut q.markers), + _ => Err(EngineError::Other("Source marker editing requires media or a sequence".into())), + } +} + +/// `None` leaves the existing Program command implementation in charge. +pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result> { + match p.get("target") { + None => return Ok(None), + Some(Value::String(t)) if t == "program" => return Ok(None), + Some(Value::String(t)) if t == "source" => {} + _ => return Err(bad(id, "target must be program or source")), + } + let item = s.state.source_item.ok_or_else(|| bad(id, "no Source clip is open"))?; + let view = source_view(s, item).ok_or_else(|| bad(id, "Source item is unavailable"))?; + let cursor = s.state.source_playhead; + match id { + "markers.goToIn" | "markers.goToOut" => { + let time = if id.ends_with("In") { + view.mark_in.unwrap_or(view.start) + } else { + view.mark_out.unwrap_or(Tick(view.end.0.saturating_sub(view.rate.frame_duration().0)).max(view.start)) + }; + s.execute("source.setPlayhead", json!({"time":time.0}))?; + } + "markers.clearIn" | "markers.clearOut" | "markers.clearInOut" => { + let mut marks = json!({"item":item.0}); + if id != "markers.clearOut" { + marks["in"] = Value::Null; + } + if id != "markers.clearIn" { + marks["out"] = Value::Null; + } + s.execute("project.setMarks", marks)?; + } + "markers.goNext" | "markers.goPrev" => { + let time = if id.ends_with("Next") { + view.markers.iter().map(|m| m.start).filter(|t| *t > cursor).min() + } else { + view.markers.iter().map(|m| m.start).filter(|t| *t < cursor).max() + }; + if let Some(t) = time { + s.execute("source.setPlayhead", json!({"time":t.0}))?; + } + } + "markers.add" => { + let time = match p.get("time") { + None => cursor, + Some(v) => Tick(v.as_i64().ok_or_else(|| bad(id, "time must be integer ticks"))?), + }; + if time < view.start || time >= view.end { + return Err(bad(id, "marker time is outside the Source clip")); + } + let duration = duration(p, &view, id)?.min(Tick(view.end.0.saturating_sub(time.0))); + let name = text(p, "name", id)?.unwrap_or_default().to_string(); + let comment = text(p, "comment", id)?.unwrap_or_default().to_string(); + let color = color(p, id)?.unwrap_or(Label::Green); + let root = view.media; + let marker = s.edit("Add Source Marker", |pr, _| { + if pr.next_id == u64::MAX { + return Err(bad(id, "project ids are exhausted")); + } + let marker = MarkerId(pr.alloc_id()); + let list = marker_list(pr, root)?; + list.push(Marker { id: marker, start: time, duration, name, comment, color, kind: MarkerKind::Comment }); + list.sort_by_key(|m| m.start); + Ok(marker) + })?; + return Ok(Some(json!({"marker":marker.0}))); + } + "markers.clearCurrent" | "markers.clearAll" => { + s.edit("Clear Source Markers", |pr, _| { + let list = marker_list(pr, view.media)?; + if id.ends_with("All") { + list.clear(); + } else { + list.retain(|m| m.start != cursor); + } + Ok(()) + })?; + } + "markers.edit" => { + let marker = MarkerId(p.get("marker").and_then(Value::as_u64).ok_or_else(|| bad(id, "need marker id"))?); + let name = text(p, "name", id)?.map(str::to_string); + let comment = text(p, "comment", id)?.map(str::to_string); + let color = color(p, id)?; + let duration = if p.get("durationFrames").is_some() { Some(duration(p, &view, id)?) } else { None }; + s.edit("Edit Source Marker", |pr, _| { + let m = marker_list(pr, view.media)?.iter_mut().find(|m| m.id == marker).ok_or_else(|| bad(id, "no such Source marker"))?; + if let Some(name) = name { + m.name = name; + } + if let Some(comment) = comment { + m.comment = comment; + } + if let Some(color) = color { + m.color = color; + } + if let Some(d) = duration { + m.duration = d.min(Tick(view.end.0.saturating_sub(m.start.0))); + } + Ok(()) + })?; + } + _ => return Err(bad(id, "unsupported Source command")), + } + Ok(Some(Value::Null)) +} + +fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() +} +fn color(p: &Value, id: &str) -> Result> { + text(p, "color", id)?.map(|t| Label::from_name(t).ok_or_else(|| bad(id, "unknown marker color"))).transpose() +} +fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result { + let Some(v) = p.get("durationFrames") else { return Ok(Tick::ZERO) }; + let f = v.as_i64().ok_or_else(|| bad(id, "durationFrames must be an integer"))?; + let span = view.end.0.saturating_sub(view.start.0).max(0); + if f < 0 || f > view.rate.frame_at(Tick(span)) { + return Err(bad(id, "marker duration is outside the Source clip")); + } + Ok(view.rate.tick_of(f)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("source.open", json!({"item":5})).unwrap(); + s + } + #[test] + fn ranges_snap_and_clamp_without_crossing_or_changing_move_duration() { + for rate in FrameRate::COMMON { + let bounds = TimeRange::from_bounds(Tick::ZERO, rate.tick_of(240)); + let range = TimeRange::from_bounds(rate.tick_of(48), rate.tick_of(96)); + for delta in [Tick(i64::MIN), -rate.tick_of(200), -rate.tick_of(8), Tick::ZERO, rate.tick_of(8), rate.tick_of(200), Tick(i64::MAX)] { + for edge in [RangeEdge::In, RangeEdge::Out, RangeEdge::Move] { + let r = adjust_range(bounds, range, rate, edge, delta).unwrap(); + assert!(r.start >= bounds.start && r.end() <= bounds.end() && r.duration >= rate.frame_duration()); + if edge == RangeEdge::Move { + assert_eq!(r.duration, range.duration); + } + if edge == RangeEdge::In { + assert_eq!(r.end(), range.end()); + } + if edge == RangeEdge::Out { + assert_eq!(r.start, range.start); + } + } + } + } + } + #[test] + fn hostile_ranges_and_rates_return_errors_without_panicking() { + let bounds = TimeRange::new(Tick::ZERO, Tick::from_seconds_f64(10.0)); + let range = TimeRange::new(Tick::ZERO, Tick::from_seconds_f64(1.0)); + for rate in [FrameRate { num: 0, den: 0 }, FrameRate { num: -1, den: 1 }, FrameRate { num: 1, den: i64::MAX }, FrameRate { num: i64::MAX, den: 1 }] { + assert!(adjust_range(bounds, range, rate, RangeEdge::Move, Tick(i64::MAX)).is_err()); + } + for r in [ + TimeRange::new(Tick(i64::MAX), Tick(2)), + TimeRange::new(Tick(-1), Tick(1)), + TimeRange::new(Tick::ZERO, Tick(-1)), + TimeRange::new(Tick::ZERO, Tick::ZERO), + ] { + assert!(adjust_range(bounds, r, FrameRate::FPS_24, RangeEdge::In, Tick(i64::MIN)).is_err()); + } + let small = TimeRange::new(Tick::ZERO, Tick(1)); + assert_eq!(adjust_range(small, small, FrameRate::FPS_24, RangeEdge::Out, Tick(i64::MAX)).unwrap(), small); + } + #[test] + fn source_markers_are_undoable_and_independent_of_sequence_markers() { + let mut s = fixture(); + let cursor = source_view(&s, ItemId(5)).unwrap().rate.tick_of(48); + s.execute("source.setPlayhead", json!({"time":cursor.0})).unwrap(); + let before = s.project.to_json(); + let qbefore = s.active_sequence().unwrap().clone(); + let marker = s.execute("markers.add", json!({"target":"source","name":"Cue"})).unwrap()["marker"].as_u64().unwrap(); + let v = source_view(&s, ItemId(5)).unwrap(); + assert_eq!(v.markers.len(), 1); + assert_eq!(v.markers[0].start, cursor); + assert_eq!(s.active_sequence().unwrap(), &qbefore); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("markers.edit", json!({"target":"source","marker":marker,"name":"Changed"})).unwrap(); + assert_eq!(source_view(&s, ItemId(5)).unwrap().markers[0].name, "Changed"); + s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap(); + assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty()); + } + #[test] + fn invalid_marker_parameters_do_not_edit_the_project() { + let mut s = fixture(); + let before = s.project.to_json(); + for p in [ + json!({"target":true}), + json!({"target":"unknown"}), + json!({"target":"source","time":i64::MAX}), + json!({"target":"source","time":"bad"}), + json!({"target":"source","durationFrames":i64::MAX}), + json!({"target":"source","durationFrames":-1}), + json!({"target":"source","name":23}), + json!({"target":"source","color":"unknown"}), + ] { + assert!(s.execute("markers.add", p).is_err()); + assert_eq!(s.project.to_json(), before); + } + } +} diff --git a/crates/engine/src/source_placement_tests.rs b/crates/engine/src/source_placement_tests.rs new file mode 100644 index 000000000..0fb56556c --- /dev/null +++ b/crates/engine/src/source_placement_tests.rs @@ -0,0 +1,98 @@ +use super::*; +use filmcraft_project::ItemId; +use filmcraft_time::{Tick, TimeRange}; +use serde_json::json; + +fn setup() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name":"Source test","width":1280,"height":720,"fps":24})).unwrap(); + s.execute("source.open", json!({"item":5})).unwrap(); + s +} +fn counts(s: &Session) -> (usize, usize) { + let q = s.active_sequence().unwrap(); + (q.video_tracks.iter().map(|t| t.items.len()).sum(), q.audio_tracks.iter().map(|t| t.items.len()).sum()) +} + +#[test] +fn unmarked_source_defaults_to_full_span_without_writing_marks() { + let s = setup(); + let view = clip_ops::source_view(&s, ItemId(5)).unwrap(); + assert_eq!(view.selected_range(), TimeRange::from_bounds(view.start, view.end)); + assert!(view.mark_in.is_none() && view.mark_out.is_none()); +} + +#[test] +fn marked_range_places_video_audio_or_linked_both_as_one_undo_step() { + for (video, audio, expected) in [(true, false, (1, 0)), (false, true, (0, 1)), (true, true, (1, 1))] { + let mut s = setup(); + let start = Tick::from_seconds_f64(1.0); + let out = Tick::from_seconds_f64(3.0); + s.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap(); + let view = clip_ops::source_view(&s, ItemId(5)).unwrap(); + let range = view.selected_range(); + assert_eq!(range.end(), out + view.rate.frame_duration()); + let before = s.project.to_json(); + s.execute( + "timeline.place", + json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"sourceIn":range.start.0,"duration":range.duration.0,"video":video,"audio":audio}), + ) + .unwrap(); + assert_eq!(counts(&s), expected); + let q = s.active_sequence().unwrap(); + for clip in q.all_tracks().flat_map(|t| t.items.iter()) { + assert_eq!(clip.source_in, start); + assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration)); + } + if video && audio { + assert!(q.video_tracks[0].items[0].link.is_some()); + assert_eq!(q.video_tracks[0].items[0].link, q.audio_tracks[0].items[0].link); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(counts(&s), expected); + } +} + +#[test] +fn invalid_stream_choices_and_locked_destinations_do_not_mutate_project() { + let mut s = setup(); + let before = s.project.to_json(); + for params in [json!({"video":false,"audio":false}), json!({"video":"yes"}), json!({"audio":2})] { + let mut p = json!({"item":5,"track":"V1","audioTrack":"A1","time":0}); + for (k, v) in params.as_object().unwrap() { + p[k] = v.clone(); + } + assert!(s.execute("timeline.place", p).is_err()); + assert_eq!(s.project.to_json(), before); + } + let id = s.state.active_sequence.unwrap(); + std::sync::Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().audio_tracks[0].locked = true; + let locked = s.project.to_json(); + assert!(s.execute("timeline.place", json!({"item":5,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).is_err()); + assert_eq!(s.project.to_json(), locked); +} + +#[test] +fn audio_only_source_drops_only_sound() { + let mut s = setup(); + s.execute("timeline.place", json!({"item":11,"track":"A2","time":0,"video":false,"audio":true})).unwrap(); + assert_eq!(counts(&s), (0, 1)); + assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1); +} + +#[test] +fn malformed_source_spans_are_bounded() { + let s = setup(); + let mut view = clip_ops::source_view(&s, ItemId(5)).unwrap(); + view.mark_in = Some(Tick::MAX); + view.mark_out = Some(Tick::ZERO); + let range = view.selected_range(); + assert_eq!(range.start, view.end); + assert_eq!(range.duration, Tick::ZERO); + view.end = Tick::ZERO; + view.start = Tick::from_seconds_f64(1.0); + assert_eq!(view.selected_range().duration, Tick::ZERO); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4193afe93..15af29270 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -35,6 +35,50 @@ fn demo_project_is_valid_and_renders() { assert!(img.px.chunks(4).any(|p| p[3] > 0.9)); } +/// Ordinary and configured multi-frame offsets remain valid. +#[test] +fn frame_steps_in_new_custom_rate_sequences_move_in_the_requested_direction() { + for fps in [31.0, 23.98, 24.999] { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"fps": fps})).unwrap(); + let rate = s.sequence_rate(); + s.execute("playhead.set", json!({"frame": 10})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 10); + for frame in 11..=42 { + s.execute("playhead.stepForward", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), frame); + } + for frame in (10..42).rev() { + s.execute("playhead.stepBack", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), frame); + } + s.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap(); + s.execute("playhead.stepForward5", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 22); + s.execute("playhead.stepBack5", json!({})).unwrap(); + assert_eq!(rate.frame_at(s.playhead()), 10); + s.execute("playhead.step", json!({"frames": -20})).unwrap(); + s.execute("playhead.stepBack", json!({})).unwrap(); + assert_eq!(s.playhead(), Tick::ZERO); + } +} + +#[test] +fn overflowing_frame_steps_are_errors_and_leave_the_playhead_unchanged() { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"fps": 31.0})).unwrap(); + for start in [10, 0] { + s.execute("playhead.set", json!({"frame": start})).unwrap(); + let before = s.playhead(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute("playhead.step", json!({"frames": i64::MAX})))); + assert!(result.is_ok(), "an extreme frame offset must not panic"); + assert!(matches!(result.unwrap(), Err(EngineError::BadParams { .. }))); + assert_eq!(s.playhead(), before); + } + s.execute("playhead.step", json!({"frames": i64::MIN})).unwrap(); + assert_eq!(s.playhead(), Tick::ZERO); +} + #[test] fn add_edit_undo_redo() { let mut s = demo(); @@ -144,6 +188,73 @@ fn sequence_parameters_are_bounded_and_failed_changes_are_atomic() { assert_eq!(s.active_sequence().unwrap().settings.height, 8192, "a 16384x8192 panorama is accepted"); } +/// The first video clip's Motion Position and Scale (static values). +fn first_motion(s: &Session) -> (u64, (f64, f64), f64) { + let q = s.active_sequence().unwrap(); + let it = q.video_tracks.iter().flat_map(|t| &t.items).find(|i| i.effect("motion").is_some()).unwrap(); + let m = it.effect("motion").unwrap(); + let p = match m.param("position").unwrap().value { + filmcraft_project::ParamValue::Vec2(v) => (v.x, v.y), + _ => panic!("position is a point"), + }; + let sc = match m.param("scale").unwrap().value { + filmcraft_project::ParamValue::Float(v) => v, + _ => panic!("scale is a number"), + }; + (it.id.0, p, sc) +} + +#[test] +fn sequence_settings_scales_motion_with_the_frame_size_in_one_undo_step() { + let mut s = demo(); + let before = (*s.project).clone(); + let (id, p0, s0) = first_motion(&s); + assert_eq!((s.active_sequence().unwrap().settings.width, s.active_sequence().unwrap().settings.height), (1920, 1080)); + let r = s.execute("sequence.settings", json!({"width":1280,"height":720,"scaleMotion":true})).unwrap(); + assert!(r["scaledClips"].as_u64().unwrap() >= 1, "{r}"); + let (id1, p1, s1) = first_motion(&s); + assert_eq!(id1, id); + assert!((p1.0 - p0.0 * 2.0 / 3.0).abs() < 1e-9 && (p1.1 - p0.1 * 2.0 / 3.0).abs() < 1e-9, "{p0:?} -> {p1:?}"); + assert!((s1 - s0 * 2.0 / 3.0).abs() < 1e-9, "{s0} -> {s1}"); + let after = (*s.project).clone(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, before, "size and Motion come back in one undo step"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(*s.project, after); + // without the option the frame size changes alone (the command's default, as before) + let r = s.execute("sequence.settings", json!({"width":3840,"height":2160})).unwrap(); + assert_eq!(r["scaledClips"], json!(0)); + assert_eq!(first_motion(&s), (id, p1, s1)); + assert_eq!(s.active_sequence().unwrap().settings.width, 3840); +} + +#[test] +fn sequence_settings_timecode_render_quality_and_colour() { + let mut s = demo(); + s.execute("sequence.settings", json!({"fps":29.97,"dropFrame":true,"maxRenderQuality":true})).unwrap(); + let st = s.active_sequence().unwrap().settings.clone(); + assert_eq!(st.frame_rate, FrameRate::FPS_29_97); + assert!(st.drop_frame && st.max_render_quality); + s.execute("sequence.settings", json!({"dropFrame":false})).unwrap(); + assert!(!s.active_sequence().unwrap().settings.drop_frame); + // drop-frame only exists for the NTSC rates; a refused change leaves everything as it was + let before = (*s.project).clone(); + let history = s.history.undo.len(); + for params in [json!({"fps":25,"dropFrame":true}), json!({"workingSpace":"rec2020-log"}), json!({"fps":25,"dropFrame":true,"width":1280})] { + assert!(s.execute("sequence.settings", params.clone()).is_err(), "{params}"); + assert_eq!(*s.project, before, "{params}"); + assert_eq!(s.history.undo.len(), history); + } + s.execute("sequence.settings", json!({"workingSpace":"rec2100-pq","wideGamut":true,"autoToneMap":true})).unwrap(); + let st = s.active_sequence().unwrap().settings.clone(); + assert_eq!(st.color.working, filmcraft_color::WorkingSpace::Rec2100Pq); + assert!(st.color.wide_gamut && st.color.auto_tone_map); + assert_eq!(st.working_space, "Rec. 2100 PQ"); + // disabled without a sequence + let mut empty = Session::default(); + assert!(empty.execute("sequence.settings", json!({"width":1280})).is_err()); +} + #[test] fn zero_fps_is_refused() { let mut s = demo(); @@ -170,6 +281,96 @@ fn effects_and_keyframes() { assert!(it.effect("motion").unwrap().params["scale"].is_animated()); } +/// `effects.addKeyframe` is the keyframe diamond of Effect Controls and Properties: it adds a +/// keyframe at the playhead or removes the one there, and the value then follows the keyframes +/// that are left (Premiere's Add/Remove Keyframe), unlike the stopwatch, which drops them all. +#[test] +fn add_keyframe_toggles_the_keyframe_at_the_playhead() { + let mut s = demo(); + let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + let scale = |s: &Session| { + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1; + let p = &it.effect("motion").unwrap().params["scale"]; + (p.keyframes.len(), p.f64_at(it.source_time_at(s.playhead()))) + }; + let key = json!({"clip": c, "effect": "motion", "param": "scale"}); + s.execute("playhead.set", json!({"seconds": 0.5})).unwrap(); + s.execute("effects.addKeyframe", key.clone()).unwrap(); + assert_eq!(scale(&s), (1, 100.0), "the first keyframe turns animation on"); + s.execute("playhead.set", json!({"seconds": 2.5})).unwrap(); + s.execute("effects.addKeyframe", key.clone()).unwrap(); + s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "scale", "value": 50.0})).unwrap(); + assert_eq!(scale(&s), (2, 50.0)); + // on a keyframe: only that keyframe goes, and Scale is the other keyframe's 100 again + s.execute("effects.addKeyframe", key.clone()).unwrap(); + assert_eq!(scale(&s), (1, 100.0)); + s.undo(); + assert_eq!(scale(&s), (2, 50.0)); + s.redo(); + assert_eq!(scale(&s), (1, 100.0)); + // parameters that name nothing are errors and change nothing + for bad in [ + json!({}), + json!({"clip": c}), + json!({"clip": u64::MAX, "param": "scale"}), + json!({"clip": c, "effect": "nope", "param": "scale"}), + json!({"clip": c, "effect": u64::MAX, "param": "scale"}), + json!({"clip": c, "effect": "motion", "param": "nope"}), + json!({"clip": c, "effect": "motion", "param": "scale", "mask": 7}), + ] { + assert!(s.execute("effects.addKeyframe", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(scale(&s), (1, 100.0)); + // the stopwatch ends the animation and keeps the value at the playhead + s.execute("effects.toggleAnimation", key).unwrap(); + assert_eq!(scale(&s), (0, 100.0)); +} + +/// Dragging the Volume line or one of its keyframes in the timeline (#223) sends a keyframe edit +/// every frame; `merge` keeps the whole drag one undo step and `begin` starts the next one. +#[test] +fn keyframe_drags_are_one_undo_step() { + let mut s = demo(); + let c = s.active_sequence().unwrap().audio_tracks[0].items[0].id.0; + let level = |s: &Session| -> Vec<(i64, f64)> { + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1; + it.effect("volume").unwrap().params["level"].keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect() + }; + let key = json!({"clip": c, "effect": "volume", "param": "level"}); + for sec in [0.5, 1.5] { + s.execute("playhead.set", json!({"seconds": sec})).unwrap(); + s.execute("effects.addKeyframe", key.clone()).unwrap(); + } + let before = level(&s); + let [(t0, _), (t1, _)] = before[..] else { panic!("two keyframes: {before:?}") }; + + // the line between them: both keyframes move together, frame after frame + for (i, v) in [-1.0, -2.0, -3.0].into_iter().enumerate() { + for t in [t0, t1] { + let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t, "value": v, "merge": true, "begin": i == 0 && t == t0}); + s.execute("effects.setKeyframe", p).unwrap(); + } + } + assert_eq!(level(&s), [(t0, -3.0), (t1, -3.0)]); + s.undo(); + assert_eq!(level(&s), before); + + // one keyframe: time and value in one command + let mut at = t1; + for (i, d) in [1000, 2000, 3000].into_iter().enumerate() { + let p = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": at, "to": t1 + d, "value": -6.0, "merge": true, "begin": i == 0}); + s.execute("effects.moveKeyframe", p).unwrap(); + at = t1 + d; + } + assert_eq!(level(&s), [before[0], (t1 + 3000, -6.0)]); + s.undo(); + assert_eq!(level(&s), before); + + let bad = json!({"clip": c, "effect": "volume", "param": "level", "mediaTime": t0, "to": t0 + 1, "value": "loud"}); + assert!(s.execute("effects.moveKeyframe", bad).is_err()); + assert_eq!(level(&s), before); +} + #[test] fn transitions_and_markers() { let mut s = demo(); @@ -529,3 +730,37 @@ fn dragging_an_effect_parameter_is_one_undo_step() { s.undo(); assert_eq!(opacity(&s), start, "and then the whole first one"); } + +/// #484: Enable flips each selected clip on its own, as in Premiere. With one enabled and one +/// disabled clip selected it swaps them, instead of first making both the same. +#[test] +fn enable_flips_each_selected_clip() { + let mut s = demo(); + let enabled = |s: &Session, c: u64| s.active_sequence().unwrap().find_item(ClipId(c)).unwrap().1.enabled; + let partner = |s: &Session, c: u64| { + let q = s.active_sequence().unwrap(); + let link = q.find_item(ClipId(c)).unwrap().1.link?; + q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != ClipId(c)).map(|i| i.id.0) + }; + let (a, b) = (v1(&s)[0].0, v1(&s)[1].0); + s.execute("clip.enable", json!({"clips": [a]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // mixed selection: each flips + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (true, false)); + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // a clip named twice still flips once, and one press is one undo step + s.execute("clip.enable", json!({"clips": [a, a]})).unwrap(); + assert!(enabled(&s, a)); + s.undo(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (false, true)); + // with linked selection on, a clip's linked audio flips with it + if let Some(au) = partner(&s, a) { + assert_eq!(enabled(&s, au), enabled(&s, a)); + } + // a uniform selection still toggles as before + s.execute("clip.enable", json!({"clips": [b]})).unwrap(); + s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); + assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); +} diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index 5b73786e1..dda7a4d14 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -169,7 +169,7 @@ pub fn toggle_type(s: &mut Session) -> Result { /// Trim every selected edit point by `delta` (edit point movement; positive = later). fn trim_all(s: &mut Session, delta: Tick) -> Result { - let applied = trim_all_raw(s, delta)?; + let applied = trim_all_raw(s, &|_, _| Some(delta))?; let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -193,7 +193,8 @@ fn reset_shift(s: &mut Session) { s.state.trim_shift = TrimShift::default(); } -fn trim_all_raw(s: &mut Session, delta: Tick) -> Result> { +/// Trim every selected edit point; `delta_for` gives each point's own movement, read from the sequence as it is when that point is reached. +fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Option) -> Result> { if s.state.edit_points.is_empty() { return Err(EngineError::Other("no edit points selected".into())); } @@ -201,6 +202,7 @@ fn trim_all_raw(s: &mut Session, delta: Tick) -> Result> { let mut applied = Vec::new(); for ep in &pts { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let Some(delta) = delta_for(seq, ep) else { continue }; let r = match ep.kind { TrimKind::Roll => { let Some((l, r)) = roll_pair(seq, ep) else { continue }; @@ -226,9 +228,10 @@ pub fn nudge(s: &mut Session, p: &Value) -> Result { /// `trim.extendToPlayhead`: move each selected edit point to the playhead. pub fn extend_to_playhead(s: &mut Session) -> Result { let ph = s.playhead(); - let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; - let first = s.state.edit_points.first().and_then(|e| edit_time(seq, e)).ok_or_else(|| EngineError::Other("no edit points selected".into()))?; - trim_all(s, ph - first) + let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?; + let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); + add_shift(s, first); + Ok(json!({"applied": applied})) } /// `trim.toPlayhead {side: "previous"|"next", ripple}` (Q/W, ⌥Q/⌥W): remove the material between @@ -502,14 +505,14 @@ fn apply_dynamic(s: &mut Session, offset: Tick) -> Result { let redo = std::mem::take(&mut s.history.redo); s.history.limit = usize::MAX; s.project = d.before.clone(); - let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) }; + let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, &|_, _| Some(offset)) }; let applied = match r { Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(), Err(_) => { // refused outright (e.g. it would break sync): stay where we were s.project = d.before.clone(); if d.offset != Tick::ZERO { - let _ = trim_all_raw(s, d.offset); + let _ = trim_all_raw(s, &|_, _| Some(d.offset)); } d.offset } diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index bbeb147f1..257357120 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -210,3 +210,29 @@ fn trim_monitor_sides_and_shift_counters() { let seq = s.execute("sequence.inspect", json!({})).unwrap(); assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty()); } + +#[test] +fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { + let mut s = demo(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let q = s.active_sequence().unwrap(); + // a clip on V1 and the overlay on V2 that overlap in time but end at different frames + let mut found = None; + for a in &q.video_tracks[0].items { + for b in &q.video_tracks[1].items { + let (lo, hi) = (a.start.max(b.start), a.end().min(b.end())); + if found.is_none() && a.end() != b.end() && hi - lo > rate.tick_of(2) { + found = Some((a.id, b.id, lo + rate.tick_of(1))); + } + } + } + let (a, b, ph) = found.expect("demo has overlapping clips on V1 and V2 with different ends"); + s.execute("playhead.set", json!({"time": ph.0})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": a.0, "edge": "out", "kind": "trim"})).unwrap(); + s.execute("trim.selectEditPoint", json!({"clip": b.0, "edge": "out", "kind": "trim", "add": true})).unwrap(); + s.execute("trim.extendToPlayhead", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(a).unwrap().1.end(), ph); + assert_eq!(q.find_item(b).unwrap().1.end(), ph); +} diff --git a/crates/engine/src/vfx_tests.rs b/crates/engine/src/vfx_tests.rs index f01ced7fa..cb2df9587 100644 --- a/crates/engine/src/vfx_tests.rs +++ b/crates/engine/src/vfx_tests.rs @@ -12,6 +12,38 @@ fn demo() -> Session { s } +#[test] +fn transition_preview_is_read_only_and_matches_apply_and_undo() { + use crate::commands::preview_transition; + use filmcraft_project::TrackKind; + for (kind, effect) in [(TrackKind::Video, "cross_dissolve"), (TrackKind::Audio, "constant_power")] { + let mut s = demo(); + let clips: Vec<_> = s.active_sequence().unwrap().tracks(kind)[0].items.iter().map(|c| c.id).collect(); + for clip in clips { + for edge in ["in", "out"] { + let params = json!({"effect": effect, "clip": clip.0, "edge": edge, "frames": 7}); + let before = s.project.to_json(); + let revision = s.revision; + let (track, planned) = preview_transition(&s, ¶ms, kind).unwrap(); + assert_eq!(s.project.to_json(), before); + assert_eq!(s.revision, revision); + let result = s.execute("effects.apply", params).unwrap(); + let applied = + s.active_sequence().unwrap().track(track).unwrap().transitions.iter().find(|t| Some(t.id.0) == result["transition"].as_u64()).unwrap(); + assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + } + } + assert!(preview_transition(&s, &json!({"effect": effect, "clip": u64::MAX}), kind).is_err()); + let track = s.active_sequence().unwrap().tracks(kind)[0].id; + std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true; + assert!(preview_transition(&s, &json!({"effect": effect, "clip": s.active_sequence().unwrap().track(track).unwrap().items[0].id.0}), kind).is_err()); + } +} + fn first_v1_clip(s: &Session) -> u64 { s.active_sequence().unwrap().video_tracks[0].items[0].id.0 } diff --git a/crates/engine/src/voiceover.rs b/crates/engine/src/voiceover.rs index 695f8d7d1..fa3dd42a7 100644 --- a/crates/engine/src/voiceover.rs +++ b/crates/engine/src/voiceover.rs @@ -406,18 +406,18 @@ pub(crate) fn media_dir(s: &Session, p: &Value, fallback: &str) -> String { base.join(fallback).to_string_lossy().into_owned() } -/// `

/ .wav` with the first `n` used by neither a project item nor an existing file. -pub(crate) fn unique_wav_path(s: &Session, dir: &str, base: &str) -> String { +/// `/ .wav` with the first `n` used by neither a project item nor an existing file; +/// an error once `n` passes 99 999 (bounded: `file_size` is a filesystem call per try). +pub(crate) fn unique_wav_path(s: &Session, dir: &str, base: &str) -> Result { let names: std::collections::HashSet = s.project.items.values().map(|i| i.name.clone()).collect(); - let mut k = 1u64; - loop { + for k in 1..=99_999u32 { let file = format!("{base} {k}.wav"); let path = std::path::Path::new(dir).join(&file).to_string_lossy().into_owned(); if !names.contains(&file) && s.services.file_size(&path).is_err() { - return path; + return Ok(path); } - k += 1; } + Err(EngineError::Other(format!("{dir}: no free file name for \"{base} .wav\""))) } fn stop(s: &mut Session, p: &Value) -> Result { @@ -470,7 +470,7 @@ fn stop(s: &mut Session, p: &Value) -> Result { // write the file let dir = record_dir(s, p); let base = s.prefs.voice_over.name.clone(); - let path = unique_wav_path(s, &dir, &base); + let path = unique_wav_path(s, &dir, &base)?; if !cfg!(target_arch = "wasm32") { std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?; } diff --git a/crates/engine/src/wasm_clock_tests.rs b/crates/engine/src/wasm_clock_tests.rs new file mode 100644 index 000000000..6a2a13fca --- /dev/null +++ b/crates/engine/src/wasm_clock_tests.rs @@ -0,0 +1,53 @@ +//! The web build runs every job synchronously (`wait || cfg!(target_arch = "wasm32")`), so a job +//! body executes on wasm32 — where `std::time::Instant::now()` panics with "time not implemented +//! on this platform" and takes the whole app down (#230: Create Proxies and mask tracking both +//! did). `web_time::Instant` is `std::time::Instant` on native targets and `performance.now()` +//! on the web, so there is never a reason to name the std one in this crate. + +use std::path::Path; + +/// Every non-test source file of the crate, as (path, contents). +fn sources() -> Vec<(String, String)> { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "media_test_util.rs" { + continue; + } + out.push((p.display().to_string(), std::fs::read_to_string(&p).unwrap())); + } + } + assert!(out.len() > 20, "found {} source files", out.len()); + out +} + +#[test] +fn no_std_instant_in_code_the_web_build_runs() { + let mut hits = Vec::new(); + for (path, text) in sources() { + for (i, line) in text.lines().enumerate() { + // `use std::time::{.., Instant, ..}` or a fully qualified `std::time::Instant` + let names_std_instant = line.contains("std::time::Instant") || (line.contains("use std::time::") && line.contains("Instant")); + if names_std_instant && !line.contains("web_time") && !line.trim_start().starts_with("//") { + hits.push(format!("{path}:{}: {}", i + 1, line.trim())); + } + } + } + assert!(hits.is_empty(), "std::time::Instant panics on wasm32; use web_time::Instant:\n{}", hits.join("\n")); +} + +/// The clock the jobs use works here as well: a job's "Done in 0.0s" report needs an elapsed time. +#[test] +fn web_time_instant_measures_elapsed_time() { + let t0 = web_time::Instant::now(); + let secs = t0.elapsed().as_secs_f64(); + assert!((0.0..60.0).contains(&secs), "{secs}"); +} diff --git a/crates/export/src/determinism_tests.rs b/crates/export/src/determinism_tests.rs index 6b119c8d6..2866cb9d4 100644 --- a/crates/export/src/determinism_tests.rs +++ b/crates/export/src/determinism_tests.rs @@ -46,6 +46,24 @@ fn same_file_whatever_the_core_count() { } } +#[test] +fn an_export_adds_up_the_wall_time_of_its_stages() { + let before: Vec<(Stage, u64)> = stage_times(); + let s = ExportSettings { format: Format::H264, path: tmp("stages.mp4"), ..Default::default() }; + let (p, seq, m) = project(); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let after = stage_times(); + let grew = |stage: Stage| { + let at = |v: &[(Stage, u64)]| v.iter().find(|(s, _)| *s == stage).map_or(0, |(_, ns)| *ns); + at(&after) > at(&before) + }; + // other tests export in parallel, so the counters can only be checked for growth + for stage in [Stage::Setup, Stage::Render, Stage::Encode, Stage::Convert, Stage::Audio, Stage::Mux, Stage::Finish] { + assert!(grew(stage), "{} did not grow", stage.name()); + } + assert_eq!(Stage::ALL.len(), after.len(), "one counter per stage"); +} + /// Slice NAL units (types 1 and 5) in a length-prefixed (4-byte) H.264 sample. fn slices_in(sample: &[u8]) -> usize { let (mut n, mut i) = (0, 0); @@ -78,3 +96,215 @@ fn h264_slices_follow_the_frame_size_not_the_cores() { assert_eq!(slices_in(&sample), 64, "picture {i}"); } } + +// --------------------------------------------------------------------------------------------- +// rendering the next batch while the current one is encoded (job.rs, "Overlapping render and encode") +// --------------------------------------------------------------------------------------------- + +use filmcraft_media::SharedSource; +use filmcraft_render::SourceMap; +use std::sync::atomic::AtomicUsize; + +fn pool(threads: usize) -> rayon::ThreadPool { + rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap() +} + +/// Step an export on a pool of `threads` cores with `batch` frames per step, rendering ahead or +/// not; returns the file and how many steps rendered ahead. +fn export_overlapped(threads: usize, batch: i64, overlap: bool, settings: &ExportSettings) -> (Vec, u64) { + let (p, seq, m) = project(); + pool(threads).install(|| { + let prog = Progress::default(); + let mut ex = Exporter::new(p, seq, settings, &prog).unwrap(); + ex.set_batch(batch); + ex.set_overlap(overlap); + while !matches!(ex.step(&m, &prog).unwrap(), Step::Done(_)) {} + assert_eq!(prog.done.load(Ordering::Relaxed), prog.total.load(Ordering::Relaxed), "every frame is counted once"); + assert_eq!(ex.frames_ahead(), 0, "nothing is left rendered at the end"); + (std::fs::read(&settings.path).unwrap(), ex.overlapped_steps()) + }) +} + +#[test] +fn rendering_ahead_does_not_change_the_file() { + for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] { + let settings = |name: &str| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() }; + let (reference, ahead) = export_overlapped(3, 7, false, &settings(&format!("ov-{}-off", format.id()))); + assert_eq!(ahead, 0, "{}: overlap is off", format.label()); + for (threads, batch) in [(2, 1), (3, 3), (5, 7), (16, 24)] { + let (other, ahead) = export_overlapped(threads, batch, true, &settings(&format!("ov-{}-{threads}-{batch}", format.id()))); + // 24 frames in one batch (the last case) has nothing to render ahead; the others do + assert_eq!(ahead > 0, batch < 24, "{}: {ahead} overlapped steps at batch {batch}", format.label()); + assert!(reference == other, "{}: rendering ahead ({threads} cores, {batch} per step) gives a different file", format.label()); + } + } +} + +#[test] +fn rendering_ahead_in_a_two_pass_export() { + let settings = |name: &str| ExportSettings { + format: Format::H264, + path: tmp(name), + bitrate_mode: BitrateMode::Vbr2Pass, + bitrate_kbps: 2000, + include_audio: false, + ..Default::default() + }; + let (reference, _) = export_overlapped(3, 5, false, &settings("2pass-off.mp4")); + let (other, ahead) = export_overlapped(3, 5, true, &settings("2pass-on.mp4")); + assert!(ahead > 0); + assert!(reference == other, "two-pass: rendering ahead gives a different file"); + // the whole export, with audio, through `export`: both passes counted once + let prog = Progress::default(); + let (p, seq, m) = project(); + pool(4).install(|| export(&p, seq, &ExportSettings { include_audio: true, ..settings("2pass-all.mp4") }, &m, &prog)).unwrap(); + assert_eq!(prog.total.load(Ordering::Relaxed), 48); + assert_eq!(prog.done.load(Ordering::Relaxed), 48); +} + +#[test] +fn hdr_with_audio_is_the_same_file_when_the_memory_budget_caps_the_batch() { + use filmcraft_color::{ColorPipeline, WorkingSpace}; + for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4")] { + let run = |name: &str, batch: i64, overlap: bool, budget: Option| { + let (p, seq, m) = project(); + let mut p = (*p).clone(); + p.sequence_mut(seq).unwrap().settings.color = ColorPipeline { working: WorkingSpace::Rec2100Pq, ..ColorPipeline::REC709 }; + let p = Arc::new(p); + let s = ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() }; + pool(3).install(|| { + let prog = Progress::default(); + let mut ex = Exporter::new(p, seq, &s, &prog).unwrap(); + ex.set_batch(batch); + ex.set_overlap(overlap); + if let Some(b) = budget { + ex.set_budget(b); + } + let mut first_ahead = None; + loop { + let step = ex.step(&m, &prog).unwrap(); + first_ahead.get_or_insert(ex.frames_ahead()); + if matches!(step, Step::Done(_)) { + break; + } + } + (std::fs::read(&s.path).unwrap(), first_ahead.unwrap_or(0)) + }) + }; + let (reference, _) = run(&format!("hdrcap-{}-ref", format.id()), 16, false, None); + // 320 x 180 HDR frames are 691 200 bytes: a budget of six of them lets two batches of three be in + // flight, so a batch of 16 is cut to 3 and the interleave cut at frame 16 falls in a prefetched batch + let budget = 6 * frame_bytes_for_test(); + let (capped, ahead) = run(&format!("hdrcap-{}-3", format.id()), 16, true, Some(budget)); + assert_eq!(ahead, 3, "{}: the batch was not capped to 3", format.label()); + assert!(reference == capped, "{}: a capped HDR batch gives a different file", format.label()); + let (one, ahead) = run(&format!("hdrcap-{}-1", format.id()), 16, true, Some(1)); + assert_eq!(ahead, 1); + assert!(reference == one, "{}: one-frame HDR batches give a different file", format.label()); + let (full, ahead) = run(&format!("hdrcap-{}-16", format.id()), 16, true, None); + assert_eq!(ahead, 8, "{}: 24 frames in batches of 16 leave 8 ahead", format.label()); + assert!(reference == full, "{}: an uncapped HDR batch gives a different file", format.label()); + } +} + +/// Bytes of one HDR frame of the test project (320 x 180, three f32 per pixel). +fn frame_bytes_for_test() -> u64 { + 320 * 180 * 12 +} + +/// A provider that answers like an asynchronous (web) reader whose bytes have not arrived yet for +/// the calls numbered in `loading` (marks pending, offers no source), and can be told to panic. +struct Flaky<'a> { + inner: &'a SourceMap, + calls: AtomicUsize, + loading: std::ops::Range, + panic_from: AtomicUsize, +} + +impl<'a> Flaky<'a> { + fn new(inner: &'a SourceMap, loading: std::ops::Range) -> Self { + Self { inner, calls: AtomicUsize::new(0), loading, panic_from: AtomicUsize::new(usize::MAX) } + } +} + +impl SourceProvider for Flaky<'_> { + fn source(&self, item: ItemId) -> Option { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n >= self.panic_from.load(Ordering::SeqCst) { + panic!("a source blew up (test)"); + } + if self.loading.contains(&n) { + filmcraft_media::pending::mark(); + return None; + } + self.inner.source(item) + } +} + +#[test] +fn a_batch_rendered_ahead_with_missing_media_is_never_encoded() { + let settings = |name: &str| ExportSettings { format: Format::Mjpeg, path: tmp(name), include_audio: false, ..Default::default() }; + let (reference, _) = export_overlapped(3, 3, false, &settings("pending-ref.mov")); + let (p, seq, m) = project(); + let flaky = Flaky::new(&m, 5..70); + let s = settings("pending.mov"); + let pendings = pool(3).install(|| { + let prog = Progress::default(); + let mut ex = Exporter::new(p, seq, &s, &prog).unwrap(); + ex.set_batch(3); + let mut pendings = 0; + for _ in 0..1000 { + match ex.step(&flaky, &prog).unwrap() { + Step::Done(_) => { + assert_eq!(prog.done.load(Ordering::Relaxed), 24); + return pendings; + } + Step::Pending => { + pendings += 1; + // the batch that was not complete is not kept for the next step + assert_eq!(ex.frames_ahead(), 0); + } + Step::Progress => {} + } + } + panic!("the export did not finish"); + }); + assert!(pendings > 0, "the missing media was never noticed"); + // a frame drawn without its media would be black, not the red of the matte + assert!(std::fs::read(&s.path).unwrap() == reference, "frames rendered without their media were encoded"); +} + +#[test] +fn cancelling_drops_the_batch_rendered_ahead() { + let s = ExportSettings { format: Format::Mjpeg, path: tmp("cancel.mov"), ..Default::default() }; + let (p, seq, m) = project(); + pool(3).install(|| { + let prog = Progress::default(); + let mut ex = Exporter::new(p, seq, &s, &prog).unwrap(); + ex.set_batch(4); + assert!(matches!(ex.step(&m, &prog).unwrap(), Step::Progress)); + assert_eq!(ex.frames_ahead(), 4, "the second batch was rendered while the first was encoded"); + assert_eq!(prog.done.load(Ordering::Relaxed), 4); + prog.cancel.store(true, Ordering::Relaxed); + assert!(matches!(ex.step(&m, &prog), Err(ExportError::Cancelled))); + assert_eq!(ex.frames_ahead(), 0); + assert_eq!(prog.done.load(Ordering::Relaxed), 4, "nothing more was encoded"); + }); +} + +#[test] +fn a_panic_while_rendering_ahead_is_an_error() { + let s = ExportSettings { format: Format::Mjpeg, path: tmp("panic.mov"), include_audio: false, ..Default::default() }; + let (p, seq, m) = project(); + let flaky = Flaky::new(&m, 0..0); + pool(3).install(|| { + let prog = Progress::default(); + let mut ex = Exporter::new(p, seq, &s, &prog).unwrap(); + ex.set_batch(4); + assert!(matches!(ex.step(&flaky, &prog).unwrap(), Step::Progress)); + // the batch rendered ahead during the next step is the one that panics + flaky.panic_from.store(flaky.calls.load(Ordering::SeqCst), Ordering::SeqCst); + let r = ex.step(&flaky, &prog); + assert!(matches!(r, Err(ExportError::Encode(_))), "{:?}", r.map(|_| ())); + }); +} diff --git a/crates/export/src/hdr_tests.rs b/crates/export/src/hdr_tests.rs new file mode 100644 index 000000000..f5f048080 --- /dev/null +++ b/crates/export/src/hdr_tests.rs @@ -0,0 +1,201 @@ +//! HDR plumbing shared by hardware encoders: the 10-bit 4:2:0 converter, the HDR probe registry, the +//! decision whether an export is HDR, and the one definition of the HDR10 static metadata. +use super::*; +use std::sync::atomic::{AtomicBool, Ordering}; + +const BT2020: (f32, f32) = (0.2627, 0.0593); + +fn convert(rgb: &[f32], w: usize, h: usize) -> (Vec, Vec, Vec) { + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + rgbf_to_yuv420_10(rgb, w, h, BT2020.0, BT2020.1, &mut y, &mut u, &mut v).unwrap(); + (y, u, v) +} + +fn flat(w: usize, h: usize, px: [f32; 3]) -> Vec { + px.iter().copied().cycle().take(w * h * 3).collect() +} + +#[test] +fn black_white_and_grey_have_the_limited_range_codes() { + for (px, code) in [([0.0; 3], 64u16), ([1.0; 3], 940), ([0.5; 3], 502)] { + let (y, u, v) = convert(&flat(4, 4, px), 4, 4); + assert!(y.iter().all(|c| *c == code), "{px:?}: {y:?}"); + assert!(u.iter().chain(&v).all(|c| *c == 512), "{px:?}: neutral chroma"); + assert_eq!((y.len(), u.len(), v.len()), (16, 4, 4)); + } +} + +#[test] +fn bt2020_primaries_have_exact_codes() { + // (R', G', B') -> (Y, Cb, Cr) with the BT.2020 non-constant-luminance matrix, 10-bit limited range + for (px, want) in [ + ([1.0, 0.0, 0.0], (294u16, 387u16, 960u16)), + ([0.0, 1.0, 0.0], (658, 189, 100)), + ([0.0, 0.0, 1.0], (116, 960, 476)), + ([1.0, 0.5, 0.0], (591, 225, 754)), + ] { + let (y, u, v) = convert(&flat(2, 2, px), 2, 2); + assert_eq!((y[0], u[0], v[0]), want, "{px:?}"); + } +} + +#[test] +fn out_of_range_and_non_finite_values_clamp_instead_of_wrapping() { + // above 1, below 0 and infinities clamp to the legal range; NaN counts as 0 (black, not code 0) + let mut px = vec![0.0f32; 4 * 3]; + px[..3].copy_from_slice(&[2.0, 2.0, 2.0]); + px[3..6].copy_from_slice(&[-1.0, -5.0, -0.5]); + px[6..9].copy_from_slice(&[f32::INFINITY, f32::INFINITY, f32::INFINITY]); + px[9..].copy_from_slice(&[f32::NAN, f32::NEG_INFINITY, f32::NAN]); + let (y, u, v) = convert(&px, 4, 1); + assert_eq!(y, vec![940, 64, 940, 64]); + assert!(u.iter().chain(&v).all(|c| (64..=960).contains(c)), "{u:?} {v:?}"); + // all NaN: black + let (y, u, v) = convert(&[f32::NAN; 2 * 2 * 3], 2, 2); + assert_eq!((y, u, v), (vec![64; 4], vec![512], vec![512])); +} + +#[test] +fn odd_sizes_average_the_pixels_they_have() { + // 3x3: the chroma planes are 2x2; the right column and bottom row average one or two pixels + let (y, u, v) = convert(&flat(3, 3, [1.0, 0.0, 0.0]), 3, 3); + assert_eq!((y.len(), u.len(), v.len()), (9, 4, 4)); + assert!(y.iter().all(|c| *c == 294) && u.iter().all(|c| *c == 387) && v.iter().all(|c| *c == 960)); + // 1x1 + let (y, u, v) = convert(&[0.0, 0.0, 1.0], 1, 1); + assert_eq!((y, u, v), (vec![116], vec![960], vec![476])); + // chroma is the mean over the 2x2 block: red and blue pixels give the mean of their Cb and Cr + let rgb = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + let (y, u, v) = convert(&rgb, 2, 2); + assert_eq!(y, vec![294, 116, 294, 116]); + assert_eq!((u[0], v[0]), ((387u16 + 960) / 2, (960u16 + 476) / 2)); +} + +#[test] +fn a_ramp_has_every_code_a_10_bit_picture_can_have() { + // a smooth horizontal ramp: 877 distinct luma codes in 64..=940, far more than 8 bits hold + let w = 1024usize; + let rgb: Vec = (0..w).flat_map(|x| [x as f32 / (w - 1) as f32; 3]).collect(); + let (y, _, _) = convert(&rgb, w, 1); + let mut codes: Vec = y.clone(); + codes.dedup(); + assert_eq!((y[0], y[w - 1]), (64, 940)); + assert_eq!(codes.len(), 877, "{} distinct codes", codes.len()); + assert!(y.windows(2).all(|p| p[0] <= p[1])); +} + +#[test] +fn hostile_input_is_an_error_not_a_panic() { + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + let mut run = |rgb: &[f32], w: usize, h: usize| rgbf_to_yuv420_10(rgb, w, h, 0.2627, 0.0593, &mut y, &mut u, &mut v); + assert!(run(&[], 0, 0).is_err()); + assert!(run(&[0.0; 3], 0, 1).is_err()); + assert!(run(&[0.0; 3], 1, 0).is_err()); + assert!(run(&[0.0; 11], 2, 2).is_err(), "short"); + assert!(run(&[0.0; 13], 2, 2).is_err(), "long"); + assert!(run(&[0.0; 6], 2, 2).is_err()); + assert!(run(&[0.0; 3], usize::MAX, 2).is_err(), "w*h overflows"); + assert!(run(&[0.0; 3], usize::MAX / 2, 3).is_err(), "w*h*3 overflows"); + assert!(run(&[0.0; 3], 1 << 40, 1 << 40).is_err()); +} + +#[test] +fn the_hdr_probe_registry_follows_its_probes() { + static HERE: AtomicBool = AtomicBool::new(false); + fn probe() -> bool { + HERE.load(Ordering::SeqCst) + } + // a format nothing registered a probe for is never HDR; registering twice is harmless + assert!(!hdr_available(Format::ProRes)); + register_hdr_probe(Format::ProRes, probe); + register_hdr_probe(Format::ProRes, probe); + assert!(!hdr_available(Format::ProRes)); + HERE.store(true, Ordering::SeqCst); + assert!(hdr_available(Format::ProRes)); + assert!(!hdr_available(Format::DnxHr), "probes are per format"); + HERE.store(false, Ordering::SeqCst); + assert!(!hdr_available(Format::ProRes)); +} + +#[test] +fn which_exports_are_hdr() { + use crate::job::hdr_output; + let never = |_: Format| false; + let hevc_only = |f: Format| f == Format::Hevc; + // H.264, ProRes, DNxHR and APV follow the working space, whatever the probes say + for f in [Format::H264, Format::ProRes, Format::DnxHr, Format::Apv] { + assert!(hdr_output(true, false, f, never), "{f:?}"); + assert!(!hdr_output(false, false, f, hevc_only), "{f:?}: SDR working space"); + assert!(!hdr_output(true, true, f, hevc_only), "{f:?}: SDR asked for"); + } + // H.265 only where a registered encoder writes HDR + assert!(!hdr_output(true, false, Format::Hevc, never), "no probe: tone-mapped SDR, as with VideoToolbox"); + assert!(hdr_output(true, false, Format::Hevc, hevc_only)); + assert!(!hdr_output(true, true, Format::Hevc, hevc_only), "settings.sdr forces SDR"); + assert!(!hdr_output(false, false, Format::Hevc, hevc_only)); + // formats without HDR output + assert!(!hdr_output(true, false, Format::Mjpeg, |_| true)); +} + +#[test] +fn only_pq_has_static_metadata_and_it_is_what_h264_writes() { + assert!(ColorSignal::HLG.static_metadata().is_none()); + assert!(ColorSignal::default().static_metadata().is_none()); + let (md, cll) = ColorSignal::PQ.static_metadata().unwrap(); + assert_eq!(cll, (0, 0)); + assert_eq!(md, filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001)); + assert_eq!(md.to_bytes().len(), 24); + // the box on the sample entry is the same value + let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64); + ColorSignal::PQ.apply_to(&mut e, false); + let v = e.video.as_ref().unwrap(); + assert_eq!((v.mastering_display, v.content_light), (Some(md), Some((0, 0)))); + let mut e = filmcraft_isobmff::SampleEntry::hevc(Default::default(), 64, 64); + ColorSignal::HLG.apply_to(&mut e, false); + let v = e.video.as_ref().unwrap(); + assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none()); +} + +/// Remove emulation-prevention bytes (`00 00 03` -> `00 00`). +fn unescape(b: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut zeros = 0; + for &x in b { + if zeros >= 2 && x == 3 { + zeros = 0; + continue; + } + zeros = if x == 0 { zeros + 1 } else { 0 }; + out.push(x); + } + out +} + +#[test] +fn the_software_h264_sei_is_the_static_metadata_laid_out_as_messages() { + let s = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..Default::default() }; + let mut enc = h264_factory(Format::H264, 64, 64, FrameRate::FPS_24, &s).unwrap().unwrap(); + let rgb = vec![0.5f32; 64 * 64 * 3]; + let mut packets = enc.encode(&EncoderFrame { width: 64, height: 64, rgba: &[], hdr: Some(&rgb), index: 0 }).unwrap(); + packets.extend(enc.flush().unwrap()); + // find the SEI NAL unit (type 6) of the length-prefixed first access unit + let data = &packets[0].data; + let (mut pos, mut sei) = (0usize, None); + while let Some(len) = data.get(pos..pos + 4) { + let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize; + let nal = &data[pos + 4..pos + 4 + n]; + if nal[0] & 0x1f == 6 { + sei = Some(unescape(&nal[1..])); + } + pos += 4 + n; + } + let sei = sei.expect("an SEI NAL unit in the first access unit"); + let (md, (cll, fall)) = ColorSignal::PQ.static_metadata().unwrap(); + let mut want = vec![137u8, 24]; + want.extend(md.to_bytes()); + want.extend([144u8, 4]); + want.extend(cll.to_be_bytes()); + want.extend(fall.to_be_bytes()); + want.push(0x80); + assert_eq!(sei, want); +} diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index c9ce7b8f5..88864eaf3 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -9,15 +9,59 @@ //! then encodes for real with the first pass's statistics. //! //! When a source's bytes are not available yet (asynchronous web reads mark -//! [`filmcraft_media::pending`]), the batch is rendered again on the next call: nothing is encoded -//! from frames or audio with missing media. +//! [`filmcraft_media::pending`]), the video batch is rendered again on the next call: nothing is encoded +//! from frames with missing media. Known, pre-existing web issue (TODO, separate fix): the audio pull +//! has already advanced its position when it hits a pending source, so a retried group loses its audio. //! //! The file does not depend on how many frames a step renders (one per core natively, one on the //! web): packets go to the muxer in fixed groups of [`INTERLEAVE`] output frames, each followed by //! the audio up to its end, so the same project and settings give the same bytes on any machine. +//! +//! # Overlapping render and encode +//! +//! Rendering a batch is parallel (rayon); encoding, muxing and the audio are serial and own the +//! encoder, the container writer and the audio state. Run one after the other the wall time was +//! `render + encode + audio + mux`. A step now renders batch *k+1* on the rayon pool +//! ([`rayon::in_place_scope`] + `spawn`) while the *calling* thread encodes, muxes and mixes the +//! audio of batch *k*, and keeps the finished frames in `ahead` for the next step: the wall time +//! is about `max(render, encode + audio + mux)`. Design points: +//! +//! * **Bytes do not change.** Batches are cut exactly as before (`next .. next + batch`), frames +//! are encoded in order, and the audio / mux interleave still follows [`INTERLEAVE`] groups, so +//! the file is identical with or without overlap, on any core count and for any step size +//! (`determinism_tests`). +//! * **The encode side stays on the thread that called [`Exporter::step`].** Encoders (hardware +//! ones in particular) may be tied to a thread, and the exporter need not be `Send`; only the +//! render of the next batch moves to the pool. It needs no `&mut` access to the exporter: it +//! holds an `Arc` and the source provider. +//! * **Pending media.** [`filmcraft_media::pending`] is a thread-local flag, set by web readers +//! (whose export never overlaps: wasm has no threads, so [`Exporter::overlapping`] is false and +//! every step is the sequential render-then-encode of before). The render of a batch takes the +//! flag after every frame, on whatever thread drew it, and a batch that saw it is discarded: +//! a prefetched batch that hit pending is dropped and rendered again by the next step, and +//! nothing is ever encoded from it. The audio pull checks the flag on the encoding thread right +//! after pulling, before a frame is encoded (the batch is then not encoded; its audio is not +//! recovered, see the known issue above) and the prefetched batch is dropped too, because the step +//! did not advance `next`. +//! * **Cancellation** is checked at the start of every step, which drops the prefetched frames; +//! the prefetch is not started once it is set. +//! * **Panics.** The prefetch runs under `catch_unwind` and becomes an [`ExportError`]; a panic of +//! the encode side unwinds the calling thread as before (the job guard turns it into an error). +//! * **Memory.** Two batches are in flight instead of one, so the batch is capped by +//! [`IN_FLIGHT_BUDGET`]: `2 * batch * frame_bytes <= budget`, at least one frame +//! ([`overlap_batch`]). A batch never grows over what it was without overlap, so the peak is the +//! old peak plus at most the finished frames of the batch being encoded (half the budget, +//! 256 MiB); a 4K HDR export (100 MB a frame) runs 2-frame batches and 4K SDR 8-frame ones instead of 16. Frames are +//! dropped as soon as they are encoded. +//! * **Two-pass** exports overlap in the analysis pass (encode only) and in the real pass; the +//! prefetch never runs past the end of a pass. +//! * **Timers.** `Render` and `Encode` are wall times of work that now runs at the same time, so +//! their sum may exceed the export's wall time; `Wait` is the time the encoding side then spent +//! waiting for the render (large: render-bound; near zero: encode-bound). +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; use filmcraft_project::{ItemId, Project}; @@ -30,8 +74,8 @@ use crate::mxf_out::{MxfMux, MxfSetup}; use crate::pipeline::Pipeline; use crate::settings::{AudioCodec, BitrateMode, Multiplexer}; use crate::{ - AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, VideoEncoder, - audio_factories, export_range, frame_span, video_factories, + AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder, + audio_factories, export_range, frame_span, note_stage, timed, video_factories, }; /// Output frames per interleaved group: the video packets of these frames, then the audio up to @@ -39,6 +83,58 @@ use crate::{ /// count) so the file layout is the same on every machine. pub const INTERLEAVE: i64 = 16; +/// Most bytes of rendered frames an overlapped export keeps in flight (the batch being encoded and +/// the one being rendered), see [`overlap_batch`]. +pub const IN_FLIGHT_BUDGET: u64 = 1 << 29; + +/// One rendered picture: straight RGBA8, or for HDR exports the encoded R'G'B' floats. +type Frame = (Vec, Vec); + +/// Bytes of one [`Frame`] of a `w` x `h` export (RGBA8, or three f32 per pixel for HDR). +pub(crate) fn frame_bytes(w: u32, h: u32, hdr: bool) -> u64 { + u64::from(w).saturating_mul(u64::from(h)).saturating_mul(if hdr { 12 } else { 4 }) +} + +/// Frames per batch when two batches are in flight: `batch` (what a sequential export would use), +/// lowered so that `2 * frames * frame_bytes` stays within `budget`, but never below one frame. +pub(crate) fn overlap_batch(batch: i64, frame_bytes: u64, budget: u64) -> i64 { + let want = u64::try_from(batch.max(1)).unwrap_or(1); + let fit = (budget / 2) / frame_bytes.max(1); + i64::try_from(fit.min(want)).unwrap_or(1).max(1) +} + +/// A batch rendered while the previous one was being encoded. +struct Ahead { + /// First frame. + first: i64, + frames: Vec, +} + +/// Render frames `f..end` in parallel. `None` when a source was not ready for one of them (the +/// pending flag, taken after every frame because it is per thread): the batch is incomplete and +/// must be rendered again. +fn render_batch(pipe: &Pipeline, f: i64, end: i64, sources: &dyn SourceProvider) -> Option> { + let pending = AtomicBool::new(false); + let frames: Vec = (f..end) + .into_par_iter() + .map(|fi| { + let frame = pipe.frame(fi, sources); + if filmcraft_media::pending::take() { + pending.store(true, Ordering::Relaxed); + } + frame + }) + .collect(); + (!pending.load(Ordering::Relaxed)).then_some(frames) +} + +/// What encoding a batch came to. +enum Encoded { + Done, + /// Audio media was not ready: nothing was encoded, run the batch again. + Pending, +} + /// What one [`Exporter::step`] did. #[derive(Debug)] pub enum Step { @@ -52,13 +148,21 @@ pub enum Step { /// An export of an encoded-video format, advanced one batch at a time. pub struct Exporter { - pipe: Pipeline, + pipe: Arc, settings: ExportSettings, f0: i64, f1: i64, - /// Next frame to render. + /// Next frame to encode. next: i64, batch: i64, + /// Render the next batch while this one is encoded (see the module docs). + overlap: bool, + /// Bytes of frames the overlap may keep in flight ([`IN_FLIGHT_BUDGET`]; tests lower it). + budget: u64, + /// The batch starting at `next`, already rendered. + ahead: Option, + /// Steps that rendered a batch alongside the encoding of the previous one. + overlapped_steps: u64, brand: Brand, venc: Box, aenc: Option>, @@ -85,6 +189,18 @@ pub fn stepped(format: Format) -> bool { matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom) } +/// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder +/// writes HDR (`hdr_available` says so for H.265, which has no built-in encoder). +pub(crate) fn hdr_output(working_hdr: bool, sdr: bool, format: Format, hdr_available: impl Fn(Format) -> bool) -> bool { + working_hdr + && !sdr + && match format { + Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv => true, + Format::Hevc => hdr_available(Format::Hevc), + _ => false, + } +} + fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Result> { video_factories() .read() @@ -97,14 +213,19 @@ fn make_venc(settings: &ExportSettings, w: u32, h: u32, rate: FrameRate) -> Resu impl Exporter { /// Set up an export (encoder, output size, colour signalling); sets `progress.total`. pub fn new(project: Arc, seq: ItemId, settings: &ExportSettings, progress: &Progress) -> Result { + timed(Stage::Setup, || Self::set_up(project, seq, settings, progress)) + } + + fn set_up(project: Arc, seq: ItemId, settings: &ExportSettings, progress: &Progress) -> Result { if !stepped(settings.format) { return Err(ExportError::Unsupported(format!("{} is not a stepped export", settings.format.label()))); } settings.validate()?; let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; - // HDR sequences export HDR (H.264 / ProRes / DNxHR / APV) unless SDR is asked for (H.265 export is 8-bit SDR) + // HDR sequences export HDR (H.264 / ProRes / DNxHR / APV, and H.265 where a registered encoder + // writes Main 10) unless SDR is asked for; elsewhere H.265 is tone-mapped 8-bit SDR let pipe = q.settings.color; - let hdr_out = pipe.working.is_hdr() && !settings.sdr && matches!(settings.video_format(), Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv); + let hdr_out = hdr_output(pipe.working.is_hdr(), settings.sdr, settings.video_format(), crate::hdr_available); let mut settings = settings.clone(); settings.signal = match (hdr_out, pipe.working) { (true, filmcraft_color::WorkingSpace::Rec2100Pq) => ColorSignal::PQ, @@ -120,7 +241,7 @@ impl Exporter { let two_pass = settings.video_format() == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass; settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single }; let range = export_range(&project, seq, &settings)?; - let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?; + let pipe = Arc::new(Pipeline::new(project.clone(), seq, &settings, hdr_out)?); let (f0, f1) = frame_span(pipe.rate, range); let nframes = (f1 - f0) as u64; if !settings.part_of_batch { @@ -145,6 +266,10 @@ impl Exporter { f1, next: f0, batch: rayon::current_num_threads().clamp(2, 16) as i64, + overlap: true, + budget: IN_FLIGHT_BUDGET, + ahead: None, + overlapped_steps: 0, brand, venc, aenc, @@ -173,6 +298,42 @@ impl Exporter { self.batch = n.max(1); } + /// Render the next batch while encoding the current one (the default natively; the web, with + /// no threads, never does). Off gives the sequential render-then-encode; the file is the same. + pub fn set_overlap(&mut self, on: bool) { + self.overlap = on; + if !on { + self.ahead = None; + } + } + + /// Lower the in-flight byte budget (tests make small frames hit the cap). + #[cfg(test)] + pub(crate) fn set_budget(&mut self, bytes: u64) { + self.budget = bytes; + } + + /// Whether steps overlap rendering with encoding now: switched on, a pool with more than one + /// thread, and not on wasm. + pub fn overlapping(&self) -> bool { + self.overlap && !cfg!(target_arch = "wasm32") && rayon::current_num_threads() > 1 + } + + /// Steps so far that rendered the next batch alongside the encoding of the current one. + pub fn overlapped_steps(&self) -> u64 { + self.overlapped_steps + } + + /// Frames rendered ahead of the encoder now (memory held by the overlap). + pub fn frames_ahead(&self) -> usize { + self.ahead.as_ref().map_or(0, |a| a.frames.len()) + } + + /// Frames in the next batch: [`Self::set_batch`], lowered to the memory budget when overlapping. + fn batch_len(&self) -> i64 { + if self.overlapping() { overlap_batch(self.batch, frame_bytes(self.pipe.w, self.pipe.h, self.pipe.hdr_out), self.budget) } else { self.batch } + } + fn sample_at_frame(&self, f: i64, sr: u32) -> i64 { self.pipe.rate.tick_of(f).to_units_floor(sr as i64) } @@ -275,6 +436,7 @@ impl Exporter { /// Render, encode and mux the next batch (or finish the file). pub fn step(&mut self, sources: &dyn SourceProvider, progress: &Progress) -> Result { if progress.cancel.load(Ordering::Relaxed) { + self.ahead = None; return Err(ExportError::Cancelled); } let _ = filmcraft_media::pending::take(); @@ -283,7 +445,8 @@ impl Exporter { progress.set_status("Measuring loudness"); } let a = self.audio.as_mut().ok_or_else(|| ExportError::Encode("internal: the audio pipeline was not created".into()))?; - a.measure(&self.settings, sources, &|| progress.cancel.load(Ordering::Relaxed))?; + let settings = &self.settings; + timed(Stage::Loudness, || a.measure(settings, sources, &|| progress.cancel.load(Ordering::Relaxed)))?; if filmcraft_media::pending::take() { return Ok(Step::Pending); } @@ -295,49 +458,9 @@ impl Exporter { return Ok(Step::Progress); } if self.next < self.f1 { - let (f, end) = (self.next, (self.next + self.batch).min(self.f1)); - let pipe = &self.pipe; - let frames: Vec<(Vec, Vec)> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources)).collect(); - if self.first_pass { - if filmcraft_media::pending::take() { - return Ok(Step::Pending); - } - self.encode(&frames, f)?; - progress.done.fetch_add((end - f) as u64, Ordering::Relaxed); - self.next = end; - return Ok(Step::Progress); - } - // the audio of every interleave group that ends in this batch (before encoding, so a - // pending source can still make the batch run again) - let cuts: Vec = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect(); - let mut mixed = Vec::with_capacity(cuts.len()); - if let Some(sr) = self.audio.as_ref().map(|a| a.sr) { - for &g in &cuts { - let until = self.sample_at_frame(g, sr); - mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources))); - } - } - if filmcraft_media::pending::take() { - return Ok(Step::Pending); - } - let mut mixed = mixed.into_iter(); - for (k, frame) in frames.iter().enumerate() { - let fi = f + k as i64; - let packets = self.encode(std::slice::from_ref(frame), fi)?; - self.queued.extend(packets); - if cuts.contains(&(fi + 1)) { - if self.mux.is_none() && self.mxf.is_none() { - self.open_mux()?; - } - let packets = std::mem::take(&mut self.queued); - self.write_video(packets)?; - self.write_audio(mixed.next().flatten())?; - } - } - progress.done.fetch_add((end - f) as u64, Ordering::Relaxed); - self.next = end; - return Ok(Step::Progress); + return self.step_batch(sources, progress); } + self.ahead = None; if self.first_pass { // end of the analysis pass: encode for real with its statistics let _ = self.venc.flush()?; @@ -348,6 +471,7 @@ impl Exporter { self.next = self.f0; return Ok(Step::Progress); } + let finishing = web_time::Instant::now(); let mixed = self.audio.as_mut().and_then(|a| a.rest(sources)); if filmcraft_media::pending::take() { return Ok(Step::Pending); @@ -379,6 +503,7 @@ impl Exporter { (w.finish(&self.settings)?, Vec::new()) } }; + note_stage(Stage::Finish, finishing.elapsed()); let secs = self.t0.elapsed().as_secs_f64(); let nframes = self.frames(); if !self.settings.part_of_batch { @@ -395,6 +520,115 @@ impl Exporter { })) } + /// Encode (and mux) the batch starting at `self.next`, rendering the one after it meanwhile. + fn step_batch(&mut self, sources: &dyn SourceProvider, progress: &Progress) -> Result { + let (f, frames) = match self.ahead.take() { + Some(a) if a.first == self.next => (a.first, a.frames), + _ => { + let (f, end) = (self.next, self.next.saturating_add(self.batch_len()).min(self.f1)); + let pipe = &self.pipe; + match timed(Stage::Render, || render_batch(pipe, f, end, sources)) { + Some(frames) => (f, frames), + None => return Ok(Step::Pending), + } + } + }; + let end = f.saturating_add(i64::try_from(frames.len()).unwrap_or(0)); + if end <= f { + return Err(ExportError::Encode("internal: an empty batch was rendered".into())); + } + // the batch after this one, when the pool can render it while this one is encoded + let plan = + (end < self.f1 && self.overlapping() && !progress.cancel.load(Ordering::Relaxed)).then(|| (end, end.saturating_add(self.batch_len()).min(self.f1))); + let Some((a, b)) = plan else { + return match self.encode_batch(f, frames, sources)? { + Encoded::Pending => Ok(Step::Pending), + Encoded::Done => { + progress.done.fetch_add((end - f) as u64, Ordering::Relaxed); + self.next = end; + Ok(Step::Progress) + } + }; + }; + let pipe = self.pipe.clone(); + let mut slot: Option>>> = None; + let mut encode_done: Option = None; + let encoded = rayon::in_place_scope(|s| { + s.spawn(|_| { + slot = Some(catch_unwind(AssertUnwindSafe(|| timed(Stage::Render, || render_batch(&pipe, a, b, sources))))); + }); + let r = self.encode_batch(f, frames, sources); + encode_done = Some(web_time::Instant::now()); + r + }); + // the scope returned only now: what is left since the encoding ended is waiting for the render + if let Some(t) = encode_done { + note_stage(Stage::Wait, t.elapsed()); + } + match encoded? { + Encoded::Pending => Ok(Step::Pending), + Encoded::Done => { + self.overlapped_steps += 1; + progress.done.fetch_add((end - f) as u64, Ordering::Relaxed); + self.next = end; + match slot { + Some(Ok(Some(frames))) => self.ahead = Some(Ahead { first: a, frames }), + // a source was not ready: the next step renders this batch again + Some(Ok(None)) | None => {} + Some(Err(_)) => return Err(ExportError::Encode("internal error while rendering a frame (the panic was logged by the panic hook)".into())), + } + Ok(Step::Progress) + } + } + } + + /// Encode the rendered frames `f..` (the analysis pass of a two-pass export only encodes), mux + /// them with their audio, and drop each frame as soon as it is encoded. + fn encode_batch(&mut self, f: i64, frames: Vec, sources: &dyn SourceProvider) -> Result { + if self.first_pass { + for (k, frame) in frames.into_iter().enumerate() { + timed(Stage::Encode, || self.encode(std::slice::from_ref(&frame), f + k as i64))?; + } + return Ok(Encoded::Done); + } + let end = f.saturating_add(i64::try_from(frames.len()).unwrap_or(0)); + // the audio of every interleave group that ends in this batch, mixed before encoding so a + // pending source stops the step before any frame of the batch is encoded (the audio position has + // already moved though: known web issue, see the module docs) + let cuts: Vec = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect(); + let mut mixed = Vec::with_capacity(cuts.len()); + if let Some(sr) = self.audio.as_ref().map(|a| a.sr) { + let t = web_time::Instant::now(); + for &g in &cuts { + let until = self.sample_at_frame(g, sr); + mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources))); + } + note_stage(Stage::Audio, t.elapsed()); + } + // taken right here, on the thread that pulled, before other work can touch the flag + if filmcraft_media::pending::take() { + return Ok(Encoded::Pending); + } + let mut mixed = mixed.into_iter(); + for (k, frame) in frames.into_iter().enumerate() { + let fi = f + k as i64; + let packets = timed(Stage::Encode, || self.encode(std::slice::from_ref(&frame), fi))?; + self.queued.extend(packets); + if cuts.contains(&(fi + 1)) { + timed(Stage::Mux, || -> Result<()> { + if self.mux.is_none() && self.mxf.is_none() { + self.open_mux()?; + } + let packets = std::mem::take(&mut self.queued); + self.write_video(packets) + })?; + let pcm = mixed.next().flatten(); + timed(Stage::Audio, || self.write_audio(pcm))?; + } + } + Ok(Encoded::Done) + } + fn encode(&mut self, frames: &[(Vec, Vec)], f: i64) -> Result> { let mut packets = Vec::new(); for (k, (rgba, hdr)) in frames.iter().enumerate() { @@ -410,3 +644,48 @@ impl Exporter { Ok(packets) } } + +#[cfg(test)] +mod tests { + use super::*; + + const MIB: u64 = 1 << 20; + + #[test] + fn frame_bytes_by_format() { + assert_eq!(frame_bytes(1920, 1080, false), 1920 * 1080 * 4); + assert_eq!(frame_bytes(3840, 2160, true), 3840 * 2160 * 12); + assert_eq!(frame_bytes(u32::MAX, u32::MAX, true), u64::MAX, "saturates"); + assert_eq!(frame_bytes(0, 1080, false), 0); + } + + #[test] + fn the_overlap_batch_fits_the_memory_budget() { + let budget = IN_FLIGHT_BUDGET; + // 1080p keeps the full 16 frames, 4K SDR (33 MB a frame) 8 + 8 in flight (2 x 8 x 33 MB = 512 MiB) + assert_eq!(overlap_batch(16, frame_bytes(1920, 1080, false), budget), 16); + assert_eq!(overlap_batch(16, frame_bytes(3840, 2160, false), budget), 8); + // 4K HDR (100 MB a frame): 256 MiB per batch is 2 frames, not 16 + let hdr4k = frame_bytes(3840, 2160, true); + let n = overlap_batch(16, hdr4k, budget); + assert_eq!(n, 2); + assert!(2 * n as u64 * hdr4k <= budget); + // 8K HDR: one frame at a time, even though two do not fit (never fewer than one) + assert_eq!(overlap_batch(16, frame_bytes(7680, 4320, true), budget), 1); + // a smaller batch (web: 1) is never raised, a degenerate one is one frame + assert_eq!(overlap_batch(1, 4, budget), 1); + assert_eq!(overlap_batch(0, 4, budget), 1); + assert_eq!(overlap_batch(-3, 4, budget), 1); + assert_eq!(overlap_batch(8, 0, budget), 8, "no division by zero"); + assert_eq!(overlap_batch(8, u64::MAX, budget), 1); + assert_eq!(overlap_batch(8, 100 * MIB, 0), 1); + // two batches in flight never exceed the budget unless a single frame alone does + for batch in 1..=16 { + for fb in [MIB, 8 * MIB, 33 * MIB, 100 * MIB, 400 * MIB, 700 * MIB] { + let n = overlap_batch(batch, fb, budget) as u64; + assert!(n >= 1 && n <= batch as u64); + assert!(n == 1 || 2 * n * fb <= budget, "batch {batch}, frame {fb}: {n}"); + } + } + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 419a7368c..6c918b4c4 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -23,6 +23,7 @@ mod pcm; mod pipeline; pub mod presets; pub mod settings; +pub mod still; pub use audio_out::LoudnessReport; pub use job::{Exporter, Step, stepped}; pub use mxf_out::opatom_audio_paths; @@ -64,7 +65,9 @@ pub enum Format { H264, /// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no /// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so - /// with [`register_format_probe`]; [`available`] is false without it. + /// with [`register_format_probe`]; [`available`] is false without it. HDR sequences export as HDR + /// (Main 10, PQ / HLG) only where an encoder also says so with [`register_hdr_probe`] + /// ([`hdr_available`]); elsewhere they are tone-mapped to SDR. #[serde(rename = "hevc", alias = "Hevc")] Hevc, /// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM. @@ -253,6 +256,10 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, + /// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include + /// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it. + #[serde(default)] + pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). pub frame_size: Option<(u32, u32)>, /// Output frame rate (None = Match Source). @@ -420,12 +427,18 @@ impl ColorSignal { } else { filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false } }); - if self.transfer == 16 { - v.mastering_display = Some(filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001)); - v.content_light = Some((0, 0)); + if let Some((md, cll)) = self.static_metadata() { + v.mastering_display = Some(md); + v.content_light = Some(cll); } } } + /// HDR10 static metadata of a PQ signal: the mastering display (BT.2020/D65, 1000 / 0.0001 cd/m²) + /// and (MaxCLL, MaxFALL) = (0, 0), unknown. `None` for HLG and SDR. The one definition of these + /// values: the `mdcv` / `clli` boxes and the SEI messages of every encoder come from here. + pub fn static_metadata(&self) -> Option<(filmcraft_isobmff::MasteringDisplay, (u16, u16))> { + (self.transfer == 16).then(|| (filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001), (0, 0))) + } } impl Default for ExportSettings { @@ -445,6 +458,7 @@ impl Default for ExportSettings { apv_profile: String::new(), mxf_video_codec: MxfVideoCodec::default(), sdr: false, + alpha: false, frame_size: None, frame_rate: None, scaling: Scaling::default(), @@ -658,6 +672,79 @@ pub fn note_hw_encode_declined() { HW_DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } +/// The stages of an export whose wall time [`stage_times`] adds up (`perf.stats` `export.stages`). The +/// render of one batch overlaps the encoding of the previous one (`Exporter::overlapping`), so +/// [`Stage::Render`] and [`Stage::Encode`] with its neighbours can sum to more than the export took; +/// [`Stage::Wait`] is the time left waiting for the render. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage { + /// [`Exporter::new`]: settings, pipeline, encoder and audio set-up. + Setup, + /// The loudness pass before the first frame (normalised audio only). + Loudness, + /// Rendering a batch of frames (parallel; includes decoding the sources, effects, colour). + Render, + /// Encoding the rendered frames, colour conversion included (serial). + Encode, + /// The part of [`Stage::Encode`] spent converting RGB to the encoder's YUV. + Convert, + /// Mixing and encoding audio and writing it to the container. + Audio, + /// Writing video samples to the container (and creating it). + Mux, + /// Flushing the encoders and finishing the file. + Finish, + /// Time the encoding side waited, after encoding a batch, for the next batch to finish rendering + /// (only with overlap: large means the export is render-bound, near zero encode-bound). + Wait, +} + +const STAGES: usize = 9; + +impl Stage { + pub const ALL: [Stage; STAGES] = + [Stage::Setup, Stage::Loudness, Stage::Render, Stage::Encode, Stage::Convert, Stage::Audio, Stage::Mux, Stage::Finish, Stage::Wait]; + + /// The name in `perf.stats`. + pub fn name(self) -> &'static str { + match self { + Stage::Setup => "setup", + Stage::Loudness => "loudness", + Stage::Render => "render", + Stage::Encode => "encode", + Stage::Convert => "convert", + Stage::Audio => "audio", + Stage::Mux => "mux", + Stage::Finish => "finish", + Stage::Wait => "wait", + } + } +} + +static STAGE_NS: [std::sync::atomic::AtomicU64; STAGES] = [const { std::sync::atomic::AtomicU64::new(0) }; STAGES]; + +/// Wall time each export stage has taken so far in this process, in nanoseconds, summed over every +/// stepped export (they only grow: diff two readings to measure one export). Measuring costs two +/// clock reads per stage per batch; nothing an export does depends on it. +pub fn stage_times() -> Vec<(Stage, u64)> { + Stage::ALL.iter().map(|s| (*s, STAGE_NS.get(*s as usize).map_or(0, |a| a.load(std::sync::atomic::Ordering::Relaxed)))).collect() +} + +/// Add `d` to a stage's wall time (also for encoders outside this crate: [`Stage::Convert`]). +pub fn note_stage(stage: Stage, d: std::time::Duration) { + if let Some(a) = STAGE_NS.get(stage as usize) { + a.fetch_add(u64::try_from(d.as_nanos()).unwrap_or(u64::MAX), std::sync::atomic::Ordering::Relaxed); + } +} + +/// Run `f`, adding its wall time to `stage`. +pub fn timed(stage: Stage, f: impl FnOnce() -> T) -> T { + let t = web_time::Instant::now(); + let r = f(); + note_stage(stage, t.elapsed()); + r +} + /// Register a video encoder factory (tried before the built-in ones and those registered earlier). /// Registering the same factory twice is harmless. pub fn register_encoder(f: EncoderFactory) { @@ -692,6 +779,29 @@ pub fn register_format_probe(format: Format, probe: fn() -> bool) { } } +fn hdr_probes() -> &'static RwLock> { + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(|| RwLock::new(Vec::new())) +} + +/// Say how to find out whether the registered encoder of `format` can write HDR (PQ / HLG) on this +/// machine: `probe` runs when [`hdr_available`] asks (it should cache its answer). Only H.265 needs +/// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence +/// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless. +pub fn register_hdr_probe(format: Format, probe: fn() -> bool) { + let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner()); + if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) { + g.push((format, probe)); + } +} + +/// Whether a registered probe says the encoder of `format` writes HDR here (false without one). +pub fn hdr_available(format: Format) -> bool { + // the probes run outside the lock: one may take a while + let probes: Vec bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect(); + probes.into_iter().any(|p| p()) +} + /// Whether a format can currently be exported: it has a built-in encoder, or a registered probe /// says its encoder works here. pub fn available(format: Format) -> bool { @@ -795,13 +905,13 @@ impl VideoEncoder for ProResEncoder { } fn encode(&mut self, f: &EncoderFrame) -> Result> { let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false); - match f.hdr { + timed(Stage::Convert, || match f.hdr { Some(rgb) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) } None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr), - } + }); let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) } @@ -1072,13 +1182,13 @@ impl VideoEncoder for H264Encoder { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { - match f.hdr { + timed(Stage::Convert, || match f.hdr { Some(rgb) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv420_8(rgb, f.width as usize, f.height as usize, kr, kb, &mut self.y, &mut self.u, &mut self.v) } None => rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, &mut self.y, &mut self.u, &mut self.v), - } + }); let cw = (f.width as usize).div_ceil(2); let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: f.width as usize, uv_stride: cw }; let ps = self.enc.try_encode(&frame, f.index as i64 * self.rate.den).map_err(|e| ExportError::Encode(e.to_string()))?; @@ -1160,6 +1270,59 @@ pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &m }); } +/// Limited-range 10-bit 4:2:0 (code values 0..1023, not MSB-aligned) from encoded R'G'B' floats with +/// matrix (Kr, Kb): Y = 64 + 876·Y', C = 512 + 896·C, chroma averaged over each 2x2 block (one pixel +/// or pair at an odd edge). `rgb` is 3 floats per pixel; NaN counts as 0, infinities and +/// out-of-range values clamp to 0..1 first, and codes clamp to 4..1019 like [`rgbf_to_yuv422_10`] +/// (so black is 64, peak white 940 and the chroma of any colour stays within 64..960). +/// The planes are resized to `w * h` and `ceil(w / 2) * ceil(h / 2)`. A `rgb` slice of the wrong +/// length, a zero size or a size whose arithmetic overflows is an error. +#[allow(clippy::too_many_arguments)] +pub fn rgbf_to_yuv420_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec, u: &mut Vec, v: &mut Vec) -> Result<()> { + let bad = || ExportError::Encode(format!("a {w}x{h} HDR picture needs w*h*3 floats, got {}", rgb.len())); + let px = w.checked_mul(h).ok_or_else(bad)?; + if w == 0 || h == 0 || px.checked_mul(3) != Some(rgb.len()) { + return Err(bad()); + } + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let kg = 1.0 - kr - kb; + let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); + y.clear(); + y.resize(px, 0); + u.clear(); + u.resize(cw * ch, 0); + v.clear(); + v.resize(cw * ch, 0); + let unit = |x: f32| if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; + let code = |x: f32| x.round().clamp(4.0, 1019.0) as u16; + y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { + let rows = yr.len() / w; + let mut us = vec![0f32; cw]; + let mut vs = vec![0f32; cw]; + let mut cnt = vec![0f32; cw]; + for (dy, yrow) in yr.chunks_exact_mut(w).enumerate().take(rows) { + let row = cy * 2 + dy; + let Some(src) = rgb.get(row * w * 3..(row + 1) * w * 3) else { continue }; + for (x, (&[r, g, b], yo)) in src.as_chunks::<3>().0.iter().zip(yrow.iter_mut()).enumerate() { + let (r, g, b) = (unit(r), unit(g), unit(b)); + let yy = kr * r + kg * g + kb * b; + *yo = code(64.0 + 876.0 * yy); + if let (Some(a), Some(c), Some(n)) = (us.get_mut(x / 2), vs.get_mut(x / 2), cnt.get_mut(x / 2)) { + *a += (b - yy) / sb; + *c += (r - yy) / sr; + *n += 1.0; + } + } + } + for (cx, (uo, vo)) in ur.iter_mut().zip(vr.iter_mut()).enumerate() { + let n = cnt.get(cx).copied().unwrap_or(1.0).max(1.0); + *uo = code(512.0 + 896.0 * us.get(cx).copied().unwrap_or(0.0) / n); + *vo = code(512.0 + 896.0 * vs.get(cx).copied().unwrap_or(0.0) / n); + } + }); + Ok(()) +} + /// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core /// count, which would make the stream (and every decoded picture) depend on the machine; this is /// what it picks on a machine with enough cores. @@ -1199,12 +1362,11 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti } if s.signal.is_hdr() { cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false }; - if s.signal.transfer == 16 { - let md = filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001).to_bytes(); + if let Some((md, cll)) = s.signal.static_metadata() { let mut b = [0u8; 24]; - b.copy_from_slice(&md); + b.copy_from_slice(&md.to_bytes()); cfg.mastering_display = Some(b); - cfg.content_light = Some((0, 0)); + cfg.content_light = Some(cll); } } Some( @@ -1266,11 +1428,12 @@ impl Write for SharedBuf { /// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews). pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { - encode_still(Format::PngSequence, rgba, w, h) + encode_still(Format::PngSequence, rgba, w, h, false) } -/// Encode one still of an image sequence (also Export Frame). -pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32) -> Result> { +/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha +/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB. +pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { let enc = |e: image::ImageError| ExportError::Encode(e.to_string()); let mut out = std::io::Cursor::new(Vec::new()); match format { @@ -1279,9 +1442,12 @@ pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32) -> Result { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?; - let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8(); let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp }; - rgb.write_to(&mut out, f).map_err(enc)? + if alpha && format == Format::TiffSequence { + img.write_to(&mut out, f).map_err(enc)? + } else { + image::DynamicImage::ImageRgba8(img).to_rgb8().write_to(&mut out, f).map_err(enc)? + } } _ => return Err(ExportError::Unsupported(format!("{} is not an image sequence", format.label()))), } @@ -1358,7 +1524,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let written: Vec> = (f..end) .into_par_iter() .map(|fi| { - let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h)?; + let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?; write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data) }) .collect(); @@ -1404,3 +1570,6 @@ mod mxf_tests; #[cfg(test)] mod determinism_tests; + +#[cfg(test)] +mod hdr_tests; diff --git a/crates/export/src/mxf_tests.rs b/crates/export/src/mxf_tests.rs index cb61bb744..6fc4ac54a 100644 --- a/crates/export/src/mxf_tests.rs +++ b/crates/export/src/mxf_tests.rs @@ -176,7 +176,10 @@ fn mxf_formats_and_presets() { assert!(s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).format.contains("MXF OP1a (Apple ProRes)")); let names: Vec = builtin_presets().into_iter().filter(|p| p.settings.format.is_mxf()).map(|p| p.name).collect(); assert_eq!(names.len(), 4, "{names:?}"); - assert_eq!(opatom_audio_paths("/a/b/clip.mxf", 2), vec!["/a/b/clip_A1.mxf".to_string(), "/a/b/clip_A2.mxf".to_string()]); + assert_eq!( + opatom_audio_paths("/a/b/clip.mxf", 2).iter().map(std::path::PathBuf::from).collect::>(), + vec![std::path::PathBuf::from("/a/b/clip_A1.mxf"), std::path::PathBuf::from("/a/b/clip_A2.mxf")] + ); } /// ffprobe identifies our files (operational pattern, codec, frame count, duration, timecode); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 88988697e..c479a0171 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -117,8 +117,9 @@ mod tests { #[test] fn sequence_names() { - assert_eq!(image_sequence_path("/a/Sequence 01.png", 0, 24), "/a/Sequence 01000.png"); - assert_eq!(image_sequence_path("/a/x.tif", 1234, 2000), "/a/x1234.tif"); + use std::path::Path; + assert_eq!(Path::new(&image_sequence_path("/a/Sequence 01.png", 0, 24)), Path::new("/a/Sequence 01000.png")); + assert_eq!(Path::new(&image_sequence_path("/a/x.tif", 1234, 2000)), Path::new("/a/x1234.tif")); assert_eq!(image_sequence_path("shot.bmp", 7, 10), "shot007.bmp"); } diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index fde70f83f..e88f7b2c2 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -12,7 +12,7 @@ use filmcraft_time::FrameRate; use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; -use crate::{ExportError, ExportSettings, Result}; +use crate::{ExportError, ExportSettings, Format, Result}; /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] @@ -61,11 +61,29 @@ pub(crate) struct Pipeline { geom: Geometry, opts: RenderOptions, pub hdr_out: bool, + /// Keep straight alpha in the RGBA8 output instead of flattening over black. + alpha: bool, out_tf: Option, effects: ExportEffects, overlay: Option, start_tc: i64, drop_frame: bool, + /// Free the pool's float images when the export ends: a standalone export, not one part of a + /// batch (`ExportSettings::part_of_batch`). + trim_pool: bool, +} + +impl Drop for Pipeline { + /// The export is over, however it ended (done, failed, cancelled, dropped half way). The float + /// images its frames left on the `filmcraft_frame::pool` float shelf are the size of its + /// frames, which nothing else asks for, so free them instead of holding up to 320 MiB idle. A + /// job still rendering at that moment allocates a few images again; the pool is only a cache. + /// Parts of a larger job (render-preview segments, proxies) keep them for the next part. + fn drop(&mut self) { + if self.trim_pool { + filmcraft_frame::pool::trim_f32(); + } + } } impl Pipeline { @@ -98,6 +116,7 @@ impl Pipeline { seq_rate: q.settings.frame_rate, start_tc: q.start_timecode, drop_frame: q.settings.drop_frame, + trim_pool: !settings.part_of_batch, project: project.clone(), seq, rate: r.rate, @@ -106,6 +125,7 @@ impl Pipeline { geom, opts, hdr_out, + alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence), out_tf, effects, overlay, @@ -134,7 +154,7 @@ impl Pipeline { filmcraft_frame::pool::recycle_f32(img.px); return (Vec::new(), out); } - let mut rgba = img.over_black_rgba8(); + let mut rgba = if self.alpha { img.to_rgba8() } else { img.over_black_rgba8() }; // the float image is not needed any more: its buffer serves the next frame's layers filmcraft_frame::pool::recycle_f32(img.px); if lim.enabled { diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 91d89a164..dad3b0e45 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -509,7 +509,9 @@ impl ExportSettings { v += &format!(", keyframe every {} frames", r.keyint); } Format::Hevc => { - v += &format!(", HEVC Main (hardware encoder), {}", self.bitrate_mode.label()); + // Main 10 only where a registered encoder writes HDR (NVENC); VideoToolbox is 8-bit + let profile = if crate::hdr_available(Format::Hevc) { "HEVC Main / Main 10 for HDR" } else { "HEVC Main" }; + v += &format!(", {profile} (hardware encoder), {}", self.bitrate_mode.label()); v += &match self.bitrate_mode { BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)), _ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)), diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b4fcbc57c..f08414847 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -109,6 +109,28 @@ fn image_sequences_are_numbered_like_premiere() { } } +/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). +#[test] +fn image_sequences_keep_alpha_when_asked() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + for (fmt, ext) in [(Format::PngSequence, "png"), (Format::TiffSequence, "tif")] { + for alpha in [false, true] { + let dir = Scratch::new(&format!("alpha-{ext}-{alpha}")); + let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2))); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap(); + let px = img.to_rgba8().get_pixel(32, 18).0; + if alpha { + assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel"); + assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}"); + } else { + assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}"); + } + } + } +} + #[test] fn wav_and_aiff_audio_only() { let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); diff --git a/crates/export/src/still.rs b/crates/export/src/still.rs new file mode 100644 index 000000000..c60c2159f --- /dev/null +++ b/crates/export/src/still.rs @@ -0,0 +1,138 @@ +//! Single-frame formats and true per-channel depth, separate from sequence export settings. +use crate::{ExportError, Format, Result}; +use filmcraft_render::Image; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StillFormat { + Png, + Tiff, + Bmp, + Jpeg, +} +impl StillFormat { + pub const ALL: [Self; 4] = [Self::Png, Self::Jpeg, Self::Tiff, Self::Bmp]; + pub fn parse(name: &str) -> Result { + match name.to_ascii_lowercase().as_str() { + "png" => Ok(Self::Png), + "tif" | "tiff" => Ok(Self::Tiff), + "bmp" => Ok(Self::Bmp), + "jpg" | "jpeg" => Ok(Self::Jpeg), + _ => Err(ExportError::Unsupported("still format must be png, jpeg, tiff or bmp".into())), + } + } + pub fn label(self) -> &'static str { + match self { + Self::Png => "PNG", + Self::Tiff => "TIFF", + Self::Bmp => "BMP", + Self::Jpeg => "JPEG", + } + } + pub fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Tiff => "tiff", + Self::Bmp => "bmp", + Self::Jpeg => "jpg", + } + } + pub fn supports_16(self) -> bool { + matches!(self, Self::Png | Self::Tiff) + } +} + +/// Quantize the original float image directly, never pad an 8-bit image to 16 bits. +pub fn encode(image: &Image, format: StillFormat, depth: u8) -> Result> { + if !matches!(depth, 8 | 16) || (depth == 16 && !format.supports_16()) { + return Err(ExportError::Unsupported("16-bit stills require PNG or TIFF; JPEG and BMP support 8 bits per channel".into())); + } + let bad = || ExportError::Encode("invalid still image dimensions or pixel buffer".into()); + let w = u32::try_from(image.w).map_err(|_| bad())?; + let h = u32::try_from(image.h).map_err(|_| bad())?; + filmcraft_project::validate_frame_size(w, h).map_err(|_| bad())?; + let samples = image.w.checked_mul(image.h).and_then(|n| n.checked_mul(4)).ok_or_else(bad)?; + if image.px.len() != samples { + return Err(bad()); + } + let err = |e: image::ImageError| ExportError::Encode(e.to_string()); + if depth == 8 { + let rgba = image.to_rgba8(); + if format == StillFormat::Jpeg { + let rgb: Vec = rgba + .as_chunks::<4>() + .0 + .iter() + .flat_map(|p| { + let [r, g, b, _] = *p; + [r, g, b] + }) + .collect(); + let mut out = Vec::new(); + image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), &rgb, w, h, image::ExtendedColorType::Rgb8) + .map_err(err)?; + return Ok(out); + } + let f = match format { + StillFormat::Png => Format::PngSequence, + StillFormat::Tiff => Format::TiffSequence, + _ => Format::BmpSequence, + }; + // 8-bit TIFF stays RGB, as Export Frame wrote it before; 16-bit output keeps alpha. + return crate::encode_still(f, rgba, w, h, false); + } + let capacity = samples.checked_mul(2).ok_or_else(bad)?; + let mut bytes = Vec::with_capacity(capacity); + for pixel in image.px.as_chunks::<4>().0 { + let [r, g, b, a] = *pixel; + let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let encode_channel = |v: f32| { + let v = if a > 0.0 && v.is_finite() { filmcraft_color::linear_to_srgb(v / a).clamp(0.0, 1.0) } else { 0.0 }; + (v * 65535.0 + 0.5) as u16 + }; + for v in [encode_channel(r), encode_channel(g), encode_channel(b), (a * 65535.0 + 0.5) as u16] { + bytes.extend_from_slice(&v.to_ne_bytes()); + } + } + let mut out = std::io::Cursor::new(Vec::new()); + match format { + StillFormat::Png => { + image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &bytes, w, h, image::ExtendedColorType::Rgba16).map_err(err)? + } + StillFormat::Tiff => { + image::ImageEncoder::write_image(image::codecs::tiff::TiffEncoder::new(&mut out), &bytes, w, h, image::ExtendedColorType::Rgba16).map_err(err)? + } + _ => return Err(ExportError::Unsupported("16-bit still format".into())), + } + Ok(out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn sixteen_bit_stills_preserve_sub_eight_bit_precision_and_alpha() { + let img = Image { w: 2, h: 1, px: vec![0.101, 0.203, 0.307, 1.0, 0.101 / 2.0, 0.203 / 2.0, 0.307 / 2.0, 0.5] }; + for format in [StillFormat::Png, StillFormat::Tiff] { + let bytes = encode(&img, format, 16).unwrap(); + let decoded = image::load_from_memory(&bytes).unwrap(); + assert_eq!(decoded.color(), image::ColorType::Rgba16); + let px = decoded.to_rgba16(); + let a = px.get_pixel(0, 0).0; + let b = px.get_pixel(1, 0).0; + assert_ne!(a[0] % 257, 0, "must not be padded 8-bit values"); + assert_eq!(&a[..3], &b[..3]); + assert_eq!(b[3], 32768); + } + } + #[test] + fn jpeg_and_bmp_are_eight_bit_and_invalid_buffers_are_errors() { + let image = Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]); + for format in [StillFormat::Jpeg, StillFormat::Bmp] { + let bytes = encode(&image, format, 8).unwrap(); + assert_eq!(image::load_from_memory(&bytes).unwrap().color(), image::ColorType::Rgb8); + assert!(encode(&image, format, 16).is_err()); + } + assert!(encode(&Image { w: 2, h: 2, px: vec![] }, StillFormat::Png, 16).is_err()); + assert!(encode(&image, StillFormat::Png, 12).is_err()); + } +} diff --git a/crates/export/src/surround_tests.rs b/crates/export/src/surround_tests.rs index bb7068ec7..890e9131f 100644 --- a/crates/export/src/surround_tests.rs +++ b/crates/export/src/surround_tests.rs @@ -46,7 +46,7 @@ fn surround() -> (Arc, ItemId, SourceMap) { kind: MediaKind::AudioOnly, duration: Tick(2 * TICKS_PER_SECOND), video: None, - audio: Some(AudioStreamInfo { sample_rate: SR, channels: 6, codec: "test".into(), bits_per_sample: Some(32) }), + audio_streams: vec![AudioStreamInfo { sample_rate: SR, channels: 6, codec: "test".into(), bits_per_sample: Some(32) }], container: "test".into(), start_timecode: None, file_size: None, @@ -185,7 +185,7 @@ fn aac_51_in_mp4_keeps_every_channel_in_place() { // our own demuxer sees an AAC track with channel configuration 6 let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); let src = filmcraft_codecs::open_bytes("s51.mp4", bytes).unwrap(); - assert_eq!(src.info().audio.as_ref().unwrap().channels, 6); + assert_eq!(src.info().audio().unwrap().channels, 6); if let Some((n, layout)) = probe(&path) { assert_eq!(n, 6); assert!(is_51(&layout), "{layout}"); diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/pool_trim.rs new file mode 100644 index 000000000..832888e78 --- /dev/null +++ b/crates/export/tests/pool_trim.rs @@ -0,0 +1,102 @@ +//! A standalone export gives back the float images it left on the frame pool +//! (`filmcraft_frame::pool`) when it ends, however it ends; one part of a batch keeps them. One test in this file on purpose: the pool is shared by every test of a binary, +//! and its numbers mean something only while nothing else renders. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_export::{ExportSettings, Exporter, Format, Progress, Step, export}; +use filmcraft_frame::pool; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{Generator, MediaSource}; +use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; + +/// A red matte filling a 320×180 sequence for a second. +fn project() -> (Arc, ItemId, SourceMap) { + let mut p = Project::new("x"); + let rate = FrameRate::FPS_24; + let g = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, 320, 180, rate, Tick(2 * TICKS_PER_SECOND)); + let info = g.info().clone(); + let clip = MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info: info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + let red = p.add_item(&info.name, Label::Iris, ItemKind::Media(clip), None); + let seq = p.new_sequence("s", SequenceSettings { width: 320, height: 180, frame_rate: rate, ..Default::default() }, 1, 1, None); + let item = p.make_track_item(red, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(24)), rate).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(item); + let mut sources = SourceMap::default(); + sources.0.insert(red, Arc::new(g)); + (Arc::new(p), seq, sources) +} + +fn idle_mb() -> f64 { + pool::stats().f32_idle_bytes as f64 / 1e6 +} + +#[test] +fn the_float_shelf_is_empty_when_a_standalone_export_ends_however_it_ended() { + let (p, seq, sources) = project(); + let dir = std::env::temp_dir().join(format!("fc-pool-trim-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let settings = + |format: Format, name: &str| ExportSettings { format, path: dir.join(name).to_string_lossy().to_string(), include_audio: false, ..Default::default() }; + // an image the pool could keep, standing in for what the frames of an export leave (the frames + // themselves do too: the checks below fail without the trim) + let leave_something = || { + pool::recycle_f32(vec![0.0; 100_000]); + assert!(pool::stats().f32_idle_bytes > 0); + }; + let check = |what: &str| assert_eq!(pool::stats().f32_idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb()); + + // images, which run in `export` itself + leave_something(); + export(&p, seq, &settings(Format::PngSequence, "seq.png"), &sources, &Progress::default()).unwrap(); + check("an image sequence"); + + // an encoded video, run to the end + leave_something(); + export(&p, seq, &settings(Format::ProRes, "a.mov"), &sources, &Progress::default()).unwrap(); + check("a finished video"); + + // cancelled before the first frame + leave_something(); + let cancelled = Progress::default(); + cancelled.cancel.store(true, Ordering::Relaxed); + assert!(export(&p, seq, &settings(Format::ProRes, "b.mov"), &sources, &cancelled).is_err()); + check("a cancelled video"); + + // an encoder that is not there: the pipeline was built, the export never started + leave_something(); + assert!(export(&p, seq, &settings(Format::Hevc, "c.mp4"), &sources, &Progress::default()).is_err()); + check("an export that could not start"); + + // stepped by the host (the web build), dropped half way: 6 frames of 24 + let progress = Progress::default(); + let mut ex = Exporter::new(p.clone(), seq, &settings(Format::ProRes, "d.mov"), &progress).unwrap(); + ex.set_batch(2); + for _ in 0..3 { + assert!(matches!(ex.step(&sources, &progress), Ok(Step::Progress))); + } + leave_something(); + drop(ex); + check("an export dropped half way"); + + // one part of a batch (a render-preview segment, a proxy): the next part reuses the images + leave_something(); + let part = ExportSettings { part_of_batch: true, ..settings(Format::ProRes, "e.mov") }; + export(&p, seq, &part, &sources, &Progress::default()).unwrap(); + assert!(pool::stats().f32_idle_bytes > 0, "a part of a batch keeps the float images"); + pool::trim_f32(); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index cb8140ce6..f2c1e982e 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,10 +11,11 @@ workspace = true [dependencies] filmcraft-project = { workspace = true } +filmcraft-media = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] filmcraft-time = { workspace = true } -filmcraft-media = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index ad6e3b6be..0b4a10eda 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -33,7 +33,9 @@ //! - **v12** (M10.7): graphics templates, rolls / crawls, responsive design (pins, intro / outro), //! per-character text styles (`TrackItem::graphic`, `EffectInstance::layer`) and source graphics //! (`Project::source_graphics`). No-op step; older builds would drop these fields when saving. -//! - **v13** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds +//! - **v13**: all container audio streams, and per-timeline-clip stream selection. Old single-stream +//! `audio` objects read as stream zero; older builds must refuse new multi-stream projects. +//! - **v14** (M7.9): narrations (`Project::narrations`, Text to Speech). No-op step; older builds //! would drop them when saving. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] @@ -56,7 +58,7 @@ pub type Migration = fn(Value) -> Result; /// `MIGRATIONS[i]` upgrades schema `i + 1` to `i + 2`. Append one function per schema bump; never /// edit a shipped one. pub const MIGRATIONS: &[Migration] = - &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13]; + &[v1_to_v2, v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, v6_to_v7, v7_to_v8, v8_to_v9, v9_to_v10, v10_to_v11, v11_to_v12, v12_to_v13, v13_to_v14]; /// The schema version this build writes (and the newest it reads). pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32 + 1; @@ -211,7 +213,29 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. -fn validate_loaded(loaded: Loaded) -> Result { +fn validate_loaded(mut loaded: Loaded) -> Result { + // Out-of-range audio stream data is repaired, not refused: failing the load would lose the + // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a + // clip pointing past that plays stream 0. + let max = filmcraft_media::MAX_AUDIO_STREAMS; + for item in loaded.project.items.values_mut() { + let name = item.name.clone(); + match &mut item.kind { + filmcraft_project::ItemKind::Media(m) if m.info.audio_streams.len() > max => { + log::warn!("media `{name}`: {} audio streams, keeping the first {max}", m.info.audio_streams.len()); + m.info.audio_streams.truncate(max); + } + filmcraft_project::ItemKind::Sequence(sequence) => { + for clip in sequence.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) { + if clip.audio_stream >= max { + log::warn!("sequence `{name}`: clip `{}` uses audio stream {}, reset to 0", clip.name, clip.audio_stream); + clip.audio_stream = 0; + } + } + } + _ => {} + } + } for item in loaded.project.sequences() { if let filmcraft_project::ItemKind::Sequence(sequence) = &item.kind { sequence.check_bounds().map_err(|e| FormatError::Corrupt(format!("sequence `{}`: {e}", item.name)))?; @@ -318,18 +342,24 @@ fn v11_to_v12(doc: Value) -> Result { Ok(doc) } -/// v12 → v13: narrations (Text to Speech). Existing data needs no change. +/// v12 → v13: container audio streams and per-clip stream selection. `MediaInfo` reads the old +/// `audio` field as stream zero; `TrackItem::audio_stream` defaults to zero. fn v12_to_v13(doc: Value) -> Result { Ok(doc) } +/// v13 → v14: narrations (Text to Speech). Existing data needs no change. +fn v13_to_v14(doc: Value) -> Result { + Ok(doc) +} + #[cfg(test)] mod tests { use super::*; #[test] fn schema_version_matches_table() { - assert_eq!(SCHEMA_VERSION, 13); + assert_eq!(SCHEMA_VERSION, 14); } #[test] @@ -395,6 +425,7 @@ mod tests { open_sequences: vec![seq], active_sequence: Some(seq), sequences: [(seq, SequenceView { pps: 80.0, scroll: 1.5, v_scroll: 0.0, a_scroll: 4.0, video_track_h: 60.0, audio_track_h: 56.0 })].into(), + playheads: [(seq, filmcraft_time::Tick(3 * filmcraft_time::TICKS_PER_SECOND))].into(), }; for pretty in [true, false] { let l = decode(&encode_with_view(&p, Some(&view), pretty)).unwrap(); diff --git a/crates/format/tests/fixtures.rs b/crates/format/tests/fixtures.rs index 1026e8c77..7cfe2281e 100644 --- a/crates/format/tests/fixtures.rs +++ b/crates/format/tests/fixtures.rs @@ -52,6 +52,38 @@ fn v1_edit_loads_with_defaults_for_later_fields() { seq.check().unwrap(); } +#[test] +fn legacy_audio_field_becomes_primary_stream_and_clip_defaults_to_zero() { + let p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let media = p.item(ItemId(5)).unwrap().as_media().unwrap(); + assert_eq!(media.info.audio_streams.len(), 1); + assert!(media.info.has_audio()); + let clip = &p.sequence(ItemId(21)).unwrap().audio_tracks[0].items[0]; + assert_eq!(clip.audio_stream, 0); + let again = decode(&encode(&p, false)).unwrap(); + assert_eq!(again.project.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams, media.info.audio_streams); +} + +/// Out-of-range audio stream data is repaired on load, never a reason to refuse the project. +#[test] +fn hostile_audio_stream_counts_and_indices_are_repaired_not_rejected() { + let mut p = decode(&fixture("v1-edit.fcproj")).unwrap().project; + let seq = ItemId(21); + for bad in [filmcraft_media::MAX_AUDIO_STREAMS, usize::MAX] { + p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = bad; + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, 0); + } + p.sequence_mut(seq).unwrap().audio_tracks[0].items[0].audio_stream = filmcraft_media::MAX_AUDIO_STREAMS - 1; + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.sequence(seq).unwrap().audio_tracks[0].items[0].audio_stream, filmcraft_media::MAX_AUDIO_STREAMS - 1, "in range: kept"); + let media = p.item_mut(ItemId(5)).unwrap().as_media_mut().unwrap(); + let stream = media.info.audio_streams[0].clone(); + media.info.audio_streams.resize(filmcraft_media::MAX_AUDIO_STREAMS + 1, stream); + let loaded = decode(&encode(&p, false)).unwrap().project; + assert_eq!(loaded.item(ItemId(5)).unwrap().as_media().unwrap().info.audio_streams.len(), filmcraft_media::MAX_AUDIO_STREAMS); +} + /// Schema 8 (before M3.10): no time interpolation / Hold Filters / Field Options / source channels /// on clips, no audio channel map, subclips without Restrict Trims. #[test] diff --git a/crates/frame/src/pool.rs b/crates/frame/src/pool.rs index 22f04b9e3..b9952398a 100644 --- a/crates/frame/src/pool.rs +++ b/crates/frame/src/pool.rs @@ -9,6 +9,9 @@ //! The compositor's working images (premultiplied linear `f32`, 33 MB at 1080p) get the same //! treatment ([`take_f32_overwritten`], [`recycle_f32`]): allocated and freed once per layer per //! frame they cost a zero-fill and fresh page faults every time. +//! +//! The shelves are sized for the busiest work, so they are a lot to keep once it is over: whoever +//! finishes such work (a standalone export) calls [`trim_f32`]. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -138,10 +141,22 @@ pub fn recycle(frame: Arc) { } } +/// Free the idle float images. A standalone export leaves that shelf full (up to [`F32_MAX_BYTES`] +/// of images, each the size of that export's frames) and nothing else asks for those sizes again, +/// so they would sit idle until the next export. Images still in use are not touched. The 8- and +/// 16-bit plane shelves are left alone: playback recycles decoded frames through them all the time. +pub fn trim_f32() { + // taken under the lock, freed after it: giving back hundreds of megabytes takes a moment + let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner)); + drop(floats); +} + /// Idle buffers held and buffers handed out again so far (`perf.stats`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolStats { pub idle_bytes: usize, + /// The part of `idle_bytes` held as float images ([`recycle_f32`]). + pub f32_idle_bytes: usize, pub reused: u64, } @@ -150,7 +165,7 @@ pub fn stats() -> PoolStats { let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes; let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes; let c: usize = F32.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|b| b.len().saturating_mul(4)).sum(); - PoolStats { idle_bytes: idle(idle(a, b), c), reused: REUSED.load(Ordering::Relaxed) } + PoolStats { idle_bytes: idle(idle(a, b), c), f32_idle_bytes: c, reused: REUSED.load(Ordering::Relaxed) } } #[cfg(test)] @@ -226,21 +241,37 @@ mod tests { pts: filmcraft_time::Tick::ZERO, }) }; - let before = stats().reused; - // a frame someone else still holds is left alone + let plane_ptrs = |frame: &VideoFrame| match &frame.data { + PixelData::Yuv8 { planes, .. } => planes.each_ref().map(|plane| plane.as_ptr() as usize), + _ => panic!("fixture has three byte planes"), + }; + // A frame someone else still holds is left alone. The reuse statistic is global: + // concurrent float-buffer tests can change it without touching these planes. let shared = yuv(1); let held = shared.clone(); + let held_ptrs = plane_ptrs(&held); recycle(shared); - assert!(take_u8(n).capacity() >= n); - assert_eq!(stats().reused, before); + let fresh = take_u8(n); + assert!(fresh.capacity() >= n); + assert!(!held_ptrs.contains(&(fresh.as_ptr() as usize)), "a shared plane cannot be handed out"); assert_eq!(held.byte_size(), n + 2 * c); - // an unshared frame's planes come back, empty - recycle(yuv(2)); + // An unshared frame's exact allocations come back, empty. Equal-size chroma + // planes may be taken in either order. + let unshared = yuv(2); + let returned_ptrs = plane_ptrs(&unshared); + recycle(unshared); let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c)); - assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c); - assert_eq!(stats().reused, before + 3); - // and are not handed out twice - take_u8(n); - assert_eq!(stats().reused, before + 3); + assert!(y.is_empty() && u.is_empty() && v.is_empty()); + assert!(y.capacity() >= n && u.capacity() >= c && v.capacity() >= c); + assert_eq!(y.as_ptr() as usize, returned_ptrs[0]); + let mut expected_chroma = [returned_ptrs[1], returned_ptrs[2]]; + let mut actual_chroma = [u.as_ptr() as usize, v.as_ptr() as usize]; + expected_chroma.sort_unstable(); + actual_chroma.sort_unstable(); + assert_eq!(actual_chroma, expected_chroma); + // The returned allocations remain owned by y/u/v and cannot be handed out twice. + let another = take_u8(n); + assert!(another.capacity() >= n); + assert!(!returned_ptrs.contains(&(another.as_ptr() as usize))); } } diff --git a/crates/frame/tests/pool_trim.rs b/crates/frame/tests/pool_trim.rs new file mode 100644 index 000000000..c019568c1 --- /dev/null +++ b/crates/frame/tests/pool_trim.rs @@ -0,0 +1,55 @@ +//! `pool::trim_f32` frees the idle float images of the process-wide pool and leaves the plane +//! shelves alone. This file holds one test on purpose: the pool is shared by every test of a binary, +//! and another test recycling or taking buffers while this one counts them would make the counts +//! meaningless. + +use std::sync::Arc; + +use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool}; + +const FLOATS: usize = 300_000; +const BYTES: usize = 200_000; +const WORDS: usize = 100_000; + +fn frame(data: PixelData) -> Arc { + Arc::new(VideoFrame { width: 1, height: 1, data, color: filmcraft_color::ColorInfo::SRGB_FULL, par: (1, 1), pts: filmcraft_time::Tick::ZERO }) +} + +/// Hand the pool one 8-bit and one 16-bit plane (the other planes are too small to be kept). +fn recycle_planes() { + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv8 { planes: [Arc::new(vec![7u8; BYTES]), empty(), empty()], chroma: Chroma::C420, alpha: None })); + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv16 { planes: [Arc::new(vec![7u16; WORDS]), empty(), empty()], chroma: Chroma::C420, bits: 10, alpha: None })); +} + +#[test] +fn trim_f32_frees_the_float_images_keeps_the_planes_and_the_pool_keeps_working() { + assert_eq!(pool::stats().idle_bytes, 0, "nothing has been recycled yet"); + + // one idle buffer on each of the three shelves + pool::recycle_f32(vec![f32::NAN; FLOATS]); + recycle_planes(); + let planes = BYTES + WORDS * 2; + assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4); + assert!(pool::stats().idle_bytes >= FLOATS * 4 + planes, "idle {}", pool::stats().idle_bytes); + + pool::trim_f32(); + assert_eq!(pool::stats().f32_idle_bytes, 0, "the float shelf is empty"); + assert!(pool::stats().idle_bytes >= planes, "the plane shelves are untouched"); + + // the float image is a fresh (zeroed) one and doesn't count as a reuse; the planes come back + let reused = pool::stats().reused; + let image = pool::take_f32_overwritten(FLOATS); + assert!(image.iter().all(|v| *v == 0.0), "the recycled image was freed"); + assert_eq!(pool::stats().reused, reused); + drop((pool::take_u8(BYTES), pool::take_u16(WORDS))); + assert!(pool::stats().reused > reused, "the planes were kept"); + + // the pool works after a trim, and trimming twice (or an empty shelf) is fine + pool::recycle_f32(image); + assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4); + pool::trim_f32(); + pool::trim_f32(); + assert_eq!(pool::stats().f32_idle_bytes, 0); +} diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index a402c61c1..1433c9a55 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -14,6 +14,7 @@ filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-geom = { workspace = true } filmcraft-render = { workspace = true } +filmcraft-project = { workspace = true } wgpu = { version = "30", default-features = false, features = ["wgsl"] } log = { workspace = true } rayon = { workspace = true } @@ -22,5 +23,4 @@ rayon = { workspace = true } pollster = "0.4" wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] } filmcraft-media = { workspace = true } -filmcraft-project = { workspace = true } filmcraft-time = { workspace = true } diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl index 5e897ba32..cc40e08b6 100644 --- a/crates/gpu/src/composite.wgsl +++ b/crates/gpu/src/composite.wgsl @@ -14,7 +14,7 @@ struct U { p0: vec4, // opacity, kind (0 rgba8 srgb straight, 1 rgba16f premul linear, 2 yuv), taps, transfer (0 srgb, 1 linear, 2 pq, 3 hlg) p1: vec4, // y_off y_scale c_off c_scale (code units) p2: vec4, // kr kb code_scale footprint - p3: vec4, // blend mode (index into filmcraft_render::Blend::ALL), alpha-plane scale (0: none), unused ×2 + p3: vec4, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused }; @group(0) @binding(0) var u: U; @@ -274,6 +274,15 @@ fn linear_to_srgb(v: vec3) -> vec3 { return select(hi, v * 12.92, v <= vec3(0.0031308)); } +// Effect sources are axis-aligned integer-decimated working images. Raster-interpolated source +// coordinates can mix adjacent texels at a nominal pixel center, inventing alpha near zero. +// Keep the regular transformed/minified layer path unchanged; only this source draw uses its +// exact working-pixel position and the host's integer decimation. +@fragment +fn fs_fx_source(in: VOut) -> @location(0) vec4 { + return layer_color(in.pos.xy * u.p3.z); +} + // Blend modes that need the destination: written without fixed-function blending. @fragment fn fs_blend(in: VOut) -> @location(0) vec4 { diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 930a43d87..660ee2c35 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -254,7 +254,7 @@ impl FxStage { multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: composite, - entry_point: Some("fs"), + entry_point: Some("fs_fx_source"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: FX_FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })], }), diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 5e201d117..f47c0546a 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -489,3 +489,74 @@ fn layers_with_effects_fall_back_to_the_cpu_without_a_stage() { assert!(p99 <= 6 && mean < 1.5, "stage {stage}: p99 {p99}, mean {mean}"); } } + +/// The half-float upload is lossless for this input, so a 1:1 effect source draw must be too. +/// Odd dimensions expose interpolation drift that can invent opacity before any effect runs. +#[test] +fn effect_source_identity_preserves_odd_sized_half_float_texels() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + for (w, h) in [(67u32, 41u32), (96, 54), (64, 32)] { + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + for n in [0, 1] { + let (ow, oh, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: n, ops: Vec::new() }).expect("source image"); + assert_eq!((ow, oh, gpu.len()), (w, h, px.len())); + for (i, (expected, actual)) in px.iter().zip(&gpu).enumerate() { + assert_eq!(actual.to_bits(), expected.to_bits(), "{w}x{h} n{n} source component{i}: {actual} != {expected}"); + } + } + } +} + +#[test] +fn alpha_invert_does_not_amplify_invented_opacity_at_odd_source_pixels() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let (w, h) = (67, 41); + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + let op = FxOp::Invert { channel: 4, blend: 0.2 }; + let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() }; + op.apply(&mut cpu); + let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("alpha invert image"); + assert_eq!(gpu.len(), px.len()); + let (worst, flips) = compare(&cpu.px, &gpu); + assert!(worst <= EXACT && flips == 0.0, "alpha invert: {worst}, {flips}"); + let mut transparent = 0; + for (source, result) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) { + if source[3] == 0.0 { + transparent += 1; + assert_eq!(&result[..3], &[0.0; 3], "a transparent source must not acquire neighboring RGB"); + assert!((result[3] - 0.8).abs() <= f32::EPSILON); + } + } + assert_eq!(transparent, 402); +} + +/// The exact source grid keeps the existing box-minification behavior at non-unit factors. +#[test] +fn effect_source_integer_decimation_preserves_cpu_box_oracle() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + for (w, h) in [(67u32, 41u32), (96, 54)] { + let frame = VideoFrame::rgba_f32(w, h, picture(w, h)); + for n in [2, 4, 8] { + let (ow, oh, cpu) = frame.to_linear_f32_decimated(n as usize); + let (gw, gh, gpu) = + c.effect_image(&frame, &LayerFx { size: (ow as u32, oh as u32), decimation: n, ops: Vec::new() }).expect("decimated source image"); + assert_eq!((gw as usize, gh as usize, gpu.len()), (ow, oh, cpu.len())); + let (worst, flips) = compare(&cpu, &gpu); + assert!(worst <= EXACT && flips == 0.0, "{w}x{h} n{n} source box: {worst}, {flips}"); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index f71db2b67..17929fcff 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -36,11 +36,12 @@ use rayon::prelude::*; use filmcraft_color::{Matrix, Range, Transfer}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use filmcraft_render::Blend; -use filmcraft_render::plan::{FramePlan, PlanLayer}; +use filmcraft_render::plan::{FramePlan, PlanLayer, PlanStep}; pub mod fx; pub mod lut; pub mod mask; +mod transition; pub use lut::GpuLut; pub use mask::GpuMask; @@ -83,6 +84,10 @@ pub struct GpuCompositor { /// The effect stage (None on devices without compute shaders or storage textures: layers /// with effects are then rendered on the CPU here). fx: Option, + transition: transition::PushStage, + transition_inputs: [Option<(wgpu::Texture, wgpu::TextureView, (u32, u32))>; 2], + pub gpu_transitions: u64, + pub cpu_transitions: u64, /// Total bytes uploaded (stats). pub uploaded_bytes: u64, } @@ -149,11 +154,12 @@ impl Prepared { pub struct PreparedPlan { layers: Vec>, image: Option, + children: Vec, } impl PreparedPlan { pub fn bytes(&self) -> usize { - self.layers.iter().flatten().chain(&self.image).map(Prepared::bytes).sum() + self.layers.iter().flatten().chain(&self.image).map(Prepared::bytes).sum::() + self.children.iter().map(Self::bytes).sum::() } } @@ -206,11 +212,25 @@ pub fn prepare_frame(f: &VideoFrame) -> Option { /// Convert every layer of a plan for upload. Thread-safe and GPU-free: run it on a worker. pub fn prepare(plan: &FramePlan) -> PreparedPlan { match plan { - FramePlan::Layers { layers, .. } => PreparedPlan { layers: layers.iter().map(|l| prepare_frame(&l.frame)).collect(), image: None }, - FramePlan::Image(img) => PreparedPlan { layers: Vec::new(), image: Some(Prepared { planes: vec![f32_to_f16_bytes(&img.px)] }) }, + FramePlan::Layers { layers, .. } => prepare_layers(layers), + FramePlan::Image(img) => PreparedPlan { image: Some(Prepared { planes: vec![f32_to_f16_bytes(&img.px)] }), ..Default::default() }, + FramePlan::Composite { steps, .. } => PreparedPlan { + children: steps + .iter() + .flat_map(|s| match s { + PlanStep::Layers(layers) => vec![prepare_layers(layers)], + PlanStep::Transition { inputs, .. } => inputs.iter().map(|ls| prepare_layers(ls)).collect(), + }) + .collect(), + ..Default::default() + }, } } +fn prepare_layers(layers: &[PlanLayer]) -> PreparedPlan { + PreparedPlan { layers: layers.iter().map(|l| prepare_frame(&l.frame)).collect(), ..Default::default() } +} + impl GpuCompositor { pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -341,6 +361,10 @@ impl GpuCompositor { clock: 0, dummy, fx, + transition: transition::PushStage::new(device), + transition_inputs: [None, None], + gpu_transitions: 0, + cpu_transitions: 0, uploaded_bytes: 0, } } @@ -505,15 +529,28 @@ impl GpuCompositor { /// The source description of an uploaded frame. fn src_info(&self, f: &VideoFrame, key: (usize, u32, u32)) -> Option { let up = self.uploads.get(&key)?; - Some(SrcInfo { kind: up.kind, code_scale: up.code_scale, alpha: up.alpha_scale, chroma: up.chroma, size: (f.width, f.height), color: f.color }) + let code_levels = match &f.data { + PixelData::Yuv8 { .. } => 256.0, + PixelData::Yuv16 { bits, .. } => (*bits as f32).exp2(), + _ => 1.0, + }; + Some(SrcInfo { + kind: up.kind, + code_scale: up.code_scale, + code_levels, + alpha: up.alpha_scale, + chroma: up.chroma, + size: (f.width, f.height), + color: f.color, + }) } fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] { let (kr, kb) = src.color.matrix.kr_kb(); - let bits_scale = src.code_scale / 255.0; // code units relative to 8-bit + let bits_scale = src.code_levels / 256.0; // 2^(bits - 8), independent of texture encoding let (yo, ys, co, cs) = match (src.color.range, src.kind) { (Range::Limited, 2) => (16.0 * bits_scale, 219.0 * bits_scale, 128.0 * bits_scale, 224.0 * bits_scale), - (Range::Full, 2) => (0.0, src.code_scale - 1.0, src.code_scale / 2.0, src.code_scale - 1.0), + (Range::Full, 2) => (0.0, src.code_levels - 1.0, src.code_levels / 2.0, src.code_levels - 1.0), _ => (0.0, 1.0, 0.0, 1.0), }; let transfer = match src.color.transfer { @@ -580,6 +617,10 @@ impl GpuCompositor { /// [`composite`](Self::composite) with texel conversions already done by [`prepare`] (the /// result is identical; only the upload work on this thread differs). pub fn composite_prepared(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) { + self.render_plan(plan, prep, true, true) + } + + fn render_plan(&mut self, plan: &FramePlan, prep: Option<&PreparedPlan>, clear: bool, resolve: bool) -> (wgpu::TextureView, (u32, u32)) { let owned; let (w, h, layers): (u32, u32, &[PlanLayer]) = match plan { FramePlan::Layers { width, height, layers } => (*width as u32, *height as u32, layers.as_slice()), @@ -597,6 +638,7 @@ impl GpuCompositor { owned = [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }]; (img.w as u32, img.h as u32, &owned[..]) } + FramePlan::Composite { width, height, steps } => return self.composite_steps(*width, *height, steps, prep), }; let (w, h) = (w.max(1), h.max(1)); let accum_view = Self::target(&self.device, &mut self.accum, w, h, ACCUM_FORMAT, wgpu::TextureUsages::COPY_SRC); @@ -645,6 +687,7 @@ impl GpuCompositor { let src = self.src_info(&l.frame, *k).unwrap_or(SrcInfo { kind: 1, code_scale: 1.0, + code_levels: 1.0, alpha: 0.0, chroma: (1, 1), size: (l.frame.width, l.frame.height), @@ -655,7 +698,10 @@ impl GpuCompositor { Some(lfx) => { // the source drawn onto the working image (box-decimated by n, as the CPU decodes) let n = lfx.decimation.max(1) as f64; - let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1))); + let mut su = + Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1))); + // Effect sources use the working pixel center, avoiding interpolated vertex coordinates. + su[26] = n as f32; let sbuf = self.uniform_buffer(&su); let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("fx-source"), @@ -678,7 +724,7 @@ impl GpuCompositor { // what the layer draws: the effect result (linear premultiplied RGBA) or the frame let (u, tex) = match &job { Some(j) => { - let s = SrcInfo { kind: 1, code_scale: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color }; + let s = SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color }; (Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()]) } None => { @@ -727,7 +773,7 @@ impl GpuCompositor { // ends it, copies the accumulator under its quad into the backdrop and draws on its own. let accum_tex = self.accum.as_ref().map(|a| a.0.clone()); let backdrop_tex = self.backdrop.as_ref().map(|b| b.0.clone()); - let mut cleared = false; + let mut cleared = !clear; let mut i = 0; while i < bind_groups.len() || !cleared { // a layer's effects run right before it is drawn (pooled working textures are reused @@ -768,7 +814,7 @@ impl GpuCompositor { } i = end; } - { + if resolve { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("resolve"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -787,7 +833,40 @@ impl GpuCompositor { pass.draw(0..3, 0..1); } self.queue.submit([enc.finish()]); - (out_view, (w, h)) + (if resolve { out_view } else { accum_view }, (w, h)) + } + + fn composite_steps(&mut self, width: usize, height: usize, steps: &[PlanStep], prep: Option<&PreparedPlan>) -> (wgpu::TextureView, (u32, u32)) { + if steps.iter().any(|s| matches!(s, PlanStep::Transition { effect, .. } if effect.effect != "push")) { + self.cpu_transitions += 1; + let plan = FramePlan::Composite { width, height, steps: steps.to_vec() }; + return self.composite_prepared(&FramePlan::Image(filmcraft_render::plan::execute_cpu(&plan)), None); + } + let empty = FramePlan::Layers { width, height, layers: Vec::new() }; + let (canvas, dims) = self.render_plan(&empty, None, true, false); + let mut children = prep.map(|p| p.children.iter()); + for step in steps { + match step { + PlanStep::Layers(layers) => { + let p = children.as_mut().and_then(Iterator::next); + self.render_plan(&FramePlan::Layers { width, height, layers: layers.clone() }, p, false, false); + } + PlanStep::Transition { inputs, effect, progress, .. } => { + let mut views = [self.dummy.clone(), self.dummy.clone()]; + for (index, layers) in inputs.iter().enumerate() { + let p = children.as_mut().and_then(Iterator::next); + // Isolate each clip; queued draws keep the parent accumulator and blend order. + std::mem::swap(&mut self.accum, &mut self.transition_inputs[index]); + let (view, _) = self.render_plan(&FramePlan::Layers { width, height, layers: layers.clone() }, p, true, false); + std::mem::swap(&mut self.accum, &mut self.transition_inputs[index]); + views[index] = view; + } + self.transition.draw(&self.device, &self.queue, &canvas, [&views[0], &views[1]], dims, effect, *progress); + self.gpu_transitions += 1; + } + } + } + self.render_plan(&empty, None, false, true) } /// Run the effect stage of a layer alone: `frame` decoded into its working image and `fx` @@ -799,7 +878,9 @@ impl GpuCompositor { let views = self.uploads.get(&key)?.views.clone(); let n = fx.decimation.max(1) as f64; let (w, h) = (fx.size.0.max(1), fx.size.1.max(1)); - let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h)); + let mut su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h)); + // Same integer source-grid mapping as the composited effect path. + su[26] = n as f32; let sbuf = self.uniform_buffer(&su); let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("fx-source"), @@ -884,11 +965,13 @@ impl GpuCompositor { } } -/// What a layer's shader samples: the texture kind (0 RGBA8 sRGB, 1 linear premultiplied RGBA -/// float, 2 YUV planes), code scale, chroma plane size, picture size and colour description. +/// Shader source: kind (0 RGBA8 sRGB, 1 linear premultiplied RGBA, 2 YUV), sampling scales, dimensions, color. struct SrcInfo { kind: u32, + /// Texture sample -> code units: 255 for R8Unorm, 2^bits for R16Float. code_scale: f32, + /// YUV quantization levels: 2^bits; R8Unorm's decoding scale is one less. + code_levels: f32, /// Alpha texture sample → alpha (0: the frame has no alpha plane). alpha: f32, chroma: (u32, u32), diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index a11cef8b1..8953da0a7 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -9,6 +9,83 @@ fn device() -> Option<(wgpu::Device, wgpu::Queue)> { pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok() } +#[test] +fn yuv_code_range_uniforms() { + let mut mismatches = Vec::new(); + for bits in [8, 10, 12, 16] { + let levels = (1u32 << bits) as f32; + for range in [Range::Full, Range::Limited] { + let src = SrcInfo { + kind: 2, + code_scale: if bits == 8 { 255.0 } else { levels }, + code_levels: levels, + alpha: 0.0, + chroma: (1, 1), + size: (1, 1), + color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 }, + }; + let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1)); + // Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8). + let expected = match range { + Range::Full => [0.0, levels - 1.0, levels / 2.0, levels - 1.0], + Range::Limited => [16.0, 219.0, 128.0, 224.0].map(|v| v * (1u32 << (bits - 8)) as f32), + }; + if u[16..20] != expected { + mismatches.push(format!("{bits}-bit {range:?}: {:?} != {expected:?}", &u[16..20])); + } + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +#[test] +fn yuv_code_range_gpu_matches_cpu() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let mut prepared_compositor = GpuCompositor::new(&dev, &q); + for bits in [8, 10, 12, 16] { + let scale = 1u32 << (bits - 8); + for range in [Range::Full, Range::Limited] { + // Constant chroma isolates range normalization from CPU/GPU chroma reconstruction. + for (u, v) in [(128, 128), (64, 192), (192, 64)] { + let y = (0..=255).map(|code| (code * scale) as u16).collect::>(); + let planes = [Arc::new(y), Arc::new(vec![(u * scale) as u16; 256]), Arc::new(vec![(v * scale) as u16; 256])]; + let data = if bits == 8 { + PixelData::Yuv8 { planes: planes.map(|p| Arc::new(p.iter().map(|v| *v as u8).collect())), chroma: Chroma::C444, alpha: None } + } else { + PixelData::Yuv16 { planes, chroma: Chroma::C444, bits, alpha: None } + }; + let frame = Arc::new(VideoFrame { + width: 256, + height: 1, + data, + color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 }, + par: (1, 1), + pts: Default::default(), + }); + let cpu = frame.to_rgba8(); + let plan = FramePlan::Layers { width: 256, height: 1, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] }; + c.composite(&plan); + let (w, h, gpu) = c.read_output().expect("readback"); + assert_eq!((w, h, gpu.len()), (256, 1, cpu.len())); + let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().expect("pixels"); + // One output-code tolerance: CPU f32 vs GPU f16 accumulator quantization. + assert!(error <= 1, "{bits}-bit {range:?} U={u} V={v}: max error {error}"); + if u == 128 && v == 128 { + assert!(gpu.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]), "neutral tint: {bits}-bit {range:?}"); + } + let prepared = prepare(&plan); + prepared_compositor.composite_prepared(&plan, Some(&prepared)); + assert_eq!(prepared_compositor.read_output().expect("prepared readback").2, gpu, "prepared: {bits}-bit {range:?}"); + eprintln!("range gate: {bits}-bit {range:?} U={u} V={v}, max error={error}, prepared=inline"); + } + } + } +} + fn yuv_frame(w: u32, h: u32) -> Arc { let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let y: Vec = (0..w * h).map(|i| (16 + ((i % w) * 219 / w)) as u8).collect(); @@ -85,6 +162,101 @@ fn half_float_conversion() { } } +#[test] +fn gpu_push_matches_cpu_with_alpha_and_surrounding_blends() { + use filmcraft_project::ParamValue; + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let (w, h) = (128usize, 72usize); + let layer = |side: usize| { + let px = (0..w * h) + .flat_map(|i| { + let u = (i % w % 2) as f32; + let v = (i / w % 2) as f32; + let a = if side == 0 { (u + v) / 2.0 } else { 1.0 - (u + v) / 2.0 }; + [u * a, v * a, 0.5 * a, a] + }) + .collect(); + PlanLayer::new(Arc::new(VideoFrame::rgba_f32(w as u32, h as u32, px)), Affine::IDENTITY, 0.7, Blend::Normal) + }; + let mut worst = (0, 0.0f64); + for direction in 0..4 { + for blur in [0.0, 50.0, 100.0] { + for progress in [0.0, 0.25, 0.37, 0.5, 0.75, 1.0] { + let mut effect = filmcraft_project::find_effect("push").unwrap().instance(); + effect.param_mut("direction").unwrap().value = ParamValue::Choice(direction); + effect.param_mut("motion_blur").unwrap().value = ParamValue::Float(blur); + let mut above = layer(1); + above.blend = Blend::Multiply; + let plan = FramePlan::Composite { + width: w, + height: h, + steps: vec![ + PlanStep::Layers(vec![layer(1)]), + PlanStep::Transition { inputs: [vec![layer(0)], vec![layer(1)]], effect, progress, scale: 1.0 }, + PlanStep::Layers(vec![above]), + ], + }; + let cpu = execute_cpu(&plan).over_black_rgba8(); + c.composite_prepared(&plan, Some(&prepare(&plan))); + let (_, _, gpu) = c.read_output().expect("readback"); + let mut diffs: Vec = + cpu.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0).map(|(a, b)| (0..3).map(|k| a[k].abs_diff(b[k]) as u32).max().unwrap()).collect(); + diffs.sort_unstable(); + let p99 = diffs[diffs.len() * 99 / 100]; + let mean = diffs.iter().sum::() as f64 / diffs.len() as f64; + worst = (worst.0.max(p99), worst.1.max(mean)); + // Existing compositor acceptance band; moving edges included. + assert!(p99 <= 6 && mean < 1.5, "direction {direction}, blur {blur}, progress {progress}: p99 {p99}, mean {mean}"); + } + } + } + assert_eq!(c.gpu_transitions, 4 * 3 * 6); + assert_eq!(c.cpu_transitions, 0); + eprintln!("Push 72 cases: worst p99 {}, mean {}", worst.0, worst.1); + let fallback = FramePlan::Composite { + width: w, + height: h, + steps: vec![PlanStep::Transition { + inputs: [vec![layer(0)], vec![layer(1)]], + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + progress: 0.37, + scale: 1.0, + }], + }; + c.composite(&fallback); + let (_, _, got) = c.read_output().expect("fallback readback"); + let expected = execute_cpu(&fallback).over_black_rgba8(); + assert!(got.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1)); + assert_eq!(c.cpu_transitions, 1); + assert_eq!(c.gpu_transitions, 4 * 3 * 6); + + // Reuse both input targets across ordered transitions; preparation advances past intervening layers. + let transition = |progress| PlanStep::Transition { + inputs: [vec![layer(0)], vec![layer(1)]], + effect: filmcraft_project::find_effect("push").unwrap().instance(), + progress, + scale: 1.0, + }; + let mut middle = layer(0); + middle.blend = Blend::Screen; + let stacked = FramePlan::Composite { + width: w, + height: h, + steps: vec![PlanStep::Layers(vec![layer(1)]), transition(0.25), PlanStep::Layers(vec![middle]), transition(0.75)], + }; + c.composite_prepared(&stacked, Some(&prepare(&stacked))); + let (_, _, gpu) = c.read_output().expect("stacked readback"); + let cpu = execute_cpu(&stacked).over_black_rgba8(); + // One byte level tolerates half-float resolve rounding. + assert!(gpu.iter().zip(cpu).all(|(a, b)| a.abs_diff(b) <= 1), "stacked transitions exceed one byte level"); + assert_eq!(c.gpu_transitions, 4 * 3 * 6 + 2); + assert_eq!(c.cpu_transitions, 1); +} + #[test] fn prepared_upload_matches_inline_conversion() { let Some((dev, q)) = device() else { diff --git a/crates/gpu/src/transition.rs b/crates/gpu/src/transition.rs new file mode 100644 index 000000000..119fb75c1 --- /dev/null +++ b/crates/gpu/src/transition.rs @@ -0,0 +1,78 @@ +use filmcraft_project::{EffectInstance, ParamValue}; +use wgpu::util::DeviceExt; + +pub(crate) struct PushStage { + pipeline: wgpu::RenderPipeline, +} + +impl PushStage { + pub fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("push"), + source: wgpu::ShaderSource::Wgsl(include_str!("transition.wgsl").into()), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("push"), + layout: None, + vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[] }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: super::ACCUM_FORMAT, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Self { pipeline } + } + + #[allow(clippy::too_many_arguments)] + pub fn draw( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + canvas: &wgpu::TextureView, + inputs: [&wgpu::TextureView; 2], + size: (u32, u32), + effect: &EffectInstance, + progress: f32, + ) { + let value = effect.param("direction").map(|p| p.value.clone()).or_else(|| effect.def().and_then(|d| d.param("direction")).map(|p| p.default.clone())); + let direction = match value { + Some(ParamValue::Choice(v)) => v, + _ => 0, + }; + let blur = effect.f64_at("motion_blur", Default::default()) as f32; + let blur = if blur.is_finite() { (blur / 100.0).clamp(0.0, 1.0) } else { 0.0 }; + let progress = if progress.is_finite() { progress.clamp(0.0, 1.0) } else { 0.0 }; + let values = [size.0 as f32, size.1 as f32, progress, blur, direction as f32, 0.0, 0.0, 0.0]; + let bytes: Vec = values.iter().flat_map(|v| v.to_le_bytes()).collect(); + let uniform = + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("push-params"), contents: &bytes, usage: wgpu::BufferUsages::UNIFORM }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("push"), + layout: &self.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(inputs[0]) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(inputs[1]) }, + wgpu::BindGroupEntry { binding: 2, resource: uniform.as_entire_binding() }, + ], + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("push") }); + { + let mut pass = super::accum_pass(&mut encoder, canvas, wgpu::LoadOp::Load); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + } + queue.submit([encoder.finish()]); + } +} diff --git a/crates/gpu/src/transition.wgsl b/crates/gpu/src/transition.wgsl new file mode 100644 index 000000000..9150a7034 --- /dev/null +++ b/crates/gpu/src/transition.wgsl @@ -0,0 +1,63 @@ +struct Params { image: vec4, direction: vec4 } +@group(0) @binding(0) var a: texture_2d; +@group(0) @binding(1) var b: texture_2d; +@group(0) @binding(2) var params: Params; + +@vertex +fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = array, 3>(vec2(-1.0,-1.0), vec2(3.0,-1.0), vec2(-1.0,3.0)); + return vec4(p[i],0.0,1.0); +} +fn pixel(side: u32, at: vec2) -> vec4 { + if side == 0u { return textureLoad(a,vec2(at),0); } + return textureLoad(b,vec2(at),0); +} +fn sample_clamped(side: u32, at: vec2) -> vec4 { + let f = clamp(at-vec2(0.5), vec2(0.0), params.image.xy-vec2(1.0)); + let lo = vec2(floor(f)); + let hi = min(lo+vec2(1u), vec2(params.image.xy)-vec2(1u)); + let k = f-vec2(lo); + let va = pixel(side,lo); let vb = pixel(side,vec2(hi.x,lo.y)); + let vc = pixel(side,vec2(lo.x,hi.y)); let vd = pixel(side,hi); + let top = va+(vb-va)*k.x; let bot = vc+(vd-vc)*k.x; + return top+(bot-top)*k.y; +} +fn card(side: u32, offset: vec2, xy: vec2) -> vec4 { + // CPU Card::hit order preserves rounding at moving edges. + let eye = vec3(params.image.xy/2.0, -max(params.image.x,params.image.y)*6.0); + let ex = vec3(1.0,0.0,0.0); let ey = vec3(0.0,1.0,0.0); + let d = vec3(xy,0.0)-eye; let rhs = eye-vec3(offset,0.0); let nd = -d; + let det = dot(ex,cross(ey,nd)); + let local = vec2(dot(rhs,cross(ey,nd)),dot(ex,cross(rhs,nd)))/det; + let edge = min(min(local.x,params.image.x-local.x),min(local.y,params.image.y-local.y)); + let coverage = clamp(edge+0.5,0.0,1.0); + if coverage <= 0.0 { return vec4(0.0); } + return sample_clamped(side,local)*coverage; +} +fn eased(q: f32) -> f32 { + if q < 0.5 { return 4.0*q*q*q; } + let v = -2.0*q+2.0; + return 1.0-v*v*v/2.0; +} +@fragment +fn fs(@builtin(position) at: vec4) -> @location(0) vec4 { + let p = params.image.z; + if p <= 0.0 { return pixel(0u,vec2(at.xy)); } + if p >= 1.0 { return pixel(1u,vec2(at.xy)); } + var direction = vec2(0.0,1.0); + switch u32(params.direction.x) { case 1u: { direction=vec2(-1.0,0.0); } case 2u: { direction=vec2(0.0,-1.0); } case 3u: { direction=vec2(1.0,0.0); } default: {} } + let travel = max(dot(abs(direction),params.image.xy),1.0); + // CPU shutter: 12 taps at full blur, 0.12 timeline fraction exposure. + let blur = params.image.w; var count=1u; + if blur > 0.0 { count=clamp(u32(ceil(blur*12.0)),2u,12u); } + var sum=vec4(0.0); + for(var s=0u;s1u { q=clamp(p+(f32(s)/f32(count-1u)-0.5)*(0.12*blur),0.0,1.0); } + let e=eased(q); + let va=card(0u,direction*travel*e,at.xy); + let vb=card(1u,direction*travel*(e-1.0),at.xy); + sum+=vb+va*(1.0-vb.a); + } + return sum*(1.0/f32(count)); +} diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 5e2f810d8..59070d359 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -6,11 +6,11 @@ //! jobs of later pictures block per row on the reference data they need, so several pictures decode //! concurrently (frame-level parallelism). -use crate::dpb::{Dpb, Output, OutputMeta}; +use crate::dpb::{Dpb, Output, OutputMeta, RefMark}; use crate::error::{Error, Result, ensure, invalid, unsupported}; use crate::params::{Pps, Sps}; use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic}; -use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, nal_type}; +use crate::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, is_new_picture, nal_type}; use crate::slicedec::{PicState, SliceDecoder}; use crate::transform::LevelScale; use crate::{ColorInfo, Picture}; @@ -81,7 +81,7 @@ pub struct Decoder { spss: Vec>>, ppss: Vec>>, nal_length_size: Option, - dpb: Dpb, + dpb: Dpb, poc_state: PocState, prev_ref_frame_num: u32, pending: Option, @@ -89,7 +89,7 @@ pub struct Decoder { active_sps: Option>, ls_cache: Vec<(Arc, Arc)>, /// Pictures leaving the DPB, converted once their decoding job has finished. - out_queue: VecDeque, + out_queue: VecDeque>, shared: Arc, #[cfg(feature = "threads")] pool: Option, @@ -106,27 +106,6 @@ impl Default for Decoder { } } -/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)? -fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool { - if sh.frame_num != prev.frame_num - || sh.pps_id != prev.pps_id - || sh.field_pic != prev.field_pic - || sh.bottom_field != prev.bottom_field - || (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0) - || sh.idr != prev.idr - || (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id) - { - return true; - } - if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) { - return true; - } - if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt { - return true; - } - false -} - /// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads` /// feature on native targets, 1 otherwise. pub fn default_threads() -> usize { @@ -384,7 +363,9 @@ impl Decoder { let Some(pending) = self.pending.as_mut() else { return invalid("slice without a started picture"); }; - let refs = self.dpb.build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num())?; + let refs = self + .dpb + .build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num(), |e| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long })?; if !sh.slice_type.is_intra() { ensure!(!refs[0].is_empty(), "no reference pictures available for inter slice"); if sh.slice_type.is_b() { @@ -464,7 +445,7 @@ impl Decoder { } _ => Planes::gray(mb_w * 16, mb_h * 16), }; - (Arc::new(Frame::from_planes(id, 0, &planes)), meta.clone()) + (Arc::new(Frame::from_planes(id, 0, &planes)), 0, meta.clone()) }; let mut upd = |fnum: u32| poc_state.update_gap_frame(fnum, sps); self.shared.stats.lock().map(|mut s| s.frame_num_gaps += 1).ok(); @@ -516,7 +497,7 @@ impl Decoder { run_job(frame, slices, &shared, draft); } - fn emit(&mut self, outs: Vec) { + fn emit(&mut self, outs: Vec>) { self.out_queue.extend(outs); } } @@ -613,7 +594,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta { } } -fn make_picture(o: &Output) -> Picture { +fn make_picture(o: &Output) -> Picture { let (cx, cy, cw, ch) = o.meta.crop; let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize); let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch)); diff --git a/crates/h264/src/dpb.rs b/crates/h264/src/dpb.rs index 691c62b15..657aa411a 100644 --- a/crates/h264/src/dpb.rs +++ b/crates/h264/src/dpb.rs @@ -1,8 +1,11 @@ //! Decoded picture buffer: reference marking (8.2.5), reference list construction (8.2.4) and output //! ordering / bumping (C.4). +//! +//! The buffer only does bookkeeping on picture headers; what a picture is (`F`: the software +//! decoder's [`crate::picture::FrameRef`], a hardware decoder's surface) is up to the decoder, so +//! hardware decoders driven by the same headers (VA-API) share this logic and output order. use crate::error::{Result, ensure}; -use crate::picture::{FrameRef, RefPic}; use crate::slice::{Mmco, SliceHeader, SliceType}; use std::sync::Arc; @@ -28,8 +31,8 @@ pub struct OutputMeta { pub draft: bool, } -pub struct DpbEntry { - pub frame: FrameRef, +pub struct DpbEntry { + pub frame: F, pub poc: i32, pub frame_num: u32, pub long_term_frame_idx: u32, @@ -39,8 +42,8 @@ pub struct DpbEntry { pub meta: Arc, } -pub struct Dpb { - pub entries: Vec, +pub struct Dpb { + pub entries: Vec>, /// None = "no long-term frame indices". pub max_long_term_frame_idx: Option, pub capacity: usize, @@ -48,18 +51,18 @@ pub struct Dpb { } /// A picture leaving the DPB for output. -pub struct Output { - pub frame: FrameRef, +pub struct Output { + pub frame: F, pub poc: i32, pub meta: Arc, } -impl Dpb { +impl Dpb { pub fn new() -> Self { Dpb { entries: Vec::new(), max_long_term_frame_idx: None, capacity: 16, max_reorder: 16 } } - fn frame_num_wrap(e: &DpbEntry, cur_frame_num: u32, max_frame_num: u32) -> i32 { + fn frame_num_wrap(e: &DpbEntry, cur_frame_num: u32, max_frame_num: u32) -> i32 { if e.frame_num > cur_frame_num { e.frame_num as i32 - max_frame_num as i32 } else { e.frame_num as i32 } } @@ -73,7 +76,7 @@ impl Dpb { } /// Output the picture with the smallest POC (C.4.5.3 "bumping"). Returns false if none waits. - fn bump(&mut self, out: &mut Vec) -> bool { + fn bump(&mut self, out: &mut Vec>) -> bool { let Some(i) = self.entries.iter().enumerate().filter(|(_, e)| e.needed_for_output).min_by_key(|(_, e)| e.poc).map(|(i, _)| i) else { return false; }; @@ -85,13 +88,13 @@ impl Dpb { } /// Output everything in POC order and clear non-reference entries. - pub fn flush(&mut self, out: &mut Vec) { + pub fn flush(&mut self, out: &mut Vec>) { while self.bump(out) {} self.prune(); } /// IDR: mark all references unused; output (or drop) prior pictures. - pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec) { + pub fn idr(&mut self, no_output_of_prior_pics: bool, out: &mut Vec>) { for e in &mut self.entries { e.mark = RefMark::Unused; } @@ -122,7 +125,8 @@ impl Dpb { self.prune(); } - /// Insert "non-existing" frames for a frame_num gap (8.2.5.2). + /// Insert "non-existing" frames for a frame_num gap (8.2.5.2). `make_frame` gives each one's + /// picture and POC. #[allow(clippy::too_many_arguments)] pub fn fill_frame_num_gap( &mut self, @@ -130,17 +134,17 @@ impl Dpb { frame_num: u32, max_frame_num: u32, max_num_ref_frames: usize, - make_frame: &mut dyn FnMut(u32) -> (FrameRef, Arc), + make_frame: &mut dyn FnMut(u32) -> (F, i32, Arc), poc_state: &mut dyn FnMut(u32), ) { let mut unused = (prev_ref_frame_num + 1) % max_frame_num; let mut guard = 0; while unused != frame_num && guard < max_frame_num { self.sliding_window(unused, max_frame_num, max_num_ref_frames); - let (frame, meta) = make_frame(unused); + let (frame, poc, meta) = make_frame(unused); poc_state(unused); self.entries.push(DpbEntry { - poc: frame.poc, + poc, frame, frame_num: unused, long_term_frame_idx: 0, @@ -154,26 +158,26 @@ impl Dpb { } } - /// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice. - pub fn build_ref_lists(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32) -> Result<[Vec; 2]> { - let mut lists: [Vec; 2] = [Vec::new(), Vec::new()]; + /// Initial reference picture lists (8.2.4.2) + modification (8.2.4.3) for a frame slice, each + /// entry made by `to_ref` from the DPB entry it refers to. + pub fn build_ref_lists(&self, sh: &SliceHeader, cur_poc: i32, max_frame_num: u32, to_ref: impl Fn(&DpbEntry) -> R) -> Result<[Vec; 2]> { + let mut lists: [Vec; 2] = [Vec::new(), Vec::new()]; if sh.slice_type.is_intra() { return Ok(lists); } let cur_fn = sh.frame_num; - let pic_num = |e: &DpbEntry| Self::frame_num_wrap(e, cur_fn, max_frame_num); - let short: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect(); - let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect(); + let pic_num = |e: &DpbEntry| Self::frame_num_wrap(e, cur_fn, max_frame_num); + let short: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Short).collect(); + let mut long: Vec<&DpbEntry> = self.entries.iter().filter(|e| e.mark == RefMark::Long).collect(); long.sort_by_key(|e| e.long_term_frame_idx); - let to_ref = |e: &&DpbEntry| RefPic { frame: e.frame.clone(), long_term: e.mark == RefMark::Long }; - let mut entry_lists: [Vec<&DpbEntry>; 2] = [Vec::new(), Vec::new()]; + let mut entry_lists: [Vec<&DpbEntry>; 2] = [Vec::new(), Vec::new()]; if sh.slice_type != SliceType::B { let mut s = short.clone(); s.sort_by_key(|e| std::cmp::Reverse(pic_num(e))); entry_lists[0] = s.into_iter().chain(long.iter().copied()).collect(); } else { - let mut before: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect(); - let mut after: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect(); + let mut before: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc < cur_poc).collect(); + let mut after: Vec<&DpbEntry> = short.iter().copied().filter(|e| e.poc > cur_poc).collect(); before.sort_by_key(|e| std::cmp::Reverse(e.poc)); after.sort_by_key(|e| e.poc); entry_lists[0] = before.iter().chain(after.iter()).chain(long.iter()).copied().collect(); @@ -188,7 +192,7 @@ impl Dpb { let nlists = if sh.slice_type == SliceType::B { 2 } else { 1 }; for l in 0..nlists { let n = sh.num_ref_idx_active[l] as usize; - let mut list: Vec> = entry_lists[l].iter().copied().map(Some).collect(); + let mut list: Vec>> = entry_lists[l].iter().copied().map(Some).collect(); list.truncate(n); list.resize(n, None); if !sh.ref_pic_list_mod[l].is_empty() { @@ -199,7 +203,7 @@ impl Dpb { for m in &sh.ref_pic_list_mod[l] { ensure!(ref_idx < n, "too many ref_pic_list_modification entries"); list.push(None); // temporarily one longer - let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { + let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc { 0 | 1 => { let d = m.value as i32 + 1; let no_wrap = if m.idc == 0 { @@ -249,7 +253,7 @@ impl Dpb { } // Fill missing entries (should not happen in conforming streams) with the first valid entry. let fallback = list.iter().flatten().next().copied().or_else(|| self.entries.last()); - let out: Vec = list.iter().filter_map(|e| e.or(fallback)).map(|e| to_ref(&e)).collect(); + let out: Vec = list.iter().filter_map(|e| e.or(fallback)).map(&to_ref).collect(); lists[l] = out; } Ok(lists) @@ -327,12 +331,12 @@ impl Dpb { pub fn store_picture( &mut self, sh: &SliceHeader, - frame: FrameRef, + frame: F, poc: i32, max_frame_num: u32, max_num_ref_frames: usize, meta: Arc, - out: &mut Vec, + out: &mut Vec>, ) { let mut mark = RefMark::Unused; let mut long_idx = 0; @@ -404,7 +408,7 @@ impl Dpb { } } -impl Default for Dpb { +impl Default for Dpb { fn default() -> Self { Self::new() } diff --git a/crates/h264/src/lib.rs b/crates/h264/src/lib.rs index 6cc05a0cf..c70e8b0fb 100644 --- a/crates/h264/src/lib.rs +++ b/crates/h264/src/lib.rs @@ -28,7 +28,7 @@ mod cavlc; mod cavlc_tables; mod deblock; mod decoder; -mod dpb; +pub mod dpb; mod error; mod inter; mod intra; diff --git a/crates/h264/src/slice.rs b/crates/h264/src/slice.rs index b92bdf69a..f832bc9d6 100644 --- a/crates/h264/src/slice.rs +++ b/crates/h264/src/slice.rs @@ -387,6 +387,27 @@ pub struct PocState { prev_frame_num: u32, } +/// Is `sh` the first slice of a new primary coded picture relative to `prev` (7.4.1.2.4)? +pub fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool { + if sh.frame_num != prev.frame_num + || sh.pps_id != prev.pps_id + || sh.field_pic != prev.field_pic + || sh.bottom_field != prev.bottom_field + || (sh.nal_ref_idc == 0) != (prev.nal_ref_idc == 0) + || sh.idr != prev.idr + || (sh.idr && prev.idr && sh.idr_pic_id != prev.idr_pic_id) + { + return true; + } + if sps.pic_order_cnt_type == 0 && (sh.pic_order_cnt_lsb != prev.pic_order_cnt_lsb || sh.delta_pic_order_cnt_bottom != prev.delta_pic_order_cnt_bottom) { + return true; + } + if sps.pic_order_cnt_type == 1 && sh.delta_pic_order_cnt != prev.delta_pic_order_cnt { + return true; + } + false +} + /// Output of POC computation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Poc { diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 4b87295f4..48f500666 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -25,7 +25,7 @@ struct PendingPic { /// Header of the latest independent slice segment (for dependent slice segments). last_sh: SliceHeader, first_sh: SliceHeader, - rps: RefPicSet, + rps: RefPicSet, poc: i32, output: bool, meta: Arc, @@ -72,7 +72,7 @@ pub struct Decoder { spss: Vec>>, ppss: Vec>>, nal_length_size: Option, - dpb: Dpb, + dpb: Dpb, poc_state: PocState, pending: Option, next_id: u32, @@ -82,7 +82,7 @@ pub struct Decoder { /// RASL pictures associated with the last IRAP are skipped (it had NoRaslOutputFlag = 1). skip_rasl: bool, layout_cache: Vec<(Arc, Arc, Arc, Option>>>)>, - out_queue: VecDeque, + out_queue: VecDeque>, shared: Arc, #[cfg(feature = "threads")] pool: Option, @@ -578,7 +578,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool, draft: bool) -> OutputMeta { } } -fn make_picture(o: &Output) -> Picture { +fn make_picture(o: &Output) -> Picture { let (cx, cy, cw, ch) = o.meta.crop; let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize); let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch)); diff --git a/crates/hevc/src/dpb.rs b/crates/hevc/src/dpb.rs index 6bb4e1777..d6f029a98 100644 --- a/crates/hevc/src/dpb.rs +++ b/crates/hevc/src/dpb.rs @@ -1,8 +1,11 @@ //! Decoded picture buffer: POC derivation (8.3.1), reference picture set (8.3.2), missing //! reference generation (8.3.3), reference picture lists (8.3.4) and output / bumping (C.5.2). +//! +//! The buffer only does bookkeeping on picture headers; what a picture is (`F`: the software +//! decoder's [`crate::picture::FrameRef`], a hardware decoder's surface) is up to the decoder, so +//! hardware decoders driven by the same headers (VA-API) share this logic and output order. use crate::error::{Result, ensure}; -use crate::picture::{FrameRef, RefPic}; use crate::slice::SliceHeader; use std::sync::Arc; @@ -29,8 +32,8 @@ pub enum Marking { Long, } -pub struct Entry { - pub frame: FrameRef, +pub struct Entry { + pub frame: F, pub poc: i32, pub marking: Marking, pub needed_for_output: bool, @@ -39,31 +42,49 @@ pub struct Entry { } /// A picture leaving the DPB for output. -pub struct Output { - pub frame: FrameRef, +pub struct Output { + pub frame: F, pub poc: i32, pub meta: Arc, } -/// Reference picture set of the current picture (8.3.2) as DPB indices / generated frames. -#[derive(Default)] -pub struct RefPicSet { - pub st_curr_before: Vec, - pub st_curr_after: Vec, - pub lt_curr: Vec, +/// A reference picture of the current picture. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ref { + pub frame: F, + pub poc: i32, + pub long_term: bool, } -#[derive(Default)] -pub struct Dpb { - pub entries: Vec, +/// Reference picture set of the current picture (8.3.2) as DPB indices / generated frames. +pub struct RefPicSet { + pub st_curr_before: Vec>, + pub st_curr_after: Vec>, + pub lt_curr: Vec>, +} + +impl Default for RefPicSet { + fn default() -> Self { + Self { st_curr_before: Vec::new(), st_curr_after: Vec::new(), lt_curr: Vec::new() } + } +} + +pub struct Dpb { + pub entries: Vec>, pub max_dec_pic_buffering: usize, pub max_num_reorder: usize, /// SpsMaxLatencyPictures (0 = no limit). pub max_latency: u32, } -impl Dpb { - fn bump(&mut self, outs: &mut Vec) -> bool { +impl Default for Dpb { + fn default() -> Self { + Self { entries: Vec::new(), max_dec_pic_buffering: 0, max_num_reorder: 0, max_latency: 0 } + } +} + +impl Dpb { + fn bump(&mut self, outs: &mut Vec>) -> bool { let Some(i) = (0..self.entries.len()).filter(|&i| self.entries[i].needed_for_output).min_by_key(|&i| self.entries[i].poc) else { return false }; let e = &mut self.entries[i]; e.needed_for_output = false; @@ -85,7 +106,7 @@ impl Dpb { } /// Output everything (end of stream or IRAP without no_output_of_prior_pics). - pub fn flush(&mut self, outs: &mut Vec) { + pub fn flush(&mut self, outs: &mut Vec>) { while self.bump(outs) {} for e in &mut self.entries { e.marking = Marking::Unused; @@ -100,7 +121,7 @@ impl Dpb { /// C.5.2.2: removal of pictures before decoding the current (non-IRAP-reset) picture, after the RPS /// has been applied. - pub fn bump_before_decode(&mut self, outs: &mut Vec) { + pub fn bump_before_decode(&mut self, outs: &mut Vec>) { self.remove_unused(); loop { let full = self.entries.len() >= self.max_dec_pic_buffering.max(1); @@ -116,7 +137,7 @@ impl Dpb { } /// C.5.2.3: insert the current picture and apply "additional bumping". - pub fn insert(&mut self, frame: FrameRef, poc: i32, output: bool, meta: Arc, outs: &mut Vec) { + pub fn insert(&mut self, frame: F, poc: i32, output: bool, meta: Arc, outs: &mut Vec>) { for e in &mut self.entries { if e.needed_for_output { e.latency += 1; @@ -138,8 +159,8 @@ impl Dpb { poc: i32, max_poc_lsb: i32, irap_no_rasl: bool, - make_missing: &mut dyn FnMut(i32) -> FrameRef, - ) -> Result { + make_missing: &mut dyn FnMut(i32) -> F, + ) -> Result> { let mut set = RefPicSet::default(); if sh.nal.is_irap() && irap_no_rasl { for e in &mut self.entries { @@ -196,13 +217,13 @@ impl Dpb { self.entries[i].marking = Marking::Long; } } - let mut make = |found: Option, p: i32, lt: bool, entries: &mut Vec| -> RefPic { + let mut make = |found: Option, p: i32, lt: bool, entries: &mut Vec>| -> Ref { match found { - Some(i) => RefPic { frame: entries[i].frame.clone(), poc: entries[i].poc, long_term: lt }, + Some(i) => Ref { frame: entries[i].frame.clone(), poc: entries[i].poc, long_term: lt }, None => { // 8.3.3: generate an unavailable reference picture let frame = make_missing(p); - RefPic { frame, poc: p, long_term: lt } + Ref { frame, poc: p, long_term: lt } } } }; @@ -228,8 +249,8 @@ impl Dpb { } /// Build RefPicList0/1 (8.3.4). -pub fn build_ref_lists(sh: &SliceHeader, rps: &RefPicSet) -> Result<[Vec; 2]> { - let mut lists: [Vec; 2] = [Vec::new(), Vec::new()]; +pub fn build_ref_lists(sh: &SliceHeader, rps: &RefPicSet) -> Result<[Vec>; 2]> { + let mut lists: [Vec>; 2] = [Vec::new(), Vec::new()]; if sh.is_intra() { return Ok(lists); } diff --git a/crates/hevc/src/lib.rs b/crates/hevc/src/lib.rs index a9fa837f0..c6a6484c6 100644 --- a/crates/hevc/src/lib.rs +++ b/crates/hevc/src/lib.rs @@ -23,7 +23,7 @@ mod cabac; mod decoder; -mod dpb; +pub mod dpb; mod error; mod filter; mod inter; diff --git a/crates/hevc/src/picture.rs b/crates/hevc/src/picture.rs index 5aadbf6e2..55d2730fa 100644 --- a/crates/hevc/src/picture.rs +++ b/crates/hevc/src/picture.rs @@ -207,9 +207,4 @@ fn copy_line(line: &[u16], x0: i32, out: &mut [i16]) { pub type FrameRef = Arc; /// An entry of RefPicList0/1 of the current slice. -#[derive(Clone)] -pub struct RefPic { - pub frame: FrameRef, - pub poc: i32, - pub long_term: bool, -} +pub type RefPic = crate::dpb::Ref; diff --git a/crates/interchange/src/aaf/ids.rs b/crates/interchange/src/aaf/ids.rs index c010f5a75..262d1d0b8 100644 --- a/crates/interchange/src/aaf/ids.rs +++ b/crates/interchange/src/aaf/ids.rs @@ -82,6 +82,10 @@ pub(crate) mod pid { // Root pub const ROOT_META_DICTIONARY: u16 = 0x0001; pub const ROOT_HEADER: u16 = 0x0002; + // MetaDictionary and MetaDefinition + pub const META_CLASS_DEFINITIONS: u16 = 0x0003; + pub const META_TYPE_DEFINITIONS: u16 = 0x0004; + pub const META_IDENTIFICATION: u16 = 0x0005; // Header pub const BYTE_ORDER: u16 = 0x3B01; pub const LAST_MODIFIED: u16 = 0x3B02; diff --git a/crates/interchange/src/aaf/store.rs b/crates/interchange/src/aaf/store.rs index c89b143d8..69b17e888 100644 --- a/crates/interchange/src/aaf/store.rs +++ b/crates/interchange/src/aaf/store.rs @@ -172,6 +172,8 @@ fn prop_name(pid: u16) -> &'static str { match pid { 0x0001 => "MetaDictionary", 0x0002 => "Header", + 0x0003 => "ClassDefinitions", + 0x0004 => "TypeDefinitions", 0x3B03 => "Content", 0x3B04 => "Dictionary", 0x3B06 => "IdentificationList", @@ -200,6 +202,16 @@ fn prop_name(pid: u16) -> &'static str { } } +/// The key size of a strong reference set keyed by `key_pid`, for a set with no entry to take it +/// from: AUIDs (definitions) are 16 bytes, MobIDs 32. +fn set_key_size(key_pid: u16) -> usize { + match key_pid { + 0x0005 | 0x1B01 => 16, + 0x4401 | 0x2701 => 32, + _ => 0, + } +} + fn storage_name(pid: u16) -> String { let suffix = format!("-{pid:x}"); let name: String = prop_name(pid).chars().take(22 - suffix.len()).collect(); @@ -269,7 +281,7 @@ impl W { index.extend_from_slice(&(items.len() as u32).to_le_bytes()); // first free key index.extend_from_slice(&u32::MAX.to_le_bytes()); // last free key let set_key = if let Value::StrongSet(_, k) = v { Some(*k) } else { None }; - let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or(0, <[u8]>::len)).unwrap_or(0); + let key_size = set_key.map(|k| items.first().and_then(|i| i.data(k)).map_or_else(|| set_key_size(k), <[u8]>::len)).unwrap_or(0); if let Some(k) = set_key { index.extend_from_slice(&k.to_le_bytes()); index.push(key_size as u8); diff --git a/crates/interchange/src/aaf/tests.rs b/crates/interchange/src/aaf/tests.rs index 4cfbf62ee..e8a7e6a59 100644 --- a/crates/interchange/src/aaf/tests.rs +++ b/crates/interchange/src/aaf/tests.rs @@ -28,7 +28,7 @@ fn project() -> (Project, ItemId) { bitrate: None, hdr: None, }), - audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(24) }), + audio_streams: vec![filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(24) }], container: String::new(), start_timecode: Some(86_400), file_size: None, @@ -94,6 +94,32 @@ fn keys(dict: &Obj, p: u16) -> HashSet> { dict.objs(p).iter().filter_map(|d| d.data(pid::IDENTIFICATION).map(<[u8]>::to_vec)).collect() } +/// #323: readers that load the meta-dictionary (pyaaf2) need its ClassDefinitions and +/// TypeDefinitions sets. They are empty: everything written uses the baseline classes and types +/// those readers already know. +#[test] +fn the_meta_dictionary_has_its_class_and_type_definition_sets() { + let (p, s) = project(); + for small in [false, true] { + let (bytes, _) = export(&p, s, &AafOptions { small_sectors: small, ..Default::default() }).unwrap(); + let cf = filmcraft_cfb::CompoundFile::open(&bytes).unwrap(); + assert_eq!(cf.entries[cf.find("MetaDictionary-1").unwrap()].clsid, ids::META_DICTIONARY); + for name in ["ClassDefinitions-3", "TypeDefinitions-4"] { + // u32 count, u32 first free key, u32 last free key, u16 key pid, u8 key size + let index = cf.read_path(&format!("MetaDictionary-1/{name} index")).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(index.len(), 15, "{name}: an empty set"); + assert_eq!(index[0..4], 0u32.to_le_bytes(), "{name}: no entries"); + assert_eq!(u16::from_le_bytes([index[12], index[13]]), pid::META_IDENTIFICATION, "{name}: keyed by the definition's AUID"); + assert_eq!(index[14], 16, "{name}: 16-byte keys"); + } + let root = store::read(&bytes).unwrap(); + let meta = root.strong(pid::ROOT_META_DICTIONARY).unwrap(); + for set in [pid::META_CLASS_DEFINITIONS, pid::META_TYPE_DEFINITIONS] { + assert!(matches!(meta.get(set), Some(Value::StrongSet(items, k)) if items.is_empty() && *k == pid::META_IDENTIFICATION), "{set:#06x}"); + } + } +} + #[test] fn written_files_have_the_required_structure() { let (p, s) = project(); diff --git a/crates/interchange/src/aaf/write.rs b/crates/interchange/src/aaf/write.rs index f0876cfeb..ea0ea8e8f 100644 --- a/crates/interchange/src/aaf/write.rs +++ b/crates/interchange/src/aaf/write.rs @@ -349,9 +349,13 @@ pub(crate) fn write(doc: &Document, version: filmcraft_cfb::Version, report: &mu .with(pid::CONTENT, Value::Strong(Box::new(content))) .with(pid::DICTIONARY, Value::Strong(Box::new(dict))) .with(pid::IDENTIFICATION_LIST, Value::StrongVec(vec![ident])); - let root = Obj::new(ROOT) - .with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(Obj::new(META_DICTIONARY)))) - .with(pid::ROOT_HEADER, Value::Strong(Box::new(header))); + // The meta-dictionary holds only extensions to the baseline object model, and everything written + // here is baseline, so its class and type definition sets are empty; readers that load them + // (pyaaf2) still need the sets to be there (#323). + let meta = Obj::new(META_DICTIONARY) + .with(pid::META_CLASS_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)) + .with(pid::META_TYPE_DEFINITIONS, Value::StrongSet(Vec::new(), pid::META_IDENTIFICATION)); + let root = Obj::new(ROOT).with(pid::ROOT_META_DICTIONARY, Value::Strong(Box::new(meta))).with(pid::ROOT_HEADER, Value::Strong(Box::new(header))); store::write(&root, version).map_err(Error::Other) } diff --git a/crates/interchange/src/ale.rs b/crates/interchange/src/ale.rs index b833d00c7..55bb17840 100644 --- a/crates/interchange/src/ale.rs +++ b/crates/interchange/src/ale.rs @@ -87,7 +87,7 @@ pub fn export_items(p: &Project, items: &[ItemId]) -> AleDoc { let rows_src: Vec = items.iter().copied().filter(|i| media_of(*i).is_some()).collect(); let first_video = rows_src.iter().find_map(|i| media_of(*i).and_then(|(m, ..)| m.info.video.as_ref().map(|v| (v.frame_rate, v.height)))); let (rate, height) = first_video.unwrap_or((FrameRate::FPS_24, 1080)); - let sr = rows_src.iter().find_map(|i| media_of(*i).and_then(|(m, ..)| m.info.audio.as_ref().map(|a| a.sample_rate))).unwrap_or(48_000); + let sr = rows_src.iter().find_map(|i| media_of(*i).and_then(|(m, ..)| m.info.audio().map(|a| a.sample_rate))).unwrap_or(48_000); let heading = vec![ ("FIELD_DELIM".to_string(), "TABS".to_string()), ("VIDEO_FORMAT".to_string(), video_format(height).to_string()), @@ -103,7 +103,7 @@ pub fn export_items(p: &Project, items: &[ItemId]) -> AleDoc { if m.info.video.is_some() { tracks.push('V'); } - for c in 1..=m.info.audio.as_ref().map_or(0, |a| a.channels.min(16)) { + for c in 1..=m.info.audio().map_or(0, |a| a.channels.min(16)) { tracks.push_str(&format!("A{c}")); } // media start timecode (frames at the media's own rate) + the subclip offset @@ -223,7 +223,10 @@ mod tests { bitrate: None, hdr: None, }), - audio: (audio > 0).then(|| AudioStreamInfo { sample_rate: 48_000, channels: audio, codec: "aac".into(), bits_per_sample: None }), + audio_streams: (audio > 0) + .then(|| AudioStreamInfo { sample_rate: 48_000, channels: audio, codec: "aac".into(), bits_per_sample: None }) + .into_iter() + .collect(), container: "mp4".into(), start_timecode: tc, file_size: None, diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index 1988ffa78..45dfa500f 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -81,6 +81,15 @@ pub fn file_url_to_path(url: &str) -> String { /// Convert an absolute path to a `file://` URL (`localhost` form for FCP7 XML). pub fn path_to_file_url(path: &str, localhost: bool) -> String { let p = path.replace('\\', "/"); + // Windows verbatim paths (`\\?\C:\x`, `\\?\UNC\server\share`, as `canonicalize` returns them) + // are not network shares: drop the prefix so `?` doesn't become the URL's host. + let p = if let Some(unc) = p.strip_prefix("//?/UNC/") { + format!("//{unc}") + } else if let Some(local) = p.strip_prefix("//?/") { + local.to_string() + } else { + p + }; let host = if localhost { "localhost" } else { "" }; if is_windows_abs(&p) { format!("file://{host}/{}", percent_encode_path(&p)) @@ -251,7 +260,11 @@ pub(crate) fn media_info(name: &str, spec: &MediaSpec) -> MediaInfo { bitrate: None, hdr: None, }), - audio: spec.audio.map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, codec: String::new(), bits_per_sample: None }), + audio_streams: spec + .audio + .map(|(sr, ch)| AudioStreamInfo { sample_rate: sr, channels: ch, codec: String::new(), bits_per_sample: None }) + .into_iter() + .collect(), container: String::new(), start_timecode: spec.start_tc, file_size: None, @@ -443,6 +456,7 @@ impl Builder { hold_filters: false, field_options: None, source_channels: Vec::new(), + audio_stream: 0, graphic: None, } } @@ -670,6 +684,8 @@ mod tests { assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov"); assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov"); assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf"); + assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4"); + assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4"); for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] { assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p); assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p); diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index 839e43424..2ade96bcc 100644 --- a/crates/interchange/src/comp.rs +++ b/crates/interchange/src/comp.rs @@ -191,7 +191,7 @@ struct Exporter<'a> { p: &'a Project, media: &'a MediaOptions, doc: Document, - memo: HashMap<(String, CKind, Option), usize>, + memo: HashMap<(String, CKind, usize, Option), usize>, report: &'a mut Report, seq_rate: FrameRate, nests: Nests, @@ -307,7 +307,7 @@ impl Exporter<'_> { if ckind == CKind::Sound && let Some(e) = self.media.essence_for(clip_key, channel).cloned() { - let memo_key = (format!("clip:{}", c.id.0), ckind, channel); + let memo_key = (format!("clip:{}", c.id.0), ckind, 0, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } @@ -346,7 +346,7 @@ impl Exporter<'_> { self.report.warn(format!("the sound of the nested sequence \"{name}\" was not rendered (left as a gap)")); return None; } - let memo_key = (format!("seq:{}", item.0), ckind, channel); + let memo_key = (format!("seq:{}", item.0), ckind, 0, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } @@ -429,21 +429,29 @@ impl Exporter<'_> { // the source channel this slot plays when broken out to mono let channel = sub.map(|s| c.source_channels.get(s as usize).map(|&x| x as u32).unwrap_or(s)); let clip_key = EssenceKey::Clip(c.id); - let media_key = EssenceKey::Media(item); + let media_key = EssenceKey::media(item, c.audio_stream); let ess = (ckind == CKind::Sound) .then(|| self.media.essence_for(clip_key, channel).or_else(|| self.media.essence_for(media_key, channel))) .flatten() .cloned(); let key = match &ess { Some(e) if e.key == clip_key => format!("clip:{}", c.id.0), + _ if ckind == CKind::Sound && c.audio_stream > 0 => format!("item:{}:stream:{}", item.0, c.audio_stream), _ => format!("item:{}", item.0), }; - let memo_key = (key.clone(), ckind, channel); + // Original linked file locators cannot name a container stream. Leave an explicit + // unsupported-feature report rather than reference its first audio stream. + if ckind == CKind::Sound && c.audio_stream > 0 && ess.is_none() { + self.report.warn("selected container audio streams require separate or embedded audio essence (left as gaps)"); + return None; + } + let stream = if ckind == CKind::Sound { c.audio_stream } else { 0 }; + let memo_key = (key.clone(), ckind, stream, channel); if let Some(&i) = self.memo.get(&memo_key) { return Some(i); } let video = m.info.video.as_ref(); - let audio = m.info.audio.as_ref(); + let audio = m.info.audio_streams.get(c.audio_stream); let still = m.info.kind == MediaKind::Still; match ckind { CKind::Picture if video.is_none() && !still => return None, diff --git a/crates/interchange/src/essence.rs b/crates/interchange/src/essence.rs index c0c20eb0f..f1a9cb939 100644 --- a/crates/interchange/src/essence.rs +++ b/crates/interchange/src/essence.rs @@ -19,15 +19,25 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum EssenceKey { Media(ItemId), + MediaStream { item: ItemId, stream: usize }, Clip(ClipId), } +impl EssenceKey { + pub fn media(item: ItemId, stream: usize) -> Self { + if stream == 0 { Self::Media(item) } else { Self::MediaStream { item, stream } } + } +} + /// One range of source audio an export references. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AudioNeed { pub key: EssenceKey, /// The media item (for a clip key: the clip's media). pub item: ItemId, + /// Selected container stream; zero for legacy and sequence sources. + #[serde(default)] + pub audio_stream: usize, /// The media file, when the item is file media. pub path: Option, /// Media time range to supply (handles included, clamped to the media). @@ -120,7 +130,8 @@ pub struct NeedOptions { /// Extra media before and after every used range. pub handles: Tick, /// One range per clip ([`EssenceKey::Clip`]; for rendering clip effects) instead of one range - /// per media item covering all its uses ([`EssenceKey::Media`]). + /// per media item and container stream covering all its uses. Stream zero keeps + /// the legacy [`EssenceKey::Media`] identity. pub per_clip: bool, /// Whole media instead of the used range (embedding / copying without trimming). pub whole_media: bool, @@ -163,7 +174,7 @@ fn collect_needs( let (mut start, mut end) = ((lo - opts.handles).max(Tick::ZERO), hi + opts.handles); match project.item(item).map(|i| &i.kind) { Some(ItemKind::Media(m)) => { - let Some(a) = m.info.audio.as_ref() else { continue }; + let Some(a) = m.info.audio_streams.get(c.audio_stream) else { continue }; let path = match &m.media { MediaRef::File { path } => Some(path.clone()), MediaRef::Generator(_) => None, @@ -176,8 +187,17 @@ fn collect_needs( start = Tick::ZERO; end = dur.max(end); } - let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::Media(item) }; - let e = by_item.entry(key).or_insert_with(|| AudioNeed { key, item, path, start, end, channels: a.channels, sample_rate: a.sample_rate }); + let key = if opts.per_clip { EssenceKey::Clip(c.id) } else { EssenceKey::media(item, c.audio_stream) }; + let e = by_item.entry(key).or_insert_with(|| AudioNeed { + key, + item, + audio_stream: c.audio_stream, + path, + start, + end, + channels: a.channels, + sample_rate: a.sample_rate, + }); e.start = e.start.min(start); e.end = e.end.max(end); } @@ -196,7 +216,10 @@ fn collect_needs( _ => 2, }; let key = EssenceKey::Clip(c.id); - by_item.insert(key, AudioNeed { key, item, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) }); + by_item.insert( + key, + AudioNeed { key, item, audio_stream: 0, path: None, start, end, channels, sample_rate: nested.settings.sample_rate.max(1) }, + ); } }, _ => {} diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index 047b013fc..1b94f7122 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -932,6 +932,10 @@ impl Exp<'_, '_> { let hold = c.frame_hold; let src_in = hold.unwrap_or(c.source_in); let src_out = if hold.is_some() { src_in + c.duration } else { c.source_out() }; + let (start_f, end_f, in_f) = (frames_round(rate, c.start), frames_round(rate, c.end()), frames_round(rate, src_in)); + // At normal speed (and on a freeze frame) the source span is the record span. Rounded on its own + // it can come out a frame shorter or longer than `end - start` (#340), so derive it from them. + let out_f = if hold.is_some() || c.speed.abs() == 1.0 { in_f.saturating_add(end_f.saturating_sub(start_f)) } else { frames_round(rate, src_out) }; self.w.open(tag, &[("id", xid)]); if generator.is_none() && !is_seq { let mid = self.master_id(base); @@ -940,12 +944,12 @@ impl Exp<'_, '_> { self.w.text("name", &c.name); self.w.text("enabled", bool_str(c.enabled)); let media_dur = self.p.item(base).map(|i| i.duration()).unwrap_or(Tick::ZERO).max(src_out); - self.w.text("duration", frames_round(rate, media_dur)); + self.w.text("duration", frames_round(rate, media_dur).max(out_f)); write_rate(&mut self.w, rate); - self.w.text("start", frames_round(rate, c.start)); - self.w.text("end", frames_round(rate, c.end())); - self.w.text("in", frames_round(rate, src_in)); - self.w.text("out", frames_round(rate, src_out)); + self.w.text("start", start_f); + self.w.text("end", end_f); + self.w.text("in", in_f); + self.w.text("out", out_f); self.w.text("pproTicksIn", src_in.0); self.w.text("pproTicksOut", src_out.0); if let Some(g) = &generator { @@ -1055,7 +1059,7 @@ impl Exp<'_, '_> { self.w.close(); self.w.close(); } - if let Some(a) = &m.info.audio { + if let Some(a) = m.info.audio() { self.w.open("audio", &[]); self.w.open("samplecharacteristics", &[]); self.w.text("depth", a.bits_per_sample.unwrap_or(16)); diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index 623afb08d..faf6a77cf 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -730,12 +730,12 @@ impl Exp<'_, '_> { fmt_time(start, mrate), fmt_time(m.info.duration, mrate), m.info.video.is_some() as u8, - m.info.audio.is_some() as u8 + m.info.has_audio() as u8 ); if let Some(f) = fmt { a.push_str(&format!(" format=\"{f}\"")); } - if let Some(au) = &m.info.audio { + if let Some(au) = m.info.audio() { a.push_str(&format!(" audioSources=\"1\" audioChannels=\"{}\" audioRate=\"{}\"", au.channels, au.sample_rate)); } let path = item_path(self.p, item).unwrap_or(""); @@ -986,7 +986,7 @@ impl Exp<'_, '_> { }; let retimed = it.speed != 1.0 || it.reverse || it.frame_hold.is_some(); let local = origin + it.source_in; - let has_audio = item_media(self.p, base).is_some_and(|m| m.info.audio.is_some()) || (is_seq && audio.is_some()); + let has_audio = item_media(self.p, base).is_some_and(|m| m.info.has_audio()) || (is_seq && audio.is_some()); let has_video = item_media(self.p, base).is_none_or(|m| m.info.video.is_some()); let src_enable = match (kind, audio.is_some()) { (TrackKind::Video, true) => "", diff --git a/crates/interchange/src/omf/tests.rs b/crates/interchange/src/omf/tests.rs index fb47e58f7..b291d2f9b 100644 --- a/crates/interchange/src/omf/tests.rs +++ b/crates/interchange/src/omf/tests.rs @@ -17,7 +17,7 @@ fn project() -> (Project, ItemId) { kind: filmcraft_media::MediaKind::AudioOnly, duration: FrameRate::FPS_25.tick_of(2500), video: None, - audio: Some(filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(16) }), + audio_streams: vec![filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: String::new(), bits_per_sample: Some(16) }], container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/interchange/tests/aaf_omf.rs b/crates/interchange/tests/aaf_omf.rs index 6b6ac8680..3e16492f2 100644 --- a/crates/interchange/tests/aaf_omf.rs +++ b/crates/interchange/tests/aaf_omf.rs @@ -132,7 +132,7 @@ fn aaf_round_trip_preserves_the_edit() { .unwrap(); let ma = a.as_media().unwrap(); assert_eq!(ma.info.start_timecode, Some(rate.timecode_base() * 3600 + 12)); - assert!(ma.info.video.is_some() && ma.info.audio.is_some()); + assert!(ma.info.video.is_some() && ma.info.has_audio()); assert_eq!(ma.markers.len(), 1); assert_eq!((ma.markers[0].name.as_str(), ma.markers[0].comment.as_str()), ("slate", "take 3")); let mus = imp.project.items.values().find(|i| i.name == "music.wav").unwrap(); @@ -373,7 +373,7 @@ fn omf_separate_files_and_breakout() { let k = media_key(&imp.project, c.item); assert!(k.starts_with("/omf/media/") && k.ends_with(".aif"), "{k}"); let m = imp.project.item(c.item).unwrap().as_media().unwrap(); - assert_eq!(m.info.audio.as_ref().map(|a| (a.channels, a.sample_rate)), Some((1, 48_000))); + assert_eq!(m.info.audio().map(|a| (a.channels, a.sample_rate)), Some((1, 48_000))); } } } diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/fcp7.rs index 2b72d1e43..4d3677efc 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/fcp7.rs @@ -483,3 +483,47 @@ fn a_master_clip_used_by_an_earlier_sequence_still_lands_in_its_bin() { assert_ne!(bin, p.root.id, "the take was left loose at the top"); assert_eq!(p.root.find_bin(bin).unwrap().name, "Comedy"); } + +/// `start`/`end`/`in`/`out` of every sequence clip item in an exported document. +fn clip_spans(xml: &str) -> Vec<(i64, i64, i64, i64)> { + let tag = |block: &str, name: &str| -> i64 { + let open = format!("<{name}>"); + let at = block.find(&open).unwrap_or_else(|| panic!("no <{name}> in {block}")) + open.len(); + block[at..].split('<').next().unwrap().trim().parse().unwrap() + }; + xml.split("")).map(|b| (tag(b, "start"), tag(b, "end"), tag(b, "in"), tag(b, "out"))).collect() +} + +/// #340: a clip placed by seconds in a 23.976 sequence has sub-frame start, duration and source in. +/// Its record span and source span were rounded to frames independently, so `out - in` could come +/// out a frame shorter than `end - start` (Premiere then reads a different source range). +#[test] +fn sub_frame_clips_keep_equal_source_and_record_spans() { + const SECOND: i64 = 254_016_000_000; + let r = FrameRate::FPS_23_976; + let mut p = Project::new("P"); + let a = media(&mut p, "/m/a.mov", true, true, r); + let s = sequence(&mut p, "t", r, false); + let c = clip(&mut p, s, TrackKind::Video, 0, a, 0, 24, 0); + // the issue's case first (start 0, source in 20 s, 3 s long), then starts, ins and lengths in tenths of a second + let mut cases = vec![(0, 200, 30)]; + for start in [0, 1, 5, 17, 33] { + for src in [0, 3, 7, 200, 413] { + for dur in [1, 4, 30, 71, 125] { + cases.push((start, src, dur)); + } + } + } + for (start, src, dur) in cases { + let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(c).unwrap(); + it.start = Tick(start * SECOND / 10); + it.source_in = Tick(src * SECOND / 10); + it.duration = Tick(dur * SECOND / 10); + let (bytes, _) = export(&p, s, Format::Fcp7Xml, &ExportOptions::default()).expect("export"); + let xml = String::from_utf8(bytes).unwrap(); + let spans = clip_spans(&xml); + assert_eq!(spans.len(), 1, "{xml}"); + let (start_f, end_f, in_f, out_f) = spans[0]; + assert_eq!(out_f - in_f, end_f - start_f, "start {start}, in {src}, duration {dur} (tenths of a second): {spans:?}"); + } +} diff --git a/crates/interchange/tests/support/mod.rs b/crates/interchange/tests/support/mod.rs index 52bd52668..4c9835c81 100644 --- a/crates/interchange/tests/support/mod.rs +++ b/crates/interchange/tests/support/mod.rs @@ -25,7 +25,10 @@ pub fn media_info(name: &str, video: bool, audio: bool, rate: FrameRate, secs: i bitrate: None, hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "aac".into(), bits_per_sample: None }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "aac".into(), bits_per_sample: None }) + .into_iter() + .collect(), container: "mp4".into(), start_timecode: None, file_size: None, diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index 117b6725c..ee926cb5d 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1505,10 +1505,8 @@ impl SampleEntry { CodecConfig::Aac(c) => { b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); } - CodecConfig::Mp3 => { - if format == *b"mp4a" { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } + CodecConfig::Mp3 if format == *b"mp4a" => { + b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); } CodecConfig::Alac { cookie } => { let m = b.start_full(b"alac", 0, 0); diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs index 808b45b76..9344059cd 100644 --- a/crates/matroska/src/demux.rs +++ b/crates/matroska/src/demux.rs @@ -713,10 +713,8 @@ impl Walker<'_> { match h.id { SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), INFO => file.info = parse_info(&data), - TRACKS => { - if file.tracks.is_empty() { - file.tracks = parse_tracks(&data, file.info.timestamp_scale); - } + TRACKS if file.tracks.is_empty() => { + file.tracks = parse_tracks(&data, file.info.timestamp_scale); } CUES => parse_cues(&data, &mut file.cues), CHAPTERS => parse_chapters(&data, &mut file.chapters), diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index 7848a845f..bfb391dd2 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -13,6 +13,10 @@ //! [`with_draft`] marks reduced-resolution playback (opt-in): decoders may take spec-safe //! shortcuts that only change pictures nothing references (H.264: no deblocking of non-reference //! pictures). Never set for exports, renders or a frame shown while paused. +//! +//! [`with_background`] marks work nobody waits on interactively (thumbnails of the Project panel, +//! the Media Browser and the timeline): sources should not keep expensive state, such as a +//! frame-threaded 4K decoder, for it once it is done. use std::cell::{Cell, RefCell}; use std::sync::Arc; @@ -24,6 +28,28 @@ thread_local! { static CURRENT: RefCell>> = const { RefCell::new(None) }; static CATCH_UP: Cell> = const { Cell::new(None) }; static DRAFT: Cell = const { Cell::new(false) }; + static BACKGROUND: Cell = const { Cell::new(false) }; +} + +/// Run `f` as background work when `on` (restoring the previous hint after): a thumbnail, not a +/// frame a monitor or playback waits for. +pub fn with_background(on: bool, f: impl FnOnce() -> R) -> R { + /// Restores the previous hint however `f` ends (a panic caught further up must not leave the + /// worker thread marked as background). + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + BACKGROUND.with(|c| c.set(self.0)); + } + } + let _restore = Restore(BACKGROUND.with(|c| c.replace(on))); + f() +} + +/// Whether the work running on this thread is background work (off unless inside +/// [`with_background`]`(true, …)`). +pub fn background() -> bool { + BACKGROUND.with(Cell::get) } /// Run `f` with the draft-decoding hint `on` (restoring the previous one after). Frames decoded @@ -97,6 +123,24 @@ mod tests { assert!(!draft()); } + #[test] + fn background_is_off_by_default_and_scoped() { + assert!(!background()); + with_background(true, || { + assert!(background()); + with_background(false, || assert!(!background())); + assert!(background()); + }); + assert!(!background()); + } + + #[test] + fn a_panic_inside_background_work_does_not_leave_the_thread_marked() { + let r = std::panic::catch_unwind(|| with_background(true, || panic!("boom"))); + assert!(r.is_err()); + assert!(!background()); + } + #[test] fn catch_up_is_scoped_to_the_closure() { assert_eq!(catch_up(), None); diff --git a/crates/media/src/generators.rs b/crates/media/src/generators.rs index bbe9efbe2..97d42d1de 100644 --- a/crates/media/src/generators.rs +++ b/crates/media/src/generators.rs @@ -108,7 +108,7 @@ impl GeneratorSource { kind: if matches!(generator, Generator::Demo(_)) { MediaKind::Movie } else { MediaKind::Synthetic }, duration, video, - audio, + audio_streams: audio.into_iter().collect(), container: if matches!(generator, Generator::Demo(_)) { "Synthetic MPEG-4".into() } else { "Synthetic".into() }, start_timecode: None, file_size: None, diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 3dc3cdd5b..6b5ff1b1e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -77,6 +77,9 @@ pub struct VideoStreamInfo { pub hdr: Option, } +/// Most audio streams a source reports (the count comes from the file: never trusted). +pub const MAX_AUDIO_STREAMS: usize = 64; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AudioStreamInfo { pub sample_rate: u32, @@ -92,13 +95,32 @@ pub struct MediaInfo { /// Media duration. Stills report a default duration (the still-image default preference). pub duration: Tick, pub video: Option, - pub audio: Option, + /// Every audio stream the container carries, in file order. Stream 0 is the one + /// [`MediaSource::audio`] reads; the others are read with [`MediaSource::audio_stream`]. + /// Project files written before multi-stream support hold a single `audio` object (or + /// `null`): both still load. + #[serde(default, alias = "audio", deserialize_with = "deserialize_audio_streams")] + pub audio_streams: Vec, pub container: String, /// Timecode of the first frame (in frames at `video.frame_rate`), if the file carries one. pub start_timecode: Option, pub file_size: Option, } +/// Reads `audio_streams` from a list, or from the single optional stream older project files wrote. +fn deserialize_audio_streams<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Streams { + Many(Vec), + One(Option), + } + Ok(match Streams::deserialize(d)? { + Streams::Many(v) => v, + Streams::One(o) => o.into_iter().collect(), + }) +} + impl MediaInfo { pub fn frame_rate(&self) -> FrameRate { self.video.as_ref().map(|v| v.frame_rate).unwrap_or_default() @@ -107,7 +129,11 @@ impl MediaInfo { self.video.is_some() } pub fn has_audio(&self) -> bool { - self.audio.is_some() + !self.audio_streams.is_empty() + } + /// The primary audio stream (stream 0), the one [`MediaSource::audio`] reads. + pub fn audio(&self) -> Option<&AudioStreamInfo> { + self.audio_streams.first() } } @@ -132,6 +158,12 @@ pub trait MediaSource: Send + Sync { fn video_frame(&self, req: FrameRequest) -> Result>; /// `frames` audio frames starting at sample index `start` (at `sample_rate`, media time). fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result; + /// Like [`audio`](Self::audio) for audio stream `stream` (an index into + /// [`MediaInfo::audio_streams`]). Sources with a single audio stream need not override this: + /// stream 0 is `audio`, any other index is [`MediaError::NoStream`]. + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + if stream == 0 { self.audio(start, frames, sample_rate) } else { Err(MediaError::NoStream("audio")) } + } } pub type SharedSource = Arc; @@ -195,6 +227,6 @@ impl MediaSource for OfflineSource { Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(req.time))) } fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> Result { - 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)) } } diff --git a/crates/media/src/reader.rs b/crates/media/src/reader.rs index d4f87ab8f..2aeb47bfe 100644 --- a/crates/media/src/reader.rs +++ b/crates/media/src/reader.rs @@ -189,7 +189,7 @@ mod tests { let small = wav.len() as u64; let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default())); let src = open_reader_within("a.wav", r.clone(), &[], &[], small).unwrap(); - assert_eq!(src.info().audio.as_ref().unwrap().channels, 2); + assert_eq!(src.info().audio().unwrap().channels, 2); // the same file over the limit: refused after reading the sniffing head only let r = Arc::new(Counting(MemReader(Arc::from(wav.clone())), Default::default())); let e = open_reader_within("a.wav", r.clone(), &[], &[], small - 1).err().unwrap(); diff --git a/crates/media/src/sequence.rs b/crates/media/src/sequence.rs index 281867ce4..9521cba44 100644 --- a/crates/media/src/sequence.rs +++ b/crates/media/src/sequence.rs @@ -147,7 +147,7 @@ impl ImageSequenceSource { bitrate: None, hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: "Image Sequence".into(), start_timecode: None, file_size: None, diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index 199395a65..4a1cb53a5 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -52,7 +52,7 @@ impl StillSource { bitrate: None, hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: "Image".into(), start_timecode: None, file_size: Some(file_size), diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index a7d9acb0d..6b63bf4ae 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -84,12 +84,12 @@ impl WavSource { 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: ch as u32, codec: if float { "PCM float".into() } else { "PCM".into() }, bits_per_sample: Some(bits as u32), - }), + }], container: if time_reference.is_some() { "Broadcast WAV".into() } else { "WAV".into() }, start_timecode: None, file_size: Some(b.len() as u64), @@ -201,7 +201,7 @@ mod tests { let s: Vec = (0..200).map(|i| ((i as f32) * 0.1).sin() * 0.5).collect(); let bytes = write_wav16(&s, 2, 48000); let src = WavSource::parse("t.wav", bytes.into()).unwrap(); - assert_eq!(src.info().audio.as_ref().unwrap().channels, 2); + assert_eq!(src.info().audio().unwrap().channels, 2); let a = src.audio(0, 100, 48000).unwrap(); assert!((a.channels[0][3] - s[6]).abs() < 1e-4); assert!((a.channels[1][3] - s[7]).abs() < 1e-4); diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index f52c12906..55e3be066 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-platform" -description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS), with transparent fallback to the pure-Rust decoders" +description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders" version.workspace = true edition.workspace = true license.workspace = true @@ -21,10 +21,13 @@ type_complexity = "allow" undocumented_unsafe_blocks = "deny" [dependencies] +filmcraft-bitstream = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-color = { workspace = true } filmcraft-export = { workspace = true } filmcraft-frame = { workspace = true } +filmcraft-h264 = { workspace = true } +filmcraft-hevc = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-time = { workspace = true } log = { workspace = true } @@ -49,6 +52,11 @@ windows = { version = "0.62", features = [ "Win32_System_LibraryLoader", ] } +# Linux: libva (VA-API) is loaded at run time with libloading (ISC; already in the lockfile through +# wgpu), so building needs no libva headers and a system without libva simply decodes in software. +[target.'cfg(target_os = "linux")'.dependencies] +libloading = "0.8" + [dev-dependencies] filmcraft-testkit = { workspace = true } filmcraft-media = { workspace = true } diff --git a/crates/platform/README.md b/crates/platform/README.md index eb361484c..8170e6292 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -8,7 +8,7 @@ It is the one crate of the workspace allowed to contain `unsafe`, under the rule ```rust // at startup (the desktop app, filmcraft-cli, the bench) -let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows +let availability = filmcraft_platform::register(); // Available("VideoToolbox") on macOS, Available("Media Foundation") on Windows, Available("VA-API") on Linux with a driver ``` ## What it does @@ -61,15 +61,54 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache always seeks after a flush, and a caller that continues from the middle of a GOP gets an error that `HybridDecoder` answers by replaying the run in software. -- **Windows: NVIDIA NVENC H.264 encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's - `nvEncodeAPI64.dll` (API 12.1, no CUDA or SDK) is loaded at run time, so machines without NVIDIA - still start. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12 - input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC +- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's + `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, + so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary + CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. + No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go + in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709 + limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver + that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on + the encode thread, where it competed with the render of the next frames for the thread pool and + held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it. It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export - ends it with an error, since a hardware stream cannot be finished in software. + ends it with an error, since a hardware stream cannot be finished in software. The same session, + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, + NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). +- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained + Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2` + are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens + its own display on the first DRM render node (`/dev/dri/renderD128`…) with a working driver. + VA-API decoding is *stateless*: the host parses the stream and keeps the decoded picture buffer, + and the GPU decodes one picture's slice data at a time into a surface. That host side + (`vaapi/h264.rs`, `vaapi/hevc.rs`, safe code) is the software decoders' own: their parameter-set + and slice-header parsers, POC computation and DPBs (`filmcraft_h264::dpb`, `filmcraft_hevc::dpb`, + generic over what a picture is: H.264 reference marking including MMCOs and reference lists with + modifications; HEVC reference picture sets and lists, RASL pictures of a CRA the run starts at + left out; output order). The two decoders therefore make + the same decisions and output the same pictures in the same order, and the parity tests are + bit-exact. Each picture is sent as one picture-parameter buffer, the scaling matrices, and a slice + parameter + slice data buffer per slice (the NAL unit as stored, emulation prevention included). + HEVC pictures are sent the same way per slice segment (scaling lists only when enabled, converted + to raster order). Pictures the DPB outputs are read back right away (`vaGetImage` into an NV12 or + P010 image, then `biplanar.rs`), **the one GPU to CPU copy**. Surfaces: the stream's DPB size plus two. + - *frame_num gaps* (and every start at a non-IDR picture: an open-GOP seek) get "non-existing" + frames like the software decoder's: copies of the latest decoded frame, mid-gray when there is + none (`vaPutImage`). Pictures predicted from them (the leading pictures of an open GOP after a + seek) are concealment in both decoders and can differ: the hardware also reads motion data the + stand-ins do not have. Everything from the seek point on is bit-exact. + - *Declined up front* (our decoder is used): VP9 and AV1 (not through VA-API yet), H.264 profiles + other than Baseline / Main / High, 10-bit H.264, HEVC range extensions and profiles other than + Main / Main 10 / Main Still Picture, 4:2:2 / 4:4:4, field / MBAFF coding, no libva, no render + node or driver, a profile or size the driver does not decode. *Mid-stream errors* + (`HybridDecoder` continues in software): FMO, SP / SI slices, data partitioning, a size or DPB + change, a missing reference picture (HEVC: one the RPS names that was never decoded), more than + 15 HEVC reference frames, any driver error. + - An HEVC `flush` ends the stream as in software (references are dropped and the next CRA + leaves out its RASL pictures); the GOP cache always seeks after a flush. - **Other systems:** `register()` does nothing and returns `Availability::Unavailable`. - **`HybridDecoder`** (`hybrid.rs`, safe code): the hardware decoder plus the means to build our software decoder for the same `SampleEntry` (`filmcraft_codecs::software_video_decoder`). On a @@ -114,8 +153,10 @@ built-in encoder, which changes three things from H.264: - **Choosing the format is the opt-in.** No `hardware_encoding` setting: Export ▸ Format ▸ H.265 (HEVC), or `"format": "hevc"` in `file.exportMedia` (`h265` is accepted too). The format is listed as available only on a machine with a hardware HEVC encoder: `register()` hands - `hardware_encode::hevc_available` (one small hardware session, created on the first question) to - `filmcraft_export::register_format_probe`. + `hardware_encode::hevc_available` (one small hardware session, about 0.1 s) to + `filmcraft_export::register_format_probe`, and asks it right away on a thread of its own + (`warm_hevc_probe`), so the first draw of the format list does not create the session on the UI + thread. - **There is no fallback encoder.** What the hardware path does not take is an error, not a different encoder: two-pass VBR is refused up front (`ExportSettings::validate`), HDR sequences are exported as SDR (the H.265 path is 8-bit), and odd sizes, non-square pixels or a machine @@ -135,18 +176,99 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, level and flags are the encoder's own. If it is missing the export stops with that reason. +## Hardware H.265 (HEVC) encoding (Windows, NVENC) + +One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it +for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability +query, the `hevcConfig` member of the codec-configuration union, how NAL units are told apart +(`(b0 >> 1) & 0x3f`) and the level numbering (`level × 30`, so 4.1 is 123). The level is the lowest +whose **Main tier** limits hold the picture size, sample rate and peak bitrate (H.265 Table A.8, +`nvenc::hevc::main_tier_level`): left to choose, NVENC answered 1080p30 at an 18 Mbit/s peak with +level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main tier. The H.264 path is unchanged: the same export is the same file, byte for byte. + +- **Choosing the format is the opt-in,** as on macOS: Export ▸ Format ▸ H.265 (HEVC) or + `"format": "hevc"`. `register()` hands `nvenc::hevc_available` to + `filmcraft_export::register_format_probe`: one small HEVC session (a 640×360 encoder, the answer + kept; about half a second in a fresh process, nearly all of it opening the Direct3D 11 device and + the NVENC session, which every NVENC export pays too). `register()` also asks it on a thread of its + own (`nvenc::warm_hevc_probe`), so the first draw of the format list does not wait for it; a + caller that asks while it runs waits for the same answer. The + Hardware encoding toggle governs H.264 only: with H.265 selected NVENC is used whether it is Auto + or Off, because nothing else can encode it. +- **What it writes:** HEVC Main, 8-bit 4:2:0, SDR BT.709 limited range (Main 10 HDR: see below) (the VUI says so, with the + frame rate as `time_scale / num_units_in_tick`), progressive, preset P5 with high-quality tuning, + constant or one-pass variable bitrate, an IDR at every keyframe distance, one B-frame when the GPU + reports support and the GOP is longer than two pictures (shorter ones are written without), in + MP4 (`hvc1`) or QuickTime. Pictures are converted exactly like the H.264 path's. The coded size is + a whole number of 32-pixel coding tree blocks and the SPS carries a conformance window (1080 is + coded as 1088 and cropped back); the `hvcC` is only accepted when the cropped size is the export's. +- **Samples are length-prefixed (4 bytes) with the parameter sets only in the `hvcC`:** VPS, SPS, + PPS, access unit delimiters and end-of-sequence / bitstream markers are dropped; slices and SEI + stay. `nvEncGetSequenceParams` returns all three parameter sets. +- **The `hvcC` is built from the SPS the encoder wrote** (`nvenc/hevc.rs`, parsed with + `filmcraft_hevc`): profile space, tier, profile, compatibility flags and level from the SPS's + profile / tier / level; the 48 general constraint flags and `sps_temporal_id_nesting_flag` read from + the SPS bits that carry them; chroma format, bit depths and temporal layers from the SPS; the + arrays complete (`array_completeness` 1). Nothing is a constant of ours except what the format + leaves unspecified (`avg_frame_rate`, `constant_frame_rate`, `min_spatial_segmentation_idc` and + `parallelism_type` are 0). It is built, and validated as Main 8-bit 4:2:0 of the right size, when + the encoder is created, so a stream we cannot describe is a declined export and never a default + sample entry. +- **Timestamps are the H.264 path's:** dts is the decode index minus the B-frame delay, composition + offsets are `pts - dts` in frame durations, and the media starts `delay` frames early (edit list). +- **There is no fallback encoder.** A request NVENC cannot take is counted in + `export.hardware.declined` and ends the export with "H.265 export with NVENC: …" and the reason: + 10-bit on a GPU without it (HDR), interlaced output, two-pass (`ExportSettings::validate` refuses it first), non-square pixels, + MXF, sizes above 65535, odd sizes, sizes outside the GPU's limits (129×33 to 8192×8192 on the RTX + 5060). On a machine without an HEVC encoder the format is not available and an export fails with + "encoder not available yet". Frames, sessions and declines are the same `export.hardware` counters + as for H.264. +- **Main 10 HDR (PQ / HLG).** An HDR sequence (Rec. 2100 PQ or HLG working space) exports as HEVC + Main 10 when `settings.sdr` is off and this GPU has a 10-bit HEVC encoder. `register()` hands + `nvenc::hevc_hdr_available` (a Main 10 session, cached, asked on the same background thread as the + 8-bit probe and short-circuiting when HEVC itself is missing) to `filmcraft_export::register_hdr_probe`, + and the export job makes the export HDR for `Format::Hevc` only when + `filmcraft_export::hdr_available(Format::Hevc)` says so: on macOS (VideoToolbox is 8-bit) and on GPUs + without 10-bit an HDR sequence is tone-mapped to 8-bit SDR HEVC exactly as before, and `settings.sdr` + still forces that. The pixels are real: `EncoderFrame::hdr` (encoded BT.2020 R'G'B', 3 floats per + pixel) goes through `filmcraft_export::rgbf_to_yuv420_10` (BT.2020 non-constant-luminance matrix, + Y = 64 + 876·Y', C = 512 + 896·C, 2x2 chroma average, NaN counts as 0, values clamp to 0..1 and codes + to 4..1019, wrong-length input is an error) and into a **P010** input buffer (`code << 6`, + little-endian, chroma interleaved after the luma rows, pitch in bytes; a driver pitch under 2 bytes + per pixel is refused). One buffer format (`NV_ENC_BUFFER_FORMAT_YUV420_10BIT`) is used for the + initialisation, the input buffers and every picture; `pixelBitDepthMinus8` is 2, the profile GUID + is Main 10, `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` is queried first and a GPU without it declines + with "no 10-bit (HEVC Main 10) support". The VUI says BT.2020 primaries, transfer 16 (PQ) or 18 + (HLG), matrix 9, limited range. The `hvcC` is checked against what was asked (`hevc_config(…, 10)`: + profile 2, 10-bit 4:2:0). NVENC chose the High tier for level 4 at 12 Mb/s; the record just says so. + Pictures of the wrong depth for the encoder (an `hdr` picture to a Main encoder, an RGBA one to + Main 10, or a float slice of the wrong length) are errors, never panics. +- **HDR10 static metadata is SEI, not an API.** NVENC 12.1 has no mastering-display field, so PQ + streams get the mastering display colour volume (payload 137) and content light level (144) + messages through `NV_ENC_PIC_PARAMS_HEVC::seiPayloadArray` on every IDR picture, with the values + of `ColorSignal::static_metadata` (BT.2020/D65, 1000 / 0.0001 cd/m², MaxCLL/MaxFALL 0), the same + that the software H.264 encoder's SEI and the `mdcv` / `clli` boxes carry. NVENC writes them as + two prefix SEI NAL units (type 39) in front of the IDR slice, adding the emulation-prevention + byte its `00 00 00 01` needs; nothing is attached to other pictures; HLG has no static metadata and + gets none. The payload bytes and the descriptor array are heap blocks owned by the `Session` for + its whole life (it is moved by value, and the driver may queue a picture), and IDR pictures are + known from a submission counter (`n % gop == 0`). The sample entry carries `colr` (nclx BT.2020, + PQ / HLG, limited), and for PQ `mdcv` and `clli`; 8-bit SDR HEVC is unchanged and has no `colr` + (the VUI carries its description, as in the software H.264 path). + ## Guarantees - **Never undecodable:** the factory declines (returns `None`, so the software decoder is used) when Settings ▸ Playback ▸ Hardware decoding is Off, for formats it does not take (field-coded H.264, bit depths other than 8 / 10, 4:4:4 or monochrome, luma / chroma depth mismatch, larger - than 8192×8192; on Windows also 4:2:2 and the profiles listed above) and when the OS cannot - create a hardware session (VideoToolbox) or a GPU-backed decoder (Media Foundation). + than 8192×8192; on Windows also 4:2:2 and the profiles listed above; on Linux everything but + 8-bit 4:2:0 progressive H.264 and 4:2:0 HEVC Main / Main 10) and when the OS cannot create a hardware session (VideoToolbox), a + GPU-backed decoder (Media Foundation) or a VA-API configuration and context (Linux). - **Interchangeable:** colour, pixel aspect, pts, presentation order, `is_random_access` and `is_disposable` come from the software decoders' own helpers (`filmcraft_codecs::hw`, `video::vui_color`, `sar_par`). -- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback runs under - `catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe. +- **Never crash:** no `unwrap` / `expect` / `panic!` outside tests; the output callback (and + libva's error-message callback) runs under `catch_unwind`; every `unsafe` block has a `// SAFETY:` comment; the public API is safe. - **Counted:** `perf.stats` `decode.hardware` (frames, software frames, sessions, declined, fallbacks; `filmcraft_codecs::hw::hw_stats`) and `backend` (the registered backend's name, `filmcraft_codecs::hw::hw_backend`). `export.hardware` counts the NVENC encoder's frames, sessions and declines. @@ -159,9 +281,21 @@ level and flags are the encoder's own. If it is missing the export stops with th | `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software | | `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available | | `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | -| `tests/nvenc.rs` (Windows, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | -| `tests/nvenc_export.rs` (Windows, NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants and GUIDs against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | +| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | +| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | +| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | +| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | +| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | +| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | +| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | +| `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads | +| `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors | +| `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) | Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked); tests skip without ffmpeg or without a hardware decoder. @@ -185,9 +319,27 @@ M4 Pro, load 150–190 (`cargo xtask bench --hw off|auto`): CPU per decoded fram playback with no dropped frames at Full, 1/2 and 1/4. Details in [docs/performance.md](../../docs/performance.md). +Hardware decoding, Linux (Intel Iris Xe, iHD driver; `cargo xtask bench --hw off|auto`): CPU per +decoded frame H.264 1080p 104 → 2.3 ms, 2160p 417 → 11.4 ms; HEVC 1080p 46 → 2.3 ms, 2160p +203 → 10.1 ms, Main 10 2160p 212 → 18.4 ms. +Details in [docs/performance.md](../../docs/performance.md). + ## Not yet Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11 -textures on Windows); B-frames and 10-bit / HDR HEVC (Main 10) in hardware encoding; VA-API (Linux) -decoders; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other -vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. +textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in +VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR +H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from +the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders +from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. + +### Linux NVENC sources and limits + +The device binding uses NVIDIA's [CUDA Driver API 12.1 primary-context reference](https://docs.nvidia.com/cuda/archive/12.1.0/cuda-driver-api/group__CUDA__PRIMARY__CTX.html) +and the existing [Video Codec SDK 12.1 encode API](https://docs.nvidia.com/video-technologies/video-codec-sdk/12.1/nvenc-video-encoder-api-prog-guide/index.html). +It uses the first CUDA device. On Linux this backend handles H.264 only (H.265 through NVENC is +Windows-only for now); other formats and unsupported H.264 settings use the existing encoder selection. +Linux registers the export factory even without a driver; `register()`'s return value and +`registered()` still describe hardware **decoding**, which this backend does not provide. +Absent / old drivers decline once per export, with an informational log and +`perf.stats` → `export.hardware.declined`; hardware encoding Off never opens the driver. diff --git a/crates/platform/src/hardware_encode.rs b/crates/platform/src/hardware_encode.rs index 498b68ea6..4af317817 100644 --- a/crates/platform/src/hardware_encode.rs +++ b/crates/platform/src/hardware_encode.rs @@ -13,7 +13,7 @@ //! configurations are declined, and a declined export fails with "encoder not available" instead of //! falling back; [`hevc_available`] tells the format list whether this machine can do it at all. -use std::sync::OnceLock; +use std::sync::{Once, OnceLock}; use filmcraft_export::{BitrateMode, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, VideoEncoder}; use filmcraft_isobmff::{AvcConfig, HevcConfig, SampleEntry}; @@ -66,11 +66,13 @@ pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result }) } -/// Whether this machine has a hardware HEVC encoder (asked once, by creating a small session): what -/// makes `Format::Hevc` available. +static HEVC_PROBE: OnceLock = OnceLock::new(); + +/// Whether this machine has a hardware HEVC encoder (asked once, by creating a small session, which +/// takes about 0.1 s): what makes `Format::Hevc` available. [`warm_hevc_probe`] asks it ahead of the +/// first caller. pub fn hevc_available() -> bool { - static AVAILABLE: OnceLock = OnceLock::new(); - *AVAILABLE.get_or_init(|| { + *HEVC_PROBE.get_or_init(|| { let probe = VtConfig { width: 640, height: 360, @@ -88,6 +90,27 @@ pub fn hevc_available() -> bool { }) } +/// Whether [`hevc_available`] has its answer yet (diagnostics and tests). +pub fn hevc_probed() -> bool { + HEVC_PROBE.get().is_some() +} + +/// Ask [`hevc_available`] now, on a thread of its own, so that the first draw of the format list does +/// not spend its 0.1 s creating a hardware session on the UI thread. Whoever asks while the thread +/// runs waits for the same answer (no longer than asking first); if the thread cannot start, or the +/// probe panics, the first caller asks, as before. Only the first call starts a thread. +pub fn warm_hevc_probe() { + static STARTED: Once = Once::new(); + STARTED.call_once(|| { + let spawned = std::thread::Builder::new().name("filmcraft-hevc-probe".into()).spawn(|| { + let _ = std::panic::catch_unwind(hevc_available); + }); + if let Err(why) = spawned { + log::info!("HEVC probe not started ahead of time: {why}"); + } + }); +} + /// The encoder factory `register` puts in front of the built-in ones: a hardware encoder when the /// export asks for one (H.264 with `hardware_encoding`; HEVC always) and the OS takes the /// configuration, `None` (the built-in encoder, if there is one) otherwise. @@ -194,7 +217,7 @@ impl VideoEncoder for HardwareEncoder { if f.rgba.len() < w.saturating_mul(h).saturating_mul(4) { return Err(ExportError::Encode("the RGBA picture is smaller than its size".into())); } - filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v); + filmcraft_export::timed(filmcraft_export::Stage::Convert, || filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v)); let packets = self.vt.encode(f.index, &self.y, &self.u, &self.v).map_err(ExportError::Encode)?; // the muxer needs the parameter sets after the first group of pictures if f.index == 0 { diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index 073549182..c010e79e0 100644 --- a/crates/platform/src/hybrid.rs +++ b/crates/platform/src/hybrid.rs @@ -129,7 +129,10 @@ impl HybridDecoder { } /// Switch to the software decoder after `err`, replaying the log (which ends with the sample - /// that failed). + /// that failed). The replay goes on past samples the software decoder rejects, as decoding in + /// software from the restart point would: one that the hardware had already decoded is logged + /// (its call returned long ago), and if the failed sample itself is rejected its error is this + /// call's, while the pictures replayed before it are kept for the next output. fn fall_back(&mut self, err: CodecError) -> Result> { let Some(mut hw) = self.hw.take() else { return Err(err) }; log::warn!("{} failed ({err}); continuing with the software decoder", hw.name()); @@ -149,12 +152,28 @@ impl HybridDecoder { return Err(CodecError::Decode(format!("{err} (continuing in software after the next seek)"))); }; let mut frames = Vec::new(); - for (s, p) in &log { + let mut rejected = None; + let last = log.len().saturating_sub(1); + for (i, (s, p)) in log.iter().enumerate() { let Some(sw) = self.sw.as_mut() else { break }; - frames.extend(sw.decode(s, *p)?); + match sw.decode(s, *p) { + Ok(out) => frames.extend(out), + Err(e) if i == last => rejected = Some(e), + Err(e) => log::warn!("software replay after the hardware decoder failed: {e}"), + } } self.log = Some(log); - Ok(self.merge(frames)) + match rejected { + None => Ok(self.merge(frames)), + Some(e) => { + for f in frames { + if !self.emitted.contains(&f.pts) { + self.carry.insert(f.pts, f); + } + } + Err(e) + } + } } } diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 448757964..da0164e29 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -3,10 +3,13 @@ //! //! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders //! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264 -//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; on other systems -//! registration does nothing and reports [`Availability::Unavailable`]. It also registers a +//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; Media Foundation / DXVA on +//! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API +//! driver) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an -//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]. +//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on +//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows +//! (Main 10) lets HDR sequences export as HDR H.265. //! //! Hardware decoding never makes a file undecodable: //! @@ -25,18 +28,21 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] -// Used by the Windows decoder only; compiled everywhere so their tests run on every system. +// Used by the Windows (and, for `biplanar`, Linux) decoders; compiled everywhere so their tests run +// on every system. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] mod annexb; -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))] mod biplanar; #[cfg(target_os = "macos")] pub mod hardware_encode; pub mod hybrid; #[cfg(target_os = "windows")] pub mod media_foundation; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] pub mod nvenc; +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub mod vaapi; #[cfg(target_os = "macos")] #[allow(unsafe_code)] pub mod videotoolbox; @@ -59,14 +65,30 @@ pub enum Availability { /// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep /// using FilmCraft's own decoders. pub fn register() -> Availability { + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + filmcraft_export::register_encoder(nvenc::export::factory); #[cfg(target_os = "macos")] { filmcraft_codecs::register_video_decoder(videotoolbox_factory); filmcraft_export::register_encoder(hardware_encode::videotoolbox_encoder_factory); filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, hardware_encode::hevc_available); + // the probe creates a hardware session (about 0.1 s): answer it now, off the UI thread, + // before the first draw of the format list asks + hardware_encode::warm_hevc_probe(); filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); Availability::Available("VideoToolbox") } + // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available + // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of + // its own now rather than by the first draw of the format list. + #[cfg(target_os = "windows")] + { + filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); + // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere + // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before + filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); + nvenc::warm_hevc_probe(); + } #[cfg(target_os = "windows")] { static ENCODERS: std::sync::Once = std::sync::Once::new(); @@ -77,7 +99,23 @@ pub fn register() -> Availability { filmcraft_codecs::hw::set_hw_backend("Media Foundation"); Availability::Available("Media Foundation") } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + match vaapi::va::probe() { + Ok(driver) => { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}")); + filmcraft_codecs::register_video_decoder(vaapi_factory); + filmcraft_codecs::hw::set_hw_backend("VA-API"); + Availability::Available("VA-API") + } + Err(why) => { + log::info!("no VA-API hardware decoding: {why}"); + Availability::Unavailable("no VA-API driver (libva and a DRM render node)") + } + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { Availability::Unavailable("no hardware video decoder for this system yet") } @@ -93,7 +131,11 @@ pub fn registered() -> bool { { filmcraft_codecs::video_decoder_registered(media_foundation_factory) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + filmcraft_codecs::video_decoder_registered(vaapi_factory) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { false } @@ -110,7 +152,11 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool { { media_foundation::stream_info(entry).is_some_and(|info| media_foundation::MfDecoder::new(info).is_ok()) } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(target_os = "linux")] + { + filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok()) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] { let _ = entry; false @@ -153,3 +199,22 @@ pub fn media_foundation_factory(entry: &filmcraft_isobmff::SampleEntry) -> Optio } } } + +/// The VA-API factory: a [`HybridDecoder`] around [`vaapi::VaDecoder`] for H.264 and HEVC +/// streams this system's VA-API driver decodes, `None` otherwise (other codecs keep the software +/// decoders). +#[cfg(target_os = "linux")] +pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option>> { + if !filmcraft_codecs::hw::hardware_decoding() { + return None; + } + let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?; + match vaapi::VaDecoder::new(info.clone()) { + Ok(va) => Some(Ok(Box::new(HybridDecoder::new(Box::new(va), entry.clone(), info)))), + Err(why) => { + log::info!("hardware decoding declined for {} video: {why}", entry.codec.name()); + filmcraft_codecs::hw::note_hw_declined(); + None + } + } +} diff --git a/crates/platform/src/nvenc/abi_tests.rs b/crates/platform/src/nvenc/abi_tests.rs index 20b9e9907..270108a58 100644 --- a/crates/platform/src/nvenc/abi_tests.rs +++ b/crates/platform/src/nvenc/abi_tests.rs @@ -2,7 +2,17 @@ //! alignments, field offsets, constants and GUIDs. Generated by running a C program (built with //! MSVC from the NVIDIA header) that prints them; the header itself is not in this repository. //! To regenerate, put NVIDIA's MIT-licensed `nvEncodeAPI.h` 12.1 in `ffnvcodec/` next to a program -//! printing `sizeof` / `offsetof` of the structures of `src/nvenc/ffi.rs` and the constants it uses. +//! printing `sizeof` / `alignof` / `offsetof` of the structures of `src/nvenc/ffi.rs`, the constants +//! and GUIDs it uses and, for the bit-fields of the `flags` words (`offsetof` cannot take those), the +//! word with that one field set (see docs/testing.md). H.264 and HEVC are both covered. +//! +//! The expectations were generated with MSVC (Windows x64, LLP64) and also hold on Linux x86_64 +//! (LP64), where these tests run too: every field the structures use is a fixed-width integer +//! (`uint32_t`, `int32_t`, `uint64_t`, ...), `int` / an enum (32 bits on both), a pointer (64 bits on +//! both) or a GUID (four fixed-width members, 16 bytes, aligned to 4). None is a `long` / +//! `unsigned long`, the one C type whose size differs between the two (32 bits on Windows, 64 on +//! Linux). The `flags` bit-fields are all `uint32_t` fields of one `uint32_t` word, which MSVC and +//! GCC / Clang lay out the same way (from the least significant bit) on x86_64. #![allow(clippy::unreadable_literal)] use super::ffi::*; @@ -111,9 +121,38 @@ fn layouts_match_the_header() { assert_eq!(offset_of!(NV_ENC_CONFIG_H264, numRefL1), 208, "NV_ENC_CONFIG_H264.numRefL1"); assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved1), 212, "NV_ENC_CONFIG_H264.reserved1"); assert_eq!(offset_of!(NV_ENC_CONFIG_H264, reserved2), 1280, "NV_ENC_CONFIG_H264.reserved2"); + assert_eq!( + (size_of::(), align_of::()), + (112, 4), + "NV_ENC_CONFIG_HEVC_VUI_PARAMETERS" + ); + assert_eq!((size_of::(), align_of::()), (1560, 8), "NV_ENC_CONFIG_HEVC"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, level), 0, "NV_ENC_CONFIG_HEVC.level"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, tier), 4, "NV_ENC_CONFIG_HEVC.tier"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, minCUSize), 8, "NV_ENC_CONFIG_HEVC.minCUSize"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxCUSize), 12, "NV_ENC_CONFIG_HEVC.maxCUSize"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, idrPeriod), 20, "NV_ENC_CONFIG_HEVC.idrPeriod"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshPeriod), 24, "NV_ENC_CONFIG_HEVC.intraRefreshPeriod"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, intraRefreshCnt), 28, "NV_ENC_CONFIG_HEVC.intraRefreshCnt"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxNumRefFramesInDPB), 32, "NV_ENC_CONFIG_HEVC.maxNumRefFramesInDPB"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrNumFrames), 36, "NV_ENC_CONFIG_HEVC.ltrNumFrames"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, vpsId), 40, "NV_ENC_CONFIG_HEVC.vpsId"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, spsId), 44, "NV_ENC_CONFIG_HEVC.spsId"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ppsId), 48, "NV_ENC_CONFIG_HEVC.ppsId"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceMode), 52, "NV_ENC_CONFIG_HEVC.sliceMode"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, sliceModeData), 56, "NV_ENC_CONFIG_HEVC.sliceModeData"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, maxTemporalLayersMinus1), 60, "NV_ENC_CONFIG_HEVC.maxTemporalLayersMinus1"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, hevcVUIParameters), 64, "NV_ENC_CONFIG_HEVC.hevcVUIParameters"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, ltrTrustMode), 176, "NV_ENC_CONFIG_HEVC.ltrTrustMode"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, useBFramesAsRef), 180, "NV_ENC_CONFIG_HEVC.useBFramesAsRef"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL0), 184, "NV_ENC_CONFIG_HEVC.numRefL0"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, numRefL1), 188, "NV_ENC_CONFIG_HEVC.numRefL1"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved1), 192, "NV_ENC_CONFIG_HEVC.reserved1"); + assert_eq!(offset_of!(NV_ENC_CONFIG_HEVC, reserved2), 1048, "NV_ENC_CONFIG_HEVC.reserved2"); assert_eq!((size_of::(), align_of::()), (1792, 8), "NV_ENC_CODEC_CONFIG"); assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, h264Config), 0, "NV_ENC_CODEC_CONFIG.h264Config"); assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, reserved), 0, "NV_ENC_CODEC_CONFIG.reserved"); + assert_eq!(offset_of!(NV_ENC_CODEC_CONFIG, hevcConfig), 0, "NV_ENC_CODEC_CONFIG.hevcConfig"); assert_eq!((size_of::(), align_of::()), (3584, 8), "NV_ENC_CONFIG"); assert_eq!(offset_of!(NV_ENC_CONFIG, version), 0, "NV_ENC_CONFIG.version"); assert_eq!(offset_of!(NV_ENC_CONFIG, profileGUID), 4, "NV_ENC_CONFIG.profileGUID"); @@ -305,6 +344,41 @@ fn layouts_match_the_header() { assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncRestoreEncoderState), 336, "NV_ENCODE_API_FUNCTION_LIST.nvEncRestoreEncoderState"); assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, nvEncLookaheadPicture), 344, "NV_ENCODE_API_FUNCTION_LIST.nvEncLookaheadPicture"); assert_eq!(offset_of!(NV_ENCODE_API_FUNCTION_LIST, reserved2), 352, "NV_ENCODE_API_FUNCTION_LIST.reserved2"); + assert_eq!((size_of::(), align_of::()), (16, 8), "NV_ENC_SEI_PAYLOAD"); + assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadSize), 0, "NV_ENC_SEI_PAYLOAD.payloadSize"); + assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payloadType), 4, "NV_ENC_SEI_PAYLOAD.payloadType"); + assert_eq!(offset_of!(NV_ENC_SEI_PAYLOAD, payload), 8, "NV_ENC_SEI_PAYLOAD.payload"); + assert_eq!((size_of::(), align_of::()), (8, 4), "NV_ENC_CLOCK_TIMESTAMP_SET"); + assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, timeOffset), 4, "NV_ENC_CLOCK_TIMESTAMP_SET.timeOffset"); + assert_eq!((size_of::(), align_of::()), (28, 4), "NV_ENC_TIME_CODE"); + assert_eq!(offset_of!(NV_ENC_TIME_CODE, displayPicStruct), 0, "NV_ENC_TIME_CODE.displayPicStruct"); + assert_eq!(offset_of!(NV_ENC_TIME_CODE, clockTimestamp), 4, "NV_ENC_TIME_CODE.clockTimestamp"); + assert_eq!((size_of::(), align_of::()), (1536, 8), "NV_ENC_PIC_PARAMS_HEVC"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, displayPOCSyntax), 0, "NV_ENC_PIC_PARAMS_HEVC.displayPOCSyntax"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, refPicFlag), 4, "NV_ENC_PIC_PARAMS_HEVC.refPicFlag"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, temporalId), 8, "NV_ENC_PIC_PARAMS_HEVC.temporalId"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, forceIntraRefreshWithFrameCnt), 12, "NV_ENC_PIC_PARAMS_HEVC.forceIntraRefreshWithFrameCnt"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeData), 24, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeData"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceTypeArrayCnt), 32, "NV_ENC_PIC_PARAMS_HEVC.sliceTypeArrayCnt"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceMode), 36, "NV_ENC_PIC_PARAMS_HEVC.sliceMode"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, sliceModeData), 40, "NV_ENC_PIC_PARAMS_HEVC.sliceModeData"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrMarkFrameIdx), 44, "NV_ENC_PIC_PARAMS_HEVC.ltrMarkFrameIdx"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUseFrameBitmap), 48, "NV_ENC_PIC_PARAMS_HEVC.ltrUseFrameBitmap"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, ltrUsageMode), 52, "NV_ENC_PIC_PARAMS_HEVC.ltrUsageMode"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArrayCnt), 56, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArrayCnt"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved), 60, "NV_ENC_PIC_PARAMS_HEVC.reserved"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, seiPayloadArray), 64, "NV_ENC_PIC_PARAMS_HEVC.seiPayloadArray"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, timeCode), 72, "NV_ENC_PIC_PARAMS_HEVC.timeCode"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved2), 100, "NV_ENC_PIC_PARAMS_HEVC.reserved2"); + assert_eq!(offset_of!(NV_ENC_PIC_PARAMS_HEVC, reserved3), 1048, "NV_ENC_PIC_PARAMS_HEVC.reserved3"); + assert_eq!(offset_of!(NV_ENC_CLOCK_TIMESTAMP_SET, bits), 0, "NV_ENC_CLOCK_TIMESTAMP_SET.bits"); + assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, hevcPicParams), 0, "NV_ENC_CODEC_PIC_PARAMS.hevcPicParams"); + assert_eq!(offset_of!(NV_ENC_CODEC_PIC_PARAMS, reserved), 0, "NV_ENC_CODEC_PIC_PARAMS.reserved"); + assert_eq!( + offset_of!(NV_ENC_PIC_PARAMS_HEVC, flags), + 16, + "NV_ENC_PIC_PARAMS_HEVC.flags (the C program sets constrainedFrame / ltrUseFrames and reads the word at 16: 0x1 / 0x8)" + ); } #[test] @@ -353,7 +427,29 @@ fn constants_match_the_header() { assert_eq!(NV_ENC_LOCK_BITSTREAM_VER as i64, 4043374604, "NV_ENC_LOCK_BITSTREAM_VER"); assert_eq!(NV_ENC_LOCK_INPUT_BUFFER_VER as i64, 1895890956, "NV_ENC_LOCK_INPUT_BUFFER_VER"); assert_eq!(NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER as i64, 1895890956, "NV_ENC_SEQUENCE_PARAM_PAYLOAD_VER"); + assert_eq!(NV_ENC_TIER_HEVC_MAIN as i64, 0, "NV_ENC_TIER_HEVC_MAIN"); + assert_eq!(NV_ENC_HEVC_CUSIZE_AUTOSELECT as i64, 0, "NV_ENC_HEVC_CUSIZE_AUTOSELECT"); + assert_eq!(NV_ENC_LEVEL_HEVC_1 as i64, 30, "NV_ENC_LEVEL_HEVC_1"); + assert_eq!(NV_ENC_LEVEL_HEVC_2 as i64, 60, "NV_ENC_LEVEL_HEVC_2"); + assert_eq!(NV_ENC_LEVEL_HEVC_21 as i64, 63, "NV_ENC_LEVEL_HEVC_21"); + assert_eq!(NV_ENC_LEVEL_HEVC_3 as i64, 90, "NV_ENC_LEVEL_HEVC_3"); + assert_eq!(NV_ENC_LEVEL_HEVC_31 as i64, 93, "NV_ENC_LEVEL_HEVC_31"); + assert_eq!(NV_ENC_LEVEL_HEVC_4 as i64, 120, "NV_ENC_LEVEL_HEVC_4"); + assert_eq!(NV_ENC_LEVEL_HEVC_41 as i64, 123, "NV_ENC_LEVEL_HEVC_41"); + assert_eq!(NV_ENC_LEVEL_HEVC_5 as i64, 150, "NV_ENC_LEVEL_HEVC_5"); + assert_eq!(NV_ENC_LEVEL_HEVC_51 as i64, 153, "NV_ENC_LEVEL_HEVC_51"); + assert_eq!(NV_ENC_LEVEL_HEVC_52 as i64, 156, "NV_ENC_LEVEL_HEVC_52"); + assert_eq!(NV_ENC_LEVEL_HEVC_6 as i64, 180, "NV_ENC_LEVEL_HEVC_6"); + assert_eq!(NV_ENC_LEVEL_HEVC_61 as i64, 183, "NV_ENC_LEVEL_HEVC_61"); + assert_eq!(NV_ENC_LEVEL_HEVC_62 as i64, 186, "NV_ENC_LEVEL_HEVC_62"); assert_eq!(NVENCAPI_VERSION as i64, 16777228, "NVENCAPI_VERSION"); + assert_eq!(NV_ENC_BUFFER_FORMAT_YUV420_10BIT as i64, 65536, "NV_ENC_BUFFER_FORMAT_YUV420_10BIT"); + assert_eq!(NV_ENC_BUFFER_FORMAT_ABGR as i64, 268435456, "NV_ENC_BUFFER_FORMAT_ABGR"); + assert_eq!(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE as i64, 39, "NV_ENC_CAPS_SUPPORT_10BIT_ENCODE"); + assert_eq!(NV_ENC_VUI_COLOR_PRIMARIES_BT2020 as i64, 9, "NV_ENC_VUI_COLOR_PRIMARIES_BT2020"); + assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084 as i64, 16, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084"); + assert_eq!(NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67 as i64, 18, "NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67"); + assert_eq!(NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL as i64, 9, "NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL"); } #[test] @@ -364,4 +460,23 @@ fn guids_match_the_header() { assert_eq!(NV_ENC_H264_PROFILE_HIGH_GUID.to_u128(), 0xe7cbc3094f7a4b89af2ad537c92be310u128, "NV_ENC_H264_PROFILE_HIGH_GUID"); assert_eq!(NV_ENC_PRESET_P4_GUID.to_u128(), 0x90a7b826df064862b9d2cd6d73a08681u128, "NV_ENC_PRESET_P4_GUID"); assert_eq!(NV_ENC_PRESET_P5_GUID.to_u128(), 0x21c6e6b4297a4cba998fb6cbde72ade3u128, "NV_ENC_PRESET_P5_GUID"); + assert_eq!(NV_ENC_CODEC_HEVC_GUID.to_u128(), 0x790cdc8845224d7b9425bda9975f7603u128, "NV_ENC_CODEC_HEVC_GUID"); + assert_eq!(NV_ENC_HEVC_PROFILE_MAIN_GUID.to_u128(), 0xb514c39ab55b40fa878ff1253b4dfdecu128, "NV_ENC_HEVC_PROFILE_MAIN_GUID"); + assert_eq!(NV_ENC_HEVC_PROFILE_MAIN10_GUID.to_u128(), 0xfa4d2b6c3a5b411a80180a3f5e3c9be5u128, "NV_ENC_HEVC_PROFILE_MAIN10_GUID"); +} + +/// The bit-field masks of the `flags` words (the C compiler's layout of each bit-field set alone). +#[test] +fn bit_field_masks_match_the_header() { + assert_eq!(H264_OUTPUT_AUD, 0x40, "NV_ENC_CONFIG_H264.outputAUD"); + assert_eq!(H264_REPEAT_SPSPPS, 0x1000, "NV_ENC_CONFIG_H264.repeatSPSPPS"); + assert_eq!(HEVC_OUTPUT_AUD, 0x10, "NV_ENC_CONFIG_HEVC.outputAUD"); + assert_eq!(HEVC_DISABLE_SPSPPS, 0x40, "NV_ENC_CONFIG_HEVC.disableSPSPPS"); + assert_eq!(HEVC_REPEAT_SPSPPS, 0x80, "NV_ENC_CONFIG_HEVC.repeatSPSPPS"); + assert_eq!(HEVC_ENABLE_INTRA_REFRESH, 0x100, "NV_ENC_CONFIG_HEVC.enableIntraRefresh"); + assert_eq!(HEVC_CHROMA_FORMAT_MASK, 0x600, "NV_ENC_CONFIG_HEVC.chromaFormatIDC"); + assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK, 0x3800, "NV_ENC_CONFIG_HEVC.pixelBitDepthMinus8"); + assert_eq!(HEVC_OUTPUT_RECOVERY_POINT_SEI, 0x40000, "NV_ENC_CONFIG_HEVC.outputRecoveryPointSEI"); + assert_eq!(HEVC_CHROMA_FORMAT_MASK >> HEVC_CHROMA_FORMAT_SHIFT, 3, "chromaFormatIDC is 2 bits"); + assert_eq!(HEVC_PIXEL_BIT_DEPTH_MASK >> HEVC_PIXEL_BIT_DEPTH_SHIFT, 7, "pixelBitDepthMinus8 is 3 bits"); } diff --git a/crates/platform/src/nvenc/device.rs b/crates/platform/src/nvenc/device.rs new file mode 100644 index 000000000..fe898795e --- /dev/null +++ b/crates/platform/src/nvenc/device.rs @@ -0,0 +1,130 @@ +//! Devices passed to nvEncOpenEncodeSessionEx. Public APIs are safe; handles stay in this module. +use std::ffi::c_void; + +#[cfg(target_os = "linux")] +pub use cuda::Device; +#[cfg(target_os = "windows")] +pub use directx::Device; + +#[cfg(target_os = "windows")] +mod directx { + use super::c_void; + use windows::Win32::Foundation::HMODULE; + use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN; + use windows::Win32::Graphics::Direct3D11::{D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device}; + use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1}; + use windows::core::Interface; + + /// NVIDIA's PCI vendor id. + const NVIDIA: u32 = 0x10DE; + + pub struct Device(ID3D11Device); + impl Device { + pub const KIND: u32 = super::super::ffi::NV_ENC_DEVICE_TYPE_DIRECTX; + pub fn new() -> Result { + nvidia_device().map(Self) + } + pub fn as_raw(&self) -> *mut c_void { + self.0.as_raw() + } + } + /// A Direct3D 11 device on the first NVIDIA adapter (what an NVENC session is opened on). + fn nvidia_device() -> Result { + // SAFETY: plain COM / D3D calls with valid out-pointers; the adapter outlives the call. + unsafe { + let factory: IDXGIFactory1 = CreateDXGIFactory1().map_err(|e| format!("no DXGI: {e}"))?; + let mut i = 0; + while let Ok(adapter) = factory.EnumAdapters1(i) { + i += 1; + let Ok(desc) = adapter.GetDesc1() else { continue }; + if desc.VendorId != NVIDIA { + continue; + } + let mut device = None; + let adapter: IDXGIAdapter = adapter.cast().map_err(|e| e.to_string())?; + if D3D11CreateDevice( + &adapter, + D3D_DRIVER_TYPE_UNKNOWN, + HMODULE::default(), + D3D11_CREATE_DEVICE_FLAG(0), + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + None, + ) + .is_ok() + && let Some(d) = device + { + return Ok(d); + } + } + } + Err("no NVIDIA adapter".into()) + } +} + +#[cfg(target_os = "linux")] +mod cuda { + use super::c_void; + type Init = unsafe extern "C" fn(u32) -> i32; + type GetDevice = unsafe extern "C" fn(*mut i32, i32) -> i32; + type Retain = unsafe extern "C" fn(*mut *mut c_void, i32) -> i32; + type Release = unsafe extern "C" fn(i32) -> i32; + + /// One retained primary CUDA context. Retaining does not change the calling thread's + /// current context; NVENC receives the explicit context handle. No toolkit is needed. + pub struct Device { + context: *mut c_void, + ordinal: i32, + release: Release, + _library: libloading::Library, + } + + impl Device { + pub const KIND: u32 = super::super::ffi::NV_ENC_DEVICE_TYPE_CUDA; + pub fn new() -> Result { + // SAFETY: CUDA driver API signatures from NVIDIA's public Driver API reference. + // Resolve all symbols before retaining; keep the library alive until after release. + unsafe { + let library = libloading::Library::new("libcuda.so.1").map_err(|e| format!("no NVIDIA CUDA driver: {e}"))?; + let init: libloading::Symbol = library.get(b"cuInit\0").map_err(|e| e.to_string())?; + let get: libloading::Symbol = library.get(b"cuDeviceGet\0").map_err(|e| e.to_string())?; + let retain: libloading::Symbol = library.get(b"cuDevicePrimaryCtxRetain\0").map_err(|e| e.to_string())?; + let release: Release = *library.get(b"cuDevicePrimaryCtxRelease_v2\0").map_err(|e| e.to_string())?; + let st = init(0); + if st != 0 { + return Err(format!("cuInit failed ({st})")); + } + let mut ordinal = 0; + let st = get(&mut ordinal, 0); + if st != 0 { + return Err(format!("no CUDA device ({st})")); + } + let mut context = std::ptr::null_mut(); + let st = retain(&mut context, ordinal); + if st != 0 { + return Err(format!("cuDevicePrimaryCtxRetain failed ({st})")); + } + if context.is_null() { + let _ = release(ordinal); + return Err("CUDA returned a null primary context".into()); + } + Ok(Self { context, ordinal, release, _library: library }) + } + } + pub fn as_raw(&self) -> *mut c_void { + self.context + } + } + + impl Drop for Device { + fn drop(&mut self) { + // SAFETY: balances this object's successful retain, after the NVENC session is + // destroyed. Release drops our reference, not other users' primary-context references. + unsafe { + (self.release)(self.ordinal); + } + } + } +} diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 9d833147d..a63b6a0ec 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -1,29 +1,69 @@ //! NVENC as an Export encoder: [`factory`] is registered in front of the software H.264 encoder -//! (`filmcraft_export::register_encoder`) and takes an export only when Export ▸ Hardware encoding -//! is Auto and NVENC can do what the settings ask; otherwise it returns `None` and the software -//! encoder runs, exactly as before. +//! (`filmcraft_export::register_encoder`). +//! +//! - H.264: it takes an export only when Export ▸ Hardware encoding is Auto and NVENC can do what +//! the settings ask; otherwise it returns `None` and the software encoder runs, exactly as before. +//! - H.265 (HEVC Main 8-bit 4:2:0 SDR BT.709, or Main 10 HDR: 10-bit 4:2:0, BT.2020, PQ or HLG, limited +//! range; progressive): there is no software encoder, so the Hardware encoding toggle does not apply +//! (as with VideoToolbox) and choosing the format is the opt-in. The export crate hands HDR pictures +//! over (`EncoderFrame::hdr`) only where [`super::hevc_hdr_available`] said this GPU has a 10-bit +//! encoder, otherwise an HDR sequence is tone-mapped to SDR as before. What NVENC cannot do +//! (interlaced, two-pass, non-square pixels, 10-bit on a GPU without it, sizes outside its +//! limits...) is counted as declined and ends the export with an error that says why; when this +//! machine has no HEVC encoder at all, [`filmcraft_export::available`] says so and the factory +//! returns `None` (the export's own "encoder not available" error). //! //! A hardware encoder that fails in the middle of an export (a lost device) ends the export with an //! error naming it; unlike a decoder it cannot be replayed into the software encoder, because the //! two write different streams. use filmcraft_export::{ - BitrateMode, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result, VideoEncoder, + BitrateMode, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, FieldOrder, Format, H264Pass, H264Profile, HardwareEncoding, Result, + VideoEncoder, }; use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_time::FrameRate; -use super::{Config, NvencH264, Profile}; +use super::{Codec, Config, Nvenc, Profile, Sei, Signal}; /// The NVENC export encoder. struct NvencEncoder { - enc: NvencH264, + enc: Nvenc, w: u32, h: u32, rate: FrameRate, + /// The stream's colour signal (`colr` / `mdcv` / `clli` on the sample entry for HDR). + signal: ColorSignal, y: Vec, u: Vec, v: Vec, + /// Main 10: the 10-bit planes of the current picture. + y16: Vec, + u16: Vec, + v16: Vec, +} + +/// SEI payload types of the HDR10 static metadata (H.265 Annex D): mastering display colour volume +/// and content light level information. +const SEI_MASTERING_DISPLAY: u32 = 137; +const SEI_CONTENT_LIGHT_LEVEL: u32 = 144; + +/// The encoder's signal for an export's colour signal: the VUI code points and, for PQ, the HDR10 +/// static metadata as SEI messages (the same values and bytes the `mdcv` / `clli` boxes and the software +/// H.264 encoder's SEI carry: [`ColorSignal::static_metadata`]). HLG has no static metadata. +pub fn nvenc_signal(signal: &ColorSignal) -> Signal { + let mut sei = Vec::new(); + if let Some((md, (max_cll, max_fall))) = signal.static_metadata() { + sei.push(Sei { payload_type: SEI_MASTERING_DISPLAY, payload: md.to_bytes() }); + let mut cll = max_cll.to_be_bytes().to_vec(); + cll.extend_from_slice(&max_fall.to_be_bytes()); + sei.push(Sei { payload_type: SEI_CONTENT_LIGHT_LEVEL, payload: cll }); + } + Signal { primaries: signal.primaries, transfer: signal.transfer, matrix: signal.matrix, sei } +} + +fn new_encoder(enc: Nvenc, w: u32, h: u32, rate: FrameRate, signal: ColorSignal) -> NvencEncoder { + NvencEncoder { enc, w, h, rate, signal, y: Vec::new(), u: Vec::new(), v: Vec::new(), y16: Vec::new(), u16: Vec::new(), v16: Vec::new() } } impl NvencEncoder { @@ -46,7 +86,17 @@ impl VideoEncoder for NvencEncoder { let (sps, pps) = self.enc.parameter_sets(); // `config` declines sizes above u16::MAX let (w, h) = (u16::try_from(self.w).unwrap_or(u16::MAX), u16::try_from(self.h).unwrap_or(u16::MAX)); - SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h) + match self.enc.codec() { + Codec::H264 => SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w, h), + Codec::Hevc => { + // `Nvenc::new` built and validated the record, so there is always one for HEVC + let mut e = SampleEntry::hevc(self.enc.hevc_config().cloned().unwrap_or_default(), w, h); + // HDR: `colr` (BT.2020 + PQ / HLG) and, for PQ, `mdcv` / `clli`, as the software H.264 encoder does + // (nclx, whatever the container: readers take it from either). SDR stays as it was, with the VUI only. + self.signal.apply_to(&mut e, false); + e + } + } } fn timescale(&self) -> u32 { @@ -57,11 +107,28 @@ impl VideoEncoder for NvencEncoder { if f.width != self.w || f.height != self.h { return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h))); } - filmcraft_export::rgba_to_yuv420_8(f.rgba, self.w as usize, self.h as usize, &mut self.y, &mut self.u, &mut self.v); - let ps = self - .enc - .encode(&self.y, &self.u, &self.v, f.index) - .map_err(|e| ExportError::Encode(format!("NVENC: {e} (turn Export ▸ Hardware encoding off to use the software encoder)")))?; + let (w, h) = (self.w as usize, self.h as usize); + let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" }; + let ps = match (self.enc.is_ten_bit(), f.hdr) { + // Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix) + (true, Some(rgb)) => { + let (kr, kb) = self.signal.kr_kb(); + filmcraft_export::timed(filmcraft_export::Stage::Convert, || { + filmcraft_export::rgbf_to_yuv420_10(rgb, w, h, kr, kb, &mut self.y16, &mut self.u16, &mut self.v16) + })?; + self.enc.encode_10(&self.y16, &self.u16, &self.v16, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))? + } + (true, None) => return Err(ExportError::Unsupported("NVENC Main 10 needs the HDR picture (EncoderFrame::hdr)".into())), + (false, Some(_)) => return Err(ExportError::Unsupported("NVENC Main (8-bit) does not take HDR pictures".into())), + // the GPU converts the RGBA picture itself (BT.709 limited range, the same codes as `rgba_to_yuv420_8`) + (false, None) if self.enc.takes_rgba() => self.enc.encode_rgba(f.rgba, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))?, + (false, None) => { + filmcraft_export::timed(filmcraft_export::Stage::Convert, || { + filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v) + }); + self.enc.encode(&self.y, &self.u, &self.v, f.index).map_err(|e| ExportError::Encode(format!("NVENC: {e}{hint}")))? + } + }; filmcraft_export::note_hw_encode_frame(); Ok(self.packets(ps)) } @@ -77,20 +144,34 @@ impl VideoEncoder for NvencEncoder { } } -/// Why NVENC does not take this export (the software encoder does), or the encoder's configuration. +/// Why NVENC does not take this export (the software encoder does, for H.264), or the encoder's +/// configuration. fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> std::result::Result { - if format != Format::H264 || s.format.is_mxf() { - return Err("only MP4 / MOV H.264 exports".into()); + let hevc = format == Format::Hevc; + if !(format == Format::H264 || hevc) || s.format.is_mxf() { + return Err("only MP4 / MOV H.264 and H.265 exports".into()); } - if s.signal.is_hdr() { + let hdr = s.signal.is_hdr(); + if hdr && !hevc { return Err("HDR (8-bit H.264 here carries it with software signalling)".into()); } - if !matches!(s.h264_pass, H264Pass::Single) { + // HEVC: BT.709 SDR (Main) or BT.2020 NCL with PQ / HLG (Main 10); any other description is not written + if hevc && (hdr && (s.signal.primaries != 9 || s.signal.matrix != 9) || !hdr && s.signal != ColorSignal::default()) { + return Err(format!("the colour signal {:?} (H.265 here is BT.709, or BT.2020 with PQ / HLG)", s.signal)); + } + if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) { return Err("two-pass VBR".into()); } + // the hardware path writes the aspect ratio of H.264 only + if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) { + return Err("non-square pixels".into()); + } if s.field_order != FieldOrder::Progressive { return Err("interlaced output".into()); } + if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) { + return Err("NVENC needs nonzero even dimensions".into()); + } if w > u32::from(u16::MAX) || h > u32::from(u16::MAX) { return Err(format!("{w}x{h} does not fit an MP4 sample entry")); } @@ -107,15 +188,18 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - height: h, fps, bitrate_kbps: kbps, - max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps / 2 * 3), + max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| kbps.saturating_add(kbps / 2)), cbr: s.bitrate_mode == BitrateMode::Cbr, keyint: s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (f64::from(fps.0) / f64::from(fps.1) * 2.0).round().max(1.0) as u32), - profile: match s.h264_profile { - H264Profile::Baseline => Profile::Baseline, - H264Profile::Main => Profile::Main, - H264Profile::High => Profile::High, + profile: match (hevc, s.h264_profile) { + (true, _) if hdr => Profile::HevcMain10, + (true, _) => Profile::HevcMain, + (false, H264Profile::Baseline) => Profile::Baseline, + (false, H264Profile::Main) => Profile::Main, + (false, H264Profile::High) => Profile::High, }, - level: s.h264_level, + // the H.264 level setting means nothing for H.265: the encoder picks the level + level: if hevc { None } else { s.h264_level }, sar: s.pixel_aspect, bframes: true, }) @@ -123,6 +207,36 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - /// The Export encoder factory (see the module documentation). pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { + if format == Format::Hevc { + // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only + return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; + } + factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) +} + +/// Open the encoder for `cfg`. An 8-bit one takes the export's RGBA pictures as they are and converts +/// them on the GPU, which keeps the RGB → 4:2:0 conversion off the CPU (where it competes with the +/// render of the next frames); where the driver refuses RGB input, it takes the CPU's 4:2:0 as before. +/// Main 10 takes its 10-bit planes. +fn open(cfg: &Config, signal: &Signal) -> std::result::Result { + if cfg.profile == Profile::HevcMain10 { + return Nvenc::with_signal(cfg, signal); + } + Nvenc::with_rgba_input(cfg, signal).or_else(|why| { + log::info!("NVENC RGBA input declined ({why}): converting to 4:2:0 on the CPU"); + Nvenc::with_signal(cfg, signal) + }) +} + +/// The H.264 side of [`factory`], with the driver call (`open`) passed in so tests can stand in for it. +fn factory_with( + format: Format, + w: u32, + h: u32, + rate: FrameRate, + s: &ExportSettings, + open: impl FnOnce(&Config) -> std::result::Result, +) -> Option>> { if s.hardware_encoding != HardwareEncoding::Auto || format != Format::H264 { return None; } @@ -135,10 +249,36 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin Ok(c) => c, Err(why) => return declined(&why), }; - match NvencH264::new(&cfg) { + match open(&cfg) { Ok(enc) => { filmcraft_export::note_hw_encode_session(); - Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() }))) + Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal)))) + } + Err(why) => declined(&why), + } +} + +/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle +/// does not apply and a request NVENC cannot take is an error, not a fall-through. +fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { + let declined = |why: &str| { + log::info!("hardware H.265 encoding declined: {why}"); + filmcraft_export::note_hw_encode_declined(); + Some(Err(ExportError::Unsupported(format!("H.265 export with NVENC: {why}")))) + }; + let cfg = match config(Format::Hevc, w, h, rate, s) { + Ok(c) => c, + Err(why) => return declined(&why), + }; + match open(&cfg, &nvenc_signal(&s.signal)) { + Ok(enc) => { + filmcraft_export::note_hw_encode_session(); + Some(Ok(Box::new(new_encoder(enc, w, h, rate, s.signal)))) + } + // no HEVC encoder here at all: not a hardware attempt, the export's own "encoder not available" error + Err(why) if !super::hevc_available() => { + log::info!("no hardware H.265 encoder: {why}"); + None } Err(why) => declined(&why), } @@ -148,6 +288,45 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin mod tests { use super::*; + #[test] + fn off_and_unsupported_settings_never_open_the_driver() { + let declined = |format, w, h, s: &ExportSettings| { + assert!(factory_with(format, w, h, FrameRate::FPS_24, s, |_| panic!("must not open the driver")).is_none()); + }; + let mut s = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..Default::default() }; + declined(Format::H264, 640, 360, &s); + s.hardware_encoding = HardwareEncoding::Auto; + declined(Format::ProRes, 640, 360, &s); + declined(Format::H264, 641, 360, &s); + declined(Format::H264, 0, 360, &s); + s.h264_pass = H264Pass::First; + declined(Format::H264, 640, 360, &s); + s.h264_pass = H264Pass::Single; + s.field_order = FieldOrder::UpperFirst; + declined(Format::H264, 640, 360, &s); + } + + #[test] + fn missing_or_rejecting_driver_declines_for_software_fallback() { + let s = ExportSettings { hardware_encoding: HardwareEncoding::Auto, ..Default::default() }; + let mut attempted = false; + assert!( + factory_with(Format::H264, 640, 360, FrameRate::FPS_24, &s, |cfg| { + attempted = true; + assert_eq!((cfg.width, cfg.height), (640, 360)); + Err("no NVIDIA encoder driver".into()) + }) + .is_none() + ); + assert!(attempted); + } + + #[test] + fn bitrate_ceiling_does_not_wrap() { + let s = ExportSettings { bitrate_kbps: u32::MAX, ..Default::default() }; + assert_eq!(config(Format::H264, 640, 360, FrameRate::FPS_24, &s).unwrap().max_bitrate_kbps, u32::MAX); + } + #[test] fn composition_offsets_saturate() { assert_eq!(composition_offset(3, 1, 1001), 2002); @@ -157,6 +336,72 @@ mod tests { assert_eq!(composition_offset(1 << 40, 0, 1), i32::MAX); } + #[test] + fn hevc_takes_what_nvenc_can_write_and_declines_the_rest() { + let ok = ExportSettings { format: Format::Hevc, bitrate_kbps: 4000, keyframe_distance: Some(48), ..Default::default() }; + let c = config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &ok).unwrap(); + assert_eq!((c.profile, c.level, c.keyint, c.fps), (Profile::HevcMain, None, 48, (24, 1))); + // the H.264 level and profile settings mean nothing to HEVC + let h = ExportSettings { h264_level: Some(41), h264_profile: H264Profile::Baseline, ..ok.clone() }; + assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &h).unwrap().level, None); + // HDR (PQ / HLG, BT.2020) is Main 10, SDR stays Main; H.264 still declines HDR + for signal in [ColorSignal::PQ, ColorSignal::HLG] { + let hdr = ExportSettings { signal, ..ok.clone() }; + assert_eq!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &hdr).unwrap().profile, Profile::HevcMain10); + assert!(config(Format::H264, 1280, 720, FrameRate::FPS_24, &ExportSettings { format: Format::H264, ..hdr }).is_err()); + } + // the Hardware encoding toggle does not matter for HEVC (it has no software encoder) + let off = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() }; + assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &off).is_ok()); + for (what, s) in [ + ("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }), + ("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }), + ("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }), + ("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }), + ("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }), + ("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }), + ("MXF", ExportSettings { format: Format::MxfOp1a, ..ok.clone() }), + ] { + assert!(config(Format::Hevc, 1280, 720, FrameRate::FPS_24, &s).is_err(), "{what}"); + } + // hostile sizes and frame rates + assert!(config(Format::Hevc, 70_000, 64, FrameRate::FPS_24, &ok).is_err()); + assert!(config(Format::Hevc, 64, 70_000, FrameRate::FPS_24, &ok).is_err()); + for rate in [FrameRate { num: 0, den: 1 }, FrameRate { num: 24, den: 0 }, FrameRate { num: -24, den: 1 }, FrameRate { num: i64::MAX, den: 1 }] { + assert!(config(Format::Hevc, 1280, 720, rate, &ok).is_err(), "{rate:?}"); + } + // other formats are not NVENC's + assert!(config(Format::ProRes, 1280, 720, FrameRate::FPS_24, &ok).is_err()); + } + + #[test] + fn the_sei_messages_are_the_hdr10_static_metadata_the_software_h264_path_writes() { + let pq = nvenc_signal(&ColorSignal::PQ); + assert_eq!((pq.primaries, pq.transfer, pq.matrix), (9, 16, 9)); + assert_eq!(pq.sei.len(), 2); + // mastering display colour volume (137): the 24-byte payload of ST 2086, the `mdcv` box's bytes + let (md, _) = ColorSignal::PQ.static_metadata().unwrap(); + assert_eq!((pq.sei[0].payload_type, pq.sei[0].payload.as_slice()), (137, md.to_bytes().as_slice())); + assert_eq!(pq.sei[0].payload.len(), 24); + // BT.2020 primaries (G, B, R order), D65, 1000 / 0.0001 cd/m2 in 0.0001 units + assert_eq!( + pq.sei[0].payload, + [0x21, 0x34, 0x9B, 0xAA, 0x19, 0x96, 0x08, 0xFC, 0x8A, 0x48, 0x39, 0x08, 0x3D, 0x13, 0x40, 0x42, 0x00, 0x98, 0x96, 0x80, 0x00, 0x00, 0x00, 0x01] + ); + // content light level (144): MaxCLL 0, MaxFALL 0 (unknown) + assert_eq!((pq.sei[1].payload_type, pq.sei[1].payload.as_slice()), (144, &[0u8, 0, 0, 0][..])); + // laid out as one SEI RBSP (type, size, payload, ...) these are the bytes the software H.264 encoder + // writes for the same metadata (checked against its real output in the export crate's hdr_tests) + let rbsp: Vec = + pq.sei.iter().flat_map(|m| [m.payload_type as u8, m.payload.len() as u8].into_iter().chain(m.payload.iter().copied())).chain([0x80]).collect(); + assert_eq!(&rbsp[..2], &[137, 24]); + assert_eq!(&rbsp[26..], &[144, 4, 0, 0, 0, 0, 0x80]); + // HLG: the colour description only; SDR: BT.709, no messages + let hlg = nvenc_signal(&ColorSignal::HLG); + assert_eq!((hlg.primaries, hlg.transfer, hlg.matrix, hlg.sei.len()), (9, 18, 9, 0)); + assert_eq!(nvenc_signal(&ColorSignal::default()), Signal::default()); + } + #[test] fn oversized_pictures_are_declined() { let s = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Auto, ..Default::default() }; diff --git a/crates/platform/src/nvenc/ffi.rs b/crates/platform/src/nvenc/ffi.rs index cae6fa0e8..dd348cff5 100644 --- a/crates/platform/src/nvenc/ffi.rs +++ b/crates/platform/src/nvenc/ffi.rs @@ -25,12 +25,12 @@ // OTHER DEALINGS IN THE SOFTWARE. //! The NVIDIA Video Codec SDK's encode API (`nvEncodeAPI.h`, API 12.1, MIT-licensed header by -//! NVIDIA) as the driver's `nvEncodeAPI64.dll` expects it: the structures this backend fills in, +//! NVIDIA) as the driver's `nvEncodeAPI64.dll` / `libnvidia-encode.so.1` expects it: the structures this backend fills in, //! the function table, and the constants it uses. Written from the public header; sizes and field //! offsets are checked against a C compiler's by the generated `abi_tests.rs`. //! //! API 12.1 is targeted on purpose: it is understood by every driver since 531.x, and the encoder -//! features used here (H.264, presets, rate control) are the same in later versions. +//! features used here (H.264 and HEVC, presets, rate control) are the same in later versions. //! //! FFI module (docs/adr/0001-platform-ffi.md): plain `repr(C)` data, no logic. @@ -38,7 +38,26 @@ use std::ffi::{c_char, c_int, c_void}; -use windows::core::GUID; +// NVIDIA's public header defines this same layout on non-Windows systems. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Guid { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +impl Guid { + pub const fn from_values(data1: u32, data2: u16, data3: u16, data4: [u8; 8]) -> Self { + Self { data1, data2, data3, data4 } + } + + #[cfg(test)] + pub const fn to_u128(self) -> u128 { + ((self.data1 as u128) << 96) | ((self.data2 as u128) << 80) | ((self.data3 as u128) << 64) | u64::from_be_bytes(self.data4) as u128 + } +} pub const NVENCAPI_VERSION: u32 = 12 | (1 << 24); @@ -70,8 +89,13 @@ pub const NV_ENC_ERR_ENCODER_BUSY: NvStatus = 18; pub const NV_ENC_ERR_LOCK_BUSY: NvStatus = 13; pub const NV_ENC_DEVICE_TYPE_DIRECTX: u32 = 0; +pub const NV_ENC_DEVICE_TYPE_CUDA: u32 = 1; pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2; pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1; +/// P010: 10-bit 4:2:0, 16-bit little-endian samples with the value in the high 10 bits. +pub const NV_ENC_BUFFER_FORMAT_YUV420_10BIT: u32 = 0x10000; +/// ABGR: 8-bit packed, one 32-bit little-endian word per pixel with R in the lowest byte (bytes R, G, B, A). +pub const NV_ENC_BUFFER_FORMAT_ABGR: u32 = 0x10000000; pub const NV_ENC_PARAMS_RC_VBR: u32 = 1; pub const NV_ENC_PARAMS_RC_CBR: u32 = 2; pub const NV_ENC_TUNING_INFO_HIGH_QUALITY: u32 = 1; @@ -82,26 +106,50 @@ pub const NV_ENC_PIC_TYPE_I: u32 = 2; pub const NV_ENC_PIC_TYPE_IDR: u32 = 3; pub const NV_ENC_PIC_FLAG_EOS: u32 = 0x8; pub const NV_ENC_LEVEL_AUTOSELECT: u32 = 0; +/// HEVC levels are `level × 30` (4.1 is 123), unlike H.264's `level × 10`. +pub const NV_ENC_LEVEL_HEVC_1: u32 = 30; +pub const NV_ENC_LEVEL_HEVC_2: u32 = 60; +pub const NV_ENC_LEVEL_HEVC_21: u32 = 63; +pub const NV_ENC_LEVEL_HEVC_3: u32 = 90; +pub const NV_ENC_LEVEL_HEVC_31: u32 = 93; +pub const NV_ENC_LEVEL_HEVC_4: u32 = 120; +pub const NV_ENC_LEVEL_HEVC_41: u32 = 123; +pub const NV_ENC_LEVEL_HEVC_5: u32 = 150; +pub const NV_ENC_LEVEL_HEVC_51: u32 = 153; +pub const NV_ENC_LEVEL_HEVC_52: u32 = 156; +pub const NV_ENC_LEVEL_HEVC_6: u32 = 180; +pub const NV_ENC_LEVEL_HEVC_61: u32 = 183; +pub const NV_ENC_LEVEL_HEVC_62: u32 = 186; +pub const NV_ENC_TIER_HEVC_MAIN: u32 = 0; +pub const NV_ENC_HEVC_CUSIZE_AUTOSELECT: u32 = 0; pub const NV_ENC_H264_ENTROPY_CODING_MODE_CABAC: u32 = 1; pub const NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED: u32 = 5; pub const NV_ENC_VUI_COLOR_PRIMARIES_BT709: u32 = 1; pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709: u32 = 1; pub const NV_ENC_VUI_MATRIX_COEFFS_BT709: u32 = 1; +pub const NV_ENC_VUI_COLOR_PRIMARIES_BT2020: u32 = 9; +pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084: u32 = 16; +pub const NV_ENC_VUI_TRANSFER_CHARACTERISTIC_ARIB_STD_B67: u32 = 18; +pub const NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL: u32 = 9; /// `NV_ENC_CAPS` values (the enum counts from zero in the header's order). pub const NV_ENC_CAPS_NUM_MAX_BFRAMES: u32 = 0; pub const NV_ENC_CAPS_SUPPORTED_RATECONTROL_MODES: u32 = 1; +pub const NV_ENC_CAPS_SUPPORT_10BIT_ENCODE: u32 = 39; pub const NV_ENC_CAPS_WIDTH_MAX: u32 = 16; pub const NV_ENC_CAPS_HEIGHT_MAX: u32 = 17; pub const NV_ENC_CAPS_WIDTH_MIN: u32 = 45; pub const NV_ENC_CAPS_HEIGHT_MIN: u32 = 46; -pub const NV_ENC_CODEC_H264_GUID: GUID = GUID::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]); -pub const NV_ENC_H264_PROFILE_BASELINE_GUID: GUID = GUID::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]); -pub const NV_ENC_H264_PROFILE_MAIN_GUID: GUID = GUID::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]); -pub const NV_ENC_H264_PROFILE_HIGH_GUID: GUID = GUID::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]); -pub const NV_ENC_PRESET_P4_GUID: GUID = GUID::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]); -pub const NV_ENC_PRESET_P5_GUID: GUID = GUID::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]); +pub const NV_ENC_CODEC_H264_GUID: Guid = Guid::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]); +pub const NV_ENC_CODEC_HEVC_GUID: Guid = Guid::from_values(0x790cdc88, 0x4522, 0x4d7b, [0x94, 0x25, 0xbd, 0xa9, 0x97, 0x5f, 0x76, 0x03]); +pub const NV_ENC_H264_PROFILE_BASELINE_GUID: Guid = Guid::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]); +pub const NV_ENC_H264_PROFILE_MAIN_GUID: Guid = Guid::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]); +pub const NV_ENC_H264_PROFILE_HIGH_GUID: Guid = Guid::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]); +pub const NV_ENC_HEVC_PROFILE_MAIN_GUID: Guid = Guid::from_values(0xb514c39a, 0xb55b, 0x40fa, [0x87, 0x8f, 0xf1, 0x25, 0x3b, 0x4d, 0xfd, 0xec]); +pub const NV_ENC_HEVC_PROFILE_MAIN10_GUID: Guid = Guid::from_values(0xfa4d2b6c, 0x3a5b, 0x411a, [0x80, 0x18, 0x0a, 0x3f, 0x5e, 0x3c, 0x9b, 0xe5]); +pub const NV_ENC_PRESET_P4_GUID: Guid = Guid::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]); +pub const NV_ENC_PRESET_P5_GUID: Guid = Guid::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]); pub type Handle = *mut c_void; @@ -235,10 +283,58 @@ pub const H264_OUTPUT_AUD: u32 = 1 << 6; pub const H264_REPEAT_SPSPPS: u32 = 1 << 12; pub const H264_OUTPUT_RECOVERY_POINT_SEI: u32 = 1 << 9; +/// `NV_ENC_CONFIG_HEVC_VUI_PARAMETERS` is a typedef of the H.264 VUI structure. +pub type NV_ENC_CONFIG_HEVC_VUI_PARAMETERS = NV_ENC_CONFIG_H264_VUI_PARAMETERS; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NV_ENC_CONFIG_HEVC { + pub level: u32, + pub tier: u32, + pub minCUSize: u32, + pub maxCUSize: u32, + /// useConstrainedIntraPred:1, disableDeblockAcrossSliceBoundary:1, outputBufferingPeriodSEI:1, + /// outputPictureTimingSEI:1, outputAUD:1, enableLTR:1, disableSPSPPS:1, repeatSPSPPS:1, + /// enableIntraRefresh:1, chromaFormatIDC:2, pixelBitDepthMinus8:3, enableFillerDataInsertion:1, + /// enableConstrainedEncoding:1, enableAlphaLayerEncoding:1, singleSliceIntraRefresh:1, + /// outputRecoveryPointSEI:1, outputTimeCodeSEI:1, reserved:12. + pub flags: u32, + pub idrPeriod: u32, + pub intraRefreshPeriod: u32, + pub intraRefreshCnt: u32, + pub maxNumRefFramesInDPB: u32, + pub ltrNumFrames: u32, + pub vpsId: u32, + pub spsId: u32, + pub ppsId: u32, + pub sliceMode: u32, + pub sliceModeData: u32, + pub maxTemporalLayersMinus1: u32, + pub hevcVUIParameters: NV_ENC_CONFIG_HEVC_VUI_PARAMETERS, + pub ltrTrustMode: u32, + pub useBFramesAsRef: u32, + pub numRefL0: u32, + pub numRefL1: u32, + pub reserved1: [u32; 214], + pub reserved2: [*mut c_void; 64], +} + +pub const HEVC_OUTPUT_AUD: u32 = 1 << 4; +pub const HEVC_DISABLE_SPSPPS: u32 = 1 << 6; +pub const HEVC_REPEAT_SPSPPS: u32 = 1 << 7; +pub const HEVC_ENABLE_INTRA_REFRESH: u32 = 1 << 8; +/// `chromaFormatIDC` (2 bits) and `pixelBitDepthMinus8` (3 bits) share the flags word. +pub const HEVC_CHROMA_FORMAT_SHIFT: u32 = 9; +pub const HEVC_CHROMA_FORMAT_MASK: u32 = 3 << HEVC_CHROMA_FORMAT_SHIFT; +pub const HEVC_PIXEL_BIT_DEPTH_SHIFT: u32 = 11; +pub const HEVC_PIXEL_BIT_DEPTH_MASK: u32 = 7 << HEVC_PIXEL_BIT_DEPTH_SHIFT; +pub const HEVC_OUTPUT_RECOVERY_POINT_SEI: u32 = 1 << 18; + #[repr(C)] #[derive(Clone, Copy)] pub union NV_ENC_CODEC_CONFIG { pub h264Config: NV_ENC_CONFIG_H264, + pub hevcConfig: NV_ENC_CONFIG_HEVC, pub reserved: [u32; 320], align: [*mut c_void; 0], } @@ -247,7 +343,7 @@ pub union NV_ENC_CODEC_CONFIG { #[derive(Clone, Copy)] pub struct NV_ENC_CONFIG { pub version: u32, - pub profileGUID: GUID, + pub profileGUID: Guid, pub gopLength: u32, pub frameIntervalP: i32, pub monoChromeEncoding: u32, @@ -279,8 +375,8 @@ pub struct NVENC_EXTERNAL_ME_HINT_COUNTS_PER_BLOCKTYPE { #[derive(Clone, Copy)] pub struct NV_ENC_INITIALIZE_PARAMS { pub version: u32, - pub encodeGUID: GUID, - pub presetGUID: GUID, + pub encodeGUID: Guid, + pub presetGUID: Guid, pub encodeWidth: u32, pub encodeHeight: u32, pub darWidth: u32, @@ -335,10 +431,62 @@ pub struct NV_ENC_CREATE_BITSTREAM_BUFFER { pub reserved2: [*mut c_void; 64], } -/// `NV_ENC_CODEC_PIC_PARAMS`: a union of codec structures, all zero here (defaults). +/// `NV_ENC_SEI_PAYLOAD`: a user SEI message (payload type and bytes) for a picture. #[repr(C)] #[derive(Clone, Copy)] -pub struct NV_ENC_CODEC_PIC_PARAMS { +pub struct NV_ENC_SEI_PAYLOAD { + pub payloadSize: u32, + pub payloadType: u32, + pub payload: *mut u8, +} + +/// `NV_ENC_CLOCK_TIMESTAMP_SET`: countingType:1, discontinuityFlag:1, cntDroppedFrames:1, nFrames:8, +/// secondsValue:6, minutesValue:6, hoursValue:5, reserved2:4 in `bits`, then the time offset. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NV_ENC_CLOCK_TIMESTAMP_SET { + pub bits: u32, + pub timeOffset: u32, +} + +/// `NV_ENC_TIME_CODE` (`MAX_NUM_CLOCK_TS` is 3). +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NV_ENC_TIME_CODE { + pub displayPicStruct: u32, + pub clockTimestamp: [NV_ENC_CLOCK_TIMESTAMP_SET; 3], +} + +/// `NV_ENC_PIC_PARAMS_HEVC`: the per-picture HEVC parameters; all zero but the SEI payload array here. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NV_ENC_PIC_PARAMS_HEVC { + pub displayPOCSyntax: u32, + pub refPicFlag: u32, + pub temporalId: u32, + pub forceIntraRefreshWithFrameCnt: u32, + /// constrainedFrame:1, sliceModeDataUpdate:1, ltrMarkFrame:1, ltrUseFrames:1, reservedBitFields:28. + pub flags: u32, + pub sliceTypeData: *mut u8, + pub sliceTypeArrayCnt: u32, + pub sliceMode: u32, + pub sliceModeData: u32, + pub ltrMarkFrameIdx: u32, + pub ltrUseFrameBitmap: u32, + pub ltrUsageMode: u32, + pub seiPayloadArrayCnt: u32, + pub reserved: u32, + pub seiPayloadArray: *mut NV_ENC_SEI_PAYLOAD, + pub timeCode: NV_ENC_TIME_CODE, + pub reserved2: [u32; 237], + pub reserved3: [*mut c_void; 61], +} + +/// `NV_ENC_CODEC_PIC_PARAMS`: a union of codec structures (H.264 and AV1 stay opaque inside `reserved`). +#[repr(C)] +#[derive(Clone, Copy)] +pub union NV_ENC_CODEC_PIC_PARAMS { + pub hevcPicParams: NV_ENC_PIC_PARAMS_HEVC, pub reserved: [u32; 388], align: [*mut c_void; 0], } @@ -456,7 +604,7 @@ pub struct NV_ENCODE_API_FUNCTION_LIST { pub nvEncGetEncodeGUIDs: Unused, pub nvEncGetInputFormatCount: Unused, pub nvEncGetInputFormats: Unused, - pub nvEncGetEncodeCaps: Option NvStatus>, + pub nvEncGetEncodeCaps: Option NvStatus>, pub nvEncGetEncodePresetCount: Unused, pub nvEncGetEncodePresetGUIDs: Unused, pub nvEncGetEncodePresetConfig: Unused, @@ -488,7 +636,7 @@ pub struct NV_ENCODE_API_FUNCTION_LIST { pub nvEncRunMotionEstimationOnly: Unused, pub nvEncGetLastErrorString: Option *const c_char>, pub nvEncSetIOCudaStreams: Unused, - pub nvEncGetEncodePresetConfigEx: Option NvStatus>, + pub nvEncGetEncodePresetConfigEx: Option NvStatus>, pub nvEncGetSequenceParamEx: Unused, pub nvEncRestoreEncoderState: Unused, pub nvEncLookaheadPicture: Unused, diff --git a/crates/platform/src/nvenc/hevc.rs b/crates/platform/src/nvenc/hevc.rs new file mode 100644 index 000000000..e962e724f --- /dev/null +++ b/crates/platform/src/nvenc/hevc.rs @@ -0,0 +1,314 @@ +//! What is specific to HEVC in the NVENC backend (safe code): the level codes, and the `hvcC` +//! record built from the parameter sets the encoder wrote. +//! +//! Main (8-bit) and Main 10 (10-bit) are both written; the caller says which it asked the encoder for +//! and the record is refused if the SPS says otherwise. +//! +//! Nothing in the record is invented: the profile, tier, compatibility flags, level, chroma format, +//! bit depths and temporal layers come from the SPS (parsed with `filmcraft_hevc`), and the 48 +//! general constraint flags and `sps_temporal_id_nesting_flag` are read from the SPS bits that carry +//! them (`parse_ptl` skips the flags and `Sps` does not keep the nesting flag). The array +//! completeness flags are 1 and the parameter sets are only in the record, never in the samples +//! (the `hvc1` sample entry). + +use filmcraft_bitstream::unescape_rbsp; +use filmcraft_hevc::params::Sps; +use filmcraft_isobmff::{HevcConfig, HevcNalArray}; + +use super::{Codec, nal_type}; + +/// `NV_ENC_LEVEL_HEVC_*` for a level × 10 (4.1 is 41): HEVC levels are numbered `level × 30`. +/// `None` for a number that is not an HEVC level. +pub fn level_code(level_x10: u8) -> Option { + matches!(level_x10, 10 | 20 | 21 | 30 | 31 | 40 | 41 | 50 | 51 | 52 | 60 | 61 | 62).then(|| level_x10.saturating_mul(3)) +} + +/// The lowest HEVC level (× 10) whose **Main tier** limits take `width × height` pictures at `fps` +/// and a peak bitrate (and, with the one-second VBV buffer, a CPB) of `max_kbps` (H.265 Table A.8, +/// Main / Main 10 profiles: `MaxLumaPs`, `MaxLumaSr`, `MaxBR`, and pictures no wider or taller than +/// `sqrt(8 × MaxLumaPs)`). `None` when no Main tier level does (the encoder then chooses). +pub fn main_tier_level(width: u32, height: u32, fps: (u32, u32), max_kbps: u32) -> Option { + // (level × 10, MaxLumaPs, MaxLumaSr, Main tier MaxBR = MaxCPB in kbit/s) + const LEVELS: [(u8, u64, u64, u64); 13] = [ + (10, 36_864, 552_960, 128), + (20, 122_880, 3_686_400, 1_500), + (21, 245_760, 7_372_800, 3_000), + (30, 552_960, 16_588_800, 6_000), + (31, 983_040, 33_177_600, 10_000), + (40, 2_228_224, 66_846_720, 12_000), + (41, 2_228_224, 133_693_440, 20_000), + (50, 8_912_896, 267_386_880, 25_000), + (51, 8_912_896, 534_773_760, 40_000), + (52, 8_912_896, 1_069_547_520, 60_000), + (60, 35_651_584, 1_069_547_520, 60_000), + (61, 35_651_584, 2_139_095_040, 120_000), + (62, 35_651_584, 4_278_190_080, 240_000), + ]; + if fps.1 == 0 { + return None; + } + let ps = u64::from(width).checked_mul(u64::from(height))?; + // luma samples per second, rounded up + let sr = ps.checked_mul(u64::from(fps.0))?.div_ceil(u64::from(fps.1)); + let side = u64::from(width.max(height)); + LEVELS + .iter() + .find(|(_, max_ps, max_sr, max_br)| { + // side² ≤ 8 × MaxLumaPs: the dimension limit without a square root + ps <= *max_ps && sr <= *max_sr && u64::from(max_kbps) <= *max_br && side.saturating_mul(side) <= max_ps.saturating_mul(8) + }) + .map(|(l, ..)| *l) +} + +/// Bytes of the SPS RBSP before the general profile / tier / level record: `sps_video_parameter_set_id`, +/// `sps_max_sub_layers_minus1` and `sps_temporal_id_nesting_flag` share the first one. +const PTL_START: usize = 1; +/// General profile space / tier / profile (1 byte), compatibility flags (4), constraint flags (6), level (1). +const PTL_LEN: usize = 12; + +/// The `hvcC` record of a stream of `size` pictures (width, height) whose parameter sets are `vps`, +/// `sps` and `pps` (NAL units with their headers, without start codes or length prefixes). An error +/// says why the stream is not the one that was asked for: `bit_depth` 8 is HEVC Main (profile 1), +/// 10 is Main 10 (profile 2), both 4:2:0 with equal luma and chroma depth; any other depth is refused. +pub fn hevc_config(vps: &[u8], sps: &[u8], pps: &[u8], size: (u32, u32), bit_depth: u8) -> Result { + let (profile_idc, profile_name) = match bit_depth { + 8 => (1u8, "Main"), + 10 => (2, "Main 10"), + other => return Err(format!("{other}-bit HEVC is not written by this backend")), + }; + for (nal, kind, name) in [(vps, 32u8, "VPS"), (sps, 33, "SPS"), (pps, 34, "PPS")] { + if nal_type(Codec::Hevc, nal) != Some(kind) || nal.len() <= 2 { + return Err(format!("the encoder's {name} is not a {name} NAL unit")); + } + // the record stores each length in 16 bits + if u16::try_from(nal.len()).is_err() { + return Err(format!("the {name} is too long for an hvcC record")); + } + } + let rbsp = unescape_rbsp(sps.get(2..).unwrap_or_default()); + let parsed = Sps::parse(&rbsp).map_err(|e| format!("unreadable HEVC SPS: {e}"))?; + let ptl_bytes = rbsp.get(PTL_START..PTL_START + PTL_LEN).ok_or("the HEVC SPS is truncated before its profile / tier / level")?; + // bytes 5..11 of the record are the 48 general constraint indicator flags + let constraints = ptl_bytes.get(5..11).ok_or("the HEVC SPS is truncated before its constraint flags")?; + let general_constraint_indicator_flags = constraints.iter().fold(0u64, |acc, b| (acc << 8) | u64::from(*b)); + let temporal_id_nested = rbsp.first().is_some_and(|b| b & 1 != 0); + + let ptl = &parsed.ptl; + if ptl.profile_space != 0 || ptl.profile_idc != profile_idc { + return Err(format!("the encoder wrote HEVC profile {} (space {}), not {profile_name}", ptl.profile_idc, ptl.profile_space)); + } + let depth = u32::from(bit_depth); + if parsed.chroma_format_idc != 1 || parsed.bit_depth_luma != depth || parsed.bit_depth_chroma != depth { + return Err(format!( + "the encoder wrote chroma format {} at {}/{} bits, not {bit_depth}-bit 4:2:0", + parsed.chroma_format_idc, parsed.bit_depth_luma, parsed.bit_depth_chroma + )); + } + let (_, _, w, h) = parsed.crop_rect(); + if (w, h) != size { + return Err(format!("the encoder wrote {w}x{h} pictures for a {}x{} export", size.0, size.1)); + } + Ok(HevcConfig { + general_profile_space: ptl.profile_space, + general_tier_flag: ptl.tier, + general_profile_idc: ptl.profile_idc, + general_profile_compatibility_flags: ptl.compatibility, + general_constraint_indicator_flags, + general_level_idc: ptl.level_idc, + // 0: not specified, which is a valid value for both + min_spatial_segmentation_idc: 0, + parallelism_type: 0, + chroma_format_idc: u8::try_from(parsed.chroma_format_idc).map_err(|_| "chroma format".to_string())?, + bit_depth_luma: u8::try_from(parsed.bit_depth_luma).map_err(|_| "bit depth".to_string())?, + bit_depth_chroma: u8::try_from(parsed.bit_depth_chroma).map_err(|_| "bit depth".to_string())?, + // 0: unspecified average rate, and the frame rate may not be constant + avg_frame_rate: 0, + constant_frame_rate: 0, + num_temporal_layers: u8::try_from(parsed.max_sub_layers_minus1.saturating_add(1)).map_err(|_| "temporal layers".to_string())?, + temporal_id_nested, + length_size: 4, + arrays: vec![ + HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vps.to_vec()] }, + HevcNalArray { completeness: true, nal_type: 33, nalus: vec![sps.to_vec()] }, + HevcNalArray { completeness: true, nal_type: 34, nalus: vec![pps.to_vec()] }, + ], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The parameter sets NVENC wrote for a 1280x720, 24 fps, B-frame HEVC Main stream (RTX 5060, driver + // 617.14): the coded size is 1280x736 and a conformance window crops it to 1280x720. + const VPS: &str = "40010c01ffff01600000030090000003000003005d9940c0000003004000000614"; + const SPS: &str = "42010101600000030090000003000003005da00280802e1f1396654a421191bff0c05a8080808a0000030002000003003010"; + const PPS: &str = "4401c1937c0cc9"; + + fn unhex(h: &str) -> Vec { + (0..h.len()).step_by(2).filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok()).collect() + } + + fn record() -> HevcConfig { + hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720), 8).unwrap() + } + + #[test] + fn hevc_levels_are_thirty_times_the_level() { + assert_eq!(level_code(41), Some(123)); + assert_eq!(level_code(10), Some(30)); + assert_eq!(level_code(21), Some(63)); + assert_eq!(level_code(62), Some(186)); + for bad in [0, 11, 42, 53, 70, 255] { + assert_eq!(level_code(bad), None, "{bad}"); + } + } + + #[test] + fn the_level_is_the_lowest_main_tier_one_that_fits() { + // size and sample rate alone + assert_eq!(main_tier_level(1280, 720, (30, 1), 5_000), Some(31)); + assert_eq!(main_tier_level(1920, 1080, (30, 1), 8_000), Some(40)); + // the bitrate moves 1080p30 up a level instead of to the High tier (what NVENC picked by itself) + assert_eq!(main_tier_level(1920, 1080, (30, 1), 18_000), Some(41)); + assert_eq!(main_tier_level(1920, 1080, (60, 1), 12_000), Some(41)); + assert_eq!(main_tier_level(3840, 2160, (30, 1), 22_500), Some(50)); + assert_eq!(main_tier_level(3840, 2160, (30, 1), 60_000), Some(52)); + assert_eq!(main_tier_level(3840, 2160, (60, 1), 60_000), Some(52)); + assert_eq!(main_tier_level(7680, 4320, (30, 1), 60_000), Some(60)); + // 29.97 rounds the sample rate up + assert_eq!(main_tier_level(1920, 1080, (30_000, 1_001), 8_000), Some(40)); + // a long thin picture is held by the dimension limit, not the area + assert_eq!(main_tier_level(8192, 64, (30, 1), 1_000), Some(50)); + // beyond every Main tier level, or nonsense: the encoder chooses + assert_eq!(main_tier_level(1920, 1080, (30, 1), 300_000), None); + assert_eq!(main_tier_level(16_384, 16_384, (30, 1), 1_000), None); + assert_eq!(main_tier_level(1920, 1080, (30, 0), 1_000), None); + assert_eq!(main_tier_level(u32::MAX, u32::MAX, (u32::MAX, 1), u32::MAX), None); + // every answer is a level the encoder takes + assert!(main_tier_level(1920, 1080, (30, 1), 18_000).and_then(level_code).is_some()); + } + + #[test] + fn the_record_is_the_sps_it_came_from() { + let c = record(); + let sps = unhex(SPS); + let rbsp = unescape_rbsp(&sps[2..]); + // profile / tier / compatibility / constraint flags / level are the SPS's own bytes + assert_eq!(&c.to_bytes()[1..13], &rbsp[1..13]); + assert_eq!((c.general_profile_space, c.general_tier_flag, c.general_profile_idc), (0, false, 1)); + assert_eq!(c.general_profile_compatibility_flags, 0x6000_0000, "Main and Main 10"); + // progressive_source_flag and frame_only_constraint_flag + assert_eq!(c.general_constraint_indicator_flags, 0x9000_0000_0000); + assert_eq!(c.general_level_idc, 93, "level 3.1"); + assert_eq!((c.chroma_format_idc, c.bit_depth_luma, c.bit_depth_chroma), (1, 8, 8)); + assert_eq!((c.num_temporal_layers, c.temporal_id_nested, c.length_size), (1, true, 4)); + assert_eq!(c.arrays.iter().map(|a| (a.nal_type, a.completeness, a.nalus.len())).collect::>(), vec![(32, true, 1), (33, true, 1), (34, true, 1)]); + assert_eq!((c.vps(), c.sps(), c.pps()), (vec![&unhex(VPS)[..]], vec![&sps[..]], vec![&unhex(PPS)[..]])); + assert_eq!(HevcConfig::parse(&c.to_bytes()).unwrap(), c); + } + + #[test] + fn the_picture_size_has_to_match_after_cropping() { + let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); + for wrong in [(1280, 736), (1920, 1080), (1280, 719), (0, 0)] { + let e = hevc_config(&v, &s, &p, wrong, 8).unwrap_err(); + assert!(e.contains("1280x720"), "{e}"); + } + } + + #[test] + fn a_stream_that_is_not_main_8_bit_420_is_refused() { + // The same SPS with the general profile changed (bits of profile_idc in the first PTL byte), + // the chroma format or the bit depth changed would be other streams: edit the PTL byte only + // (it sits before any emulation-prevention byte). + let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); + let mut other_profile = s.clone(); + other_profile[3] = (other_profile[3] & !0x1f) | 2; // Main 10 + assert!(hevc_config(&v, &other_profile, &p, (1280, 720), 8).unwrap_err().contains("not Main")); + let mut other_space = s.clone(); + other_space[3] |= 0x40; + assert!(hevc_config(&v, &other_space, &p, (1280, 720), 8).is_err()); + } + + #[test] + fn truncated_and_corrupt_parameter_sets_are_errors_never_panics() { + let (v, s, p) = (unhex(VPS), unhex(SPS), unhex(PPS)); + // every truncation of the SPS + for n in 0..s.len() { + assert!(hevc_config(&v, &s[..n], &p, (1280, 720), 8).is_err(), "SPS cut at {n}"); + } + // a bit flip anywhere in the SPS: an error or a record, but never a panic + for byte in 0..s.len() { + for bit in 0..8 { + let mut m = s.clone(); + m[byte] ^= 1 << bit; + let r = std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720), 8)); + assert!(r.is_ok(), "panic with bit {bit} of byte {byte} flipped"); + } + } + // wrong NAL types, empty and one-byte NAL units + let cases: [(&[u8], &[u8], &[u8]); 5] = [ + (&[], &[], &[]), + (&[0x40], &[0x42], &[0x44]), + (&[0x40, 0x01, 0xAA], &[0x42, 0x01], &[0x44, 0x01, 0xBB]), + (&[0x42, 0x01, 0xAA], &s, &p), + (&v, &p, &s), + ]; + for (v, s, p) in cases { + assert!(hevc_config(v, s, p, (64, 64), 8).is_err(), "{v:?} {s:?} {p:?}"); + } + // a parameter set too long for the record's 16-bit lengths + let mut huge = s.clone(); + huge.resize(70_000, 0x55); + assert!(hevc_config(&v, &huge, &p, (1280, 720), 8).is_err()); + } + + // The parameter sets NVENC wrote for a 1280x720, 24 fps HEVC Main 10 stream with the PQ signal (VUI: BT.2020 + // primaries, SMPTE ST 2084, BT.2020 NCL matrix, limited range) on the same GPU. + const VPS10: &str = "40010c01ffff2220000003009000000300000300789940c0000003004000000614"; + const SPS10: &str = "420101222000000300900000030000030078a00280802e1f12d96654a421191bff0c05a8488048a000000300200000030301"; + const PPS10: &str = "4401c1933c0cc9"; + + #[test] + fn a_main_10_stream_is_accepted_as_main_10_and_refused_as_main() { + let (v, s, p) = (unhex(VPS10), unhex(SPS10), unhex(PPS10)); + let c = hevc_config(&v, &s, &p, (1280, 720), 10).unwrap(); + assert_eq!((c.general_profile_space, c.general_tier_flag, c.general_profile_idc), (0, true, 2), "NVENC picked the High tier for level 4 at 12 Mbps"); + assert_eq!(c.general_profile_compatibility_flags, 0x2000_0000, "Main 10 only"); + assert_eq!(c.general_constraint_indicator_flags, 0x9000_0000_0000); + assert_eq!(c.general_level_idc, 120, "level 4"); + assert_eq!((c.chroma_format_idc, c.bit_depth_luma, c.bit_depth_chroma), (1, 10, 10)); + assert_eq!(HevcConfig::parse(&c.to_bytes()).unwrap(), c); + // the same stream where Main (8-bit) was asked for, and the 8-bit stream where Main 10 was asked for + let e = hevc_config(&v, &s, &p, (1280, 720), 8).unwrap_err(); + assert!(e.contains("not Main") && !e.contains("Main 10"), "{e}"); + let e = hevc_config(&unhex(VPS), &unhex(SPS), &unhex(PPS), (1280, 720), 10).unwrap_err(); + assert!(e.contains("not Main 10"), "{e}"); + // no other depth + for depth in [0, 7, 9, 12, 16, 255] { + assert!(hevc_config(&v, &s, &p, (1280, 720), depth).is_err(), "{depth}"); + } + // a Main 10 profile byte with 8-bit depth (or the reverse) is refused whichever is asked for + let mut forged = s.clone(); + forged[3] = (forged[3] & !0x1f) | 1; // profile_idc 1 (Main) but a 10-bit SPS + for depth in [8, 10] { + assert!(hevc_config(&v, &forged, &p, (1280, 720), depth).is_err(), "{depth}"); + } + } + + #[test] + fn truncated_and_flipped_main_10_parameter_sets_never_panic() { + let (v, s, p) = (unhex(VPS10), unhex(SPS10), unhex(PPS10)); + for n in 0..s.len() { + assert!(hevc_config(&v, &s[..n], &p, (1280, 720), 10).is_err(), "SPS cut at {n}"); + } + for byte in 0..s.len() { + for bit in 0..8 { + let mut m = s.clone(); + m[byte] ^= 1 << bit; + assert!(std::panic::catch_unwind(|| hevc_config(&v, &m, &p, (1280, 720), 10)).is_ok(), "panic with bit {bit} of byte {byte} flipped"); + } + } + } +} diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index d0723adcc..f97adc5fb 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -1,23 +1,39 @@ -//! NVIDIA NVENC H.264 hardware encoding (Windows). +//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10 +//! (Windows). //! -//! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` (API 12.1, -//! [`ffi`]; no CUDA, no SDK to install): pictures go in as NV12 input buffers, the Annex B output -//! comes back as length-prefixed H.264 samples with the parameter sets split out for the `avcC`. -//! [`export`] plugs it into Export as an alternative to the software encoder. +//! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows +//! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary +//! context, `libcuda.so.1`) (API 12.1, [`ffi`]; no CUDA toolkit, no SDK to install): pictures go in as NV12 input buffers, the Annex B output +//! comes back as length-prefixed samples with the parameter sets split out for the `avcC` (H.264) +//! or the `hvcC` (HEVC, see [`hevc`]). One session, ring and NV12 path serves both codecs; the +//! codec only chooses the GUIDs, the codec configuration and how NAL units are told apart. +//! [`export`] plugs it into Export: as an alternative to the software encoder for H.264, and as the +//! only encoder of the H.265 format (which has no software encoder). //! //! ```text -//! RGBA (the export pipeline) ──► BT.709 limited 4:2:0 (the software encoder's conversion) -//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC +//! RGBA (the export pipeline) ──► ABGR input buffer ──NVENC (BT.709 limited 4:2:0 on the GPU)──► +//! Annex B ──► length-prefixed samples + avcC / hvcC +//! +//! where the driver refuses RGB input (`Nvenc::with_rgba_input` fails), as before: +//! RGBA ──► BT.709 limited 4:2:0 (the software encoder's conversion) ──► NV12 input buffer ──NVENC──► … +//! +//! HDR (PQ / HLG) HEVC Main 10: +//! encoded BT.2020 R'G'B' floats ──► 10-bit limited 4:2:0 (`filmcraft_export::rgbf_to_yuv420_10`) +//! ──► P010 input buffer ──NVENC──► same path; VUI BT.2020 + transfer, HDR10 SEI on every IDR (PQ) //! ``` //! -//! `unsafe` is confined to `ffi` (data) and `session` (every driver call); this module is safe -//! code. Hardware encoding never replaces an export that works in software: the factory declines -//! what NVENC cannot do (no NVIDIA GPU or driver, sizes, HDR, two-pass, MXF...) and the software -//! encoder takes over. +//! `unsafe` is confined to `ffi` (data), `device` (the device's lifetime) and `session` (every driver +//! call); this module is safe +//! code. Hardware H.264 encoding never replaces an export that works in software: the factory +//! declines what NVENC cannot do (no NVIDIA GPU or driver, sizes, HDR, two-pass, MXF...) and the +//! software encoder takes over. A declined HEVC export is an error that says why. +#[allow(unsafe_code)] +mod device; pub mod export; #[allow(unsafe_code)] mod ffi; +pub mod hevc; #[allow(unsafe_code)] mod session; @@ -26,15 +42,31 @@ mod abi_tests; use std::collections::VecDeque; -pub use self::session::{Caps, Params}; +use filmcraft_isobmff::HevcConfig; + +pub use self::session::{Caps, Codec, Params, Sei, Signal}; use self::session::{Locked, Session, Submitted}; -/// H.264 profile (`profile_idc` 66 / 77 / 100). +/// H.264 profile (`profile_idc` 66 / 77 / 100), or HEVC Main (8-bit 4:2:0) / Main 10 (10-bit 4:2:0). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Profile { Baseline, Main, High, + /// H.265 Main: chooses the HEVC codec (like `VtProfile::HevcMain` for VideoToolbox). + HevcMain, + /// H.265 Main 10: 10-bit 4:2:0 (P010 input), for HDR (PQ / HLG) and 10-bit SDR. + HevcMain10, +} + +impl Profile { + /// The codec this profile belongs to. + pub fn codec(self) -> Codec { + match self { + Profile::HevcMain | Profile::HevcMain10 => Codec::Hevc, + _ => Codec::H264, + } + } } /// What to encode. @@ -50,7 +82,7 @@ pub struct Config { /// Frames between IDR pictures. pub keyint: u32, pub profile: Profile, - /// Level × 10 (41 = 4.1); `None` lets the encoder pick. + /// Level × 10 (41 = 4.1), for either codec; `None` lets the encoder pick. pub level: Option, /// Sample aspect ratio. pub sar: Option<(u32, u32)>, @@ -71,11 +103,16 @@ pub struct Packet { /// Pictures in flight: input / output buffer pairs. const RING: usize = 8; -/// An NVENC H.264 encoder. -pub struct NvencH264 { +/// An NVENC encoder (H.264, or HEVC when the profile is [`Profile::HevcMain`] or [`Profile::HevcMain10`]). +pub struct Nvenc { session: Session, + codec: Codec, + /// Empty for H.264. + vps: Vec, sps: Vec, pps: Vec, + /// The `hvcC` record (HEVC only), built from the parameter sets the encoder wrote. + hvcc: Option, /// Frames the output is delayed by reordering (0 or 1). delay: u32, free: Vec, @@ -84,16 +121,134 @@ pub struct NvencH264 { ready: usize, emitted: i64, size: (u32, u32), + /// Main 10: pictures go in as 10-bit P010 ([`Nvenc::encode_10`]), otherwise as 8-bit NV12 ([`Nvenc::encode`]). + ten_bit: bool, + /// Pictures go in as packed RGBA ([`Nvenc::encode_rgba`]) and the GPU converts them to 4:2:0. + rgba_input: bool, } +/// The H.264 encoder (the name it had before HEVC joined it). +pub type NvencH264 = Nvenc; + /// Whether this system has an NVIDIA GPU with a driver that has NVENC. pub fn available() -> bool { Session::open().is_ok() } -impl NvencH264 { - /// Open an encoder, or say why NVENC does not take this configuration. +/// Whether this system's NVENC can encode HEVC: a session opened and an HEVC encoder created for a +/// small picture, once (the answer is kept). What makes the H.265 export format available. +pub fn hevc_available() -> bool { + *HEVC_AVAILABLE.get_or_init(|| { + let probe = Config { + width: 640, + height: 360, + fps: (30, 1), + bitrate_kbps: 2000, + max_bitrate_kbps: 3000, + cbr: false, + keyint: 30, + profile: Profile::HevcMain, + level: None, + sar: None, + bframes: false, + }; + let result = Nvenc::new(&probe); + if let Err(why) = &result { + log::info!("no hardware HEVC encoder: {why}"); + } + result.is_ok() + }) +} + +/// The answer of [`hevc_available`], once asked. +static HEVC_AVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Ask [`hevc_available`] on a thread of its own (once, however often this is called), so the +/// first draw of Export's format list does not wait for the probe session. Whoever asks while it +/// runs waits for the same answer; if the thread cannot start, or the probe panics (caught), the +/// first caller asks, as before. +pub fn warm_hevc_probe() { + static STARTED: std::sync::Once = std::sync::Once::new(); + STARTED.call_once(|| { + let spawned = std::thread::Builder::new().name("nvenc-hevc-probe".into()).spawn(|| { + if std::panic::catch_unwind(hevc_available).is_err() { + log::warn!("the hardware HEVC probe panicked; the format list will ask again"); + } + if std::panic::catch_unwind(hevc_hdr_available).is_err() { + log::warn!("the hardware HEVC Main 10 probe panicked; the export will ask again"); + } + }); + if let Err(e) = spawned { + log::info!("hardware HEVC probe thread not started: {e}"); + } + }); +} + +/// Whether this system's NVENC can encode HEVC Main 10 (10-bit): HEVC works and a Main 10 session +/// opened for a small picture, once (the answer is kept). What makes HDR H.265 exports possible: +/// `filmcraft_export::hdr_available(Format::Hevc)`. +pub fn hevc_hdr_available() -> bool { + *HEVC_HDR_AVAILABLE.get_or_init(|| { + if !hevc_available() { + return false; + } + let probe = Config { + width: 640, + height: 360, + fps: (30, 1), + bitrate_kbps: 2000, + max_bitrate_kbps: 3000, + cbr: false, + keyint: 30, + profile: Profile::HevcMain10, + level: None, + sar: None, + bframes: false, + }; + let result = Nvenc::with_signal(&probe, &Signal::default()); + if let Err(why) = &result { + log::info!("no hardware HEVC Main 10 encoder: {why}"); + } + result.is_ok() + }) +} + +/// The answer of [`hevc_hdr_available`], once asked. +static HEVC_HDR_AVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Whether [`hevc_hdr_available`] has its answer yet (diagnostics and tests). +pub fn hevc_hdr_probed() -> bool { + HEVC_HDR_AVAILABLE.get().is_some() +} + +/// Whether [`hevc_available`] has its answer yet (diagnostics and tests). +pub fn hevc_probed() -> bool { + HEVC_AVAILABLE.get().is_some() +} + +impl Nvenc { + /// Open an encoder with the default BT.709 signal, or say why NVENC does not take this configuration. pub fn new(cfg: &Config) -> Result { + Self::with_signal(cfg, &Signal::default()) + } + + /// Open an encoder whose HEVC stream carries `signal` (colour description in the VUI, SEI messages + /// on every IDR picture), or say why NVENC does not take this configuration. H.264 takes the + /// default signal only. + pub fn with_signal(cfg: &Config, signal: &Signal) -> Result { + Self::open(cfg, signal, false) + } + + /// Open an 8-bit encoder that takes straight RGBA8 pictures ([`Nvenc::encode_rgba`]) and converts + /// them to 4:2:0 on the GPU, or say why not (Main 10, or a driver that refuses RGB input). + pub fn with_rgba_input(cfg: &Config, signal: &Signal) -> Result { + if cfg.profile == Profile::HevcMain10 { + return Err("RGBA input is 8-bit only".into()); + } + Self::open(cfg, signal, true) + } + + fn open(cfg: &Config, signal: &Signal, rgba_input: bool) -> Result { let (w, h) = (cfg.width, cfg.height); if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 { return Err(format!("{w}x{h}: NVENC needs even dimensions")); @@ -101,13 +256,33 @@ impl NvencH264 { if cfg.fps.0 == 0 || cfg.fps.1 == 0 { return Err("frame rate".into()); } + let codec = cfg.profile.codec(); + if codec == Codec::H264 && *signal != Signal::default() { + return Err("H.264 here is BT.709 without SEI messages".into()); + } + let level = match (codec, cfg.level) { + (Codec::Hevc, Some(l)) => Some(hevc::level_code(l).ok_or_else(|| format!("{}.{} is not an HEVC level", l / 10, l % 10))?), + // left to itself NVENC answers a bitrate above its chosen level's Main tier limit with the + // High tier, which many hardware decoders refuse: pick the lowest Main tier level instead + (Codec::Hevc, None) => { + let max_kbps = if cfg.cbr { cfg.bitrate_kbps } else { cfg.max_bitrate_kbps.max(cfg.bitrate_kbps) }; + hevc::main_tier_level(w, h, cfg.fps, max_kbps).and_then(hevc::level_code) + } + (_, l) => l, + }; let mut session = Session::open()?; - let caps = session.caps()?; + let caps = session.caps(codec)?; + check_caps(&caps, cfg)?; if w < caps.min_size.0 || h < caps.min_size.1 || w > caps.max_size.0 || h > caps.max_size.1 { return Err(format!("{w}x{h} is outside NVENC's {}x{} - {}x{}", caps.min_size.0, caps.min_size.1, caps.max_size.0, caps.max_size.1)); } - let bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1); + // NVENC wants a GOP longer than the B-frame pattern: HEVC with a keyframe every one or two + // pictures is simply written without B-frames (H.264 keeps declining those, to the software encoder) + let room_for_bframes = codec == Codec::H264 || cfg.keyint > 2; + let bframes = u32::from(cfg.bframes && cfg.profile != Profile::Baseline && caps.max_bframes >= 1 && room_for_bframes); + let ten_bit = cfg.profile == Profile::HevcMain10; let params = Params { + codec, width: w, height: h, fps: cfg.fps, @@ -117,17 +292,52 @@ impl NvencH264 { gop: cfg.keyint.max(1), profile: match cfg.profile { Profile::Baseline => 0, - Profile::Main => 1, + Profile::Main | Profile::HevcMain | Profile::HevcMain10 => 1, Profile::High => 2, }, - level: cfg.level, + ten_bit, + rgba_input, + signal: signal.clone(), + level, sar: cfg.sar, bframes, }; session.initialize(¶ms, RING)?; - let (sps, pps) = split_parameter_sets(&session.sequence_params()?)?; + let params = session.sequence_params()?; + let (vps, sps, pps, hvcc) = match codec { + Codec::H264 => { + let (sps, pps) = split_parameter_sets(¶ms)?; + (Vec::new(), sps, pps, None) + } + Codec::Hevc => { + let (vps, sps, pps) = split_hevc_parameter_sets(¶ms)?; + // validated now: a stream whose parameter sets we cannot describe is a declined export + let record = hevc::hevc_config(&vps, &sps, &pps, (w, h), if ten_bit { 10 } else { 8 })?; + (vps, sps, pps, Some(record)) + } + }; let slots = session.slots(); - Ok(Self { session, sps, pps, delay: bframes, free: (0..slots).rev().collect(), pending: VecDeque::new(), ready: 0, emitted: 0, size: (w, h) }) + Ok(Self { + session, + codec, + vps, + sps, + pps, + hvcc, + delay: bframes, + free: (0..slots).rev().collect(), + pending: VecDeque::new(), + ready: 0, + emitted: 0, + size: (w, h), + ten_bit, + rgba_input, + }) + } + + /// The codec this encoder writes. + pub fn codec(&self) -> Codec { + self.codec } /// The sequence and picture parameter sets (NAL units without start codes). @@ -135,18 +345,78 @@ impl NvencH264 { (&self.sps, &self.pps) } + /// The video parameter set (HEVC; empty for H.264). + pub fn vps(&self) -> &[u8] { + &self.vps + } + + /// The `hvcC` record of an HEVC stream (`None` for H.264). + pub fn hevc_config(&self) -> Option<&HevcConfig> { + self.hvcc.as_ref() + } + /// Frames the decoding time runs behind the presentation time (B-frame reordering). pub fn delay(&self) -> u32 { self.delay } - /// Encode picture `index` from planar 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes). - /// Returns the pictures that came out (usually the oldest; none while the ring fills). - pub fn encode(&mut self, y: &[u8], u: &[u8], v: &[u8], index: u64) -> Result, String> { + /// Whether this encoder takes 10-bit pictures ([`Nvenc::encode_10`]) instead of 8-bit ones ([`Nvenc::encode`]). + pub fn is_ten_bit(&self) -> bool { + self.ten_bit + } + + /// Whether this encoder takes packed RGBA pictures ([`Nvenc::encode_rgba`]) instead of planar ones. + pub fn takes_rgba(&self) -> bool { + self.rgba_input + } + + /// Encode picture `index` from straight RGBA8 (`w * 4` bytes per row, alpha ignored); NVENC + /// converts it to 4:2:0 with the stream's matrix. The pictures that came out, as for + /// [`Nvenc::encode`]. An error for an encoder opened without [`Nvenc::with_rgba_input`]. + pub fn encode_rgba(&mut self, rgba: &[u8], index: u64) -> Result, String> { + if !self.rgba_input { + return Err("an RGBA picture for a planar-input encoder".into()); + } let (w, h) = (self.size.0 as usize, self.size.1 as usize); - if y.len() < w * h || u.len() < w / 2 * (h / 2) || v.len() < w / 2 * (h / 2) { + if rgba.len() < w.saturating_mul(h).saturating_mul(4) { return Err("the picture is smaller than the encoder's size".into()); } + self.submit_picture(index, |l| fill_rgba(l, rgba, w, h)) + } + + /// Encode picture `index` from planar 8-bit 4:2:0 (`u`, `v` at half size, rows `w` and `w / 2` bytes). + /// Returns the pictures that came out (usually the oldest; none while the ring fills). An error + /// for a Main 10 encoder. + pub fn encode(&mut self, y: &[u8], u: &[u8], v: &[u8], index: u64) -> Result, String> { + if self.ten_bit { + return Err("an 8-bit picture for a Main 10 encoder".into()); + } + if self.rgba_input { + return Err("a planar picture for an RGBA-input encoder".into()); + } + let (w, h) = (self.size.0 as usize, self.size.1 as usize); + let (luma, chroma) = (w.saturating_mul(h), (w / 2).saturating_mul(h / 2)); + if y.len() < luma || u.len() < chroma || v.len() < chroma { + return Err("the picture is smaller than the encoder's size".into()); + } + self.submit_picture(index, |l| fill_nv12(l, y, u, v, w, h)) + } + + /// Encode picture `index` from planar 10-bit 4:2:0 (code values 0..=1023 in `u16`, `u`, `v` at + /// half size). The pictures that came out, as for [`Nvenc::encode`]. An error for a Main encoder. + pub fn encode_10(&mut self, y: &[u16], u: &[u16], v: &[u16], index: u64) -> Result, String> { + if !self.ten_bit { + return Err("a 10-bit picture for a Main (8-bit) encoder".into()); + } + let (w, h) = (self.size.0 as usize, self.size.1 as usize); + let (luma, chroma) = (w.saturating_mul(h), (w / 2).saturating_mul(h / 2)); + if y.len() < luma || u.len() < chroma || v.len() < chroma { + return Err("the picture is smaller than the encoder's size".into()); + } + self.submit_picture(index, |l| fill_p010(l, y, u, v, w, h)) + } + + fn submit_picture(&mut self, index: u64, fill: impl FnOnce(Locked<'_>)) -> Result, String> { let mut out = Vec::new(); let slot = match self.free.pop() { Some(s) => s, @@ -159,7 +429,7 @@ impl NvencH264 { self.free.pop().ok_or("no free encoder buffer")? } }; - let st = self.session.submit(slot, index, |l| fill_nv12(l, y, u, v, w, h)); + let st = self.session.submit(slot, index, fill); let st = match st { Ok(s) => s, Err(e) => { @@ -193,7 +463,7 @@ impl NvencH264 { let r = self.session.read(slot); self.free.push(slot); let o = r?; - let data = annex_b_to_length_prefixed(&o.data)?; + let data = annex_b_to_length_prefixed_for(self.codec, &o.data)?; let k = self.emitted; self.emitted = self.emitted.saturating_add(1); let pts = i64::try_from(o.pts).unwrap_or(i64::MAX); @@ -227,6 +497,57 @@ fn fill_nv12(l: Locked<'_>, y: &[u8], u: &[u8], v: &[u8], w: usize, h: usize) { } } +/// Copy straight RGBA8 rows into an ABGR input buffer (the same byte order: R, G, B, A). A row the +/// pitch cannot hold is skipped, never overrun. +fn fill_rgba(l: Locked<'_>, rgba: &[u8], w: usize, h: usize) { + let row = w.saturating_mul(4); + if row == 0 || l.pitch == 0 { + return; + } + for (src, dst) in rgba.chunks_exact(row).zip(l.data.chunks_mut(l.pitch)).take(h) { + if let Some(d) = dst.get_mut(..row) { + d.copy_from_slice(src); + } + } +} + +/// Copy planar 10-bit 4:2:0 into a P010 input buffer: each sample is a little-endian `u16` with the +/// 10-bit code in its high bits (`code << 6`), the chroma plane interleaved (Cb, Cr) after the luma rows, +/// `pitch` in bytes. Codes above 10 bits are masked; a row the pitch cannot hold is skipped, never overrun. +fn fill_p010(l: Locked<'_>, y: &[u16], u: &[u16], v: &[u16], w: usize, h: usize) { + let pitch = l.pitch; + let (luma, chroma) = l.data.split_at_mut(pitch.saturating_mul(h).min(l.data.len())); + if w == 0 || pitch == 0 { + return; + } + let code = |c: u16| ((c & 0x3ff) << 6).to_le_bytes(); + for (row, dst) in y.chunks_exact(w).zip(luma.chunks_exact_mut(pitch)).take(h) { + let Some(dst) = dst.get_mut(..w.saturating_mul(2)) else { continue }; + for (d, c) in dst.as_chunks_mut::<2>().0.iter_mut().zip(row) { + *d = code(*c); + } + } + let cw = w / 2; + if cw == 0 { + return; + } + for ((ur, vr), dst) in u.chunks_exact(cw).zip(v.chunks_exact(cw)).zip(chroma.chunks_exact_mut(pitch)).take(h / 2) { + let Some(dst) = dst.get_mut(..w.saturating_mul(2)) else { continue }; + for ((d, a), b) in dst.as_chunks_mut::<4>().0.iter_mut().zip(ur).zip(vr) { + let ([u0, u1], [v0, v1]) = (code(*a), code(*b)); + *d = [u0, u1, v0, v1]; + } + } +} + +/// Why this GPU's encoder cannot take `cfg` (beyond its size limits), or `Ok`: Main 10 needs 10-bit support. +fn check_caps(caps: &Caps, cfg: &Config) -> Result<(), String> { + if cfg.profile == Profile::HevcMain10 && !caps.ten_bit { + return Err("this GPU's encoder has no 10-bit (HEVC Main 10) support".into()); + } + Ok(()) +} + /// The NAL units of an Annex B byte stream (start codes `00 00 01` / `00 00 00 01`). pub fn annex_b_nals(data: &[u8]) -> Vec<&[u8]> { let mut starts = Vec::new(); @@ -256,16 +577,41 @@ pub fn annex_b_nals(data: &[u8]) -> Vec<&[u8]> { /// Annex B to 4-byte length-prefixed NAL units, dropping parameter sets (they live in the `avcC`) /// and access unit delimiters. Errors on a stream without NAL units. pub fn annex_b_to_length_prefixed(data: &[u8]) -> Result, String> { + annex_b_to_length_prefixed_for(Codec::H264, data) +} + +/// The NAL unit type of a NAL unit (`None` when it is too short to have a header). +pub fn nal_type(codec: Codec, nal: &[u8]) -> Option { + match codec { + Codec::H264 => nal.first().map(|b| b & 0x1f), + // two header bytes: forbidden_zero_bit, nal_unit_type (6), layer id (6), temporal id plus 1 (3) + Codec::Hevc => match nal { + [b, _, ..] => Some((b >> 1) & 0x3f), + _ => None, + }, + } +} + +/// [`annex_b_to_length_prefixed`] for a codec: H.264 drops SPS / PPS / AUD (types 7 to 9); HEVC drops +/// VPS / SPS / PPS / AUD (32 to 35) and end-of-sequence / end-of-bitstream markers (36, 37), since +/// the file is one coded video sequence. A NAL unit too short for its header is dropped. +pub fn annex_b_to_length_prefixed_for(codec: Codec, data: &[u8]) -> Result, String> { let nals = annex_b_nals(data); if nals.is_empty() { return Err("the encoder produced no NAL units".into()); } let mut out = Vec::with_capacity(data.len()); for n in nals { - if n.first().is_none_or(|b| matches!(b & 0x1f, 7..=9)) { + let dropped = match (codec, nal_type(codec, n)) { + (_, None) => true, + (Codec::H264, Some(t)) => matches!(t, 7..=9), + (Codec::Hevc, Some(t)) => matches!(t, 32..=37), + }; + if dropped { continue; } - out.extend_from_slice(&(n.len() as u32).to_be_bytes()); + let len = u32::try_from(n.len()).map_err(|_| "a NAL unit longer than 4 GiB".to_string())?; + out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(n); } if out.is_empty() { @@ -284,6 +630,16 @@ pub fn split_parameter_sets(data: &[u8]) -> Result<(Vec, Vec), String> { } } +/// The VPS, SPS and PPS NAL units of an Annex B byte string from an HEVC encoder. +pub fn split_hevc_parameter_sets(data: &[u8]) -> Result<(Vec, Vec, Vec), String> { + let nals = annex_b_nals(data); + let find = |t: u8| nals.iter().find(|n| nal_type(Codec::Hevc, n) == Some(t)).map(|n| n.to_vec()); + match (find(32), find(33), find(34)) { + (Some(v), Some(s), Some(p)) => Ok((v, s, p)), + _ => Err("the encoder returned no VPS / SPS / PPS".into()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -330,10 +686,159 @@ mod tests { fill_nv12(Locked { data: &mut buf, pitch }, &[1], &[], &[], 1, 1); } + #[test] + fn p010_is_shifted_little_endian_with_interleaved_chroma_in_bytes() { + let (w, h, pitch) = (4usize, 2usize, 12usize); // 2 bytes per sample + 4 bytes of padding + let y: Vec = vec![0, 1, 64, 1023, 940, 512, 4, 1019]; + let (u, v) = (vec![512, 960], vec![64, 1023]); + let mut buf = vec![0xAAu8; pitch * h * 3 / 2]; + fill_p010(Locked { data: &mut buf, pitch }, &y, &u, &v, w, h); + let sample = |at: usize| u16::from_le_bytes([buf[at], buf[at + 1]]); + assert_eq!((0..4).map(|x| sample(x * 2)).collect::>(), vec![0, 1 << 6, 64 << 6, 1023 << 6]); + assert_eq!((0..4).map(|x| sample(pitch + x * 2)).collect::>(), vec![940 << 6, 512 << 6, 4 << 6, 1019 << 6]); + // chroma after pitch * h bytes: Cb Cr Cb Cr + let c = pitch * h; + assert_eq!((0..4).map(|x| sample(c + x * 2)).collect::>(), vec![512 << 6, 64 << 6, 960 << 6, 1023 << 6]); + // the low 6 bits of every sample are zero and the padding is untouched + assert!((0..4).all(|x| sample(x * 2) & 0x3f == 0)); + assert_eq!(&buf[8..12], &[0xAA; 4]); + // a code beyond 10 bits is masked, not shifted into the next field + let mut b2 = vec![0u8; pitch * h * 3 / 2]; + fill_p010(Locked { data: &mut b2, pitch }, &[0xFFFF; 8], &[0xFFFF; 2], &[0xFFFF; 2], w, h); + assert_eq!(u16::from_le_bytes([b2[0], b2[1]]), 1023 << 6); + } + + #[test] + fn p010_never_writes_past_a_narrow_pitch_or_short_buffer() { + // a pitch narrower than 2 bytes per pixel (rejected by `submit`) must not panic here either + let (w, h) = (8usize, 4usize); + for pitch in [0usize, 1, 7, 8, 15, 16] { + let mut buf = vec![0u8; pitch * h * 3 / 2]; + let y = vec![1000u16; w * h]; + let (u, v) = (vec![500u16; w * h / 4], vec![600u16; w * h / 4]); + fill_p010(Locked { data: &mut buf, pitch }, &y, &u, &v, w, h); + } + // empty and short inputs, a buffer shorter than the pitch says + fill_p010(Locked { data: &mut [], pitch: 0 }, &[], &[], &[], 0, 0); + fill_p010(Locked { data: &mut [0u8; 5], pitch: 64 }, &[1; 64], &[1; 16], &[1; 16], 8, 8); + let mut buf = vec![0u8; 64]; + fill_p010(Locked { data: &mut buf, pitch: 16 }, &[1], &[], &[], 1, 1); + } + + #[test] + fn main_10_needs_a_gpu_with_10_bit_support() { + let cfg = |profile| Config { + width: 640, + height: 360, + fps: (24, 1), + bitrate_kbps: 1000, + max_bitrate_kbps: 1500, + cbr: false, + keyint: 24, + profile, + level: None, + sar: None, + bframes: false, + }; + let caps = |ten_bit| Caps { ten_bit, max_bframes: 2, min_size: (130, 128), max_size: (8192, 8192) }; + assert!(check_caps(&caps(true), &cfg(Profile::HevcMain10)).is_ok()); + let e = check_caps(&caps(false), &cfg(Profile::HevcMain10)).unwrap_err(); + assert!(e.contains("10-bit"), "{e}"); + for p in [Profile::HevcMain, Profile::Main, Profile::High, Profile::Baseline] { + assert!(check_caps(&caps(false), &cfg(p)).is_ok(), "{p:?} does not need 10-bit"); + } + } + #[test] fn empty_nal_units_are_skipped() { assert!(annex_b_nals(&[0, 0, 1, 0, 0, 1]).is_empty()); assert!(annex_b_to_length_prefixed(&[0, 0, 1, 0, 0, 0, 1]).is_err()); assert!(split_parameter_sets(&[0, 0, 1]).is_err()); } + + // HEVC NAL unit headers: type in bits 1..=6 of the first byte (VPS 0x40, SPS 0x42, PPS 0x44, AUD 0x46, + // EOS 0x48, EOB 0x4a, IDR_W_RADL 0x26, IDR_N_LP 0x28, TRAIL_R 0x02, prefix SEI 0x4e, suffix SEI 0x50) + #[test] + fn hevc_nal_types_come_from_the_second_header_bit_range() { + assert_eq!(nal_type(Codec::Hevc, &[0x40, 0x01]), Some(32)); + assert_eq!(nal_type(Codec::Hevc, &[0x26, 0x01, 9]), Some(19)); + assert_eq!(nal_type(Codec::Hevc, &[0x28, 0x01]), Some(20)); + assert_eq!(nal_type(Codec::Hevc, &[0x4e, 0x01]), Some(39)); + assert_eq!(nal_type(Codec::Hevc, &[0x50, 0x01]), Some(40)); + // the H.264 reading of the same bytes is different (and wrong for HEVC) + assert_eq!(nal_type(Codec::H264, &[0x28, 0x01]), Some(8)); + // no header, no type + assert_eq!(nal_type(Codec::Hevc, &[]), None); + assert_eq!(nal_type(Codec::Hevc, &[0x40]), None); + assert_eq!(nal_type(Codec::H264, &[]), None); + } + + #[test] + fn hevc_samples_keep_slices_and_sei_and_lose_parameter_sets_and_delimiters() { + let s = [ + &[0, 0, 0, 1, 0x46, 0x01, 0x50][..], // AUD + &[0, 0, 0, 1, 0x40, 0x01, 1, 2], // VPS + &[0, 0, 0, 1, 0x42, 0x01, 3], // SPS + &[0, 0, 1, 0x44, 0x01, 4], // PPS + &[0, 0, 1, 0x4e, 0x01, 5, 5], // prefix SEI + &[0, 0, 0, 1, 0x28, 0x01, 6, 6, 6], // IDR_N_LP: its header byte is 0x28, a PPS to an H.264 reader + &[0, 0, 1, 0x26, 0x01, 7], // IDR_W_RADL + &[0, 0, 1, 0x02, 0x01, 8], // TRAIL_R + &[0, 0, 1, 0x50, 0x01, 9], // suffix SEI + &[0, 0, 1, 0x48, 0x01], // end of sequence + &[0, 0, 1, 0x4a, 0x01], // end of bitstream + ] + .concat(); + let out = annex_b_to_length_prefixed_for(Codec::Hevc, &s).unwrap(); + let expected: Vec = [ + &[0, 0, 0, 4, 0x4e, 0x01, 5, 5][..], + &[0, 0, 0, 5, 0x28, 0x01, 6, 6, 6], + &[0, 0, 0, 3, 0x26, 0x01, 7], + &[0, 0, 0, 3, 0x02, 0x01, 8], + &[0, 0, 0, 3, 0x50, 0x01, 9], + ] + .concat(); + assert_eq!(out, expected); + // the same stream read as H.264 loses and keeps the wrong NAL units + assert_ne!(annex_b_to_length_prefixed(&s).unwrap(), expected); + } + + #[test] + fn hevc_parameter_sets_are_found_by_type() { + let s = [0, 0, 0, 1, 0x40, 0x01, 1, 0, 0, 0, 1, 0x42, 0x01, 2, 0, 0, 1, 0x44, 0x01, 3, 0, 0, 1, 0x26, 0x01, 4]; + assert_eq!(split_hevc_parameter_sets(&s).unwrap(), (vec![0x40, 1, 1], vec![0x42, 1, 2], vec![0x44, 1, 3])); + // an H.264 SPS / PPS pair has none of them; one missing is an error + assert!(split_hevc_parameter_sets(&[0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3]).is_err()); + assert!(split_hevc_parameter_sets(&[0, 0, 0, 1, 0x40, 0x01, 1, 0, 0, 1, 0x42, 0x01, 2]).is_err()); + } + + #[test] + fn hostile_hevc_streams_are_errors() { + // empty, no start code, only parameter sets / delimiters / markers, one-byte NAL units + for s in [ + &[][..], + &[0, 0, 1], + &[1, 2, 3], + &[0, 0, 1, 0x46, 0x01, 0x50], + &[0, 0, 0, 1, 0x40, 0x01], + &[0, 0, 1, 0x26], + &[0, 0, 1, 0x48, 0x01], + &[0, 0, 1, 0xff], + ] { + assert!(annex_b_to_length_prefixed_for(Codec::Hevc, s).is_err(), "{s:?}"); + } + // truncated slices are kept as they are (the decoder reports them), never panic + assert!(annex_b_to_length_prefixed_for(Codec::Hevc, &[0, 0, 1, 0x26, 0x01]).is_ok()); + assert!(split_hevc_parameter_sets(&[]).is_err()); + assert!(split_hevc_parameter_sets(&[0, 0, 1, 0x40]).is_err()); + } + + #[test] + fn the_hevc_profile_picks_the_hevc_codec() { + assert_eq!(Profile::HevcMain.codec(), Codec::Hevc); + assert_eq!(Profile::HevcMain10.codec(), Codec::Hevc); + for p in [Profile::Baseline, Profile::Main, Profile::High] { + assert_eq!(p.codec(), Codec::H264); + } + } } diff --git a/crates/platform/src/nvenc/session.rs b/crates/platform/src/nvenc/session.rs index 0aa362c8a..b19691f71 100644 --- a/crates/platform/src/nvenc/session.rs +++ b/crates/platform/src/nvenc/session.rs @@ -1,4 +1,4 @@ -//! An NVENC H.264 encode session: the driver library, the Direct3D 11 device it is opened on, the +//! An NVENC (H.264 or HEVC) encode session: the driver library, the device it is opened on, the //! encoder configuration, and the input / output buffers (every call into the driver). //! //! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the @@ -7,21 +7,14 @@ use std::ffi::{CStr, c_void}; use std::sync::OnceLock; -use windows::Win32::Foundation::HMODULE; -use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN; -use windows::Win32::Graphics::Direct3D11::{D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device}; -use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1}; -use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; -use windows::core::{Interface, PCSTR, w}; - +use super::device::Device; use super::ffi::*; -/// NVIDIA's PCI vendor id. -const NVIDIA: u32 = 0x10DE; - /// The driver's entry points (loaded once). struct Api { list: NV_ENCODE_API_FUNCTION_LIST, + #[cfg(target_os = "linux")] + _library: libloading::Library, } // SAFETY: the function table is plain function pointers, immutable after loading; NVENC's API is @@ -35,7 +28,11 @@ fn api() -> Result<&'static Api, String> { API.get_or_init(load).as_ref().map_err(Clone::clone) } +#[cfg(target_os = "windows")] fn load() -> Result { + use windows::Win32::Foundation::HMODULE; + use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW}; + use windows::core::{PCSTR, w}; // SAFETY: loads the driver's library from the system directory only. let module: HMODULE = unsafe { LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.map_err(|_| "no NVIDIA encoder driver".to_string())?; @@ -64,44 +61,75 @@ fn load() -> Result { } } -/// A Direct3D 11 device on the first NVIDIA adapter (what an NVENC session is opened on). -fn nvidia_device() -> Result { - // SAFETY: plain COM / D3D calls with valid out-pointers; the adapter outlives the call. +#[cfg(target_os = "linux")] +fn load() -> Result { + // SAFETY: runtime-load NVIDIA's driver and use the signatures from nvEncodeAPI.h. + // The library lives alongside the immutable function table, for the process lifetime. unsafe { - let factory: IDXGIFactory1 = CreateDXGIFactory1().map_err(|e| format!("no DXGI: {e}"))?; - let mut i = 0; - while let Ok(adapter) = factory.EnumAdapters1(i) { - i += 1; - let Ok(desc) = adapter.GetDesc1() else { continue }; - if desc.VendorId != NVIDIA { - continue; - } - let mut device = None; - let adapter: IDXGIAdapter = adapter.cast().map_err(|e| e.to_string())?; - if D3D11CreateDevice( - &adapter, - D3D_DRIVER_TYPE_UNKNOWN, - HMODULE::default(), - D3D11_CREATE_DEVICE_FLAG(0), - None, - D3D11_SDK_VERSION, - Some(&mut device), - None, - None, - ) - .is_ok() - && let Some(d) = device - { - return Ok(d); - } + let library = libloading::Library::new("libnvidia-encode.so.1").map_err(|e| format!("no NVIDIA encoder driver: {e}"))?; + let version: libloading::Symbol = library.get(b"NvEncodeAPIGetMaxSupportedVersion\0").map_err(|e| e.to_string())?; + let create: libloading::Symbol = library.get(b"NvEncodeAPICreateInstance\0").map_err(|e| e.to_string())?; + let mut v = 0; + if version(&mut v) != NV_ENC_SUCCESS { + return Err("cannot query the NVENC version".into()); } + let ours = (NVENCAPI_VERSION & 0xf) << 4 | ((NVENCAPI_VERSION >> 24) & 0xf); + if v < ours { + return Err(format!("the NVIDIA driver's NVENC ({}.{}) is older than {}.{}", v >> 4, v & 0xf, ours >> 4, ours & 0xf)); + } + let mut list: NV_ENCODE_API_FUNCTION_LIST = std::mem::zeroed(); + list.version = NV_ENCODE_API_FUNCTION_LIST_VER; + if create(&mut list) != NV_ENC_SUCCESS { + return Err("NvEncodeAPICreateInstance failed".into()); + } + Ok(Api { list, _library: library }) } - Err("no NVIDIA adapter".into()) } -/// What the encoder says it can do for H.264. +/// The codec a session encodes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Codec { + H264, + Hevc, +} + +impl Codec { + fn guid(self) -> Guid { + match self { + Codec::H264 => NV_ENC_CODEC_H264_GUID, + Codec::Hevc => NV_ENC_CODEC_HEVC_GUID, + } + } +} + +/// One SEI message the encoder writes in front of every IDR picture (HEVC): its payload type and bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sei { + pub payload_type: u32, + pub payload: Vec, +} + +/// The colour description of a stream (ITU-T H.273 code points, limited range) and the SEI messages +/// that go with it. The default is BT.709 without SEI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Signal { + pub primaries: u8, + pub transfer: u8, + pub matrix: u8, + pub sei: Vec, +} + +impl Default for Signal { + fn default() -> Self { + Signal { primaries: 1, transfer: 1, matrix: 1, sei: Vec::new() } + } +} + +/// What the encoder says it can do for a codec. #[derive(Clone, Copy, Debug)] pub struct Caps { + /// 10-bit encoding (Main 10) is supported. + pub ten_bit: bool, pub max_bframes: u32, pub min_size: (u32, u32), pub max_size: (u32, u32), @@ -110,6 +138,7 @@ pub struct Caps { /// Encoder settings (already validated by the caller). #[derive(Clone, Debug)] pub struct Params { + pub codec: Codec, pub width: u32, pub height: u32, pub fps: (u32, u32), @@ -117,16 +146,23 @@ pub struct Params { pub max_bitrate: u32, pub cbr: bool, pub gop: u32, - /// 0 baseline, 1 main, 2 high. + /// H.264 only: 0 baseline, 1 main, 2 high (HEVC is Main, or Main 10 with `ten_bit`). pub profile: u8, + /// HEVC Main 10: 10-bit 4:2:0 pictures (P010), otherwise 8-bit (NV12). + pub ten_bit: bool, + /// 8-bit only: pictures go in as packed RGBA (ABGR) and NVENC converts them to 4:2:0 on the GPU. + pub rgba_input: bool, + /// HEVC: the VUI colour description and the SEI messages of every IDR picture (H.264 keeps BT.709). + pub signal: Signal, + /// The codec's own level code: `level × 10` for H.264, `level × 30` for HEVC. pub level: Option, pub sar: Option<(u32, u32)>, /// B-frames between references (0 or 1). pub bframes: u32, } -/// An input buffer locked for writing: NV12 rows go to `data` at `pitch` bytes per row, the -/// chroma plane after `pitch * height` bytes. +/// An input buffer locked for writing: NV12 (or P010) rows go to `data` at `pitch` bytes per row, the +/// chroma plane after `pitch * height` bytes; packed RGBA (ABGR) has `height` rows and no chroma plane. pub struct Locked<'a> { pub data: &'a mut [u8], pub pitch: usize, @@ -159,15 +195,26 @@ struct Slot { pub struct Session { api: &'static Api, enc: Handle, - // keeps the Direct3D device (the session's device) alive - _device: ID3D11Device, + // Keeps the Direct3D device / retained CUDA context alive through session destruction. + _device: Device, slots: Vec, size: (u32, u32), out_size: u32, + /// `NV_ENC_BUFFER_FORMAT_*` of the input buffers and pictures, and its bytes per sample. + fmt: u32, + bytes_per_sample: usize, + /// The SEI messages of every IDR picture. The payload bytes and the array of descriptors live on + /// the heap, owned by the session for its whole life, so the pointers handed to the driver stay + /// valid however the `Session` value is moved and however long the driver keeps a picture queued. + sei_payloads: Vec>, + sei_array: Box<[NV_ENC_SEI_PAYLOAD]>, + /// Pictures submitted, and the distance between IDR pictures (to know which pictures get the SEI). + submitted: u64, + gop: u64, } // SAFETY: an encoder session and its buffers are driver objects used from one thread at a time (the -// session is `&mut`-driven); the Direct3D device is a free-threaded COM object. +// session is `&mut`-driven); Direct3D is free-threaded, and NVENC takes an explicit CUDA context. unsafe impl Send for Session {} /// A readable form of a driver status. @@ -181,7 +228,8 @@ fn status(what: &str, s: NvStatus, enc: Handle, api: &Api) -> String { }) }) .flatten() - .filter(|d| !d.is_empty()); + // the driver's text is sometimes not text at all (HEVC parameter errors): keep it only when readable + .filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_graphic() || c == ' ')); match detail { Some(d) => format!("{what} failed ({s}): {d}"), None => format!("{what} failed ({s})"), @@ -192,14 +240,14 @@ impl Session { /// Open a session on the system's NVIDIA GPU, or say why not. pub fn open() -> Result { let api = api()?; - let device = nvidia_device()?; - // SAFETY: a zeroed parameter block with its version and the device's COM pointer; the + let device = Device::new()?; + // SAFETY: a zeroed parameter block with its version and the matching device pointer; the // device is kept in the session for as long as the encoder lives. On failure a non-null // handle is the driver's and is destroyed exactly once, here, before returning. let enc = unsafe { let mut p: NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS = std::mem::zeroed(); p.version = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER; - p.deviceType = NV_ENC_DEVICE_TYPE_DIRECTX; + p.deviceType = Device::KIND; p.device = device.as_raw(); p.apiVersion = NVENCAPI_VERSION; let open = api.list.nvEncOpenEncodeSessionEx.ok_or("the driver has no nvEncOpenEncodeSessionEx")?; @@ -218,11 +266,24 @@ impl Session { } enc }; - Ok(Session { api, enc, _device: device, slots: Vec::new(), size: (0, 0), out_size: 0 }) + Ok(Session { + api, + enc, + _device: device, + slots: Vec::new(), + size: (0, 0), + out_size: 0, + fmt: NV_ENC_BUFFER_FORMAT_NV12, + bytes_per_sample: 1, + sei_payloads: Vec::new(), + sei_array: Box::new([]), + submitted: 0, + gop: 1, + }) } - /// The encoder's limits for H.264. - pub fn caps(&self) -> Result { + /// The encoder's limits for `codec` (an error when the GPU cannot encode it). + pub fn caps(&self, codec: Codec) -> Result { let get = |cap: u32| -> Result { let f = self.api.list.nvEncGetEncodeCaps.ok_or("no nvEncGetEncodeCaps")?; // SAFETY: a valid session, a zeroed parameter block with its version, one int out. @@ -231,14 +292,16 @@ impl Session { p.version = NV_ENC_CAPS_PARAM_VER; p.capsToQuery = cap; let mut v = 0i32; - let s = f(self.enc, NV_ENC_CODEC_H264_GUID, &mut p, &mut v); + let s = f(self.enc, codec.guid(), &mut p, &mut v); if s != NV_ENC_SUCCESS { - return Err(status("querying the encoder", s, self.enc, self.api)); + return Err(status(if codec == Codec::Hevc { "querying the HEVC encoder" } else { "querying the encoder" }, s, self.enc, self.api)); } Ok(v.max(0) as u32) } }; Ok(Caps { + // a failing query means no 10-bit encoder + ten_bit: get(NV_ENC_CAPS_SUPPORT_10BIT_ENCODE).unwrap_or(0) != 0, max_bframes: get(NV_ENC_CAPS_NUM_MAX_BFRAMES)?, min_size: (get(NV_ENC_CAPS_WIDTH_MIN)?, get(NV_ENC_CAPS_HEIGHT_MIN)?), max_size: (get(NV_ENC_CAPS_WIDTH_MAX)?, get(NV_ENC_CAPS_HEIGHT_MAX)?), @@ -248,6 +311,30 @@ impl Session { /// Configure the encoder (preset P5, high quality tuning, the settings' rate control) and /// create `ring` pairs of input / output buffers. pub fn initialize(&mut self, p: &Params, ring: usize) -> Result<(), String> { + if p.ten_bit && p.codec != Codec::Hevc { + return Err("10-bit encoding is HEVC Main 10 here".into()); + } + if !p.signal.sei.is_empty() && p.codec != Codec::Hevc { + return Err("SEI messages are written for HEVC only".into()); + } + if p.ten_bit && p.rgba_input { + return Err("RGBA input is 8-bit only".into()); + } + (self.fmt, self.bytes_per_sample) = match (p.ten_bit, p.rgba_input) { + (true, _) => (NV_ENC_BUFFER_FORMAT_YUV420_10BIT, 2), + (false, true) => (NV_ENC_BUFFER_FORMAT_ABGR, 4), + (false, false) => (NV_ENC_BUFFER_FORMAT_NV12, 1), + }; + self.gop = u64::from(p.gop.max(1)); + self.submitted = 0; + // the SEI payloads: heap blocks that outlive every picture (see the field documentation) + self.sei_payloads = p.signal.sei.iter().map(|m| m.payload.clone().into_boxed_slice()).collect(); + let mut array = Vec::with_capacity(self.sei_payloads.len()); + for (m, bytes) in p.signal.sei.iter().zip(self.sei_payloads.iter_mut()) { + let size = u32::try_from(bytes.len()).map_err(|_| "an SEI payload longer than 4 GiB".to_string())?; + array.push(NV_ENC_SEI_PAYLOAD { payloadSize: size, payloadType: m.payload_type, payload: bytes.as_mut_ptr() }); + } + self.sei_array = array.into_boxed_slice(); let preset_fn = self.api.list.nvEncGetEncodePresetConfigEx.ok_or("no nvEncGetEncodePresetConfigEx")?; let init_fn = self.api.list.nvEncInitializeEncoder.ok_or("no nvEncInitializeEncoder")?; // SAFETY: zeroed parameter blocks with their versions; the preset call fills `preset`; the @@ -256,16 +343,18 @@ impl Session { let mut preset: Box = Box::new(std::mem::zeroed()); preset.version = NV_ENC_PRESET_CONFIG_VER; preset.presetCfg.version = NV_ENC_CONFIG_VER; - let s = preset_fn(self.enc, NV_ENC_CODEC_H264_GUID, NV_ENC_PRESET_P5_GUID, NV_ENC_TUNING_INFO_HIGH_QUALITY, &mut *preset); + let s = preset_fn(self.enc, p.codec.guid(), NV_ENC_PRESET_P5_GUID, NV_ENC_TUNING_INFO_HIGH_QUALITY, &mut *preset); if s != NV_ENC_SUCCESS { return Err(status("reading the encoder preset", s, self.enc, self.api)); } let mut cfg: Box = Box::new(preset.presetCfg); cfg.version = NV_ENC_CONFIG_VER; - cfg.profileGUID = match p.profile { - 0 => NV_ENC_H264_PROFILE_BASELINE_GUID, - 1 => NV_ENC_H264_PROFILE_MAIN_GUID, - _ => NV_ENC_H264_PROFILE_HIGH_GUID, + cfg.profileGUID = match (p.codec, p.profile) { + (Codec::Hevc, _) if p.ten_bit => NV_ENC_HEVC_PROFILE_MAIN10_GUID, + (Codec::Hevc, _) => NV_ENC_HEVC_PROFILE_MAIN_GUID, + (Codec::H264, 0) => NV_ENC_H264_PROFILE_BASELINE_GUID, + (Codec::H264, 1) => NV_ENC_H264_PROFILE_MAIN_GUID, + (Codec::H264, _) => NV_ENC_H264_PROFILE_HIGH_GUID, }; cfg.gopLength = p.gop.max(1); cfg.frameIntervalP = 1 + p.bframes as i32; @@ -277,28 +366,65 @@ impl Session { // a one-second buffer, the usual for streaming-style rate control rc.vbvBufferSize = rc.maxBitRate; rc.vbvInitialDelay = rc.vbvBufferSize; - let h = &mut cfg.encodeCodecConfig.h264Config; - h.idrPeriod = p.gop.max(1); - h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from); - h.flags &= !(H264_OUTPUT_AUD | H264_REPEAT_SPSPPS); - h.entropyCodingMode = if p.profile == 0 { 2 } else { NV_ENC_H264_ENTROPY_CODING_MODE_CABAC }; - h.maxNumRefFrames = 0; - // BT.709 limited range, like our own encoder's default signalling - let vui = &mut h.h264VUIParameters; - vui.videoSignalTypePresentFlag = 1; - vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED; - vui.videoFullRangeFlag = 0; - vui.colourDescriptionPresentFlag = 1; - vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709; - vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709; - vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709; - vui.timingInfoPresentFlag = 1; - vui.numUnitInTicks = p.fps.1; - vui.timeScale = p.fps.0.saturating_mul(2); + match p.codec { + Codec::H264 => { + let h = &mut cfg.encodeCodecConfig.h264Config; + h.idrPeriod = p.gop.max(1); + h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from); + h.flags &= !(H264_OUTPUT_AUD | H264_REPEAT_SPSPPS); + h.entropyCodingMode = if p.profile == 0 { 2 } else { NV_ENC_H264_ENTROPY_CODING_MODE_CABAC }; + h.maxNumRefFrames = 0; + // BT.709 limited range, like our own encoder's default signalling + let vui = &mut h.h264VUIParameters; + vui.videoSignalTypePresentFlag = 1; + vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED; + vui.videoFullRangeFlag = 0; + vui.colourDescriptionPresentFlag = 1; + vui.colourPrimaries = NV_ENC_VUI_COLOR_PRIMARIES_BT709; + vui.transferCharacteristics = NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709; + vui.colourMatrix = NV_ENC_VUI_MATRIX_COEFFS_BT709; + vui.timingInfoPresentFlag = 1; + vui.numUnitInTicks = p.fps.1; + vui.timeScale = p.fps.0.saturating_mul(2); + } + Codec::Hevc => { + let h = &mut cfg.encodeCodecConfig.hevcConfig; + h.level = p.level.map_or(NV_ENC_LEVEL_AUTOSELECT, u32::from); + h.tier = NV_ENC_TIER_HEVC_MAIN; + h.minCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT; + h.maxCUSize = NV_ENC_HEVC_CUSIZE_AUTOSELECT; + h.idrPeriod = p.gop.max(1); + // parameter sets only in the first IDR (and the hvcC), no access unit delimiters, 4:2:0 at 8 or 10 bits + h.flags &= !(HEVC_OUTPUT_AUD + | HEVC_DISABLE_SPSPPS + | HEVC_REPEAT_SPSPPS + | HEVC_ENABLE_INTRA_REFRESH + | HEVC_CHROMA_FORMAT_MASK + | HEVC_PIXEL_BIT_DEPTH_MASK); + h.flags |= 1 << HEVC_CHROMA_FORMAT_SHIFT; + if p.ten_bit { + h.flags |= 2 << HEVC_PIXEL_BIT_DEPTH_SHIFT; + } + h.maxNumRefFramesInDPB = 0; + // the stream's colour description (BT.709 unless the signal says otherwise), limited + // range; an HEVC VUI counts frames, not fields: frame rate = time_scale / num_units_in_tick + let vui = &mut h.hevcVUIParameters; + vui.videoSignalTypePresentFlag = 1; + vui.videoFormat = NV_ENC_VUI_VIDEO_FORMAT_UNSPECIFIED; + vui.videoFullRangeFlag = 0; + vui.colourDescriptionPresentFlag = 1; + vui.colourPrimaries = u32::from(p.signal.primaries); + vui.transferCharacteristics = u32::from(p.signal.transfer); + vui.colourMatrix = u32::from(p.signal.matrix); + vui.timingInfoPresentFlag = 1; + vui.numUnitInTicks = p.fps.1; + vui.timeScale = p.fps.0; + } + } let mut init: Box = Box::new(std::mem::zeroed()); init.version = NV_ENC_INITIALIZE_PARAMS_VER; - init.encodeGUID = NV_ENC_CODEC_H264_GUID; + init.encodeGUID = p.codec.guid(); init.presetGUID = NV_ENC_PRESET_P5_GUID; init.encodeWidth = p.width; init.encodeHeight = p.height; @@ -310,7 +436,7 @@ impl Session { init.enablePTD = 1; init.encodeConfig = &mut *cfg; init.tuningInfo = NV_ENC_TUNING_INFO_HIGH_QUALITY; - init.bufferFormat = NV_ENC_BUFFER_FORMAT_NV12; + init.bufferFormat = self.fmt; let s = init_fn(self.enc, &mut *init); if s != NV_ENC_SUCCESS { return Err(status("initialising the encoder", s, self.enc, self.api)); @@ -337,7 +463,7 @@ impl Session { i.width = self.size.0; i.height = self.size.1; i.memoryHeap = NV_ENC_MEMORY_HEAP_SYSMEM_CACHED; - i.bufferFmt = NV_ENC_BUFFER_FORMAT_NV12; + i.bufferFmt = self.fmt; let s = create_in(self.enc, &mut i); if s != NV_ENC_SUCCESS { return Err(status("creating an input buffer", s, self.enc, self.api)); @@ -363,7 +489,7 @@ impl Session { self.slots.len() } - /// The SPS and PPS as one Annex B byte string. + /// The parameter sets as one Annex B byte string (SPS and PPS; VPS, SPS and PPS for HEVC). pub fn sequence_params(&self) -> Result, String> { let f = self.api.list.nvEncGetSequenceParams.ok_or("no nvEncGetSequenceParams")?; let mut buf = vec![0u8; 1024]; @@ -397,8 +523,9 @@ impl Session { ); let (w, h) = self.size; // SAFETY: the buffer handle is one this session created. `LockInputBuffer` returns a - // writable block of `pitch * h * 3 / 2` bytes (NV12: luma rows then chroma rows) that stays - // valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`. + // writable block of `pitch * h * 3 / 2` bytes (NV12 or P010: luma rows then chroma rows, the pitch + // in bytes), or `pitch * h` bytes (ABGR: packed rows only), that stays valid until + // `UnlockInputBuffer`; the slice borrows it only inside `fill`. let pitch = unsafe { let mut l: NV_ENC_LOCK_INPUT_BUFFER = std::mem::zeroed(); l.version = NV_ENC_LOCK_INPUT_BUFFER_VER; @@ -407,13 +534,14 @@ impl Session { if st != NV_ENC_SUCCESS || l.bufferDataPtr.is_null() || l.pitch == 0 { return Err(status("locking an input buffer", st, self.enc, self.api)); } - // a driver pitch narrower than a row would make `fill` write past each row - if (l.pitch as usize) < w as usize { + // a driver pitch narrower than a row (in bytes: P010 samples are two) would make `fill` write past each row + if (l.pitch as usize) < (w as usize).saturating_mul(self.bytes_per_sample) { // the lock succeeded, so release it before giving up (its status no longer matters) let _ = unlock(self.enc, input); - return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows", l.pitch)); + return Err(format!("the driver returned an input pitch of {} bytes for {w}-pixel rows of {}-byte samples", l.pitch, self.bytes_per_sample)); } - let len = (l.pitch as usize).saturating_mul(h as usize).saturating_mul(3) / 2; + let rows = (l.pitch as usize).saturating_mul(h as usize); + let len = if self.fmt == NV_ENC_BUFFER_FORMAT_ABGR { rows } else { rows.saturating_mul(3) / 2 }; fill(Locked { data: std::slice::from_raw_parts_mut(l.bufferDataPtr.cast::(), len), pitch: l.pitch as usize }); let st = unlock(self.enc, input); if st != NV_ENC_SUCCESS { @@ -421,16 +549,27 @@ impl Session { } l.pitch }; - // SAFETY: a zeroed parameter block with its version; the buffers are this session's. + // IDR pictures (every `gop` pictures from the first, in submission = display order) carry the SEI messages + let idr = !self.sei_array.is_empty() && self.submitted.is_multiple_of(self.gop); + self.submitted = self.submitted.saturating_add(1); + // SAFETY: a zeroed parameter block with its version; the buffers are this session's. The SEI + // descriptor array and the payload bytes it points to are heap blocks owned by `self`, which + // stay allocated (and unmoved) until the session is dropped, so they outlive this call and + // whatever time the driver keeps the picture queued. let st = unsafe { let mut e: NV_ENC_PIC_PARAMS = std::mem::zeroed(); + if idr { + let h = &mut e.codecPicParams.hevcPicParams; + h.seiPayloadArrayCnt = u32::try_from(self.sei_array.len()).unwrap_or(0); + h.seiPayloadArray = self.sei_array.as_mut_ptr(); + } e.version = NV_ENC_PIC_PARAMS_VER; e.inputWidth = w; e.inputHeight = h; e.inputPitch = pitch; e.inputBuffer = input; e.outputBitstream = output; - e.bufferFmt = NV_ENC_BUFFER_FORMAT_NV12; + e.bufferFmt = self.fmt; e.pictureStruct = NV_ENC_PIC_STRUCT_FRAME; e.inputTimeStamp = pts; e.inputDuration = 1; diff --git a/crates/platform/src/vaapi/abi_tests.rs b/crates/platform/src/vaapi/abi_tests.rs new file mode 100644 index 000000000..69e5ffc01 --- /dev/null +++ b/crates/platform/src/vaapi/abi_tests.rs @@ -0,0 +1,365 @@ +//! The VA-API FFI structures against the C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23, libva +//! 2.23): sizes, alignments, field offsets, constants and bit-field positions. Generated by running a +//! C program (built with GCC against the libva-dev headers, x86-64 Linux) that prints them; the +//! header itself is not in this repository. To regenerate, compile a program printing `sizeof` / +//! `offsetof` of the structures of `src/vaapi/ffi.rs`, the constants it uses, and the `value` word of +//! each `seq_fields` / `pic_fields` bit-field set alone, against libva's MIT-licensed headers. +#![allow(clippy::unreadable_literal)] + +use super::ffi::*; +use std::mem::{align_of, offset_of, size_of}; + +#[test] +fn layouts_match_the_header() { + assert_eq!((size_of::(), align_of::()), (8, 4), "VAConfigAttrib"); + assert_eq!(offset_of!(VAConfigAttrib, type_), 0, "VAConfigAttrib.type"); + assert_eq!(offset_of!(VAConfigAttrib, value), 4, "VAConfigAttrib.value"); + assert_eq!((size_of::(), align_of::()), (36, 4), "VAPictureH264"); + assert_eq!(offset_of!(VAPictureH264, picture_id), 0, "VAPictureH264.picture_id"); + assert_eq!(offset_of!(VAPictureH264, frame_idx), 4, "VAPictureH264.frame_idx"); + assert_eq!(offset_of!(VAPictureH264, flags), 8, "VAPictureH264.flags"); + assert_eq!(offset_of!(VAPictureH264, TopFieldOrderCnt), 12, "VAPictureH264.TopFieldOrderCnt"); + assert_eq!(offset_of!(VAPictureH264, BottomFieldOrderCnt), 16, "VAPictureH264.BottomFieldOrderCnt"); + assert_eq!(offset_of!(VAPictureH264, va_reserved), 20, "VAPictureH264.va_reserved"); + assert_eq!((size_of::(), align_of::()), (672, 4), "VAPictureParameterBufferH264"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, CurrPic), 0, "VAPictureParameterBufferH264.CurrPic"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, ReferenceFrames), 36, "VAPictureParameterBufferH264.ReferenceFrames"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, picture_width_in_mbs_minus1), 612, "VAPictureParameterBufferH264.picture_width_in_mbs_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, picture_height_in_mbs_minus1), 614, "VAPictureParameterBufferH264.picture_height_in_mbs_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, bit_depth_luma_minus8), 616, "VAPictureParameterBufferH264.bit_depth_luma_minus8"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, bit_depth_chroma_minus8), 617, "VAPictureParameterBufferH264.bit_depth_chroma_minus8"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, num_ref_frames), 618, "VAPictureParameterBufferH264.num_ref_frames"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, seq_fields), 620, "VAPictureParameterBufferH264.seq_fields"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, num_slice_groups_minus1), 624, "VAPictureParameterBufferH264.num_slice_groups_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, slice_group_map_type), 625, "VAPictureParameterBufferH264.slice_group_map_type"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, slice_group_change_rate_minus1), 626, "VAPictureParameterBufferH264.slice_group_change_rate_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_init_qp_minus26), 628, "VAPictureParameterBufferH264.pic_init_qp_minus26"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_init_qs_minus26), 629, "VAPictureParameterBufferH264.pic_init_qs_minus26"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, chroma_qp_index_offset), 630, "VAPictureParameterBufferH264.chroma_qp_index_offset"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, second_chroma_qp_index_offset), 631, "VAPictureParameterBufferH264.second_chroma_qp_index_offset"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, pic_fields), 632, "VAPictureParameterBufferH264.pic_fields"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, frame_num), 636, "VAPictureParameterBufferH264.frame_num"); + assert_eq!(offset_of!(VAPictureParameterBufferH264, va_reserved), 640, "VAPictureParameterBufferH264.va_reserved"); + assert_eq!((size_of::(), align_of::()), (240, 4), "VAIQMatrixBufferH264"); + assert_eq!(offset_of!(VAIQMatrixBufferH264, ScalingList4x4), 0, "VAIQMatrixBufferH264.ScalingList4x4"); + assert_eq!(offset_of!(VAIQMatrixBufferH264, ScalingList8x8), 96, "VAIQMatrixBufferH264.ScalingList8x8"); + assert_eq!(offset_of!(VAIQMatrixBufferH264, va_reserved), 224, "VAIQMatrixBufferH264.va_reserved"); + assert_eq!((size_of::(), align_of::()), (3128, 4), "VASliceParameterBufferH264"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_size), 0, "VASliceParameterBufferH264.slice_data_size"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_offset), 4, "VASliceParameterBufferH264.slice_data_offset"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_flag), 8, "VASliceParameterBufferH264.slice_data_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_data_bit_offset), 12, "VASliceParameterBufferH264.slice_data_bit_offset"); + assert_eq!(offset_of!(VASliceParameterBufferH264, first_mb_in_slice), 14, "VASliceParameterBufferH264.first_mb_in_slice"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_type), 16, "VASliceParameterBufferH264.slice_type"); + assert_eq!(offset_of!(VASliceParameterBufferH264, direct_spatial_mv_pred_flag), 17, "VASliceParameterBufferH264.direct_spatial_mv_pred_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, num_ref_idx_l0_active_minus1), 18, "VASliceParameterBufferH264.num_ref_idx_l0_active_minus1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, num_ref_idx_l1_active_minus1), 19, "VASliceParameterBufferH264.num_ref_idx_l1_active_minus1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, cabac_init_idc), 20, "VASliceParameterBufferH264.cabac_init_idc"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_qp_delta), 21, "VASliceParameterBufferH264.slice_qp_delta"); + assert_eq!(offset_of!(VASliceParameterBufferH264, disable_deblocking_filter_idc), 22, "VASliceParameterBufferH264.disable_deblocking_filter_idc"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_alpha_c0_offset_div2), 23, "VASliceParameterBufferH264.slice_alpha_c0_offset_div2"); + assert_eq!(offset_of!(VASliceParameterBufferH264, slice_beta_offset_div2), 24, "VASliceParameterBufferH264.slice_beta_offset_div2"); + assert_eq!(offset_of!(VASliceParameterBufferH264, RefPicList0), 28, "VASliceParameterBufferH264.RefPicList0"); + assert_eq!(offset_of!(VASliceParameterBufferH264, RefPicList1), 1180, "VASliceParameterBufferH264.RefPicList1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_log2_weight_denom), 2332, "VASliceParameterBufferH264.luma_log2_weight_denom"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_log2_weight_denom), 2333, "VASliceParameterBufferH264.chroma_log2_weight_denom"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l0_flag), 2334, "VASliceParameterBufferH264.luma_weight_l0_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l0), 2336, "VASliceParameterBufferH264.luma_weight_l0"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_offset_l0), 2400, "VASliceParameterBufferH264.luma_offset_l0"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l0_flag), 2464, "VASliceParameterBufferH264.chroma_weight_l0_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l0), 2466, "VASliceParameterBufferH264.chroma_weight_l0"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_offset_l0), 2594, "VASliceParameterBufferH264.chroma_offset_l0"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l1_flag), 2722, "VASliceParameterBufferH264.luma_weight_l1_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_weight_l1), 2724, "VASliceParameterBufferH264.luma_weight_l1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, luma_offset_l1), 2788, "VASliceParameterBufferH264.luma_offset_l1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l1_flag), 2852, "VASliceParameterBufferH264.chroma_weight_l1_flag"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_weight_l1), 2854, "VASliceParameterBufferH264.chroma_weight_l1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, chroma_offset_l1), 2982, "VASliceParameterBufferH264.chroma_offset_l1"); + assert_eq!(offset_of!(VASliceParameterBufferH264, va_reserved), 3112, "VASliceParameterBufferH264.va_reserved"); + assert_eq!((size_of::(), align_of::()), (48, 4), "VAImageFormat"); + assert_eq!(offset_of!(VAImageFormat, fourcc), 0, "VAImageFormat.fourcc"); + assert_eq!(offset_of!(VAImageFormat, byte_order), 4, "VAImageFormat.byte_order"); + assert_eq!(offset_of!(VAImageFormat, bits_per_pixel), 8, "VAImageFormat.bits_per_pixel"); + assert_eq!(offset_of!(VAImageFormat, depth), 12, "VAImageFormat.depth"); + assert_eq!(offset_of!(VAImageFormat, red_mask), 16, "VAImageFormat.red_mask"); + assert_eq!(offset_of!(VAImageFormat, green_mask), 20, "VAImageFormat.green_mask"); + assert_eq!(offset_of!(VAImageFormat, blue_mask), 24, "VAImageFormat.blue_mask"); + assert_eq!(offset_of!(VAImageFormat, alpha_mask), 28, "VAImageFormat.alpha_mask"); + assert_eq!(offset_of!(VAImageFormat, va_reserved), 32, "VAImageFormat.va_reserved"); + assert_eq!((size_of::(), align_of::()), (120, 4), "VAImage"); + assert_eq!(offset_of!(VAImage, image_id), 0, "VAImage.image_id"); + assert_eq!(offset_of!(VAImage, format), 4, "VAImage.format"); + assert_eq!(offset_of!(VAImage, buf), 52, "VAImage.buf"); + assert_eq!(offset_of!(VAImage, width), 56, "VAImage.width"); + assert_eq!(offset_of!(VAImage, height), 58, "VAImage.height"); + assert_eq!(offset_of!(VAImage, data_size), 60, "VAImage.data_size"); + assert_eq!(offset_of!(VAImage, num_planes), 64, "VAImage.num_planes"); + assert_eq!(offset_of!(VAImage, pitches), 68, "VAImage.pitches"); + assert_eq!(offset_of!(VAImage, offsets), 80, "VAImage.offsets"); + assert_eq!(offset_of!(VAImage, num_palette_entries), 92, "VAImage.num_palette_entries"); + assert_eq!(offset_of!(VAImage, entry_bytes), 96, "VAImage.entry_bytes"); + assert_eq!(offset_of!(VAImage, component_order), 100, "VAImage.component_order"); + assert_eq!(offset_of!(VAImage, va_reserved), 104, "VAImage.va_reserved"); + assert_eq!((size_of::(), align_of::()), (28, 4), "VAPictureHEVC"); + assert_eq!(offset_of!(VAPictureHEVC, picture_id), 0, "VAPictureHEVC.picture_id"); + assert_eq!(offset_of!(VAPictureHEVC, pic_order_cnt), 4, "VAPictureHEVC.pic_order_cnt"); + assert_eq!(offset_of!(VAPictureHEVC, flags), 8, "VAPictureHEVC.flags"); + assert_eq!(offset_of!(VAPictureHEVC, va_reserved), 12, "VAPictureHEVC.va_reserved"); + assert_eq!((size_of::(), align_of::()), (604, 4), "VAPictureParameterBufferHEVC"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, CurrPic), 0, "VAPictureParameterBufferHEVC.CurrPic"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, ReferenceFrames), 28, "VAPictureParameterBufferHEVC.ReferenceFrames"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pic_width_in_luma_samples), 448, "VAPictureParameterBufferHEVC.pic_width_in_luma_samples"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pic_height_in_luma_samples), 450, "VAPictureParameterBufferHEVC.pic_height_in_luma_samples"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pic_fields), 452, "VAPictureParameterBufferHEVC.pic_fields"); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, sps_max_dec_pic_buffering_minus1), + 456, + "VAPictureParameterBufferHEVC.sps_max_dec_pic_buffering_minus1" + ); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, bit_depth_luma_minus8), 457, "VAPictureParameterBufferHEVC.bit_depth_luma_minus8"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, bit_depth_chroma_minus8), 458, "VAPictureParameterBufferHEVC.bit_depth_chroma_minus8"); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, pcm_sample_bit_depth_luma_minus1), + 459, + "VAPictureParameterBufferHEVC.pcm_sample_bit_depth_luma_minus1" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, pcm_sample_bit_depth_chroma_minus1), + 460, + "VAPictureParameterBufferHEVC.pcm_sample_bit_depth_chroma_minus1" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_min_luma_coding_block_size_minus3), + 461, + "VAPictureParameterBufferHEVC.log2_min_luma_coding_block_size_minus3" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_diff_max_min_luma_coding_block_size), + 462, + "VAPictureParameterBufferHEVC.log2_diff_max_min_luma_coding_block_size" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_min_transform_block_size_minus2), + 463, + "VAPictureParameterBufferHEVC.log2_min_transform_block_size_minus2" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_diff_max_min_transform_block_size), + 464, + "VAPictureParameterBufferHEVC.log2_diff_max_min_transform_block_size" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_min_pcm_luma_coding_block_size_minus3), + 465, + "VAPictureParameterBufferHEVC.log2_min_pcm_luma_coding_block_size_minus3" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_diff_max_min_pcm_luma_coding_block_size), + 466, + "VAPictureParameterBufferHEVC.log2_diff_max_min_pcm_luma_coding_block_size" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, max_transform_hierarchy_depth_intra), + 467, + "VAPictureParameterBufferHEVC.max_transform_hierarchy_depth_intra" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, max_transform_hierarchy_depth_inter), + 468, + "VAPictureParameterBufferHEVC.max_transform_hierarchy_depth_inter" + ); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, init_qp_minus26), 469, "VAPictureParameterBufferHEVC.init_qp_minus26"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, diff_cu_qp_delta_depth), 470, "VAPictureParameterBufferHEVC.diff_cu_qp_delta_depth"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pps_cb_qp_offset), 471, "VAPictureParameterBufferHEVC.pps_cb_qp_offset"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pps_cr_qp_offset), 472, "VAPictureParameterBufferHEVC.pps_cr_qp_offset"); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_parallel_merge_level_minus2), + 473, + "VAPictureParameterBufferHEVC.log2_parallel_merge_level_minus2" + ); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, num_tile_columns_minus1), 474, "VAPictureParameterBufferHEVC.num_tile_columns_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, num_tile_rows_minus1), 475, "VAPictureParameterBufferHEVC.num_tile_rows_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, column_width_minus1), 476, "VAPictureParameterBufferHEVC.column_width_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, row_height_minus1), 514, "VAPictureParameterBufferHEVC.row_height_minus1"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, slice_parsing_fields), 556, "VAPictureParameterBufferHEVC.slice_parsing_fields"); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, log2_max_pic_order_cnt_lsb_minus4), + 560, + "VAPictureParameterBufferHEVC.log2_max_pic_order_cnt_lsb_minus4" + ); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, num_short_term_ref_pic_sets), 561, "VAPictureParameterBufferHEVC.num_short_term_ref_pic_sets"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, num_long_term_ref_pic_sps), 562, "VAPictureParameterBufferHEVC.num_long_term_ref_pic_sps"); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, num_ref_idx_l0_default_active_minus1), + 563, + "VAPictureParameterBufferHEVC.num_ref_idx_l0_default_active_minus1" + ); + assert_eq!( + offset_of!(VAPictureParameterBufferHEVC, num_ref_idx_l1_default_active_minus1), + 564, + "VAPictureParameterBufferHEVC.num_ref_idx_l1_default_active_minus1" + ); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pps_beta_offset_div2), 565, "VAPictureParameterBufferHEVC.pps_beta_offset_div2"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, pps_tc_offset_div2), 566, "VAPictureParameterBufferHEVC.pps_tc_offset_div2"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, num_extra_slice_header_bits), 567, "VAPictureParameterBufferHEVC.num_extra_slice_header_bits"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, st_rps_bits), 568, "VAPictureParameterBufferHEVC.st_rps_bits"); + assert_eq!(offset_of!(VAPictureParameterBufferHEVC, va_reserved), 572, "VAPictureParameterBufferHEVC.va_reserved"); + assert_eq!((size_of::(), align_of::()), (264, 4), "VASliceParameterBufferHEVC"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_data_size), 0, "VASliceParameterBufferHEVC.slice_data_size"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_data_offset), 4, "VASliceParameterBufferHEVC.slice_data_offset"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_data_flag), 8, "VASliceParameterBufferHEVC.slice_data_flag"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_data_byte_offset), 12, "VASliceParameterBufferHEVC.slice_data_byte_offset"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_segment_address), 16, "VASliceParameterBufferHEVC.slice_segment_address"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, RefPicList), 20, "VASliceParameterBufferHEVC.RefPicList"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, LongSliceFlags), 52, "VASliceParameterBufferHEVC.LongSliceFlags"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, collocated_ref_idx), 56, "VASliceParameterBufferHEVC.collocated_ref_idx"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, num_ref_idx_l0_active_minus1), 57, "VASliceParameterBufferHEVC.num_ref_idx_l0_active_minus1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, num_ref_idx_l1_active_minus1), 58, "VASliceParameterBufferHEVC.num_ref_idx_l1_active_minus1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_qp_delta), 59, "VASliceParameterBufferHEVC.slice_qp_delta"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_cb_qp_offset), 60, "VASliceParameterBufferHEVC.slice_cb_qp_offset"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_cr_qp_offset), 61, "VASliceParameterBufferHEVC.slice_cr_qp_offset"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_beta_offset_div2), 62, "VASliceParameterBufferHEVC.slice_beta_offset_div2"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_tc_offset_div2), 63, "VASliceParameterBufferHEVC.slice_tc_offset_div2"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, luma_log2_weight_denom), 64, "VASliceParameterBufferHEVC.luma_log2_weight_denom"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, delta_chroma_log2_weight_denom), 65, "VASliceParameterBufferHEVC.delta_chroma_log2_weight_denom"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, delta_luma_weight_l0), 66, "VASliceParameterBufferHEVC.delta_luma_weight_l0"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, luma_offset_l0), 81, "VASliceParameterBufferHEVC.luma_offset_l0"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, delta_chroma_weight_l0), 96, "VASliceParameterBufferHEVC.delta_chroma_weight_l0"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, ChromaOffsetL0), 126, "VASliceParameterBufferHEVC.ChromaOffsetL0"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, delta_luma_weight_l1), 156, "VASliceParameterBufferHEVC.delta_luma_weight_l1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, luma_offset_l1), 171, "VASliceParameterBufferHEVC.luma_offset_l1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, delta_chroma_weight_l1), 186, "VASliceParameterBufferHEVC.delta_chroma_weight_l1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, ChromaOffsetL1), 216, "VASliceParameterBufferHEVC.ChromaOffsetL1"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, five_minus_max_num_merge_cand), 246, "VASliceParameterBufferHEVC.five_minus_max_num_merge_cand"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, num_entry_point_offsets), 248, "VASliceParameterBufferHEVC.num_entry_point_offsets"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, entry_offset_to_subset_array), 250, "VASliceParameterBufferHEVC.entry_offset_to_subset_array"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, slice_data_num_emu_prevn_bytes), 252, "VASliceParameterBufferHEVC.slice_data_num_emu_prevn_bytes"); + assert_eq!(offset_of!(VASliceParameterBufferHEVC, va_reserved), 256, "VASliceParameterBufferHEVC.va_reserved"); + assert_eq!((size_of::(), align_of::()), (1016, 4), "VAIQMatrixBufferHEVC"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingList4x4), 0, "VAIQMatrixBufferHEVC.ScalingList4x4"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingList8x8), 96, "VAIQMatrixBufferHEVC.ScalingList8x8"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingList16x16), 480, "VAIQMatrixBufferHEVC.ScalingList16x16"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingList32x32), 864, "VAIQMatrixBufferHEVC.ScalingList32x32"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingListDC16x16), 992, "VAIQMatrixBufferHEVC.ScalingListDC16x16"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, ScalingListDC32x32), 998, "VAIQMatrixBufferHEVC.ScalingListDC32x32"); + assert_eq!(offset_of!(VAIQMatrixBufferHEVC, va_reserved), 1000, "VAIQMatrixBufferHEVC.va_reserved"); +} + +#[test] +fn constants_match_the_header() { + assert_eq!(VA_STATUS_SUCCESS as i64, 0, "VA_STATUS_SUCCESS"); + assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_PROFILE as i64, 12, "VA_STATUS_ERROR_UNSUPPORTED_PROFILE"); + assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT as i64, 13, "VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT"); + assert_eq!(VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT as i64, 14, "VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT"); + assert_eq!(VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED as i64, 19, "VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED"); + assert_eq!(VA_STATUS_ERROR_DECODING_ERROR as i64, 23, "VA_STATUS_ERROR_DECODING_ERROR"); + assert_eq!(VA_INVALID_ID as i64, 4294967295, "VA_INVALID_ID"); + assert_eq!(VA_INVALID_SURFACE as i64, 4294967295, "VA_INVALID_SURFACE"); + assert_eq!(VAProfileH264Main as i64, 6, "VAProfileH264Main"); + assert_eq!(VAProfileH264High as i64, 7, "VAProfileH264High"); + assert_eq!(VAProfileH264ConstrainedBaseline as i64, 13, "VAProfileH264ConstrainedBaseline"); + assert_eq!(VAEntrypointVLD as i64, 1, "VAEntrypointVLD"); + assert_eq!(VAConfigAttribRTFormat as i64, 0, "VAConfigAttribRTFormat"); + assert_eq!(VA_RT_FORMAT_YUV420 as i64, 1, "VA_RT_FORMAT_YUV420"); + assert_eq!(VA_PROGRESSIVE as i64, 1, "VA_PROGRESSIVE"); + assert_eq!(VAPictureParameterBufferType as i64, 0, "VAPictureParameterBufferType"); + assert_eq!(VAIQMatrixBufferType as i64, 1, "VAIQMatrixBufferType"); + assert_eq!(VASliceParameterBufferType as i64, 4, "VASliceParameterBufferType"); + assert_eq!(VASliceDataBufferType as i64, 5, "VASliceDataBufferType"); + assert_eq!(VA_SLICE_DATA_FLAG_ALL as i64, 0, "VA_SLICE_DATA_FLAG_ALL"); + assert_eq!(VA_PICTURE_H264_INVALID as i64, 1, "VA_PICTURE_H264_INVALID"); + assert_eq!(VA_PICTURE_H264_SHORT_TERM_REFERENCE as i64, 8, "VA_PICTURE_H264_SHORT_TERM_REFERENCE"); + assert_eq!(VA_PICTURE_H264_LONG_TERM_REFERENCE as i64, 16, "VA_PICTURE_H264_LONG_TERM_REFERENCE"); + assert_eq!(VA_FOURCC_NV12 as i64, 842094158, "VA_FOURCC_NV12"); + assert_eq!(VA_FOURCC_P010 as i64, 808530000, "VA_FOURCC_P010"); + assert_eq!(VAProfileHEVCMain as i64, 17, "VAProfileHEVCMain"); + assert_eq!(VAProfileHEVCMain10 as i64, 18, "VAProfileHEVCMain10"); + assert_eq!(VA_RT_FORMAT_YUV420_10 as i64, 256, "VA_RT_FORMAT_YUV420_10"); + assert_eq!(VA_PICTURE_HEVC_INVALID as i64, 1, "VA_PICTURE_HEVC_INVALID"); + assert_eq!(VA_PICTURE_HEVC_LONG_TERM_REFERENCE as i64, 8, "VA_PICTURE_HEVC_LONG_TERM_REFERENCE"); + assert_eq!(VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE as i64, 16, "VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE"); + assert_eq!(VA_PICTURE_HEVC_RPS_ST_CURR_AFTER as i64, 32, "VA_PICTURE_HEVC_RPS_ST_CURR_AFTER"); + assert_eq!(VA_PICTURE_HEVC_RPS_LT_CURR as i64, 64, "VA_PICTURE_HEVC_RPS_LT_CURR"); + assert_eq!(VA_PADDING_LOW as i64, 4, "VA_PADDING_LOW"); + assert_eq!(VA_PADDING_MEDIUM as i64, 8, "VA_PADDING_MEDIUM"); +} + +/// Each bit-field set alone to its largest value: the word the C compiler makes of it. +#[test] +fn bit_fields_match_the_header() { + assert_eq!(3u32 << seq_bits::CHROMA_FORMAT_IDC, 0x3, "seq_fields.chroma_format_idc"); + assert_eq!(1u32 << seq_bits::RESIDUAL_COLOUR_TRANSFORM, 0x4, "seq_fields.residual_colour_transform_flag"); + assert_eq!(1u32 << seq_bits::GAPS_IN_FRAME_NUM_ALLOWED, 0x8, "seq_fields.gaps_in_frame_num_value_allowed_flag"); + assert_eq!(1u32 << seq_bits::FRAME_MBS_ONLY, 0x10, "seq_fields.frame_mbs_only_flag"); + assert_eq!(1u32 << seq_bits::MB_ADAPTIVE_FRAME_FIELD, 0x20, "seq_fields.mb_adaptive_frame_field_flag"); + assert_eq!(1u32 << seq_bits::DIRECT_8X8_INFERENCE, 0x40, "seq_fields.direct_8x8_inference_flag"); + assert_eq!(1u32 << seq_bits::MIN_LUMA_BI_PRED_SIZE_8X8, 0x80, "seq_fields.MinLumaBiPredSize8x8"); + assert_eq!(15u32 << seq_bits::LOG2_MAX_FRAME_NUM_MINUS4, 0xf00, "seq_fields.log2_max_frame_num_minus4"); + assert_eq!(3u32 << seq_bits::PIC_ORDER_CNT_TYPE, 0x3000, "seq_fields.pic_order_cnt_type"); + assert_eq!(15u32 << seq_bits::LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4, 0x3c000, "seq_fields.log2_max_pic_order_cnt_lsb_minus4"); + assert_eq!(1u32 << seq_bits::DELTA_PIC_ORDER_ALWAYS_ZERO, 0x40000, "seq_fields.delta_pic_order_always_zero_flag"); + assert_eq!(1u32 << pic_bits::ENTROPY_CODING_MODE, 0x1, "pic_fields.entropy_coding_mode_flag"); + assert_eq!(1u32 << pic_bits::WEIGHTED_PRED, 0x2, "pic_fields.weighted_pred_flag"); + assert_eq!(3u32 << pic_bits::WEIGHTED_BIPRED_IDC, 0xc, "pic_fields.weighted_bipred_idc"); + assert_eq!(1u32 << pic_bits::TRANSFORM_8X8_MODE, 0x10, "pic_fields.transform_8x8_mode_flag"); + assert_eq!(1u32 << pic_bits::FIELD_PIC, 0x20, "pic_fields.field_pic_flag"); + assert_eq!(1u32 << pic_bits::CONSTRAINED_INTRA_PRED, 0x40, "pic_fields.constrained_intra_pred_flag"); + assert_eq!(1u32 << pic_bits::PIC_ORDER_PRESENT, 0x80, "pic_fields.pic_order_present_flag"); + assert_eq!(1u32 << pic_bits::DEBLOCKING_FILTER_CONTROL_PRESENT, 0x100, "pic_fields.deblocking_filter_control_present_flag"); + assert_eq!(1u32 << pic_bits::REDUNDANT_PIC_CNT_PRESENT, 0x200, "pic_fields.redundant_pic_cnt_present_flag"); + assert_eq!(1u32 << pic_bits::REFERENCE_PIC, 0x400, "pic_fields.reference_pic_flag"); + assert_eq!(3u32 << hevc_pic_bits::CHROMA_FORMAT_IDC, 0x3, "hevc pic_fields.chroma_format_idc"); + assert_eq!(1u32 << hevc_pic_bits::SEPARATE_COLOUR_PLANE, 0x4, "hevc pic_fields.separate_colour_plane_flag"); + assert_eq!(1u32 << hevc_pic_bits::PCM_ENABLED, 0x8, "hevc pic_fields.pcm_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::SCALING_LIST_ENABLED, 0x10, "hevc pic_fields.scaling_list_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::TRANSFORM_SKIP_ENABLED, 0x20, "hevc pic_fields.transform_skip_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::AMP_ENABLED, 0x40, "hevc pic_fields.amp_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::STRONG_INTRA_SMOOTHING_ENABLED, 0x80, "hevc pic_fields.strong_intra_smoothing_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::SIGN_DATA_HIDING_ENABLED, 0x100, "hevc pic_fields.sign_data_hiding_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::CONSTRAINED_INTRA_PRED, 0x200, "hevc pic_fields.constrained_intra_pred_flag"); + assert_eq!(1u32 << hevc_pic_bits::CU_QP_DELTA_ENABLED, 0x400, "hevc pic_fields.cu_qp_delta_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::WEIGHTED_PRED, 0x800, "hevc pic_fields.weighted_pred_flag"); + assert_eq!(1u32 << hevc_pic_bits::WEIGHTED_BIPRED, 0x1000, "hevc pic_fields.weighted_bipred_flag"); + assert_eq!(1u32 << hevc_pic_bits::TRANSQUANT_BYPASS_ENABLED, 0x2000, "hevc pic_fields.transquant_bypass_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::TILES_ENABLED, 0x4000, "hevc pic_fields.tiles_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::ENTROPY_CODING_SYNC_ENABLED, 0x8000, "hevc pic_fields.entropy_coding_sync_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::PPS_LOOP_FILTER_ACROSS_SLICES_ENABLED, 0x10000, "hevc pic_fields.pps_loop_filter_across_slices_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::LOOP_FILTER_ACROSS_TILES_ENABLED, 0x20000, "hevc pic_fields.loop_filter_across_tiles_enabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::PCM_LOOP_FILTER_DISABLED, 0x40000, "hevc pic_fields.pcm_loop_filter_disabled_flag"); + assert_eq!(1u32 << hevc_pic_bits::NO_PIC_REORDERING, 0x80000, "hevc pic_fields.NoPicReorderingFlag"); + assert_eq!(1u32 << hevc_pic_bits::NO_BI_PRED, 0x100000, "hevc pic_fields.NoBiPredFlag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::LISTS_MODIFICATION_PRESENT, 0x1, "hevc slice_parsing_fields.lists_modification_present_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::LONG_TERM_REF_PICS_PRESENT, 0x2, "hevc slice_parsing_fields.long_term_ref_pics_present_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::SPS_TEMPORAL_MVP_ENABLED, 0x4, "hevc slice_parsing_fields.sps_temporal_mvp_enabled_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::CABAC_INIT_PRESENT, 0x8, "hevc slice_parsing_fields.cabac_init_present_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::OUTPUT_FLAG_PRESENT, 0x10, "hevc slice_parsing_fields.output_flag_present_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::DEPENDENT_SLICE_SEGMENTS_ENABLED, 0x20, "hevc slice_parsing_fields.dependent_slice_segments_enabled_flag"); + assert_eq!( + 1u32 << hevc_slice_parsing_bits::PPS_SLICE_CHROMA_QP_OFFSETS_PRESENT, + 0x40, + "hevc slice_parsing_fields.pps_slice_chroma_qp_offsets_present_flag" + ); + assert_eq!(1u32 << hevc_slice_parsing_bits::SAMPLE_ADAPTIVE_OFFSET_ENABLED, 0x80, "hevc slice_parsing_fields.sample_adaptive_offset_enabled_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::DEBLOCKING_FILTER_OVERRIDE_ENABLED, 0x100, "hevc slice_parsing_fields.deblocking_filter_override_enabled_flag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::PPS_DISABLE_DEBLOCKING_FILTER, 0x200, "hevc slice_parsing_fields.pps_disable_deblocking_filter_flag"); + assert_eq!( + 1u32 << hevc_slice_parsing_bits::SLICE_SEGMENT_HEADER_EXTENSION_PRESENT, + 0x400, + "hevc slice_parsing_fields.slice_segment_header_extension_present_flag" + ); + assert_eq!(1u32 << hevc_slice_parsing_bits::RAP_PIC, 0x800, "hevc slice_parsing_fields.RapPicFlag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::IDR_PIC, 0x1000, "hevc slice_parsing_fields.IdrPicFlag"); + assert_eq!(1u32 << hevc_slice_parsing_bits::INTRA_PIC, 0x2000, "hevc slice_parsing_fields.IntraPicFlag"); + assert_eq!(1u32 << hevc_slice_bits::LAST_SLICE_OF_PIC, 0x1, "LongSliceFlags.LastSliceOfPic"); + assert_eq!(1u32 << hevc_slice_bits::DEPENDENT_SLICE_SEGMENT, 0x2, "LongSliceFlags.dependent_slice_segment_flag"); + assert_eq!(3u32 << hevc_slice_bits::SLICE_TYPE, 0xc, "LongSliceFlags.slice_type"); + assert_eq!(3u32 << hevc_slice_bits::COLOR_PLANE_ID, 0x30, "LongSliceFlags.color_plane_id"); + assert_eq!(1u32 << hevc_slice_bits::SLICE_SAO_LUMA, 0x40, "LongSliceFlags.slice_sao_luma_flag"); + assert_eq!(1u32 << hevc_slice_bits::SLICE_SAO_CHROMA, 0x80, "LongSliceFlags.slice_sao_chroma_flag"); + assert_eq!(1u32 << hevc_slice_bits::MVD_L1_ZERO, 0x100, "LongSliceFlags.mvd_l1_zero_flag"); + assert_eq!(1u32 << hevc_slice_bits::CABAC_INIT, 0x200, "LongSliceFlags.cabac_init_flag"); + assert_eq!(1u32 << hevc_slice_bits::SLICE_TEMPORAL_MVP_ENABLED, 0x400, "LongSliceFlags.slice_temporal_mvp_enabled_flag"); + assert_eq!(1u32 << hevc_slice_bits::SLICE_DEBLOCKING_FILTER_DISABLED, 0x800, "LongSliceFlags.slice_deblocking_filter_disabled_flag"); + assert_eq!(1u32 << hevc_slice_bits::COLLOCATED_FROM_L0, 0x1000, "LongSliceFlags.collocated_from_l0_flag"); + assert_eq!(1u32 << hevc_slice_bits::SLICE_LOOP_FILTER_ACROSS_SLICES_ENABLED, 0x2000, "LongSliceFlags.slice_loop_filter_across_slices_enabled_flag"); +} diff --git a/crates/platform/src/vaapi/ffi.rs b/crates/platform/src/vaapi/ffi.rs new file mode 100644 index 000000000..4fc2e28e9 --- /dev/null +++ b/crates/platform/src/vaapi/ffi.rs @@ -0,0 +1,519 @@ +// The declarations in this file are transcribed from libva's `va/va.h`, `va/va_dec_hevc.h` and `va/va_drm.h` +// (VA-API 1.23, libva 2.23), which carry this notice: +// +// Copyright (c) 2007-2009 Intel Corporation. All Rights Reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sub license, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice (including the +// next paragraph) shall be included in all copies or substantial portions +// of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. +// IN NO EVENT SHALL INTEL AND/OR ITS SUPPLIERS BE LIABLE FOR +// ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +//! VA-API (`va.h`, `va_dec_hevc.h`, `va_drm.h`; MIT-licensed headers by Intel) as `libva.so.2` / +//! `libva-drm.so.2` expect it: the H.264 and HEVC decode buffers this backend fills in, the image structures it reads back +//! through, and the entry points it calls. Written from the public header; sizes and field offsets +//! are checked against a C compiler's by `abi_tests.rs`. +//! +//! The structures are plain data and compile on every target, so the code that fills them in +//! (`vaapi::h264`) is tested everywhere; only `vaapi::va` loads the library (Linux). +//! +//! FFI module (docs/adr/0001-platform-ffi.md): plain `repr(C)` data, no logic. + +#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)] + +use std::ffi::{c_char, c_int, c_uint, c_void}; + +pub type VADisplay = *mut c_void; +pub type VAStatus = c_int; +pub type VAGenericID = c_uint; +pub type VAConfigID = VAGenericID; +pub type VAContextID = VAGenericID; +pub type VASurfaceID = VAGenericID; +pub type VABufferID = VAGenericID; +pub type VAImageID = VAGenericID; +/// `VAProfile` (a C enum: `int`). +pub type VAProfile = c_int; +/// `VAEntrypoint` (a C enum: `int`). +pub type VAEntrypoint = c_int; +/// `VABufferType` (a C enum: `int`). +pub type VABufferType = c_int; +/// `VAConfigAttribType` (a C enum: `int`). +pub type VAConfigAttribType = c_int; +pub type VAMessageCallback = Option; + +pub const VA_STATUS_SUCCESS: VAStatus = 0; +pub const VA_STATUS_ERROR_UNSUPPORTED_PROFILE: VAStatus = 0x0c; +pub const VA_STATUS_ERROR_UNSUPPORTED_ENTRYPOINT: VAStatus = 0x0d; +pub const VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT: VAStatus = 0x0e; +pub const VA_STATUS_ERROR_RESOLUTION_NOT_SUPPORTED: VAStatus = 0x13; +pub const VA_STATUS_ERROR_DECODING_ERROR: VAStatus = 0x17; + +pub const VA_INVALID_ID: VAGenericID = 0xffff_ffff; +pub const VA_INVALID_SURFACE: VASurfaceID = VA_INVALID_ID; + +pub const VAProfileH264Main: VAProfile = 6; +pub const VAProfileH264High: VAProfile = 7; +pub const VAProfileH264ConstrainedBaseline: VAProfile = 13; +pub const VAProfileHEVCMain: VAProfile = 17; +pub const VAProfileHEVCMain10: VAProfile = 18; +pub const VAEntrypointVLD: VAEntrypoint = 1; + +pub const VAConfigAttribRTFormat: VAConfigAttribType = 0; +pub const VA_RT_FORMAT_YUV420: c_uint = 0x0000_0001; +pub const VA_RT_FORMAT_YUV420_10: c_uint = 0x0000_0100; + +/// `vaCreateContext` flag: progressive pictures only. +pub const VA_PROGRESSIVE: c_int = 0x1; + +pub const VAPictureParameterBufferType: VABufferType = 0; +pub const VAIQMatrixBufferType: VABufferType = 1; +pub const VASliceParameterBufferType: VABufferType = 4; +pub const VASliceDataBufferType: VABufferType = 5; + +/// `VASliceParameterBuffer*::slice_data_flag`: the whole slice is in the buffer. +pub const VA_SLICE_DATA_FLAG_ALL: u32 = 0x00; + +pub const VA_PICTURE_H264_INVALID: u32 = 0x0000_0001; +pub const VA_PICTURE_H264_SHORT_TERM_REFERENCE: u32 = 0x0000_0008; +pub const VA_PICTURE_H264_LONG_TERM_REFERENCE: u32 = 0x0000_0010; + +pub const VA_FOURCC_NV12: u32 = 0x3231_564E; +pub const VA_FOURCC_P010: u32 = 0x3031_3050; + +pub const VA_PICTURE_HEVC_INVALID: u32 = 0x0000_0001; +pub const VA_PICTURE_HEVC_LONG_TERM_REFERENCE: u32 = 0x0000_0008; +pub const VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE: u32 = 0x0000_0010; +pub const VA_PICTURE_HEVC_RPS_ST_CURR_AFTER: u32 = 0x0000_0020; +pub const VA_PICTURE_HEVC_RPS_LT_CURR: u32 = 0x0000_0040; + +pub const VA_PADDING_LOW: usize = 4; +pub const VA_PADDING_MEDIUM: usize = 8; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VAConfigAttrib { + pub type_: VAConfigAttribType, + pub value: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VAPictureH264 { + pub picture_id: VASurfaceID, + pub frame_idx: u32, + pub flags: u32, + pub TopFieldOrderCnt: i32, + pub BottomFieldOrderCnt: i32, + pub va_reserved: [u32; VA_PADDING_LOW], +} + +impl VAPictureH264 { + /// An unused entry (`VA_INVALID_SURFACE`, `VA_PICTURE_H264_INVALID`). + pub const INVALID: Self = Self { + picture_id: VA_INVALID_SURFACE, + frame_idx: 0, + flags: VA_PICTURE_H264_INVALID, + TopFieldOrderCnt: 0, + BottomFieldOrderCnt: 0, + va_reserved: [0; VA_PADDING_LOW], + }; +} + +/// `VAPictureParameterBufferH264`. `seq_fields` and `pic_fields` are C bit-field unions, stored +/// here as their `value` word (bits allocated from the least significant end, as GCC and Clang do +/// on the little-endian targets libva runs on). +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VAPictureParameterBufferH264 { + pub CurrPic: VAPictureH264, + pub ReferenceFrames: [VAPictureH264; 16], + pub picture_width_in_mbs_minus1: u16, + pub picture_height_in_mbs_minus1: u16, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub num_ref_frames: u8, + pub seq_fields: u32, + pub num_slice_groups_minus1: u8, + pub slice_group_map_type: u8, + pub slice_group_change_rate_minus1: u16, + pub pic_init_qp_minus26: i8, + pub pic_init_qs_minus26: i8, + pub chroma_qp_index_offset: i8, + pub second_chroma_qp_index_offset: i8, + pub pic_fields: u32, + pub frame_num: u16, + pub va_reserved: [u32; VA_PADDING_MEDIUM], +} + +/// `seq_fields` bit positions (`chroma_format_idc` 2 bits, the log2 fields 4, `pic_order_cnt_type` 2). +pub mod seq_bits { + pub const CHROMA_FORMAT_IDC: u32 = 0; + pub const RESIDUAL_COLOUR_TRANSFORM: u32 = 2; + pub const GAPS_IN_FRAME_NUM_ALLOWED: u32 = 3; + pub const FRAME_MBS_ONLY: u32 = 4; + pub const MB_ADAPTIVE_FRAME_FIELD: u32 = 5; + pub const DIRECT_8X8_INFERENCE: u32 = 6; + pub const MIN_LUMA_BI_PRED_SIZE_8X8: u32 = 7; + pub const LOG2_MAX_FRAME_NUM_MINUS4: u32 = 8; + pub const PIC_ORDER_CNT_TYPE: u32 = 12; + pub const LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4: u32 = 14; + pub const DELTA_PIC_ORDER_ALWAYS_ZERO: u32 = 18; +} + +/// `pic_fields` bit positions (`weighted_bipred_idc` 2 bits). +pub mod pic_bits { + pub const ENTROPY_CODING_MODE: u32 = 0; + pub const WEIGHTED_PRED: u32 = 1; + pub const WEIGHTED_BIPRED_IDC: u32 = 2; + pub const TRANSFORM_8X8_MODE: u32 = 4; + pub const FIELD_PIC: u32 = 5; + pub const CONSTRAINED_INTRA_PRED: u32 = 6; + pub const PIC_ORDER_PRESENT: u32 = 7; + pub const DEBLOCKING_FILTER_CONTROL_PRESENT: u32 = 8; + pub const REDUNDANT_PIC_CNT_PRESENT: u32 = 9; + pub const REFERENCE_PIC: u32 = 10; +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VAIQMatrixBufferH264 { + /// 4x4 scaling lists in raster scan order: Intra Y, Cb, Cr, Inter Y, Cb, Cr. + pub ScalingList4x4: [[u8; 16]; 6], + /// 8x8 scaling lists in raster scan order: Intra Y, Inter Y. + pub ScalingList8x8: [[u8; 64]; 2], + pub va_reserved: [u32; VA_PADDING_LOW], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VASliceParameterBufferH264 { + pub slice_data_size: u32, + pub slice_data_offset: u32, + pub slice_data_flag: u32, + /// Bits from the start of the NAL unit (its header byte included) to `slice_data()`, counted + /// without emulation prevention bytes; the data buffer itself keeps them. + pub slice_data_bit_offset: u16, + pub first_mb_in_slice: u16, + pub slice_type: u8, + pub direct_spatial_mv_pred_flag: u8, + pub num_ref_idx_l0_active_minus1: u8, + pub num_ref_idx_l1_active_minus1: u8, + pub cabac_init_idc: u8, + pub slice_qp_delta: i8, + pub disable_deblocking_filter_idc: u8, + pub slice_alpha_c0_offset_div2: i8, + pub slice_beta_offset_div2: i8, + pub RefPicList0: [VAPictureH264; 32], + pub RefPicList1: [VAPictureH264; 32], + pub luma_log2_weight_denom: u8, + pub chroma_log2_weight_denom: u8, + pub luma_weight_l0_flag: u8, + pub luma_weight_l0: [i16; 32], + pub luma_offset_l0: [i16; 32], + pub chroma_weight_l0_flag: u8, + pub chroma_weight_l0: [[i16; 2]; 32], + pub chroma_offset_l0: [[i16; 2]; 32], + pub luma_weight_l1_flag: u8, + pub luma_weight_l1: [i16; 32], + pub luma_offset_l1: [i16; 32], + pub chroma_weight_l1_flag: u8, + pub chroma_weight_l1: [[i16; 2]; 32], + pub chroma_offset_l1: [[i16; 2]; 32], + pub va_reserved: [u32; VA_PADDING_LOW], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VAPictureHEVC { + pub picture_id: VASurfaceID, + pub pic_order_cnt: i32, + pub flags: u32, + pub va_reserved: [u32; VA_PADDING_LOW], +} + +impl VAPictureHEVC { + /// An unused entry (`VA_INVALID_SURFACE`, `VA_PICTURE_HEVC_INVALID`). + pub const INVALID: Self = Self { picture_id: VA_INVALID_SURFACE, pic_order_cnt: 0, flags: VA_PICTURE_HEVC_INVALID, va_reserved: [0; VA_PADDING_LOW] }; +} + +/// `VAPictureParameterBufferHEVC`; `pic_fields` and `slice_parsing_fields` are C bit-field unions +/// stored as their `value` word (see [`VAPictureParameterBufferH264`]). +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VAPictureParameterBufferHEVC { + pub CurrPic: VAPictureHEVC, + pub ReferenceFrames: [VAPictureHEVC; 15], + pub pic_width_in_luma_samples: u16, + pub pic_height_in_luma_samples: u16, + pub pic_fields: u32, + pub sps_max_dec_pic_buffering_minus1: u8, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub pcm_sample_bit_depth_luma_minus1: u8, + pub pcm_sample_bit_depth_chroma_minus1: u8, + pub log2_min_luma_coding_block_size_minus3: u8, + pub log2_diff_max_min_luma_coding_block_size: u8, + pub log2_min_transform_block_size_minus2: u8, + pub log2_diff_max_min_transform_block_size: u8, + pub log2_min_pcm_luma_coding_block_size_minus3: u8, + pub log2_diff_max_min_pcm_luma_coding_block_size: u8, + pub max_transform_hierarchy_depth_intra: u8, + pub max_transform_hierarchy_depth_inter: u8, + pub init_qp_minus26: i8, + pub diff_cu_qp_delta_depth: u8, + pub pps_cb_qp_offset: i8, + pub pps_cr_qp_offset: i8, + pub log2_parallel_merge_level_minus2: u8, + pub num_tile_columns_minus1: u8, + pub num_tile_rows_minus1: u8, + pub column_width_minus1: [u16; 19], + pub row_height_minus1: [u16; 21], + pub slice_parsing_fields: u32, + pub log2_max_pic_order_cnt_lsb_minus4: u8, + pub num_short_term_ref_pic_sets: u8, + pub num_long_term_ref_pic_sps: u8, + pub num_ref_idx_l0_default_active_minus1: u8, + pub num_ref_idx_l1_default_active_minus1: u8, + pub pps_beta_offset_div2: i8, + pub pps_tc_offset_div2: i8, + pub num_extra_slice_header_bits: u8, + /// Bits of `short_term_ref_pic_set()` in the slice header (0 when the SPS's set is used), + /// counted without emulation prevention bytes. + pub st_rps_bits: u32, + pub va_reserved: [u32; VA_PADDING_MEDIUM], +} + +/// `VAPictureParameterBufferHEVC::pic_fields` bit positions (`chroma_format_idc` 2 bits). +pub mod hevc_pic_bits { + pub const CHROMA_FORMAT_IDC: u32 = 0; + pub const SEPARATE_COLOUR_PLANE: u32 = 2; + pub const PCM_ENABLED: u32 = 3; + pub const SCALING_LIST_ENABLED: u32 = 4; + pub const TRANSFORM_SKIP_ENABLED: u32 = 5; + pub const AMP_ENABLED: u32 = 6; + pub const STRONG_INTRA_SMOOTHING_ENABLED: u32 = 7; + pub const SIGN_DATA_HIDING_ENABLED: u32 = 8; + pub const CONSTRAINED_INTRA_PRED: u32 = 9; + pub const CU_QP_DELTA_ENABLED: u32 = 10; + pub const WEIGHTED_PRED: u32 = 11; + pub const WEIGHTED_BIPRED: u32 = 12; + pub const TRANSQUANT_BYPASS_ENABLED: u32 = 13; + pub const TILES_ENABLED: u32 = 14; + pub const ENTROPY_CODING_SYNC_ENABLED: u32 = 15; + pub const PPS_LOOP_FILTER_ACROSS_SLICES_ENABLED: u32 = 16; + pub const LOOP_FILTER_ACROSS_TILES_ENABLED: u32 = 17; + pub const PCM_LOOP_FILTER_DISABLED: u32 = 18; + pub const NO_PIC_REORDERING: u32 = 19; + pub const NO_BI_PRED: u32 = 20; +} + +/// `VAPictureParameterBufferHEVC::slice_parsing_fields` bit positions. +pub mod hevc_slice_parsing_bits { + pub const LISTS_MODIFICATION_PRESENT: u32 = 0; + pub const LONG_TERM_REF_PICS_PRESENT: u32 = 1; + pub const SPS_TEMPORAL_MVP_ENABLED: u32 = 2; + pub const CABAC_INIT_PRESENT: u32 = 3; + pub const OUTPUT_FLAG_PRESENT: u32 = 4; + pub const DEPENDENT_SLICE_SEGMENTS_ENABLED: u32 = 5; + pub const PPS_SLICE_CHROMA_QP_OFFSETS_PRESENT: u32 = 6; + pub const SAMPLE_ADAPTIVE_OFFSET_ENABLED: u32 = 7; + pub const DEBLOCKING_FILTER_OVERRIDE_ENABLED: u32 = 8; + pub const PPS_DISABLE_DEBLOCKING_FILTER: u32 = 9; + pub const SLICE_SEGMENT_HEADER_EXTENSION_PRESENT: u32 = 10; + pub const RAP_PIC: u32 = 11; + pub const IDR_PIC: u32 = 12; + pub const INTRA_PIC: u32 = 13; +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VASliceParameterBufferHEVC { + pub slice_data_size: u32, + pub slice_data_offset: u32, + pub slice_data_flag: u32, + /// Bytes from the start of the NAL unit (its two-byte header included) to + /// `slice_segment_data()`, counted without emulation prevention bytes; the data buffer itself + /// keeps them. + pub slice_data_byte_offset: u32, + pub slice_segment_address: u32, + /// Indices into `ReferenceFrames` (0xFF: none). + pub RefPicList: [[u8; 15]; 2], + pub LongSliceFlags: u32, + pub collocated_ref_idx: u8, + pub num_ref_idx_l0_active_minus1: u8, + pub num_ref_idx_l1_active_minus1: u8, + pub slice_qp_delta: i8, + pub slice_cb_qp_offset: i8, + pub slice_cr_qp_offset: i8, + pub slice_beta_offset_div2: i8, + pub slice_tc_offset_div2: i8, + pub luma_log2_weight_denom: u8, + pub delta_chroma_log2_weight_denom: i8, + pub delta_luma_weight_l0: [i8; 15], + pub luma_offset_l0: [i8; 15], + pub delta_chroma_weight_l0: [[i8; 2]; 15], + pub ChromaOffsetL0: [[i8; 2]; 15], + pub delta_luma_weight_l1: [i8; 15], + pub luma_offset_l1: [i8; 15], + pub delta_chroma_weight_l1: [[i8; 2]; 15], + pub ChromaOffsetL1: [[i8; 2]; 15], + pub five_minus_max_num_merge_cand: u8, + pub num_entry_point_offsets: u16, + pub entry_offset_to_subset_array: u16, + /// Emulation prevention bytes in the slice segment header. + pub slice_data_num_emu_prevn_bytes: u16, + pub va_reserved: [u32; VA_PADDING_LOW - 2], +} + +/// `VASliceParameterBufferHEVC::LongSliceFlags` bit positions (`slice_type` and `color_plane_id` 2 bits). +pub mod hevc_slice_bits { + pub const LAST_SLICE_OF_PIC: u32 = 0; + pub const DEPENDENT_SLICE_SEGMENT: u32 = 1; + pub const SLICE_TYPE: u32 = 2; + pub const COLOR_PLANE_ID: u32 = 4; + pub const SLICE_SAO_LUMA: u32 = 6; + pub const SLICE_SAO_CHROMA: u32 = 7; + pub const MVD_L1_ZERO: u32 = 8; + pub const CABAC_INIT: u32 = 9; + pub const SLICE_TEMPORAL_MVP_ENABLED: u32 = 10; + pub const SLICE_DEBLOCKING_FILTER_DISABLED: u32 = 11; + pub const COLLOCATED_FROM_L0: u32 = 12; + pub const SLICE_LOOP_FILTER_ACROSS_SLICES_ENABLED: u32 = 13; +} + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub struct VAIQMatrixBufferHEVC { + /// `ScalingList[sizeId][matrixId][j]` of the spec for sizeId 0..3, raster order. + pub ScalingList4x4: [[u8; 16]; 6], + pub ScalingList8x8: [[u8; 64]; 6], + pub ScalingList16x16: [[u8; 64]; 6], + pub ScalingList32x32: [[u8; 64]; 2], + pub ScalingListDC16x16: [u8; 6], + pub ScalingListDC32x32: [u8; 2], + pub va_reserved: [u32; VA_PADDING_LOW], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VAImageFormat { + pub fourcc: u32, + pub byte_order: u32, + pub bits_per_pixel: u32, + pub depth: u32, + pub red_mask: u32, + pub green_mask: u32, + pub blue_mask: u32, + pub alpha_mask: u32, + pub va_reserved: [u32; VA_PADDING_LOW], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VAImage { + pub image_id: VAImageID, + pub format: VAImageFormat, + pub buf: VABufferID, + pub width: u16, + pub height: u16, + pub data_size: u32, + pub num_planes: u32, + pub pitches: [u32; 3], + pub offsets: [u32; 3], + pub num_palette_entries: i32, + pub entry_bytes: i32, + pub component_order: [i8; 4], + pub va_reserved: [u32; VA_PADDING_LOW], +} + +/// The `libva.so.2` entry points used (all `VAStatus`-returning unless noted). +pub type vaInitialize = unsafe extern "C" fn(dpy: VADisplay, major: *mut c_int, minor: *mut c_int) -> VAStatus; +pub type vaTerminate = unsafe extern "C" fn(dpy: VADisplay) -> VAStatus; +pub type vaErrorStr = unsafe extern "C" fn(status: VAStatus) -> *const c_char; +pub type vaQueryVendorString = unsafe extern "C" fn(dpy: VADisplay) -> *const c_char; +pub type vaSetInfoCallback = unsafe extern "C" fn(dpy: VADisplay, callback: VAMessageCallback, user_context: *mut c_void) -> VAMessageCallback; +pub type vaSetErrorCallback = unsafe extern "C" fn(dpy: VADisplay, callback: VAMessageCallback, user_context: *mut c_void) -> VAMessageCallback; +pub type vaGetConfigAttributes = + unsafe extern "C" fn(dpy: VADisplay, profile: VAProfile, entrypoint: VAEntrypoint, attrib_list: *mut VAConfigAttrib, num_attribs: c_int) -> VAStatus; +pub type vaCreateConfig = unsafe extern "C" fn( + dpy: VADisplay, + profile: VAProfile, + entrypoint: VAEntrypoint, + attrib_list: *mut VAConfigAttrib, + num_attribs: c_int, + config_id: *mut VAConfigID, +) -> VAStatus; +pub type vaDestroyConfig = unsafe extern "C" fn(dpy: VADisplay, config_id: VAConfigID) -> VAStatus; +/// The last two arguments are `VASurfaceAttrib *attrib_list, unsigned int num_attribs`; this +/// backend passes none, so the attribute structure is not declared. +pub type vaCreateSurfaces = unsafe extern "C" fn( + dpy: VADisplay, + format: c_uint, + width: c_uint, + height: c_uint, + surfaces: *mut VASurfaceID, + num_surfaces: c_uint, + attrib_list: *mut c_void, + num_attribs: c_uint, +) -> VAStatus; +pub type vaDestroySurfaces = unsafe extern "C" fn(dpy: VADisplay, surfaces: *mut VASurfaceID, num_surfaces: c_int) -> VAStatus; +pub type vaCreateContext = unsafe extern "C" fn( + dpy: VADisplay, + config_id: VAConfigID, + picture_width: c_int, + picture_height: c_int, + flag: c_int, + render_targets: *mut VASurfaceID, + num_render_targets: c_int, + context: *mut VAContextID, +) -> VAStatus; +pub type vaDestroyContext = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID) -> VAStatus; +pub type vaCreateBuffer = unsafe extern "C" fn( + dpy: VADisplay, + context: VAContextID, + type_: VABufferType, + size: c_uint, + num_elements: c_uint, + data: *mut c_void, + buf_id: *mut VABufferID, +) -> VAStatus; +pub type vaDestroyBuffer = unsafe extern "C" fn(dpy: VADisplay, buffer_id: VABufferID) -> VAStatus; +pub type vaBeginPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID, render_target: VASurfaceID) -> VAStatus; +pub type vaRenderPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID, buffers: *mut VABufferID, num_buffers: c_int) -> VAStatus; +pub type vaEndPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextID) -> VAStatus; +pub type vaSyncSurface = unsafe extern "C" fn(dpy: VADisplay, render_target: VASurfaceID) -> VAStatus; +pub type vaCreateImage = unsafe extern "C" fn(dpy: VADisplay, format: *mut VAImageFormat, width: c_int, height: c_int, image: *mut VAImage) -> VAStatus; +pub type vaDestroyImage = unsafe extern "C" fn(dpy: VADisplay, image: VAImageID) -> VAStatus; +pub type vaGetImage = + unsafe extern "C" fn(dpy: VADisplay, surface: VASurfaceID, x: c_int, y: c_int, width: c_uint, height: c_uint, image: VAImageID) -> VAStatus; +pub type vaPutImage = unsafe extern "C" fn( + dpy: VADisplay, + surface: VASurfaceID, + image: VAImageID, + src_x: c_int, + src_y: c_int, + src_width: c_uint, + src_height: c_uint, + dest_x: c_int, + dest_y: c_int, + dest_width: c_uint, + dest_height: c_uint, +) -> VAStatus; +pub type vaMapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID, pbuf: *mut *mut c_void) -> VAStatus; +pub type vaUnmapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID) -> VAStatus; +/// `libva-drm.so.2`: the display of a DRM device (render node) file descriptor. +pub type vaGetDisplayDRM = unsafe extern "C" fn(fd: c_int) -> VADisplay; diff --git a/crates/platform/src/vaapi/h264.rs b/crates/platform/src/vaapi/h264.rs new file mode 100644 index 000000000..bc79ee9a4 --- /dev/null +++ b/crates/platform/src/vaapi/h264.rs @@ -0,0 +1,512 @@ +//! The H.264 side of the VA-API decoder: everything a stateless hardware decoder needs from the +//! host, done with the software decoder's own parsers (`filmcraft_h264::params`, `slice`) and its +//! decoded picture buffer (`filmcraft_h264::dpb`: reference marking, reference lists, output +//! order), so both decoders make the same decisions and output the same pictures in the same order. +//! +//! [`Front`] parses each access unit, fills the VA-API picture / matrix / slice buffers and hands +//! them to an [`Accel`] (the libva session on Linux, a recording stand-in in tests), then reads +//! back the pictures the DPB outputs. What the hardware path does not take is an error, so the +//! hybrid decoder replays the run in software: field / MBAFF coding, FMO, SP / SI and data +//! partitioning, stream changes the surfaces were not made for. +//! +//! Safe code; compiled on every target so its logic is tested everywhere. + +use std::sync::Arc; + +use filmcraft_bitstream::{length_prefixed_nals, unescape_rbsp}; +use filmcraft_codecs::{CodecError, DecodedFrame, Result}; +use filmcraft_h264::dpb::{Dpb, DpbEntry, Output, OutputMeta, RefMark}; +use filmcraft_h264::params::{Pps, Sps}; +use filmcraft_h264::slice::{NalHeader, Poc, PocState, SliceHeader, SliceType, is_new_picture, nal_type}; + +use super::Accel; +use super::ffi::{self, VAIQMatrixBufferH264, VAPictureH264, VAPictureParameterBufferH264, VASliceParameterBufferH264, pic_bits, seq_bits}; + +/// A picture in the DPB: its surface, field order counts (after an MMCO 5, relative to itself) and +/// place in decoding order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Surface { + pub index: usize, + pub top: i32, + pub bottom: i32, + pub seq: u64, +} + +/// A frame_num gap frame not given a surface yet. +const UNASSIGNED: usize = usize::MAX; + +/// The picture whose slices are being collected. +struct Pending { + target: usize, + sps: Arc, + pps: Arc, + first: SliceHeader, + poc: Poc, + pts: i64, + slices: Vec<(VASliceParameterBufferH264, Vec)>, +} + +/// H.264 front end of a stateless hardware decoder (see the module docs). +pub struct Front { + accel: A, + length_size: usize, + /// Coded size (macroblocks) the surfaces were made for. + mbs: (u32, u32), + spss: Vec>>, + ppss: Vec>>, + dpb: Dpb, + poc_state: PocState, + prev_ref_frame_num: u32, + active_sps: Option>, + pending: Option, + /// Pictures made so far (decoding order of [`Surface::seq`]). + seq: u64, +} + +fn decode_err(e: impl std::fmt::Display) -> CodecError { + CodecError::Decode(e.to_string()) +} + +fn unsupported(what: &str) -> CodecError { + CodecError::Decode(format!("the hardware decoder does not take {what}")) +} + +impl Front { + /// A front end for samples with `length_size`-byte NAL length prefixes whose sample entry + /// carried `parameter_sets` (SPS / PPS NAL units), decoding into `accel`'s surfaces of + /// `mbs` macroblocks. + pub fn new(accel: A, length_size: usize, mbs: (u32, u32), parameter_sets: &[Vec]) -> Result { + let mut f = Self { + accel, + length_size, + mbs, + spss: vec![None; 32], + ppss: vec![None; 256], + dpb: Dpb::new(), + poc_state: PocState::default(), + prev_ref_frame_num: 0, + active_sps: None, + pending: None, + seq: 0, + }; + f.load_parameter_sets(parameter_sets)?; + Ok(f) + } + + fn load_parameter_sets(&mut self, sets: &[Vec]) -> Result<()> { + let mut frames = Vec::new(); + for nal in sets { + self.handle_nal(nal, 0, &mut frames)?; + } + Ok(()) + } + + pub fn accel(&self) -> &A { + &self.accel + } + + /// Decode one access unit; pictures that left the DPB come back in output order. + pub fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let nals = length_prefixed_nals(sample, self.length_size).map_err(decode_err)?; + let mut frames = Vec::new(); + for nal in nals { + self.handle_nal(nal, pts, &mut frames)?; + } + // the access unit is complete + self.submit_pending(&mut frames)?; + Ok(frames) + } + + /// Output every picture still held for reordering (references stay for what follows). + pub fn flush(&mut self) -> Result> { + let mut frames = Vec::new(); + self.submit_pending(&mut frames)?; + let mut outs = Vec::new(); + self.dpb.flush(&mut outs); + self.read_out(outs, &mut frames)?; + Ok(frames) + } + + /// Forget every picture (after a seek). + pub fn reset(&mut self) { + self.dpb = Dpb::new(); + self.poc_state = PocState::default(); + self.prev_ref_frame_num = 0; + self.active_sps = None; + self.pending = None; + } + + /// Read back the pictures the DPB output, right away: a surface leaving the DPB is reused by + /// the next picture. + fn read_out(&mut self, outs: Vec>, frames: &mut Vec) -> Result<()> { + for o in outs { + let frame = self.accel.read(o.frame.index).map_err(decode_err)?; + frames.push(DecodedFrame { pts: o.meta.pts, frame, draft: false }); + } + Ok(()) + } + + fn handle_nal(&mut self, nal: &[u8], pts: i64, frames: &mut Vec) -> Result<()> { + let Some(&first) = nal.first() else { return Ok(()) }; + let rest = nal.get(1..).unwrap_or_default(); + let hdr = NalHeader::parse(first).map_err(decode_err)?; + match hdr.nal_unit_type { + nal_type::SPS => { + let sps = Sps::parse(&unescape_rbsp(rest)).map_err(decode_err)?; + let slot = self.spss.get_mut(sps.id as usize).ok_or_else(|| decode_err("SPS id out of range"))?; + *slot = Some(Arc::new(sps)); + } + nal_type::PPS => { + let spss: Vec> = self.spss.iter().map(|s| s.as_ref().map(|s| (**s).clone())).collect(); + let pps = Pps::parse(&unescape_rbsp(rest), &spss).map_err(decode_err)?; + let slot = self.ppss.get_mut(pps.id as usize).ok_or_else(|| decode_err("PPS id out of range"))?; + *slot = Some(Arc::new(pps)); + } + nal_type::SLICE | nal_type::IDR => self.handle_slice(nal, hdr, pts, frames)?, + nal_type::SLICE_DPA | nal_type::SLICE_DPB | nal_type::SLICE_DPC => return Err(unsupported("data partitioning")), + nal_type::END_SEQ | nal_type::END_STREAM => self.submit_pending(frames)?, + // SEI, AUD, filler, extensions: nothing to decode + _ => {} + } + Ok(()) + } + + fn handle_slice(&mut self, nal: &[u8], hdr: NalHeader, pts: i64, frames: &mut Vec) -> Result<()> { + let rbsp = unescape_rbsp(nal.get(1..).unwrap_or_default()); + let (ppss, spss) = (&self.ppss, &self.spss); + let mut found: Option<(Arc, Arc)> = None; + let (sh, _, _) = SliceHeader::parse(&rbsp, hdr, |id| { + let missing = |what: String| filmcraft_h264::Error::MissingParameterSet(what); + let pps = ppss.get(id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("PPS {id}")))?; + let sps = spss.get(pps.sps_id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("SPS {}", pps.sps_id)))?; + found = Some((pps.clone(), sps.clone())); + Ok((&**pps, &**sps)) + }) + .map_err(decode_err)?; + let Some((pps, sps)) = found else { return Err(decode_err("slice without a PPS")) }; + sps.check_supported().map_err(decode_err)?; + if pps.num_slice_groups > 1 { + return Err(unsupported("slice groups (FMO)")); + } + if sh.field_pic { + return Err(unsupported("field pictures")); + } + if matches!(sh.slice_type, SliceType::Sp | SliceType::Si) { + return Err(unsupported("SP / SI slices")); + } + if sh.redundant_pic_cnt > 0 { + return Ok(()); // redundant slices are ignored, as in the software decoder + } + let new_pic = match &self.pending { + None => true, + Some(p) => is_new_picture(&p.first, &sh, &sps) || sh.first_mb_in_slice == 0 || !Arc::ptr_eq(&p.sps, &sps), + }; + if new_pic { + self.submit_pending(frames)?; + if sh.first_mb_in_slice != 0 && self.active_sps.is_none() { + return Err(decode_err("stream does not start with the first slice of a picture")); + } + self.start_picture(&sh, &sps, &pps, pts, frames)?; + } + let Some(pending) = self.pending.as_ref() else { return Err(decode_err("slice without a started picture")) }; + if !Arc::ptr_eq(&pending.pps, &pps) { + return Err(unsupported("a picture whose slices use different PPSs")); + } + let surfaces = self.accel.surfaces(); + let lists = self + .dpb + .build_ref_lists(&sh, pending.poc.frame(), sps.max_frame_num(), |e| (va_ref(e, surfaces), e.mark != RefMark::Unused)) + .map_err(decode_err)?; + if !sh.slice_type.is_intra() && (lists[0].is_empty() || (sh.slice_type.is_b() && lists[1].is_empty())) { + return Err(decode_err("no reference pictures available for an inter slice")); + } + // a missing reference (the software decoder conceals it with another picture) is not + // something to hand the hardware + if lists.iter().flatten().any(|(_, is_ref)| !is_ref) { + return Err(decode_err("a reference picture is missing")); + } + let refs = lists.map(|l| l.into_iter().map(|(p, _)| p).collect::>()); + let params = slice_params(&sh, nal.len(), &refs)?; + if let Some(p) = self.pending.as_mut() { + p.slices.push((params, nal.to_vec())); + } + Ok(()) + } + + fn start_picture(&mut self, sh: &SliceHeader, sps: &Arc, pps: &Arc, pts: i64, frames: &mut Vec) -> Result<()> { + if (sps.pic_width_in_mbs, sps.frame_height_in_mbs()) != self.mbs { + return Err(unsupported("a picture size change")); + } + if sps.max_dpb_frames() >= self.accel.surfaces().len() { + return Err(unsupported("a DPB larger than the decoder's surfaces")); + } + // a new SPS with another DPB size outputs and forgets what came before (as in software) + let changed = self.active_sps.as_ref().is_some_and(|a| a.max_dpb_frames() != sps.max_dpb_frames()); + let mut outs = Vec::new(); + if changed { + self.dpb.flush(&mut outs); + self.dpb.entries.clear(); + } + self.active_sps = Some(sps.clone()); + self.dpb.capacity = sps.max_dpb_frames(); + self.dpb.max_reorder = sps.max_num_reorder_frames().min(self.dpb.capacity); + if sh.idr { + self.dpb.idr(sh.no_output_of_prior_pics, &mut outs); + self.prev_ref_frame_num = 0; + } else { + let max = sps.max_frame_num(); + if sh.frame_num != self.prev_ref_frame_num && sh.frame_num != (self.prev_ref_frame_num + 1) % max { + self.fill_frame_num_gap(sh, sps, pts)?; + } + } + self.read_out(outs, frames)?; + let poc = self.poc_state.compute(sh, sps); + let target = + (0..self.accel.surfaces().len()).find(|&i| !self.dpb.entries.iter().any(|e| e.frame.index == i)).ok_or_else(|| decode_err("no free surface"))?; + self.pending = Some(Pending { target, sps: sps.clone(), pps: pps.clone(), first: sh.clone(), poc, pts, slices: Vec::new() }); + Ok(()) + } + + /// frame_num gap (8.2.5.2; also every start at a non-IDR picture, an open-GOP seek): "non-existing" + /// frames as the software decoder makes them, copies of the latest decoded frame (mid-gray when + /// there is none), so pictures predicted from them come out the same. + fn fill_frame_num_gap(&mut self, sh: &SliceHeader, sps: &Arc, pts: i64) -> Result<()> { + let max = sps.max_frame_num(); + let last = self.dpb.entries.iter().filter(|e| !e.non_existing).max_by_key(|e| e.frame.seq).map(|e| e.frame.index); + let meta = Arc::new(output_meta(sps, pts, false)); + let poc_state = &mut self.poc_state; + // surfaces are given afterwards, to the frames the sliding window keeps + let mut make = |_| (Surface { index: UNASSIGNED, top: 0, bottom: 0, seq: 0 }, 0, meta.clone()); + let mut update = |frame_num| poc_state.update_gap_frame(frame_num, sps); + self.dpb.fill_frame_num_gap(self.prev_ref_frame_num, sh.frame_num, max, sps.max_num_ref_frames as usize, &mut make, &mut update); + self.prev_ref_frame_num = (sh.frame_num + max - 1) % max; + let used: Vec = self.dpb.entries.iter().map(|e| e.frame.index).filter(|&i| i != UNASSIGNED).collect(); + let mut free = (0..self.accel.surfaces().len()).filter(|i| !used.contains(i)); + let mut fills = Vec::new(); + for e in self.dpb.entries.iter_mut().filter(|e| e.frame.index == UNASSIGNED) { + let index = free.next().ok_or_else(|| decode_err("no free surface for a frame_num gap"))?; + self.seq += 1; + e.frame = Surface { index, top: 0, bottom: 0, seq: self.seq }; + fills.push(index); + } + for index in fills { + self.accel.fill(index, last).map_err(decode_err)?; + } + Ok(()) + } + + /// Decode the collected picture, then do its reference marking and DPB insertion. + fn submit_pending(&mut self, frames: &mut Vec) -> Result<()> { + let Some(p) = self.pending.take() else { return Ok(()) }; + if p.slices.is_empty() { + return Err(decode_err("a picture without slices")); + } + let surfaces = self.accel.surfaces(); + let target_id = surfaces.get(p.target).copied().ok_or_else(|| decode_err("surface out of range"))?; + if self.dpb.entries.iter().filter(|e| e.mark != RefMark::Unused).count() > 16 { + return Err(decode_err("more than 16 reference frames")); + } + let mut pic = picture_params(&p.sps, &p.pps, &p.first); + pic.CurrPic = VAPictureH264 { + picture_id: target_id, + frame_idx: p.first.frame_num, + flags: 0, + TopFieldOrderCnt: p.poc.top, + BottomFieldOrderCnt: p.poc.bottom, + va_reserved: [0; ffi::VA_PADDING_LOW], + }; + for (slot, e) in pic.ReferenceFrames.iter_mut().zip(self.dpb.entries.iter().filter(|e| e.mark != RefMark::Unused)) { + *slot = va_ref(e, surfaces); + } + let iq = iq_matrix(&p.pps); + self.accel.decode_h264(p.target, &pic, &iq, &p.slices).map_err(decode_err)?; + + self.poc_state.update(&p.first, &p.poc); + let mmco5 = p.first.has_mmco5(); + if p.first.nal_ref_idc != 0 { + self.prev_ref_frame_num = if mmco5 { 0 } else { p.first.frame_num }; + } + // MMCO 5: the picture's order counts become relative to itself (tempPicOrderCnt, 8.2.1) + let temp = if mmco5 { p.poc.frame() } else { 0 }; + self.seq += 1; + let surface = Surface { index: p.target, top: p.poc.top.wrapping_sub(temp), bottom: p.poc.bottom.wrapping_sub(temp), seq: self.seq }; + let fpoc = if mmco5 { 0 } else { p.poc.frame() }; + let meta = Arc::new(output_meta(&p.sps, p.pts, p.first.idr)); + let mut outs = Vec::new(); + self.dpb.store_picture(&p.first, surface, fpoc, p.sps.max_frame_num(), p.sps.max_num_ref_frames as usize, meta, &mut outs); + self.read_out(outs, frames) + } +} + +/// A DPB entry as a VA-API reference picture. +fn va_ref(e: &DpbEntry, surfaces: &[ffi::VASurfaceID]) -> VAPictureH264 { + let long = e.mark == RefMark::Long; + VAPictureH264 { + picture_id: surfaces.get(e.frame.index).copied().unwrap_or(ffi::VA_INVALID_SURFACE), + frame_idx: if long { e.long_term_frame_idx } else { e.frame_num }, + flags: if long { ffi::VA_PICTURE_H264_LONG_TERM_REFERENCE } else { ffi::VA_PICTURE_H264_SHORT_TERM_REFERENCE }, + TopFieldOrderCnt: e.frame.top, + BottomFieldOrderCnt: e.frame.bottom, + va_reserved: [0; ffi::VA_PADDING_LOW], + } +} + +fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta { + let vui = sps.vui.clone().unwrap_or_default(); + OutputMeta { + pts, + key, + crop: sps.crop_rect(), + full_range: vui.full_range, + colour_primaries: vui.colour_primaries, + transfer_characteristics: vui.transfer_characteristics, + matrix_coefficients: vui.matrix_coefficients, + sar: vui.sar, + draft: false, + } +} + +fn flag(on: bool, bit: u32) -> u32 { + u32::from(on) << bit +} + +/// The picture parameters of a picture coded with `sps` / `pps` whose first slice is `sh` (the +/// current and reference pictures are filled in by the caller). +pub fn picture_params(sps: &Sps, pps: &Pps, sh: &SliceHeader) -> VAPictureParameterBufferH264 { + let seq_fields = (sps.chroma_format_idc & 3) << seq_bits::CHROMA_FORMAT_IDC + | flag(sps.separate_colour_plane, seq_bits::RESIDUAL_COLOUR_TRANSFORM) + | flag(sps.gaps_in_frame_num_allowed, seq_bits::GAPS_IN_FRAME_NUM_ALLOWED) + | flag(sps.frame_mbs_only, seq_bits::FRAME_MBS_ONLY) + | flag(sps.mb_adaptive_frame_field, seq_bits::MB_ADAPTIVE_FRAME_FIELD) + | flag(sps.direct_8x8_inference, seq_bits::DIRECT_8X8_INFERENCE) + // A.3.3.2: bi-prediction of blocks under 8x8 is not allowed from level 3.1 + | flag(sps.level_idc >= 31, seq_bits::MIN_LUMA_BI_PRED_SIZE_8X8) + | (sps.log2_max_frame_num.saturating_sub(4) & 15) << seq_bits::LOG2_MAX_FRAME_NUM_MINUS4 + | (sps.pic_order_cnt_type & 3) << seq_bits::PIC_ORDER_CNT_TYPE + | (sps.log2_max_poc_lsb.saturating_sub(4) & 15) << seq_bits::LOG2_MAX_PIC_ORDER_CNT_LSB_MINUS4 + | flag(sps.delta_pic_order_always_zero, seq_bits::DELTA_PIC_ORDER_ALWAYS_ZERO); + let pic_fields = flag(pps.entropy_coding_mode, pic_bits::ENTROPY_CODING_MODE) + | flag(pps.weighted_pred, pic_bits::WEIGHTED_PRED) + | (pps.weighted_bipred_idc & 3) << pic_bits::WEIGHTED_BIPRED_IDC + | flag(pps.transform_8x8_mode, pic_bits::TRANSFORM_8X8_MODE) + | flag(sh.field_pic, pic_bits::FIELD_PIC) + | flag(pps.constrained_intra_pred, pic_bits::CONSTRAINED_INTRA_PRED) + | flag(pps.bottom_field_pic_order_in_frame_present, pic_bits::PIC_ORDER_PRESENT) + | flag(pps.deblocking_filter_control_present, pic_bits::DEBLOCKING_FILTER_CONTROL_PRESENT) + | flag(pps.redundant_pic_cnt_present, pic_bits::REDUNDANT_PIC_CNT_PRESENT) + | flag(sh.nal_ref_idc != 0, pic_bits::REFERENCE_PIC); + // the parsers bound these (frame size by the level limits, qp offsets to ±12, qp to 0..51) + let narrow = |v: u32| u16::try_from(v.saturating_sub(1)).unwrap_or(u16::MAX); + let small = |v: i32| i8::try_from(v).unwrap_or(0); + VAPictureParameterBufferH264 { + CurrPic: VAPictureH264::INVALID, + ReferenceFrames: [VAPictureH264::INVALID; 16], + picture_width_in_mbs_minus1: narrow(sps.pic_width_in_mbs), + picture_height_in_mbs_minus1: narrow(sps.frame_height_in_mbs()), + bit_depth_luma_minus8: u8::try_from(sps.bit_depth_luma.saturating_sub(8)).unwrap_or(0), + bit_depth_chroma_minus8: u8::try_from(sps.bit_depth_chroma.saturating_sub(8)).unwrap_or(0), + num_ref_frames: u8::try_from(sps.max_num_ref_frames).unwrap_or(16), + seq_fields, + num_slice_groups_minus1: 0, + slice_group_map_type: 0, + slice_group_change_rate_minus1: 0, + pic_init_qp_minus26: small(pps.pic_init_qp - 26), + pic_init_qs_minus26: small(pps.pic_init_qs - 26), + chroma_qp_index_offset: small(pps.chroma_qp_index_offset), + second_chroma_qp_index_offset: small(pps.second_chroma_qp_index_offset), + pic_fields, + frame_num: u16::try_from(sh.frame_num).unwrap_or(0), + va_reserved: [0; ffi::VA_PADDING_MEDIUM], + } +} + +/// The scaling matrices of `pps` (after the SPS / PPS fall-back rules), raster order. +pub fn iq_matrix(pps: &Pps) -> VAIQMatrixBufferH264 { + VAIQMatrixBufferH264 { ScalingList4x4: pps.scaling.m4, ScalingList8x8: [pps.scaling.m8[0], pps.scaling.m8[1]], va_reserved: [0; ffi::VA_PADDING_LOW] } +} + +/// The slice parameters of the slice `sh` (a `nal_len`-byte NAL unit) with reference lists `refs`. +pub fn slice_params(sh: &SliceHeader, nal_len: usize, refs: &[Vec; 2]) -> Result { + let bit_offset = sh.header_bits.checked_add(8).and_then(|b| u16::try_from(b).ok()).ok_or_else(|| decode_err("slice header too long"))?; + let mut s = VASliceParameterBufferH264 { + slice_data_size: u32::try_from(nal_len).map_err(|_| decode_err("slice too large"))?, + slice_data_offset: 0, + slice_data_flag: ffi::VA_SLICE_DATA_FLAG_ALL, + slice_data_bit_offset: bit_offset, + first_mb_in_slice: u16::try_from(sh.first_mb_in_slice).map_err(|_| decode_err("first_mb_in_slice out of range"))?, + slice_type: (sh.slice_type_raw % 5) as u8, + direct_spatial_mv_pred_flag: u8::from(sh.direct_spatial_mv_pred), + num_ref_idx_l0_active_minus1: u8::try_from(sh.num_ref_idx_active[0].saturating_sub(1)).unwrap_or(31).min(31), + num_ref_idx_l1_active_minus1: u8::try_from(sh.num_ref_idx_active[1].saturating_sub(1)).unwrap_or(31).min(31), + cabac_init_idc: u8::try_from(sh.cabac_init_idc).unwrap_or(0), + slice_qp_delta: i8::try_from(sh.slice_qp_delta).map_err(|_| decode_err("slice_qp_delta out of range"))?, + disable_deblocking_filter_idc: u8::try_from(sh.disable_deblocking_filter_idc).unwrap_or(0), + slice_alpha_c0_offset_div2: i8::try_from(sh.slice_alpha_c0_offset_div2).unwrap_or(0), + slice_beta_offset_div2: i8::try_from(sh.slice_beta_offset_div2).unwrap_or(0), + RefPicList0: [VAPictureH264::INVALID; 32], + RefPicList1: [VAPictureH264::INVALID; 32], + luma_log2_weight_denom: 0, + chroma_log2_weight_denom: 0, + luma_weight_l0_flag: 0, + luma_weight_l0: [0; 32], + luma_offset_l0: [0; 32], + chroma_weight_l0_flag: 0, + chroma_weight_l0: [[0; 2]; 32], + chroma_offset_l0: [[0; 2]; 32], + luma_weight_l1_flag: 0, + luma_weight_l1: [0; 32], + luma_offset_l1: [0; 32], + chroma_weight_l1_flag: 0, + chroma_weight_l1: [[0; 2]; 32], + chroma_offset_l1: [[0; 2]; 32], + va_reserved: [0; ffi::VA_PADDING_LOW], + }; + for (dst, src) in s.RefPicList0.iter_mut().zip(&refs[0]) { + *dst = *src; + } + for (dst, src) in s.RefPicList1.iter_mut().zip(&refs[1]) { + *dst = *src; + } + // explicit weighted prediction: every entry, the default weight where none was coded + if let Some(w) = &sh.pred_weight_table { + s.luma_log2_weight_denom = u8::try_from(w.luma_log2_denom).unwrap_or(0); + s.chroma_log2_weight_denom = u8::try_from(w.chroma_log2_denom).unwrap_or(0); + let w16 = |v: i32| i16::try_from(v).unwrap_or(0); + let lists = [ + ( + &w.l0, + &mut s.luma_weight_l0, + &mut s.luma_offset_l0, + &mut s.chroma_weight_l0, + &mut s.chroma_offset_l0, + &mut s.luma_weight_l0_flag, + &mut s.chroma_weight_l0_flag, + ), + ( + &w.l1, + &mut s.luma_weight_l1, + &mut s.luma_offset_l1, + &mut s.chroma_weight_l1, + &mut s.chroma_offset_l1, + &mut s.luma_weight_l1_flag, + &mut s.chroma_weight_l1_flag, + ), + ]; + for (entries, lw, lo, cw, co, lflag, cflag) in lists { + if entries.is_empty() { + continue; + } + *lflag = 1; + *cflag = 1; + for (i, e) in entries.iter().take(32).enumerate() { + lw[i] = w16(e.luma_weight); + lo[i] = w16(e.luma_offset); + for c in 0..2 { + cw[i][c] = w16(e.chroma_weight[c]); + co[i][c] = w16(e.chroma_offset[c]); + } + } + } + } + Ok(s) +} diff --git a/crates/platform/src/vaapi/hevc.rs b/crates/platform/src/vaapi/hevc.rs new file mode 100644 index 000000000..730a139f2 --- /dev/null +++ b/crates/platform/src/vaapi/hevc.rs @@ -0,0 +1,612 @@ +//! The HEVC side of the VA-API decoder, like [`super::h264`]: the software decoder's own parsers +//! (`filmcraft_hevc::params`, `slice`) and decoded picture buffer (`filmcraft_hevc::dpb`: POC, +//! reference picture sets, reference lists, output order) drive a stateless hardware decoder, so +//! both decoders make the same decisions and output the same pictures in the same order, RASL +//! pictures of a CRA the run starts at left out as in software. +//! +//! What the hardware path does not take is an error, so the hybrid decoder replays the run in +//! software: missing reference pictures (the software decoder conceals them), more than 15 +//! reference frames, stream changes the surfaces were not made for, range extensions. +//! +//! Safe code; compiled on every target so its logic is tested everywhere. + +use std::sync::Arc; + +use filmcraft_bitstream::{length_prefixed_nals, unescape_rbsp}; +use filmcraft_codecs::{CodecError, DecodedFrame, Result}; +use filmcraft_hevc::dpb::{Dpb, Marking, Output, OutputMeta, PocState, Ref, RefPicSet, build_ref_lists}; +use filmcraft_hevc::params::{Layout, Pps, ScalingList, Sps}; +use filmcraft_hevc::slice::{NalHeader, SliceHeader, SliceType, nal_type}; + +use super::Accel; +use super::ffi::{ + self, VAIQMatrixBufferHEVC, VAPictureHEVC, VAPictureParameterBufferHEVC, VASliceParameterBufferHEVC, hevc_pic_bits, hevc_slice_bits, + hevc_slice_parsing_bits, +}; + +/// A picture in the DPB: its surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Surface { + pub index: usize, +} + +/// A reference picture that was never decoded (8.3.3): not handed to the hardware. +const MISSING: usize = usize::MAX; + +/// The picture whose slice segments are being collected. +struct Pending { + target: usize, + sps: Arc, + pps: Arc, + first: SliceHeader, + /// The latest independent slice segment's header (dependent ones continue it). + last: SliceHeader, + rps: RefPicSet, + /// The surfaces of `pic.ReferenceFrames`, in order. + ref_frames: Vec, + pic: VAPictureParameterBufferHEVC, + poc: i32, + output: bool, + meta: Arc, + slices: Vec<(VASliceParameterBufferHEVC, Vec)>, + all_intra: bool, + any_b: bool, +} + +/// HEVC front end of a stateless hardware decoder (see the module docs). +pub struct Front { + accel: A, + length_size: usize, + /// Coded size (luma samples) and bit depth the surfaces were made for. + size: (u32, u32), + bits: u32, + spss: Vec>>, + ppss: Vec>>, + dpb: Dpb, + poc_state: PocState, + active_sps: Option>, + pending: Option, + first_picture: bool, + skip_rasl: bool, + after_eos: bool, +} + +fn decode_err(e: impl std::fmt::Display) -> CodecError { + CodecError::Decode(e.to_string()) +} + +fn unsupported(what: &str) -> CodecError { + CodecError::Decode(format!("the hardware decoder does not take {what}")) +} + +impl Front { + /// A front end for samples with `length_size`-byte NAL length prefixes whose sample entry + /// carried `parameter_sets` (VPS / SPS / PPS NAL units), decoding into `accel`'s surfaces of + /// `size` luma samples and `bits` bits. + pub fn new(accel: A, length_size: usize, size: (u32, u32), bits: u32, parameter_sets: &[Vec]) -> Result { + let mut f = Self { + accel, + length_size, + size, + bits, + spss: vec![None; 16], + ppss: vec![None; 64], + dpb: Dpb::default(), + poc_state: PocState::default(), + active_sps: None, + pending: None, + first_picture: true, + skip_rasl: false, + after_eos: false, + }; + let mut frames = Vec::new(); + for nal in parameter_sets { + f.handle_nal(nal, 0, &mut frames)?; + } + Ok(f) + } + + pub fn accel(&self) -> &A { + &self.accel + } + + /// Decode one access unit; pictures that left the DPB come back in output order. + pub fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let nals = length_prefixed_nals(sample, self.length_size).map_err(decode_err)?; + let mut frames = Vec::new(); + for nal in nals { + self.handle_nal(nal, pts, &mut frames)?; + } + self.submit_pending(&mut frames)?; + Ok(frames) + } + + /// Output every picture still held for reordering; what follows starts like a new stream + /// (a CRA's RASL pictures are left out), as in software. + pub fn flush(&mut self) -> Result> { + let mut frames = Vec::new(); + self.submit_pending(&mut frames)?; + let mut outs = Vec::new(); + self.dpb.flush(&mut outs); + self.read_out(outs, &mut frames)?; + self.first_picture = true; + Ok(frames) + } + + /// Forget every picture (after a seek). + pub fn reset(&mut self) { + self.dpb = Dpb::default(); + self.poc_state = PocState::default(); + self.active_sps = None; + self.pending = None; + self.first_picture = true; + self.skip_rasl = false; + self.after_eos = false; + } + + /// Read back the pictures the DPB output, right away: a surface leaving the DPB is reused. + fn read_out(&mut self, outs: Vec>, frames: &mut Vec) -> Result<()> { + for o in outs { + let frame = self.accel.read(o.frame.index).map_err(decode_err)?; + frames.push(DecodedFrame { pts: o.meta.pts, frame, draft: false }); + } + Ok(()) + } + + fn handle_nal(&mut self, nal: &[u8], pts: i64, frames: &mut Vec) -> Result<()> { + if nal.len() < 2 { + return Ok(()); + } + let hdr = NalHeader::parse(nal).map_err(decode_err)?; + if hdr.layer_id != 0 { + return Ok(()); + } + let rbsp = || unescape_rbsp(nal.get(2..).unwrap_or_default()); + match hdr.nal_type { + nal_type::SPS => { + let sps = Sps::parse(&rbsp()).map_err(decode_err)?; + let slot = self.spss.get_mut(sps.id as usize).ok_or_else(|| decode_err("SPS id out of range"))?; + *slot = Some(Arc::new(sps)); + } + nal_type::PPS => { + let pps = Pps::parse(&rbsp()).map_err(decode_err)?; + let slot = self.ppss.get_mut(pps.id as usize).ok_or_else(|| decode_err("PPS id out of range"))?; + *slot = Some(Arc::new(pps)); + } + nal_type::EOS | nal_type::EOB => { + self.submit_pending(frames)?; + self.after_eos = true; + } + 0..=9 | 16..=21 => self.handle_slice(nal, hdr, pts, frames)?, + // VPS, AUD, SEI, filler, reserved: nothing to decode + _ => {} + } + Ok(()) + } + + fn handle_slice(&mut self, nal: &[u8], hdr: NalHeader, pts: i64, frames: &mut Vec) -> Result<()> { + let rbsp = unescape_rbsp(nal.get(2..).unwrap_or_default()); + let first = rbsp.first().is_some_and(|b| b & 0x80 != 0); + if first { + self.submit_pending(frames)?; + } + if hdr.is_rasl() && (self.skip_rasl || self.first_picture) { + return Ok(()); + } + let (ppss, spss) = (&self.ppss, &self.spss); + let prev = self.pending.as_ref().map(|p| &p.last); + let (sh, pps, sps) = SliceHeader::parse( + &rbsp, + hdr, + |id| { + let missing = |what: String| filmcraft_hevc::Error::MissingParameterSet(what); + let pps = ppss.get(id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("PPS {id}")))?; + let sps = spss.get(pps.sps_id as usize).and_then(Option::as_ref).ok_or_else(|| missing(format!("SPS {}", pps.sps_id)))?; + Ok((pps.clone(), sps.clone())) + }, + prev, + ) + .map_err(decode_err)?; + sps.check_supported().map_err(decode_err)?; + pps.check_supported().map_err(decode_err)?; + if first { + self.start_picture(&sh, &sps, &pps, pts, frames)?; + } + let Some(p) = self.pending.as_mut() else { return Err(decode_err("slice segment without the first slice of its picture")) }; + if !Arc::ptr_eq(&p.pps, &pps) { + return Err(decode_err("PPS changed within a picture")); + } + let refs = build_ref_lists(&sh, &p.rps).map_err(decode_err)?; + let params = slice_params(&sh, nal, &refs, &p.ref_frames)?; + if !sh.dependent { + p.last = sh.clone(); + } + p.all_intra &= sh.is_intra(); + p.any_b |= sh.is_b(); + p.slices.push((params, nal.to_vec())); + Ok(()) + } + + fn start_picture(&mut self, sh: &SliceHeader, sps: &Arc, pps: &Arc, pts: i64, frames: &mut Vec) -> Result<()> { + if !sh.first_slice_segment_in_pic || sh.dependent { + return Err(decode_err("invalid first slice segment")); + } + if (sps.width, sps.height) != self.size || sps.bit_depth_luma != self.bits || sps.bit_depth_chroma != self.bits || sps.chroma_format_idc != 1 { + return Err(unsupported("a picture size or format change")); + } + if sps.max_dec_pic_buffering as usize >= self.accel.surfaces().len() { + return Err(unsupported("a DPB larger than the decoder's surfaces")); + } + let hdr = sh.nal; + let changed = self.active_sps.as_ref().is_some_and(|a| a.log2_ctb != sps.log2_ctb); + let mut outs = Vec::new(); + if changed { + self.dpb.flush(&mut outs); + } + self.active_sps = Some(sps.clone()); + self.dpb.max_dec_pic_buffering = sps.max_dec_pic_buffering as usize; + self.dpb.max_num_reorder = sps.max_num_reorder as usize; + self.dpb.max_latency = if sps.max_latency_increase_plus1 != 0 { sps.max_num_reorder + sps.max_latency_increase_plus1 - 1 } else { 0 }; + let irap_no_rasl = hdr.is_irap() && (hdr.is_idr() || hdr.is_bla() || self.first_picture || self.after_eos); + if hdr.is_irap() { + self.skip_rasl = irap_no_rasl; + } + let max_lsb = sps.max_poc_lsb(); + let poc = self.poc_state.compute(sh, max_lsb, irap_no_rasl); + let mut make_missing = |_| Surface { index: MISSING }; + let rps = self.dpb.apply_rps(sh, poc, max_lsb, irap_no_rasl, &mut make_missing).map_err(decode_err)?; + if [&rps.st_curr_before, &rps.st_curr_after, &rps.lt_curr].iter().any(|l| l.iter().any(|r| r.frame.index == MISSING)) { + // the software decoder conceals them with mid-gray pictures + return Err(unsupported("missing reference pictures")); + } + if irap_no_rasl && !self.first_picture { + if sh.no_output_of_prior_pics && hdr.is_idr() { + self.dpb.clear(); + } else { + self.dpb.flush(&mut outs); + } + } else { + self.dpb.bump_before_decode(&mut outs); + } + self.read_out(outs, frames)?; + let surfaces = self.accel.surfaces(); + let target = (0..surfaces.len()).find(|&i| !self.dpb.entries.iter().any(|e| e.frame.index == i)).ok_or_else(|| decode_err("no free surface"))?; + let target_id = surfaces.get(target).copied().ok_or_else(|| decode_err("surface out of range"))?; + let mut pic = picture_params(sps, pps, sh)?; + pic.CurrPic = VAPictureHEVC { picture_id: target_id, pic_order_cnt: poc, flags: 0, va_reserved: [0; ffi::VA_PADDING_LOW] }; + // the reference frames: every picture the RPS kept, flagged with its part of the RPS + let rps_flag = |index: usize| { + let in_list = |l: &[Ref]| l.iter().any(|r| r.frame.index == index); + if in_list(&rps.st_curr_before) { + ffi::VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE + } else if in_list(&rps.st_curr_after) { + ffi::VA_PICTURE_HEVC_RPS_ST_CURR_AFTER + } else if in_list(&rps.lt_curr) { + ffi::VA_PICTURE_HEVC_RPS_LT_CURR + } else { + 0 + } + }; + let refs: Vec<_> = self.dpb.entries.iter().filter(|e| e.marking != Marking::Unused).collect(); + if refs.len() > pic.ReferenceFrames.len() { + return Err(unsupported("more than 15 reference frames")); + } + let mut ref_frames = Vec::with_capacity(refs.len()); + for (slot, e) in pic.ReferenceFrames.iter_mut().zip(&refs) { + let long = if e.marking == Marking::Long { ffi::VA_PICTURE_HEVC_LONG_TERM_REFERENCE } else { 0 }; + let picture_id = surfaces.get(e.frame.index).copied().ok_or_else(|| decode_err("surface out of range"))?; + *slot = VAPictureHEVC { picture_id, pic_order_cnt: e.poc, flags: long | rps_flag(e.frame.index), va_reserved: [0; ffi::VA_PADDING_LOW] }; + ref_frames.push(e.frame.index); + } + let output = sh.pic_output && !(hdr.is_rasl() && self.skip_rasl); + let meta = Arc::new(output_meta(sps, pts, hdr.is_irap())); + self.pending = Some(Pending { + target, + sps: sps.clone(), + pps: pps.clone(), + first: sh.clone(), + last: sh.clone(), + rps, + ref_frames, + pic, + poc, + output, + meta, + slices: Vec::new(), + all_intra: true, + any_b: false, + }); + self.first_picture = false; + self.after_eos = false; + Ok(()) + } + + /// Decode the collected picture, then insert it into the DPB. + fn submit_pending(&mut self, frames: &mut Vec) -> Result<()> { + let Some(mut p) = self.pending.take() else { return Ok(()) }; + let Some(last) = p.slices.last_mut() else { return Err(decode_err("a picture without slices")) }; + last.0.LongSliceFlags |= 1 << hevc_slice_bits::LAST_SLICE_OF_PIC; + p.pic.slice_parsing_fields |= u32::from(p.all_intra) << hevc_slice_parsing_bits::INTRA_PIC; + p.pic.pic_fields |= u32::from(!p.any_b) << hevc_pic_bits::NO_BI_PRED; + let iq = if p.sps.scaling_list_enabled { Some(iq_matrix(p.pps.scaling_list.as_ref().or(p.sps.scaling_list.as_ref()))) } else { None }; + self.accel.decode_hevc(p.target, &p.pic, iq.as_ref(), &p.slices).map_err(decode_err)?; + self.poc_state.update(&p.first, p.poc); + let mut outs = Vec::new(); + self.dpb.insert(Surface { index: p.target }, p.poc, p.output, p.meta, &mut outs); + self.read_out(outs, frames) + } +} + +fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta { + let vui = sps.vui.clone().unwrap_or_default(); + OutputMeta { + pts, + key, + crop: sps.crop_rect(), + full_range: vui.full_range, + colour_primaries: vui.colour_primaries, + transfer_characteristics: vui.transfer_characteristics, + matrix_coefficients: vui.matrix_coefficients, + sar: vui.sar, + bit_depth: sps.bit_depth_luma, + draft: false, + } +} + +fn flag(on: bool, bit: u32) -> u32 { + u32::from(on) << bit +} + +fn u8_of(v: u32) -> u8 { + u8::try_from(v).unwrap_or(u8::MAX) +} + +fn i8_of(v: i32) -> i8 { + i8::try_from(v).unwrap_or(0) +} + +/// The picture parameters of a picture coded with `sps` / `pps` whose first slice segment is `sh` +/// (the current and reference pictures, `IntraPicFlag` and `NoBiPredFlag` are filled in by the caller). +pub fn picture_params(sps: &Sps, pps: &Pps, sh: &SliceHeader) -> Result { + use hevc_pic_bits as p; + use hevc_slice_parsing_bits as s; + let pic_fields = (sps.chroma_format_idc & 3) << p::CHROMA_FORMAT_IDC + | flag(sps.separate_colour_plane, p::SEPARATE_COLOUR_PLANE) + | flag(sps.pcm, p::PCM_ENABLED) + | flag(sps.scaling_list_enabled, p::SCALING_LIST_ENABLED) + | flag(pps.transform_skip, p::TRANSFORM_SKIP_ENABLED) + | flag(sps.amp, p::AMP_ENABLED) + | flag(sps.strong_intra_smoothing, p::STRONG_INTRA_SMOOTHING_ENABLED) + | flag(pps.sign_data_hiding, p::SIGN_DATA_HIDING_ENABLED) + | flag(pps.constrained_intra_pred, p::CONSTRAINED_INTRA_PRED) + | flag(pps.cu_qp_delta_enabled, p::CU_QP_DELTA_ENABLED) + | flag(pps.weighted_pred, p::WEIGHTED_PRED) + | flag(pps.weighted_bipred, p::WEIGHTED_BIPRED) + | flag(pps.transquant_bypass, p::TRANSQUANT_BYPASS_ENABLED) + | flag(pps.tiles_enabled, p::TILES_ENABLED) + | flag(pps.entropy_coding_sync, p::ENTROPY_CODING_SYNC_ENABLED) + | flag(pps.loop_filter_across_slices, p::PPS_LOOP_FILTER_ACROSS_SLICES_ENABLED) + | flag(pps.loop_filter_across_tiles, p::LOOP_FILTER_ACROSS_TILES_ENABLED) + | flag(sps.pcm_loop_filter_disabled, p::PCM_LOOP_FILTER_DISABLED) + | flag(sps.max_num_reorder == 0, p::NO_PIC_REORDERING); + let slice_parsing_fields = flag(pps.lists_modification_present, s::LISTS_MODIFICATION_PRESENT) + | flag(sps.long_term_refs_present, s::LONG_TERM_REF_PICS_PRESENT) + | flag(sps.temporal_mvp, s::SPS_TEMPORAL_MVP_ENABLED) + | flag(pps.cabac_init_present, s::CABAC_INIT_PRESENT) + | flag(pps.output_flag_present, s::OUTPUT_FLAG_PRESENT) + | flag(pps.dependent_slice_segments_enabled, s::DEPENDENT_SLICE_SEGMENTS_ENABLED) + | flag(pps.slice_chroma_qp_offsets_present, s::PPS_SLICE_CHROMA_QP_OFFSETS_PRESENT) + | flag(sps.sao, s::SAMPLE_ADAPTIVE_OFFSET_ENABLED) + | flag(pps.deblocking_override_enabled, s::DEBLOCKING_FILTER_OVERRIDE_ENABLED) + | flag(pps.deblocking_disabled, s::PPS_DISABLE_DEBLOCKING_FILTER) + | flag(pps.slice_header_extension_present, s::SLICE_SEGMENT_HEADER_EXTENSION_PRESENT) + | flag(sh.nal.is_irap(), s::RAP_PIC) + | flag(sh.nal.is_idr(), s::IDR_PIC); + // tile sizes in CTBs, uniform spacing resolved + let layout = Layout::new(sps, pps).map_err(decode_err)?; + let mut column_width_minus1 = [0u16; 19]; + let mut row_height_minus1 = [0u16; 21]; + if pps.tiles_enabled { + let sizes = |bd: &[u32], out: &mut [u16]| -> Result<()> { + let n = bd.len().saturating_sub(1); + if n > out.len() { + return Err(unsupported("that many tiles")); + } + for (o, w) in out.iter_mut().zip(bd.windows(2)) { + *o = u16::try_from(w[1].saturating_sub(w[0]).saturating_sub(1)).unwrap_or(0); + } + Ok(()) + }; + sizes(&layout.col_bd, &mut column_width_minus1)?; + sizes(&layout.row_bd, &mut row_height_minus1)?; + } + let narrow = |v: u32| u16::try_from(v).map_err(|_| decode_err("picture too large")); + Ok(VAPictureParameterBufferHEVC { + CurrPic: VAPictureHEVC::INVALID, + ReferenceFrames: [VAPictureHEVC::INVALID; 15], + pic_width_in_luma_samples: narrow(sps.width)?, + pic_height_in_luma_samples: narrow(sps.height)?, + pic_fields, + sps_max_dec_pic_buffering_minus1: u8_of(sps.max_dec_pic_buffering.saturating_sub(1)), + bit_depth_luma_minus8: u8_of(sps.bit_depth_luma.saturating_sub(8)), + bit_depth_chroma_minus8: u8_of(sps.bit_depth_chroma.saturating_sub(8)), + pcm_sample_bit_depth_luma_minus1: u8_of(sps.pcm_bit_depth_luma.saturating_sub(1)), + pcm_sample_bit_depth_chroma_minus1: u8_of(sps.pcm_bit_depth_chroma.saturating_sub(1)), + log2_min_luma_coding_block_size_minus3: u8_of(sps.log2_min_cb.saturating_sub(3)), + log2_diff_max_min_luma_coding_block_size: u8_of(sps.log2_ctb.saturating_sub(sps.log2_min_cb)), + log2_min_transform_block_size_minus2: u8_of(sps.log2_min_tb.saturating_sub(2)), + log2_diff_max_min_transform_block_size: u8_of(sps.log2_max_tb.saturating_sub(sps.log2_min_tb)), + log2_min_pcm_luma_coding_block_size_minus3: u8_of(sps.log2_min_pcm.saturating_sub(3)), + log2_diff_max_min_pcm_luma_coding_block_size: u8_of(sps.log2_max_pcm.saturating_sub(sps.log2_min_pcm)), + max_transform_hierarchy_depth_intra: u8_of(sps.max_th_depth_intra), + max_transform_hierarchy_depth_inter: u8_of(sps.max_th_depth_inter), + init_qp_minus26: i8_of(pps.init_qp - 26), + diff_cu_qp_delta_depth: u8_of(pps.diff_cu_qp_delta_depth), + pps_cb_qp_offset: i8_of(pps.cb_qp_offset), + pps_cr_qp_offset: i8_of(pps.cr_qp_offset), + log2_parallel_merge_level_minus2: u8_of(pps.log2_parallel_merge_level.saturating_sub(2)), + num_tile_columns_minus1: u8_of(pps.num_tile_columns.saturating_sub(1)), + num_tile_rows_minus1: u8_of(pps.num_tile_rows.saturating_sub(1)), + column_width_minus1, + row_height_minus1, + slice_parsing_fields, + log2_max_pic_order_cnt_lsb_minus4: u8_of(sps.log2_max_poc_lsb.saturating_sub(4)), + num_short_term_ref_pic_sets: u8_of(u32::try_from(sps.st_rps.len()).unwrap_or(u32::MAX)), + num_long_term_ref_pic_sps: u8_of(u32::try_from(sps.lt_ref_pics.len()).unwrap_or(u32::MAX)), + num_ref_idx_l0_default_active_minus1: u8_of(pps.num_ref_idx_l0_default.saturating_sub(1)), + num_ref_idx_l1_default_active_minus1: u8_of(pps.num_ref_idx_l1_default.saturating_sub(1)), + pps_beta_offset_div2: i8_of(pps.beta_offset_div2), + pps_tc_offset_div2: i8_of(pps.tc_offset_div2), + num_extra_slice_header_bits: u8_of(pps.num_extra_slice_header_bits), + st_rps_bits: u32::try_from(sh.st_rps_bits).unwrap_or(0), + va_reserved: [0; ffi::VA_PADDING_MEDIUM], + }) +} + +/// The 8x8 base list `coded` (up-right diagonal order, as coded and stored) in raster order. +fn raster8(list: &ScalingList, size_id: usize, matrix_id: usize) -> [u8; 64] { + let mut l = list.clone(); + let row = |s: usize| l.lists.get(s).and_then(|m| m.get(matrix_id)).copied(); + if let Some(src) = row(size_id) + && let Some(dst) = l.lists.get_mut(1).and_then(|m| m.get_mut(matrix_id)) + { + *dst = src; + } + let mut out = [16u8; 64]; + for (o, v) in out.iter_mut().zip(l.factor(1, matrix_id)) { + *o = v; + } + out +} + +/// The scaling lists in force (`list`, or the default lists), in raster order. +pub fn iq_matrix(list: Option<&ScalingList>) -> VAIQMatrixBufferHEVC { + let defaults = ScalingList::default_lists(); + let l = list.unwrap_or(&defaults); + let mut iq = VAIQMatrixBufferHEVC { + ScalingList4x4: [[16; 16]; 6], + ScalingList8x8: [[16; 64]; 6], + ScalingList16x16: [[16; 64]; 6], + ScalingList32x32: [[16; 64]; 2], + ScalingListDC16x16: [16; 6], + ScalingListDC32x32: [16; 2], + va_reserved: [0; ffi::VA_PADDING_LOW], + }; + for m in 0..6 { + for (o, v) in iq.ScalingList4x4[m].iter_mut().zip(l.factor(0, m)) { + *o = v; + } + iq.ScalingList8x8[m] = raster8(l, 1, m); + iq.ScalingList16x16[m] = raster8(l, 2, m); + iq.ScalingListDC16x16[m] = l.dc[0][m]; + } + // 32x32: matrixId 0 (intra) and 3 (inter) + for (i, m) in [0, 3].into_iter().enumerate() { + iq.ScalingList32x32[i] = raster8(l, 3, m); + iq.ScalingListDC32x32[i] = l.dc[1][m]; + } + iq +} + +/// Emulation prevention bytes among the first `rbsp_len` RBSP bytes of a NAL unit payload. +fn emulation_bytes_before(payload: &[u8], rbsp_len: usize) -> usize { + let (mut zeros, mut out, mut ep) = (0usize, 0usize, 0usize); + for &b in payload { + if out >= rbsp_len { + break; + } + if zeros >= 2 && b == 3 { + ep += 1; + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + out += 1; + } + ep +} + +/// The slice parameters of the slice segment `sh` (NAL unit `nal`) with reference lists `refs`, +/// whose pictures are `ref_frames` (the surfaces of `ReferenceFrames`, in order). +pub fn slice_params(sh: &SliceHeader, nal: &[u8], refs: &[Vec>; 2], ref_frames: &[usize]) -> Result { + use hevc_slice_bits as b; + let slice_type = match sh.slice_type { + SliceType::B => 0, + SliceType::P => 1, + SliceType::I => 2, + }; + let flags = flag(sh.dependent, b::DEPENDENT_SLICE_SEGMENT) + | slice_type << b::SLICE_TYPE + | flag(sh.sao_luma, b::SLICE_SAO_LUMA) + | flag(sh.sao_chroma, b::SLICE_SAO_CHROMA) + | flag(sh.mvd_l1_zero, b::MVD_L1_ZERO) + | flag(sh.cabac_init_flag, b::CABAC_INIT) + | flag(sh.temporal_mvp, b::SLICE_TEMPORAL_MVP_ENABLED) + | flag(sh.deblocking_disabled, b::SLICE_DEBLOCKING_FILTER_DISABLED) + | flag(sh.collocated_from_l0, b::COLLOCATED_FROM_L0) + | flag(sh.loop_filter_across_slices, b::SLICE_LOOP_FILTER_ACROSS_SLICES_ENABLED); + let header_bytes = sh.data_offset.checked_add(2).ok_or_else(|| decode_err("slice header too long"))?; + let ep = emulation_bytes_before(nal.get(2..).unwrap_or_default(), sh.data_offset); + let mut s = VASliceParameterBufferHEVC { + slice_data_size: u32::try_from(nal.len()).map_err(|_| decode_err("slice too large"))?, + slice_data_offset: 0, + slice_data_flag: ffi::VA_SLICE_DATA_FLAG_ALL, + slice_data_byte_offset: u32::try_from(header_bytes).map_err(|_| decode_err("slice header too long"))?, + slice_segment_address: sh.segment_address, + RefPicList: [[0xFF; 15]; 2], + LongSliceFlags: flags, + collocated_ref_idx: u8_of(sh.collocated_ref_idx), + num_ref_idx_l0_active_minus1: u8_of(sh.num_ref_idx[0].saturating_sub(1)), + num_ref_idx_l1_active_minus1: u8_of(sh.num_ref_idx[1].saturating_sub(1)), + slice_qp_delta: i8_of(sh.qp_delta), + slice_cb_qp_offset: i8_of(sh.cb_qp_offset), + slice_cr_qp_offset: i8_of(sh.cr_qp_offset), + slice_beta_offset_div2: i8_of(sh.beta_offset_div2), + slice_tc_offset_div2: i8_of(sh.tc_offset_div2), + luma_log2_weight_denom: 0, + delta_chroma_log2_weight_denom: 0, + delta_luma_weight_l0: [0; 15], + luma_offset_l0: [0; 15], + delta_chroma_weight_l0: [[0; 2]; 15], + ChromaOffsetL0: [[0; 2]; 15], + delta_luma_weight_l1: [0; 15], + luma_offset_l1: [0; 15], + delta_chroma_weight_l1: [[0; 2]; 15], + ChromaOffsetL1: [[0; 2]; 15], + five_minus_max_num_merge_cand: u8_of(5u32.saturating_sub(sh.max_num_merge_cand)), + num_entry_point_offsets: u16::try_from(sh.entry_points.len()).map_err(|_| decode_err("too many entry points"))?, + entry_offset_to_subset_array: 0, + slice_data_num_emu_prevn_bytes: u16::try_from(ep).unwrap_or(u16::MAX), + va_reserved: [0; ffi::VA_PADDING_LOW - 2], + }; + for (l, list) in refs.iter().enumerate() { + if list.len() > 15 { + return Err(decode_err("reference list longer than 15")); + } + for (i, r) in list.iter().enumerate() { + let idx = ref_frames.iter().position(|&f| f == r.frame.index).ok_or_else(|| decode_err("a reference is not among the reference frames"))?; + s.RefPicList[l][i] = u8::try_from(idx).unwrap_or(0xFF); + } + } + if let Some(w) = &sh.pwt { + let luma_denom = i32::try_from(w.luma_log2_denom).unwrap_or(0); + let chroma_denom = i32::try_from(w.chroma_log2_denom).unwrap_or(0); + s.luma_log2_weight_denom = u8_of(w.luma_log2_denom); + s.delta_chroma_log2_weight_denom = i8_of(chroma_denom - luma_denom); + let lists = [ + (&w.l[0], &mut s.delta_luma_weight_l0, &mut s.luma_offset_l0, &mut s.delta_chroma_weight_l0, &mut s.ChromaOffsetL0), + (&w.l[1], &mut s.delta_luma_weight_l1, &mut s.luma_offset_l1, &mut s.delta_chroma_weight_l1, &mut s.ChromaOffsetL1), + ]; + for (entries, lw, lo, cw, co) in lists { + for (i, e) in entries.iter().take(15).enumerate() { + lw[i] = i8_of(e.luma.0 - (1 << luma_denom)); + lo[i] = i8_of(e.luma.1); + for c in 0..2 { + cw[i][c] = i8_of(e.chroma[c].0 - (1 << chroma_denom)); + co[i][c] = i8_of(e.chroma[c].1); + } + } + } + } + Ok(s) +} diff --git a/crates/platform/src/vaapi/mod.rs b/crates/platform/src/vaapi/mod.rs new file mode 100644 index 000000000..a78d1830c --- /dev/null +++ b/crates/platform/src/vaapi/mod.rs @@ -0,0 +1,217 @@ +//! Hardware decoding through VA-API (Linux: Intel, AMD and other Mesa / vendor drivers). +//! +//! VA-API decoding is stateless: the application parses the stream and keeps the decoded picture +//! buffer, and the GPU decodes one picture's slice data at a time into a surface. [`h264`] does the +//! host side with the software decoder's own parsers and DPB (so the two decide alike), [`va`] +//! drives libva (loaded at run time; no libva means no hardware decoder, never a failure to start), +//! and [`VaDecoder`] puts them together as a [`VideoDecoder`]. H.264 8-bit 4:2:0 progressive +//! (Constrained Baseline / Main / High) and HEVC 4:2:0 (Main, Main 10, Main Still Picture), the +//! latter through [`hevc`] the same way; everything else stays in software. +//! +//! The FFI is in [`ffi`] (declarations) and `va` (calls); the rest of this module is safe code. + +pub mod ffi; +pub mod h264; +pub mod hevc; +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +pub mod va; + +#[cfg(test)] +mod abi_tests; +#[cfg(test)] +mod tests; + +#[cfg(target_os = "linux")] +pub use linux::VaDecoder; + +use filmcraft_frame::VideoFrame; + +/// The hardware a front end ([`h264::Front`], [`hevc::Front`]) drives: a fixed set of surfaces, +/// picture decoding into one of them, and reading one back or filling it. +pub trait Accel: Send { + /// The VA surface ids of the decoder's surfaces; front ends refer to them by index. + fn surfaces(&self) -> &[ffi::VASurfaceID]; + /// Decode one H.264 picture (its slices: parameters and the NAL unit, header byte and + /// emulation prevention included) into surface `target` (an index into [`Accel::surfaces`]). + fn decode_h264( + &mut self, + target: usize, + pic: &ffi::VAPictureParameterBufferH264, + iq: &ffi::VAIQMatrixBufferH264, + slices: &[(ffi::VASliceParameterBufferH264, Vec)], + ) -> std::result::Result<(), String>; + /// Decode one HEVC picture (its slice segments: parameters and the NAL unit as stored) into + /// surface `target`; `iq` when scaling lists are enabled. + fn decode_hevc( + &mut self, + target: usize, + pic: &ffi::VAPictureParameterBufferHEVC, + iq: Option<&ffi::VAIQMatrixBufferHEVC>, + slices: &[(ffi::VASliceParameterBufferHEVC, Vec)], + ) -> std::result::Result<(), String>; + /// The picture in surface `index`, once decoded. + fn read(&mut self, index: usize) -> std::result::Result; + /// Make surface `index` a copy of surface `from`, or mid-gray (every sample half the range) + /// when `None`: the stand-ins for reference pictures that were never decoded, as the software + /// decoders make them. + fn fill(&mut self, index: usize, from: Option) -> std::result::Result<(), String>; +} + +#[cfg(target_os = "linux")] +mod linux { + use filmcraft_bitstream::unescape_rbsp; + use filmcraft_codecs::hw::{NalCodec, NalStreamInfo}; + use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder}; + + use super::va::Session; + use super::{ffi, h264, hevc}; + use crate::biplanar::Geometry; + + /// The VA-API profiles that decode an H.264 stream of `profile_idc`, best fit first (a Main or + /// High decoder also decodes what the smaller profiles allow). + pub(crate) fn h264_profiles(profile_idc: u8) -> Option<&'static [ffi::VAProfile]> { + Some(match profile_idc { + 66 => &[ffi::VAProfileH264ConstrainedBaseline, ffi::VAProfileH264Main, ffi::VAProfileH264High], + 77 => &[ffi::VAProfileH264Main, ffi::VAProfileH264High], + 100 => &[ffi::VAProfileH264High], + _ => return None, + }) + } + + /// The VA-API profiles that decode an HEVC stream of `general_profile_idc` (Main, Main 10, + /// Main Still Picture) at `bits`, best fit first. + pub(crate) fn hevc_profiles(profile_idc: u8, bits: u32) -> Option<&'static [ffi::VAProfile]> { + Some(match (profile_idc, bits) { + (1 | 3, 8) => &[ffi::VAProfileHEVCMain, ffi::VAProfileHEVCMain10], + (2, 8) => &[ffi::VAProfileHEVCMain10, ffi::VAProfileHEVCMain], + (2, 10) => &[ffi::VAProfileHEVCMain10], + _ => return None, + }) + } + + enum Front { + H264(Box>), + Hevc(Box>), + } + + /// A VA-API decoder for one H.264 or HEVC stream. + pub struct VaDecoder { + front: Front, + info: NalStreamInfo, + name: String, + fed: u64, + fail_after: Option, + } + + /// The first parameter set of NAL unit type `ty` among `sets`, without its header (`header` + /// bytes) and emulation prevention. + fn parameter_set(sets: &[Vec], header: usize, ty: impl Fn(u8) -> bool) -> Option> { + sets.iter().find(|n| n.first().is_some_and(|&h| ty(h))).and_then(|n| Some(unescape_rbsp(n.get(header..)?))) + } + + impl VaDecoder { + /// A decoder for the stream `info` describes, or why VA-API does not take it on this system. + pub fn new(info: NalStreamInfo) -> std::result::Result { + let bits = info.bit_depth_luma; + if info.chroma_format_idc != 1 || info.bit_depth_chroma != bits || info.interlaced { + return Err(format!( + "{} chroma format {} at {bits}/{}-bit is not taken", + if info.interlaced { "interlaced" } else { "progressive" }, + info.chroma_format_idc, + info.bit_depth_chroma + )); + } + let geometry = Geometry { crop: info.crop, bits, color: info.color, par: info.par }; + let (front, codec, vendor) = match info.codec { + NalCodec::H264 => { + if bits != 8 { + return Err(format!("{bits}-bit H.264 is not taken")); + } + let profiles = h264_profiles(info.profile_idc).ok_or_else(|| format!("H.264 profile_idc {} is not taken", info.profile_idc))?; + let rbsp = parameter_set(&info.parameter_sets, 1, |h| h & 0x1f == 7).ok_or("no SPS")?; + let sps = filmcraft_h264::params::Sps::parse(&rbsp).map_err(|e| e.to_string())?; + sps.check_supported().map_err(|e| e.to_string())?; + let mbs = (sps.pic_width_in_mbs, sps.frame_height_in_mbs()); + // the DPB, the picture being decoded, and one spare + let count = sps.max_dpb_frames().saturating_add(2).min(18); + let session = Session::new(profiles, (sps.width(), sps.height()), count, geometry)?; + let vendor = session.vendor().to_string(); + (Front::H264(Box::new(h264::Front::new(session, info.length_size, mbs, &info.parameter_sets).map_err(|e| e.to_string())?)), "H.264", vendor) + } + NalCodec::Hevc => { + let profiles = + hevc_profiles(info.profile_idc, bits).ok_or_else(|| format!("HEVC profile {} at {bits}-bit is not taken", info.profile_idc))?; + let rbsp = parameter_set(&info.parameter_sets, 2, |h| (h >> 1) & 0x3f == 33).ok_or("no SPS")?; + let sps = filmcraft_hevc::params::Sps::parse(&rbsp).map_err(|e| e.to_string())?; + sps.check_supported().map_err(|e| e.to_string())?; + if sps.range_extension { + return Err("HEVC range extensions are not taken".into()); + } + let count = (sps.max_dec_pic_buffering as usize).saturating_add(2).min(18); + let size = (sps.width, sps.height); + let session = Session::new(profiles, size, count, geometry)?; + let vendor = session.vendor().to_string(); + ( + Front::Hevc(Box::new(hevc::Front::new(session, info.length_size, size, bits, &info.parameter_sets).map_err(|e| e.to_string())?)), + "HEVC", + vendor, + ) + } + }; + let name = format!("VA-API {codec} ({vendor})"); + Ok(Self { front, info, name, fed: 0, fail_after: None }) + } + + /// Test hook: fail every decode call after the first `n` (a GPU lost mid-stream). + pub fn fail_after(&mut self, n: u64) { + self.fail_after = Some(n); + } + } + + impl VideoDecoder for VaDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + self.fed += 1; + if self.fail_after.is_some_and(|n| self.fed > n) { + return Err(CodecError::Decode("VA-API decoder failed (test hook)".into())); + } + match &mut self.front { + Front::H264(f) => f.decode(sample, pts), + Front::Hevc(f) => f.decode(sample, pts), + } + } + + fn flush(&mut self) -> Vec { + let flushed = match &mut self.front { + Front::H264(f) => f.flush(), + Front::Hevc(f) => f.flush(), + }; + match flushed { + Ok(out) => out, + Err(e) => { + log::warn!("{}: {e} while flushing", self.name); + Vec::new() + } + } + } + + fn reset(&mut self) { + match &mut self.front { + Front::H264(f) => f.reset(), + Front::Hevc(f) => f.reset(), + } + } + + fn name(&self) -> &str { + &self.name + } + + fn is_random_access(&self, sample: &[u8]) -> Option { + self.info.is_random_access(sample) + } + + fn is_disposable(&self, sample: &[u8]) -> bool { + self.info.is_disposable(sample) + } + } +} diff --git a/crates/platform/src/vaapi/tests.rs b/crates/platform/src/vaapi/tests.rs new file mode 100644 index 000000000..df2fe7a48 --- /dev/null +++ b/crates/platform/src/vaapi/tests.rs @@ -0,0 +1,381 @@ +//! The H.264 and HEVC front ends against a recording stand-in for the hardware, on every system: +//! for x264 streams with B-pyramids, several references, weighted prediction, several slices and +//! open GOPs, and x265 streams (Main and Main 10, open GOP with RASL pictures, B-pyramids), +//! the pictures come out in the software decoder's order with its pts (whole stream, after seeks, +//! after a mid-stream flush), and every buffer handed to the hardware is consistent: the current +//! picture's surface is free, every reference is a decoded surface listed in the DPB, reference +//! lists have the active number of entries, and slice data offsets lie inside the slice. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use filmcraft_codecs::DecodedFrame; +use filmcraft_codecs::hw::NalStreamInfo; +use filmcraft_frame::VideoFrame; + +use super::Accel; +use super::ffi::{self, VAIQMatrixBufferH264, VAPictureH264, VAPictureParameterBufferH264, VASliceParameterBufferH264}; +use super::h264::Front; +use crate::biplanar::{self, Biplanar, Geometry}; + +/// Records what it is asked to do and checks it; "decodes" nothing. +struct Recorder { + ids: Vec, + decoded: Vec, + pictures: usize, + frame: VideoFrame, +} + +impl Recorder { + fn new(count: usize) -> Self { + let g = Geometry { crop: (0, 0, 2, 2), bits: 8, color: Default::default(), par: (1, 1) }; + let frame = biplanar::to_frame(&Biplanar { luma: &[128; 4], chroma: &[128; 2], stride: 2, rows: 2 }, &g).unwrap(); + Self { ids: (0..count as u32).map(|i| 0x100 + i).collect(), decoded: vec![false; count], pictures: 0, frame } + } +} + +fn valid(p: &VAPictureH264) -> bool { + p.flags & ffi::VA_PICTURE_H264_INVALID == 0 +} + +impl Accel for Recorder { + fn surfaces(&self) -> &[ffi::VASurfaceID] { + &self.ids + } + + fn decode_hevc( + &mut self, + target: usize, + pic: &ffi::VAPictureParameterBufferHEVC, + _iq: Option<&ffi::VAIQMatrixBufferHEVC>, + slices: &[(ffi::VASliceParameterBufferHEVC, Vec)], + ) -> Result<(), String> { + assert_eq!(pic.CurrPic.picture_id, self.ids[target], "current picture surface"); + let valid = |p: &ffi::VAPictureHEVC| p.flags & ffi::VA_PICTURE_HEVC_INVALID == 0; + let n = pic.ReferenceFrames.iter().take_while(|p| valid(p)).count(); + assert!(pic.ReferenceFrames[n..].iter().all(|p| !valid(p)), "reference frames packed at the front"); + let ids: BTreeSet<_> = pic.ReferenceFrames[..n].iter().map(|r| r.picture_id).collect(); + assert_eq!(ids.len(), n, "each reference frame once"); + assert!(!ids.contains(&pic.CurrPic.picture_id), "the target is not a reference"); + for r in &pic.ReferenceFrames[..n] { + let i = self.ids.iter().position(|&id| id == r.picture_id).expect("reference is a decoder surface"); + assert!(self.decoded[i], "reference surface {i} holds a picture"); + } + let rps = ffi::VA_PICTURE_HEVC_RPS_ST_CURR_BEFORE | ffi::VA_PICTURE_HEVC_RPS_ST_CURR_AFTER | ffi::VA_PICTURE_HEVC_RPS_LT_CURR; + assert!(!slices.is_empty()); + let last = ffi::hevc_slice_bits::LAST_SLICE_OF_PIC; + for (k, (s, data)) in slices.iter().enumerate() { + assert_eq!(s.slice_data_size as usize, data.len()); + assert!((s.slice_data_byte_offset as usize) <= data.len(), "slice data starts inside the slice"); + assert_eq!(s.LongSliceFlags >> last & 1 == 1, k + 1 == slices.len(), "LastSliceOfPic on the last slice only"); + let ty = (s.LongSliceFlags >> ffi::hevc_slice_bits::SLICE_TYPE) & 3; + let lists: &[(usize, u8)] = match ty { + 2 => &[], + 1 => &[(0, s.num_ref_idx_l0_active_minus1)], + _ => &[(0, s.num_ref_idx_l0_active_minus1), (1, s.num_ref_idx_l1_active_minus1)], + }; + for &(l, minus1) in lists { + let len = minus1 as usize + 1; + for &i in &s.RefPicList[l][..len] { + assert!((i as usize) < n, "list entry {i} is a reference frame"); + assert!(pic.ReferenceFrames[i as usize].flags & rps != 0, "list entries are in the current RPS"); + } + assert!(s.RefPicList[l][len..].iter().all(|&i| i == 0xFF), "nothing past the active entries"); + } + } + self.decoded[target] = true; + self.pictures += 1; + Ok(()) + } + + fn decode_h264( + &mut self, + target: usize, + pic: &VAPictureParameterBufferH264, + _iq: &VAIQMatrixBufferH264, + slices: &[(VASliceParameterBufferH264, Vec)], + ) -> Result<(), String> { + assert_eq!(pic.CurrPic.picture_id, self.ids[target], "current picture surface"); + let refs: Vec<_> = pic.ReferenceFrames.iter().filter(|r| valid(r)).collect(); + let ref_ids: BTreeSet<_> = refs.iter().map(|r| r.picture_id).collect(); + assert_eq!(ref_ids.len(), refs.len(), "each reference frame once"); + assert!(!ref_ids.contains(&pic.CurrPic.picture_id), "the target is not a reference"); + for r in &refs { + let i = self.ids.iter().position(|&id| id == r.picture_id).expect("reference is a decoder surface"); + assert!(self.decoded[i], "reference surface {i} holds a picture"); + assert!(r.flags & (ffi::VA_PICTURE_H264_SHORT_TERM_REFERENCE | ffi::VA_PICTURE_H264_LONG_TERM_REFERENCE) != 0, "reference flags"); + } + assert!(!slices.is_empty()); + for (s, data) in slices { + assert_eq!(s.slice_data_size as usize, data.len()); + assert!((s.slice_data_bit_offset as usize) < data.len() * 8, "slice data starts inside the slice"); + let ty = s.slice_type; + let lists: &[(&[VAPictureH264; 32], u8)] = match ty { + 2 => &[], + 0 => &[(&s.RefPicList0, s.num_ref_idx_l0_active_minus1)], + _ => &[(&s.RefPicList0, s.num_ref_idx_l0_active_minus1), (&s.RefPicList1, s.num_ref_idx_l1_active_minus1)], + }; + for (list, minus1) in lists { + let n = *minus1 as usize + 1; + assert!(list[..n].iter().all(valid), "{n} active entries"); + assert!(list[n..].iter().all(|p| !valid(p)), "nothing past the active entries"); + assert!(list[..n].iter().all(|p| ref_ids.contains(&p.picture_id)), "list entries are reference frames"); + } + } + self.decoded[target] = true; + self.pictures += 1; + Ok(()) + } + + fn read(&mut self, index: usize) -> Result { + assert!(self.decoded[index], "read back a decoded surface"); + Ok(self.frame.clone()) + } + + fn fill(&mut self, index: usize, from: Option) -> Result<(), String> { + assert!(from.is_none_or(|f| self.decoded[f])); + self.decoded[index] = true; + Ok(()) + } +} + +/// (file, x264 profile, x264 parameters). +const STREAMS: &[(&str, &str, &str)] = &[ + ("va_front_high.mp4", "high", "bframes=3:b-pyramid=normal:keyint=12:min-keyint=12:scenecut=0:ref=4:weightp=2"), + ("va_front_slices.mp4", "baseline", "keyint=12:min-keyint=12:scenecut=0:slices=4:ref=2"), + ("va_front_open_gop.mp4", "high", "bframes=3:b-pyramid=strict:keyint=12:min-keyint=12:scenecut=0:open-gop=1:direct=temporal"), +]; + +fn fixture(ff: &std::path::Path, name: &str, profile: &str, params: &str) -> Option { + let out = filmcraft_testkit::fixtures_dir("platform").join(name); + let src = "testsrc2=s=320x184:r=24:d=2"; + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ff) + .args(["-y", "-v", "error", "-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", profile, "-x264-params", params, "-pix_fmt", "yuv420p"]) + .arg(tmp) + .stdin(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + }) +} + +struct Stream { + entry: filmcraft_isobmff::SampleEntry, + samples: Vec<(Vec, i64)>, + sync: Vec, +} + +fn read_stream(path: &std::path::Path) -> Stream { + let bytes = std::fs::read(path).unwrap(); + let file = filmcraft_isobmff::open(bytes.clone()).unwrap(); + let t = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap(); + let track = &file.tracks[t]; + let samples = (0..track.samples.len()).map(|i| (file.read_sample(&bytes, t, i).unwrap(), track.samples[i].pts)).collect(); + Stream { entry: track.entries[0].clone(), samples, sync: track.samples.iter().map(|s| s.is_sync).collect() } +} + +fn front(s: &Stream) -> Front { + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + let mbs = (info.coded.0 / 16, info.coded.1 / 16); + Front::new(Recorder::new(18), info.length_size, mbs, &info.parameter_sets).unwrap() +} + +fn pts(frames: &[DecodedFrame]) -> Vec { + frames.iter().map(|f| f.pts).collect() +} + +fn run_front(f: &mut Front, samples: &[(Vec, i64)]) -> Vec { + let mut out = Vec::new(); + for (s, p) in samples { + out.extend(pts(&f.decode(s, *p).unwrap())); + } + out.extend(pts(&f.flush().unwrap())); + out +} + +fn run_software(d: &mut dyn filmcraft_codecs::VideoDecoder, samples: &[(Vec, i64)]) -> Vec { + let mut out = Vec::new(); + for (s, p) in samples { + out.extend(pts(&d.decode(s, *p).unwrap())); + } + out.extend(pts(&d.flush())); + out +} + +#[test] +fn output_order_and_buffers_match_the_software_decoder() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, profile, params) in STREAMS { + let Some(path) = fixture(&ff, name, profile, params) else { continue }; + let s = read_stream(&path); + let mut f = front(&s); + let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap(); + let a = run_front(&mut f, &s.samples); + assert_eq!(a, run_software(sw.as_mut(), &s.samples), "{name}"); + assert_eq!(f.accel().pictures, s.samples.len(), "{name}: every picture decoded"); + // seeks to every sync sample (open-GOP I pictures included: frame_num gap stand-ins) + for k in (1..s.samples.len()).filter(|&i| s.sync[i]) { + f.reset(); + sw.reset(); + let end = (k + 13).min(s.samples.len()); + assert_eq!(run_front(&mut f, &s.samples[k..end]), run_software(sw.as_mut(), &s.samples[k..end]), "{name} from sample {k}"); + } + // a flush mid-GOP, then on + f.reset(); + sw.reset(); + let mid = s.samples.len() / 2 + 1; + let mut a = run_front(&mut f, &s.samples[..mid]); + a.extend(run_front(&mut f, &s.samples[mid..])); + let mut b = run_software(sw.as_mut(), &s.samples[..mid]); + b.extend(run_software(sw.as_mut(), &s.samples[mid..])); + assert_eq!(a, b, "{name} with a flush at {mid}"); + } +} + +/// Damaged samples give errors (the hybrid decoder then continues in software), never a panic. +#[test] +fn damaged_samples_are_errors() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (name, profile, params) = STREAMS[0]; + let Some(path) = fixture(&ff, name, profile, params) else { return }; + let s = read_stream(&path); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for round in 0..40 { + let mut f = front(&s); + for (i, (smp, p)) in s.samples.iter().enumerate() { + let mut m = smp.clone(); + if i % 3 == round % 3 { + match next() % 3 { + 0 => m.truncate(next() as usize % m.len().max(1)), + 1 => { + let at = next() as usize % m.len().max(1); + if let Some(b) = m.get_mut(at) { + *b ^= 1 << (next() % 8); + } + } + _ => m.iter_mut().skip(4).take(6).for_each(|b| *b = next() as u8), + } + } + if f.decode(&m, *p).is_err() { + f.reset(); + } + } + let _ = f.flush(); + } +} + +/// (file, pixel format, x265 parameters). +const HEVC_STREAMS: &[(&str, &str, &str)] = &[ + ("va_front_hevc_open_gop.mp4", "yuv420p", "log-level=error:bframes=4:b-pyramid=1:keyint=12:min-keyint=12:scenecut=0:open-gop=1:ref=3"), + ("va_front_hevc_main10.mp4", "yuv420p10le", "log-level=error:bframes=3:keyint=12:min-keyint=12:scenecut=0:slices=2:weightp=1"), +]; + +fn hevc_fixture(ff: &std::path::Path, name: &str, pix_fmt: &str, params: &str) -> Option { + let out = filmcraft_testkit::fixtures_dir("platform").join(name); + let src = "testsrc2=s=320x184:r=24:d=2"; + let profile = if pix_fmt.contains("10") { "main10" } else { "main" }; + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ff) + .args([ + "-y", + "-v", + "error", + "-f", + "lavfi", + "-i", + src, + "-c:v", + "libx265", + "-profile:v", + profile, + "-tag:v", + "hvc1", + "-x265-params", + params, + "-pix_fmt", + pix_fmt, + ]) + .arg(tmp) + .stdin(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) + }) +} + +fn hevc_front(s: &Stream) -> super::hevc::Front { + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + super::hevc::Front::new(Recorder::new(18), info.length_size, info.coded, info.bit_depth_luma, &info.parameter_sets).unwrap() +} + +fn run_hevc(f: &mut super::hevc::Front, samples: &[(Vec, i64)]) -> Vec { + let mut out = Vec::new(); + for (s, p) in samples { + out.extend(pts(&f.decode(s, *p).unwrap())); + } + out.extend(pts(&f.flush().unwrap())); + out +} + +#[test] +fn hevc_output_order_and_buffers_match_the_software_decoder() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, pix_fmt, params) in HEVC_STREAMS { + let Some(path) = hevc_fixture(&ff, name, pix_fmt, params) else { continue }; + let s = read_stream(&path); + let mut f = hevc_front(&s); + let mut sw = filmcraft_codecs::software_video_decoder(&s.entry).unwrap(); + assert_eq!(run_hevc(&mut f, &s.samples), run_software(sw.as_mut(), &s.samples), "{name}"); + // seeks to every sync sample (open-GOP CRA pictures included: RASL pictures left out) + for k in (1..s.samples.len()).filter(|&i| s.sync[i]) { + f.reset(); + sw.reset(); + let end = (k + 13).min(s.samples.len()); + assert_eq!(run_hevc(&mut f, &s.samples[k..end]), run_software(sw.as_mut(), &s.samples[k..end]), "{name} from sample {k}"); + } + } +} + +/// Damaged HEVC samples give errors, never a panic. +#[test] +fn hevc_damaged_samples_are_errors() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (name, pix_fmt, params) = HEVC_STREAMS[0]; + let Some(path) = hevc_fixture(&ff, name, pix_fmt, params) else { return }; + let s = read_stream(&path); + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for round in 0..40 { + let mut f = hevc_front(&s); + for (i, (smp, p)) in s.samples.iter().enumerate() { + let mut m = smp.clone(); + if i % 3 == round % 3 { + match next() % 3 { + 0 => m.truncate(next() as usize % m.len().max(1)), + 1 => { + let at = next() as usize % m.len().max(1); + if let Some(b) = m.get_mut(at) { + *b ^= 1 << (next() % 8); + } + } + _ => m.iter_mut().skip(4).take(6).for_each(|b| *b = next() as u8), + } + } + if f.decode(&m, *p).is_err() { + f.reset(); + } + } + let _ = f.flush(); + } +} diff --git a/crates/platform/src/vaapi/va.rs b/crates/platform/src/vaapi/va.rs new file mode 100644 index 000000000..aa07512bd --- /dev/null +++ b/crates/platform/src/vaapi/va.rs @@ -0,0 +1,585 @@ +//! libva itself (Linux): `libva.so.2` and `libva-drm.so.2` are loaded at run time (a system without +//! them simply has no hardware decoder), a display is opened on a DRM render node, and a [`Session`] +//! (decode configuration, context and surfaces for one stream) decodes pictures and reads them back +//! as NV12 images. +//! +//! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the +//! public API is safe, and the one callback into Rust (libva's error messages) runs under +//! `catch_unwind`. + +use std::ffi::{CStr, c_char, c_int, c_uint, c_void}; +use std::fs::{File, OpenOptions}; +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + +use filmcraft_frame::VideoFrame; +use libloading::Library; + +use super::Accel; +use super::ffi::{self, VAConfigAttrib, VAImage, VAImageFormat, VASurfaceID}; +use crate::biplanar::{self, Biplanar, Geometry}; + +/// The libva entry points (the libraries stay loaded for the life of the process). +struct Api { + _va: Library, + _drm: Library, + initialize: ffi::vaInitialize, + terminate: ffi::vaTerminate, + error_str: ffi::vaErrorStr, + vendor_string: ffi::vaQueryVendorString, + set_info_callback: ffi::vaSetInfoCallback, + set_error_callback: ffi::vaSetErrorCallback, + get_config_attributes: ffi::vaGetConfigAttributes, + create_config: ffi::vaCreateConfig, + destroy_config: ffi::vaDestroyConfig, + create_surfaces: ffi::vaCreateSurfaces, + destroy_surfaces: ffi::vaDestroySurfaces, + create_context: ffi::vaCreateContext, + destroy_context: ffi::vaDestroyContext, + create_buffer: ffi::vaCreateBuffer, + destroy_buffer: ffi::vaDestroyBuffer, + begin_picture: ffi::vaBeginPicture, + render_picture: ffi::vaRenderPicture, + end_picture: ffi::vaEndPicture, + sync_surface: ffi::vaSyncSurface, + create_image: ffi::vaCreateImage, + destroy_image: ffi::vaDestroyImage, + get_image: ffi::vaGetImage, + put_image: ffi::vaPutImage, + map_buffer: ffi::vaMapBuffer, + unmap_buffer: ffi::vaUnmapBuffer, + get_display_drm: ffi::vaGetDisplayDRM, +} + +static API: OnceLock> = OnceLock::new(); + +fn api() -> Result<&'static Api, String> { + API.get_or_init(load).as_ref().map_err(Clone::clone) +} + +fn load() -> Result { + // SAFETY: loading libva runs its library initialisers, which only set up libva's own state; + // it is the system's VA-API runtime, loaded the way every VA-API client loads it. + let va = unsafe { Library::new("libva.so.2") }.map_err(|e| format!("libva.so.2: {e}"))?; + // SAFETY: as above, for libva's DRM display library. + let drm = unsafe { Library::new("libva-drm.so.2") }.map_err(|e| format!("libva-drm.so.2: {e}"))?; + macro_rules! sym { + ($lib:expr, $name:ident) => {{ + // SAFETY: `ffi::$name` is the C signature of the libva function of that name, + // transcribed from va.h and checked against it (abi_tests.rs). The pointer is copied + // out; the library it points into is kept loaded in `Api` for the life of the process. + let s = unsafe { $lib.get::(concat!(stringify!($name), "\0").as_bytes()) }; + *s.map_err(|e| format!("{}: {e}", stringify!($name)))? + }}; + } + Ok(Api { + initialize: sym!(va, vaInitialize), + terminate: sym!(va, vaTerminate), + error_str: sym!(va, vaErrorStr), + vendor_string: sym!(va, vaQueryVendorString), + set_info_callback: sym!(va, vaSetInfoCallback), + set_error_callback: sym!(va, vaSetErrorCallback), + get_config_attributes: sym!(va, vaGetConfigAttributes), + create_config: sym!(va, vaCreateConfig), + destroy_config: sym!(va, vaDestroyConfig), + create_surfaces: sym!(va, vaCreateSurfaces), + destroy_surfaces: sym!(va, vaDestroySurfaces), + create_context: sym!(va, vaCreateContext), + destroy_context: sym!(va, vaDestroyContext), + create_buffer: sym!(va, vaCreateBuffer), + destroy_buffer: sym!(va, vaDestroyBuffer), + begin_picture: sym!(va, vaBeginPicture), + render_picture: sym!(va, vaRenderPicture), + end_picture: sym!(va, vaEndPicture), + sync_surface: sym!(va, vaSyncSurface), + create_image: sym!(va, vaCreateImage), + destroy_image: sym!(va, vaDestroyImage), + get_image: sym!(va, vaGetImage), + put_image: sym!(va, vaPutImage), + map_buffer: sym!(va, vaMapBuffer), + unmap_buffer: sym!(va, vaUnmapBuffer), + get_display_drm: sym!(drm, vaGetDisplayDRM), + _va: va, + _drm: drm, + }) +} + +/// libva's error messages, into our log (instead of stderr). +extern "C" fn on_error(_user: *mut c_void, message: *const c_char) { + let _ = std::panic::catch_unwind(|| { + if message.is_null() { + return; + } + // SAFETY: libva passes a NUL-terminated message that is valid for the duration of the call. + let text = unsafe { CStr::from_ptr(message) }.to_string_lossy(); + log::warn!("libva: {}", text.trim_end()); + }); +} + +/// An initialised VA display on a DRM render node. +pub struct Display { + api: &'static Api, + dpy: ffi::VADisplay, + vendor: String, + /// The render node; open for as long as the display uses it. + _device: File, +} + +// SAFETY: a VA display is not tied to the thread that opened it. +unsafe impl Send for Display {} +// SAFETY: libva's functions are thread-safe and so must its drivers' be (va.h, "Multithreading +// Guide"): one display may be used from several threads at once, as long as each VA object +// (context, surfaces, buffers, images) is used by one thread at a time. Every `Session` on a +// shared display has its own configuration, context, surfaces and image, used only through that +// session (`&mut` or `&` of one owner); the display itself is only read (`api`, `dpy`, `vendor`). +unsafe impl Sync for Display {} + +/// The display every [`Session`] decodes on. Opening a display takes a few milliseconds with +/// Intel's and Mesa's drivers but 0.2-0.3 s with NVIDIA's (a CUDA context each, about 100 MB of +/// video memory), and terminating one 60-70 ms, on the thread that creates or drops the decoder: a +/// frame worker at every clip on the timeline, the UI thread when media is removed. So one display +/// is opened (by [`probe`] at startup) and kept for the life of the process, like the libraries, +/// and sessions only make and destroy their own configuration, context and surfaces. +static SHARED: Mutex>> = Mutex::new(None); +/// Displays opened so far (diagnostics and tests). +static OPENED: AtomicUsize = AtomicUsize::new(0); + +/// The number of VA displays this process has opened (one, unless the shared one stopped working). +pub fn displays_opened() -> usize { + OPENED.load(Ordering::Relaxed) +} + +/// The shared display, opened on first use. +fn shared() -> Result, String> { + let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(d) = shared.as_ref() { + return Ok(d.clone()); + } + let d = Arc::new(Display::open()?); + *shared = Some(d.clone()); + Ok(d) +} + +/// Share `new` from now on instead of `old` (if `old` is still the shared one). Sessions on `old` +/// keep it until they end. +fn replace_shared(old: &Arc, new: Arc) { + let mut shared = SHARED.lock().unwrap_or_else(PoisonError::into_inner); + if shared.as_ref().is_none_or(|d| Arc::ptr_eq(d, old)) { + *shared = Some(new); + } +} + +impl Display { + /// The first DRM render node with a working VA-API driver. + pub fn open() -> Result { + let api = api()?; + let mut why = "no DRM render node (/dev/dri/renderD*)".to_string(); + for n in 128..144 { + let path = format!("/dev/dri/renderD{n}"); + let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else { continue }; + match Self::init(api, file) { + Ok(d) => return Ok(d), + Err(e) => why = format!("{path}: {e}"), + } + } + Err(why) + } + + fn init(api: &'static Api, device: File) -> Result { + // SAFETY: the descriptor is an open render node, kept open by `device` (moved into the + // returned display) for as long as the display exists. + let dpy = unsafe { (api.get_display_drm)(device.as_raw_fd()) }; + if dpy.is_null() { + return Err("vaGetDisplayDRM failed".into()); + } + // From here on `Drop` terminates the display. + let mut d = Self { api, dpy, vendor: String::new(), _device: device }; + // SAFETY: `dpy` is a display from vaGetDisplayDRM; a null info callback turns libva's info + // messages off, and `on_error` matches `VAMessageCallback` and never unwinds. + unsafe { + (api.set_info_callback)(dpy, None, std::ptr::null_mut()); + (api.set_error_callback)(dpy, Some(on_error), std::ptr::null_mut()); + } + let (mut major, mut minor): (c_int, c_int) = (0, 0); + // SAFETY: `dpy` is valid; the version outputs point to live locals. + let st = unsafe { (api.initialize)(dpy, &mut major, &mut minor) }; + d.check(st, "vaInitialize")?; + OPENED.fetch_add(1, Ordering::Relaxed); + // SAFETY: `dpy` is an initialised display; the vendor string is NUL-terminated and owned by + // the driver for the life of the display (copied out here). + let vendor = unsafe { (api.vendor_string)(dpy) }; + if !vendor.is_null() { + // SAFETY: non-null, NUL-terminated (see above). + d.vendor = unsafe { CStr::from_ptr(vendor) }.to_string_lossy().into_owned(); + } + Ok(d) + } + + /// The driver's description ("Intel iHD driver …"). + pub fn vendor(&self) -> &str { + &self.vendor + } + + fn check(&self, st: ffi::VAStatus, what: &str) -> Result<(), String> { + if st == ffi::VA_STATUS_SUCCESS { + return Ok(()); + } + // SAFETY: vaErrorStr takes any status and returns a static NUL-terminated string. + let text = unsafe { (self.api.error_str)(st) }; + let text = if text.is_null() { + String::new() + } else { + // SAFETY: non-null static NUL-terminated string (see above). + unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned() + }; + Err(format!("{what}: {text} ({st:#x})")) + } +} + +impl Drop for Display { + fn drop(&mut self) { + // SAFETY: `dpy` came from vaGetDisplayDRM and is terminated exactly once, here, after + // every session object made on it was destroyed (`Session` drops its objects first). + unsafe { (self.api.terminate)(self.dpy) }; + } +} + +/// One stream's decoder: a VLD configuration of a profile, its surfaces and context, and the image +/// pictures are read back through. +pub struct Session { + /// The shared display (see [`SHARED`]), or the one that replaced it. + display: Arc, + config: ffi::VAConfigID, + context: ffi::VAContextID, + surfaces: Vec, + image: Option, + /// Coded size of the surfaces (luma samples). + size: (u32, u32), + geometry: Geometry, +} + +/// The surface and image formats of 8-bit (NV12) or 10-bit (P010) 4:2:0 pictures: +/// (render-target format, image fourcc, bits per pixel of the image). +fn formats(bits: u32) -> Result<(c_uint, u32, u32), String> { + match bits { + 8 => Ok((ffi::VA_RT_FORMAT_YUV420, ffi::VA_FOURCC_NV12, 12)), + 10 => Ok((ffi::VA_RT_FORMAT_YUV420_10, ffi::VA_FOURCC_P010, 24)), + _ => Err(format!("{bits}-bit pictures are not decoded through VA-API")), + } +} + +impl Session { + /// A decoder for the first of `profiles` the driver decodes (VLD, 4:2:0 of `geometry.bits`: + /// 8 or 10), with `count` surfaces of `size` (coded luma samples); pictures read back are cut + /// to `geometry`. + pub fn new(profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result { + let display = shared()?; + match Self::on(display.clone(), profiles, size, count, geometry) { + Ok(s) => Ok(s), + Err(first) => { + // The shared display may have stopped working (a GPU reset): try a new one, and + // share that one from now on if it works. (A stream the driver does not take + // fails on both and costs the one extra display opening.) + let fresh = Arc::new(Display::open().map_err(|e| format!("{first}; a new display: {e}"))?); + let s = Self::on(fresh.clone(), profiles, size, count, geometry).map_err(|_| first)?; + replace_shared(&display, fresh); + Ok(s) + } + } + } + + fn on(display: Arc, profiles: &[ffi::VAProfile], size: (u32, u32), count: usize, geometry: Geometry) -> Result { + let (rt_format, _, _) = formats(geometry.bits)?; + let api = display.api; + let dpy = display.dpy; + let mut config = None; + let mut why = "no VA-API profile for the stream".to_string(); + for &profile in profiles { + let mut attr = VAConfigAttrib { type_: ffi::VAConfigAttribRTFormat, value: 0 }; + // SAFETY: `dpy` is initialised; `attr` is one live attribute (count 1). + let st = unsafe { (api.get_config_attributes)(dpy, profile, ffi::VAEntrypointVLD, &mut attr, 1) }; + if let Err(e) = display.check(st, "vaGetConfigAttributes") { + why = e; + continue; + } + if attr.value & rt_format == 0 { + why = format!("profile {profile} does not decode to {}-bit 4:2:0", geometry.bits); + continue; + } + attr.value = rt_format; + let mut id = ffi::VA_INVALID_ID; + // SAFETY: as above; `id` is a live output. + let st = unsafe { (api.create_config)(dpy, profile, ffi::VAEntrypointVLD, &mut attr, 1, &mut id) }; + match display.check(st, "vaCreateConfig") { + Ok(()) => { + config = Some(id); + break; + } + Err(e) => why = e, + } + } + let config = config.ok_or(why)?; + // From here on `Drop` releases what was made. + let mut s = Self { display, config, context: ffi::VA_INVALID_ID, surfaces: Vec::new(), image: None, size, geometry }; + let count_c = c_uint::try_from(count).map_err(|_| "too many surfaces")?; + let mut surfaces = vec![ffi::VA_INVALID_SURFACE; count]; + // SAFETY: `surfaces` has room for `count` ids; no attributes are passed (null, 0). + let st = unsafe { (api.create_surfaces)(dpy, rt_format, size.0, size.1, surfaces.as_mut_ptr(), count_c, std::ptr::null_mut(), 0) }; + s.display.check(st, "vaCreateSurfaces")?; + s.surfaces = surfaces; + let (w, h) = (c_int::try_from(size.0).map_err(|_| "picture too wide")?, c_int::try_from(size.1).map_err(|_| "picture too tall")?); + let n = c_int::try_from(count).map_err(|_| "too many surfaces")?; + let mut context = ffi::VA_INVALID_ID; + // SAFETY: `config` and the `count` surfaces were made on this display; `context` is a live output. + let st = unsafe { (api.create_context)(dpy, s.config, w, h, ffi::VA_PROGRESSIVE, s.surfaces.as_mut_ptr(), n, &mut context) }; + s.display.check(st, "vaCreateContext")?; + s.context = context; + Ok(s) + } + + pub fn vendor(&self) -> &str { + self.display.vendor() + } + + /// A buffer of `ty` holding a copy of `len` bytes at `data`. + fn buffer(&self, ty: ffi::VABufferType, data: *const c_void, len: usize) -> Result { + let size = c_uint::try_from(len).map_err(|_| "buffer too large")?; + let mut id = ffi::VA_INVALID_ID; + // SAFETY: `data` points to `len` readable bytes (the callers pass a live value or slice); + // libva copies them into the new buffer and never writes through the pointer. + let st = unsafe { (self.display.api.create_buffer)(self.display.dpy, self.context, ty, size, 1, data.cast_mut(), &mut id) }; + self.display.check(st, "vaCreateBuffer")?; + Ok(id) + } + + fn value_buffer(&self, ty: ffi::VABufferType, value: &T) -> Result { + self.buffer(ty, (value as *const T).cast(), std::mem::size_of::()) + } + + fn render(&self, buffers: &mut [ffi::VABufferID]) -> Result<(), String> { + let n = c_int::try_from(buffers.len()).map_err(|_| "too many buffers")?; + // SAFETY: `buffers` are live buffer ids made on this context, `n` of them. + let st = unsafe { (self.display.api.render_picture)(self.display.dpy, self.context, buffers.as_mut_ptr(), n) }; + self.display.check(st, "vaRenderPicture") + } + + /// Decode one picture into `surface`: the picture parameters `pic`, the scaling matrices `iq` + /// when given, and a parameter + data buffer per slice; the buffers made go into `buffers`. + fn decode_into( + &self, + surface: VASurfaceID, + buffers: &mut Vec, + pic: &P, + iq: Option<&Q>, + slices: &[(S, Vec)], + ) -> Result<(), String> { + buffers.push(self.value_buffer(ffi::VAPictureParameterBufferType, pic)?); + if let Some(iq) = iq { + buffers.push(self.value_buffer(ffi::VAIQMatrixBufferType, iq)?); + } + let head = buffers.len(); + for (params, data) in slices { + buffers.push(self.value_buffer(ffi::VASliceParameterBufferType, params)?); + buffers.push(self.buffer(ffi::VASliceDataBufferType, data.as_ptr().cast(), data.len())?); + } + let api = self.display.api; + // SAFETY: `surface` is one of this context's render targets. + let st = unsafe { (api.begin_picture)(self.display.dpy, self.context, surface) }; + self.display.check(st, "vaBeginPicture")?; + let (picture, slice_buffers) = buffers.split_at_mut(head); + let mut rendered = self.render(picture); + for pair in slice_buffers.chunks_mut(2) { + if rendered.is_err() { + break; + } + rendered = self.render(pair); + } + // a begun picture is always ended, so the context stays usable + // SAFETY: the picture was begun on this context above. + let st = unsafe { (api.end_picture)(self.display.dpy, self.context) }; + rendered?; + self.display.check(st, "vaEndPicture") + } + + fn image(&mut self) -> Result { + if let Some(image) = self.image { + return Ok(image); + } + let (_, fourcc, bits_per_pixel) = formats(self.geometry.bits)?; + let mut format = VAImageFormat { fourcc, byte_order: 1, bits_per_pixel, ..Default::default() }; + let mut image = VAImage { image_id: ffi::VA_INVALID_ID, buf: ffi::VA_INVALID_ID, ..Default::default() }; + let (w, h) = (c_int::try_from(self.size.0).map_err(|_| "picture too wide")?, c_int::try_from(self.size.1).map_err(|_| "picture too tall")?); + // SAFETY: `format` and `image` are live; the driver fills `image` in. + let st = unsafe { (self.display.api.create_image)(self.display.dpy, &mut format, w, h, &mut image) }; + self.display.check(st, "vaCreateImage")?; + self.image = Some(image); + Ok(image) + } + + /// The picture in surface `index`, copied into the session's image once decoded. + fn get_image(&mut self, index: usize) -> Result { + let surface = *self.surfaces.get(index).ok_or("surface out of range")?; + let image = self.image()?; + let api = self.display.api; + // SAFETY: `surface` is one of this session's surfaces. + let st = unsafe { (api.sync_surface)(self.display.dpy, surface) }; + self.display.check(st, "vaSyncSurface")?; + // SAFETY: the image and the surface were made on this display, with the same size. + let st = unsafe { (api.get_image)(self.display.dpy, surface, 0, 0, self.size.0, self.size.1, image.image_id) }; + self.display.check(st, "vaGetImage")?; + Ok(image) + } + + /// Every sample of `image` set to mid-gray: 128 in NV12, 512 (0x8000 in the high bits of + /// little-endian 16-bit words) in P010. + fn write_gray(&self, image: &VAImage) -> Result<(), String> { + let api = self.display.api; + let mut ptr: *mut c_void = std::ptr::null_mut(); + // SAFETY: `image.buf` is the data buffer of an image made on this display; `ptr` is a live output. + let st = unsafe { (api.map_buffer)(self.display.dpy, image.buf, &mut ptr) }; + self.display.check(st, "vaMapBuffer")?; + if !ptr.is_null() { + // SAFETY: a mapped image buffer holds `data_size` writable bytes at `ptr` until it is + // unmapped below; the slice does not outlive this block. + let data = unsafe { std::slice::from_raw_parts_mut(ptr.cast::(), image.data_size as usize) }; + if self.geometry.bits > 8 { + for w in data.as_chunks_mut::<2>().0 { + w.copy_from_slice(&0x8000u16.to_le_bytes()); + } + } else { + data.fill(128); + } + } + // SAFETY: the buffer was mapped above. + let st = unsafe { (api.unmap_buffer)(self.display.dpy, image.buf) }; + self.display.check(st, "vaUnmapBuffer")?; + if ptr.is_null() { Err("vaMapBuffer returned no data".into()) } else { Ok(()) } + } + + fn decode_picture(&mut self, target: usize, pic: &P, iq: Option<&Q>, slices: &[(S, Vec)]) -> Result<(), String> { + let surface = *self.surfaces.get(target).ok_or("surface out of range")?; + let mut buffers = Vec::with_capacity(2 + 2 * slices.len()); + let decoded = self.decode_into(surface, &mut buffers, pic, iq, slices); + for id in buffers { + // SAFETY: each id is a buffer made above on this display, destroyed once; vaEndPicture + // has returned, so the driver no longer reads it. + unsafe { (self.display.api.destroy_buffer)(self.display.dpy, id) }; + } + decoded + } + + fn read_image(&self, image: &VAImage) -> Result { + let api = self.display.api; + let mut ptr: *mut c_void = std::ptr::null_mut(); + // SAFETY: `image.buf` is the data buffer of an image made on this display; `ptr` is a live output. + let st = unsafe { (api.map_buffer)(self.display.dpy, image.buf, &mut ptr) }; + self.display.check(st, "vaMapBuffer")?; + let result = if ptr.is_null() { + Err("vaMapBuffer returned no data".to_string()) + } else { + // SAFETY: a mapped image buffer holds `data_size` bytes at `ptr` until it is unmapped + // below; the slice does not outlive this block. + let data = unsafe { std::slice::from_raw_parts(ptr.cast::().cast_const(), image.data_size as usize) }; + biplanar_view(image, data).and_then(|b| biplanar::to_frame(&b, &self.geometry)) + }; + // SAFETY: the buffer was mapped above; `data` is no longer used. + let st = unsafe { (api.unmap_buffer)(self.display.dpy, image.buf) }; + let frame = result?; + self.display.check(st, "vaUnmapBuffer")?; + Ok(frame) + } +} + +/// The two planes of a mapped NV12 / P010 image. +fn biplanar_view<'a>(image: &VAImage, data: &'a [u8]) -> Result, String> { + if !matches!(image.format.fourcc, ffi::VA_FOURCC_NV12 | ffi::VA_FOURCC_P010) || image.num_planes < 2 { + return Err(format!("the read-back image is not NV12 / P010 (fourcc {:#x}, {} planes)", image.format.fourcc, image.num_planes)); + } + let [p0, p1, _] = image.pitches; + if p0 != p1 { + return Err(format!("NV12 planes with different pitches ({p0}, {p1})")); + } + let luma = data.get(image.offsets[0] as usize..).ok_or("NV12 luma offset out of range")?; + let chroma = data.get(image.offsets[1] as usize..).ok_or("NV12 chroma offset out of range")?; + Ok(Biplanar { luma, chroma, stride: p0 as usize, rows: usize::from(image.height) }) +} + +impl Accel for Session { + fn surfaces(&self) -> &[VASurfaceID] { + &self.surfaces + } + + fn decode_h264( + &mut self, + target: usize, + pic: &ffi::VAPictureParameterBufferH264, + iq: &ffi::VAIQMatrixBufferH264, + slices: &[(ffi::VASliceParameterBufferH264, Vec)], + ) -> Result<(), String> { + self.decode_picture(target, pic, Some(iq), slices) + } + + fn decode_hevc( + &mut self, + target: usize, + pic: &ffi::VAPictureParameterBufferHEVC, + iq: Option<&ffi::VAIQMatrixBufferHEVC>, + slices: &[(ffi::VASliceParameterBufferHEVC, Vec)], + ) -> Result<(), String> { + self.decode_picture(target, pic, iq, slices) + } + + fn read(&mut self, index: usize) -> Result { + let image = self.get_image(index)?; + self.read_image(&image) + } + + fn fill(&mut self, index: usize, from: Option) -> Result<(), String> { + let surface = *self.surfaces.get(index).ok_or("surface out of range")?; + let image = match from { + Some(from) => self.get_image(from)?, + None => { + let image = self.image()?; + self.write_gray(&image)?; + image + } + }; + let (w, h) = self.size; + // SAFETY: the image and the surface were made on this display, with the same size. + let st = unsafe { (self.display.api.put_image)(self.display.dpy, surface, image.image_id, 0, 0, w, h, 0, 0, w, h) }; + self.display.check(st, "vaPutImage") + } +} + +impl Drop for Session { + fn drop(&mut self) { + let (api, dpy) = (self.display.api, self.display.dpy); + // SAFETY: each object below was made on this display and is destroyed once, by this + // session only; the display itself is terminated when its last user lets it go (the + // shared one never is). + unsafe { + if let Some(image) = self.image.take() { + (api.destroy_image)(dpy, image.image_id); + } + if self.context != ffi::VA_INVALID_ID { + (api.destroy_context)(dpy, self.context); + } + if !self.surfaces.is_empty() { + let n = c_int::try_from(self.surfaces.len()).unwrap_or(0); + (api.destroy_surfaces)(dpy, self.surfaces.as_mut_ptr(), n); + } + if self.config != ffi::VA_INVALID_ID { + (api.destroy_config)(dpy, self.config); + } + } + } +} + +/// Whether this system has a working VA-API driver on a DRM render node (checked once), with the +/// driver's description, or why not. +pub fn probe() -> &'static Result { + static PROBE: OnceLock> = OnceLock::new(); + // the display opened to answer is the one sessions share + PROBE.get_or_init(|| shared().map(|d| d.vendor().to_string())) +} diff --git a/crates/platform/tests/fallback.rs b/crates/platform/tests/fallback.rs index f6005c14c..95d8e4304 100644 --- a/crates/platform/tests/fallback.rs +++ b/crates/platform/tests/fallback.rs @@ -1,7 +1,9 @@ //! [`HybridDecoder`] fallback on every platform, with a stand-in "hardware" decoder (our software //! decoder that starts failing after N samples, as a lost hardware session does): the output must //! be exactly the software decoder's, whatever sample the failure hits. Also: in-band parameter -//! sets that differ from the sample entry's switch to software; identical ones do not. +//! sets that differ from the sample entry's switch to software; identical ones do not; and a +//! damaged sample that the hardware decoded but the software decoder rejects, inside the replayed +//! run, loses no picture. mod common; @@ -105,3 +107,107 @@ fn changed_in_band_parameter_sets_switch_to_software() { assert!(!d.is_hardware(), "changed SPS: software"); assert_same("changed SPS", &out, &decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &changed)); } + +/// Like [`Failing`], but tolerant of one damaged sample the way a GPU can be: at `damaged_at` it +/// decodes the intact sample instead of the one it is given (which the software decoder rejects). +struct Tolerant { + inner: Box, + intact: Vec, + damaged_at: usize, + fail_after: usize, + fed: usize, +} + +impl VideoDecoder for Tolerant { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let i = self.fed; + self.fed += 1; + if self.fed > self.fail_after { + return Err(filmcraft_codecs::CodecError::Decode("session invalidated (test)".into())); + } + self.inner.decode(if i == self.damaged_at { &self.intact } else { sample }, pts) + } + fn flush(&mut self) -> Vec { + self.inner.flush() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn name(&self) -> &str { + "stand-in hardware" + } +} + +/// Feed `samples` then flush, carrying on past samples the decoder rejects (as playback does); +/// returns the pictures and the indices of the rejected samples. +fn decode_lenient(d: &mut dyn VideoDecoder, samples: &[(Vec, i64)]) -> (Vec, Vec) { + let (mut out, mut rejected) = (Vec::new(), Vec::new()); + for (i, (s, p)) in samples.iter().enumerate() { + match d.decode(s, *p) { + Ok(f) => out.extend(f), + Err(_) => rejected.push(i), + } + } + out.extend(d.flush()); + (out, rejected) +} + +/// The sample's NAL unit with its header kept and its payload overwritten: the slice header no +/// longer parses, so the software decoder rejects the sample. +fn damaged(sample: &[u8], nal_header: usize) -> Vec { + let mut v = sample.to_vec(); + let start = (4 + nal_header).min(v.len()); + v[start..].fill(0xff); + v +} + +/// A replay (after the hardware fails) that meets a sample the software decoder rejects must go +/// on to the end of the replayed run, like decoding in software does, and must not lose the +/// pictures it decoded before that sample: every picture the software decoder outputs for the +/// stream is output, once and in order, and from the next restart point on the pictures are the +/// software decoder's. +#[test] +fn a_sample_software_rejects_during_the_replay_loses_no_pictures() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut checked = 0; + for (name, _) in FIXTURES { + let Some(path) = named(&ff, name) else { continue }; + let s = read_stream(&path); + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + let header = if name.starts_with("hevc") { 2 } else { 1 }; + // the second restart point, and a disposable picture before it that is not the first sample + let Some(next) = (1..s.samples.len()).find(|&i| s.sync[i]) else { continue }; + let Some(d) = (2..next.saturating_sub(2)).find(|&i| info.is_disposable(&s.samples[i].0)) else { continue }; + let mut stream = s.samples.clone(); + stream[d].0 = damaged(&s.samples[d].0, header); + let (reference, rejected) = decode_lenient(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &stream); + assert_eq!(rejected, [d], "{name}: the software decoder rejects the damaged sample (and only it)"); + let restart = stream[next..].iter().map(|x| x.1).min().unwrap(); + // failing at the damaged sample itself (the software decoder rejects the failed sample + // too), and after the hardware had decoded it + for fail_after in [d, d + 1, d + 2, next - 1] { + let tolerant = Tolerant { + inner: filmcraft_codecs::software_video_decoder(&s.entry).unwrap(), + intact: s.samples[d].0.clone(), + damaged_at: d, + fail_after, + fed: 0, + }; + let mut h = HybridDecoder::new(Box::new(tolerant), s.entry.clone(), info.clone()); + let (out, errors) = decode_lenient(&mut h, &stream); + let what = format!("{name}: sample {d} damaged, hardware failing after {fail_after}"); + assert!(!h.is_hardware(), "{what}: fell back"); + // the damaged sample is reported as the software decoder reports it, unless the + // hardware had already decoded it + assert_eq!(errors, if fail_after == d { vec![d] } else { vec![] }, "{what}: rejected samples"); + let pts: Vec = out.iter().map(|f| f.pts).collect(); + assert!(pts.windows(2).all(|w| w[0] < w[1]), "{what}: pictures once and in order: {pts:?}"); + let lost: Vec = reference.iter().map(|f| f.pts).filter(|p| !pts.contains(p)).collect(); + assert!(lost.is_empty(), "{what}: lost pictures at pts {lost:?}"); + let from = |v: &[DecodedFrame]| v.iter().position(|f| f.pts >= restart).unwrap_or(v.len()); + assert_same(&format!("{what}, from the next restart point"), &out[from(&out)..], &reference[from(&reference)..]); + } + checked += 1; + } + assert_eq!(checked, FIXTURES.len(), "every fixture has a damaged-sample case"); +} diff --git a/crates/platform/tests/hevc_probe.rs b/crates/platform/tests/hevc_probe.rs new file mode 100644 index 000000000..ba394a754 --- /dev/null +++ b/crates/platform/tests/hevc_probe.rs @@ -0,0 +1,29 @@ +//! `register()` answers the HEVC availability probe in the background, so the first draw of the +//! format list does not create a hardware session on the UI thread. One test in this file on +//! purpose: anything else that asked `hevc_available()` first would make the wait below meaningless. +#![cfg(target_os = "macos")] + +use std::time::{Duration, Instant}; + +use filmcraft_platform::hardware_encode::{hevc_available, hevc_probed, warm_hevc_probe}; + +#[test] +fn register_answers_the_hevc_probe_in_the_background() { + assert!(!hevc_probed(), "nothing has asked yet"); + + filmcraft_platform::register(); + + // nobody calls `hevc_available()` here: only the thread `register` started can answer + let t0 = Instant::now(); + while !hevc_probed() { + assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the probe"); + std::thread::sleep(Duration::from_millis(5)); + } + + // asking again, or registering again, changes nothing + let answer = hevc_available(); + warm_hevc_probe(); + filmcraft_platform::register(); + assert_eq!(hevc_available(), answer); + assert!(hevc_probed()); +} diff --git a/crates/platform/tests/nvenc.rs b/crates/platform/tests/nvenc.rs index e72638af0..2d43680f1 100644 --- a/crates/platform/tests/nvenc.rs +++ b/crates/platform/tests/nvenc.rs @@ -1,7 +1,7 @@ //! NVENC H.264 encoding (Windows, NVIDIA GPU): the stream decodes with our own decoder to pictures //! close to the source, keyframes and timestamps are right, and Export takes it only when asked. //! Skips without an NVIDIA GPU / driver with NVENC. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Config, NvencH264, Packet, Profile}; diff --git a/crates/platform/tests/nvenc_export.rs b/crates/platform/tests/nvenc_export.rs index be82040c8..e0d32a20c 100644 --- a/crates/platform/tests/nvenc_export.rs +++ b/crates/platform/tests/nvenc_export.rs @@ -2,7 +2,7 @@ //! pipeline: the same frames, a file both decode, counters showing which encoder ran, and what NVENC //! declines (hardware encoding Off, two-pass, MXF...) going to the software encoder. Its own test //! binary: the encoder registry is process-wide. Skips without an NVIDIA GPU with NVENC. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::sync::Arc; @@ -83,15 +83,19 @@ fn hardware_export_matches_the_software_export() { filmcraft_platform::register(); let (p, seq, m) = project(1280, 720, 72); let soft_path = tmp("soft.mp4"); + let before_soft = hw_encode_stats(); export(&p, seq, &settings(soft_path.clone(), HardwareEncoding::Off), &m, &Progress::default()).unwrap(); - assert_eq!(hw_encode_stats().sessions, 0, "Off: no hardware encoder"); + assert_eq!(hw_encode_stats(), before_soft, "Off: no hardware encoder attempt"); let hw_path = tmp("hw.mp4"); let before = hw_encode_stats(); let report = export(&p, seq, &settings(hw_path.clone(), HardwareEncoding::Auto), &m, &Progress::default()).unwrap(); let after = hw_encode_stats(); if after.sessions == before.sessions { - eprintln!("SKIPPED: no NVENC here (declined {})", after.declined - before.declined); + assert_eq!(after.declined - before.declined, 1, "Auto attempted hardware and reported the fallback"); + assert_eq!(after.frames, before.frames); + assert_eq!(std::fs::read(&soft_path).unwrap(), std::fs::read(&hw_path).unwrap(), "fallback preserves software determinism"); + eprintln!("no NVENC here: byte-identical software fallback verified"); return; } assert_eq!(report.frames, 72); @@ -135,7 +139,9 @@ fn what_nvenc_does_not_take_goes_to_the_software_encoder() { (0, 1) ); // an odd frame size is rounded to even by the pipeline, so NVENC still takes it - assert_eq!(run("odd.mp4", &|s| s.frame_size = Some((641, 359))), (24, 0)); + let hardware_available = filmcraft_platform::nvenc::available(); + let odd = run("odd.mp4", &|s| s.frame_size = Some((641, 359))); + assert_eq!(odd, if hardware_available { (24, 0) } else { (0, 1) }); // Off never touches the hardware encoder assert_eq!(run("off.mp4", &|s| s.hardware_encoding = HardwareEncoding::Off), (0, 0)); // the decoded file of a declined export is fine diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs new file mode 100644 index 000000000..d10835298 --- /dev/null +++ b/crates/platform/tests/nvenc_hevc.rs @@ -0,0 +1,323 @@ +//! NVENC H.265 (HEVC Main) encoding (Windows, NVIDIA GPU with an HEVC encoder): the stream decodes +//! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the +//! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips +//! without an NVIDIA GPU / driver with HEVC encoding. +#![cfg(target_os = "windows")] + +use filmcraft_bitstream::unescape_rbsp; +use filmcraft_hevc::params::Sps; +use filmcraft_isobmff::{HevcConfig, SampleEntry}; +use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, annex_b_to_length_prefixed_for, hevc_available, nal_type}; + +/// A moving test picture: gradient background, a moving box, a little texture. +fn rgba(w: usize, h: usize, i: usize) -> Vec { + let mut v = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let o = (y * w + x) * 4; + let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64)); + let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64; + let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8; + v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex }; + v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex }; + v[o + 2] = if in_box { 60 } else { 90 + tex }; + v[o + 3] = 255; + } + } + v +} + +fn config(w: u32, h: u32) -> Config { + Config { + width: w, + height: h, + fps: (24, 1), + bitrate_kbps: 6000, + max_bitrate_kbps: 9000, + cbr: false, + keyint: 24, + profile: Profile::HevcMain, + level: None, + sar: None, + bframes: true, + } +} + +/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +type Sources = Vec<(Vec, Vec, Vec)>; + +/// `None` (the test skips) only on a machine without an HEVC encoder; on one that has it, a +/// configuration NVENC refuses is a failure, not a skip. +fn encode_all(cfg: &Config, frames: usize) -> Option<(Nvenc, Vec, Sources)> { + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC encoder"); + return None; + } + let mut enc = Nvenc::new(cfg).unwrap_or_else(|why| panic!("NVENC HEVC is available but refused {cfg:?}: {why}")); + let (w, h) = (cfg.width as usize, cfg.height as usize); + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + let (mut packets, mut sources) = (Vec::new(), Vec::new()); + for i in 0..frames { + filmcraft_export::rgba_to_yuv420_8(&rgba(w, h, i), w, h, &mut y, &mut u, &mut v); + sources.push((y.clone(), u.clone(), v.clone())); + packets.extend(enc.encode(&y, &u, &v, i as u64).unwrap()); + } + packets.extend(enc.flush().unwrap()); + Some((enc, packets, sources)) +} + +fn psnr(a: &[u8], b: &[u8]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() } +} + +/// The NAL unit types of a length-prefixed sample. +fn sample_nal_types(sample: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut pos = 0; + while let Some(len) = sample.get(pos..pos + 4) { + let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize; + pos += 4; + let nal = sample.get(pos..pos + n).expect("a NAL unit inside the sample"); + out.push(nal_type(Codec::Hevc, nal).expect("a NAL header")); + pos += n; + } + assert_eq!(pos, sample.len(), "samples are made of whole NAL units"); + out +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[test] +fn the_stream_decodes_to_the_source() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let (w, h) = (1280u32, 720u32); + let Some((enc, packets, sources)) = encode_all(&config(w, h), 72) else { return }; + assert_eq!(enc.codec(), Codec::Hevc); + assert_eq!(packets.len(), 72, "one packet per picture"); + + // timestamps: decoding order, presentation = frame number, dts = k - delay + let delay = i64::from(enc.delay()); + for (k, p) in packets.iter().enumerate() { + assert_eq!(p.dts, k as i64 - delay, "dts of packet {k}"); + assert!(p.pts >= p.dts, "pts {} before dts {}", p.pts, p.dts); + } + assert!(packets.windows(2).all(|p| p[0].dts < p[1].dts), "dts strictly increases"); + let mut pts: Vec = packets.iter().map(|p| p.pts).collect(); + pts.sort_unstable(); + assert_eq!(pts, (0..72).collect::>(), "every picture once"); + // keyframes every 24 pictures (IDR), nothing else flagged + let keys: Vec = packets.iter().filter(|p| p.key).map(|p| p.pts).collect(); + assert_eq!(keys, vec![0, 24, 48], "IDR pictures"); + eprintln!("B-frame delay {delay}"); + + // no parameter sets, delimiters or end markers in the samples; the keyframes are IDR slices + for (k, p) in packets.iter().enumerate() { + let types = sample_nal_types(&p.data); + assert!(!types.is_empty(), "packet {k} has no NAL units"); + assert!(types.iter().all(|t| !(32..=37).contains(t)), "packet {k} carries NAL types {types:?}"); + if p.key { + assert!(types.iter().any(|t| matches!(t, 19 | 20)), "keyframe {k}: {types:?}"); + } + } + + // the record describes the SPS it came from + let hvcc = enc.hevc_config().expect("an hvcC record").clone(); + let (sps_nal, vps_nal) = (enc.parameter_sets().0.to_vec(), enc.vps().to_vec()); + eprintln!("VPS {}\nSPS {}\nPPS {}", hex(&vps_nal), hex(&sps_nal), hex(enc.parameter_sets().1)); + let rbsp = unescape_rbsp(&sps_nal[2..]); + let sps = Sps::parse(&rbsp).unwrap(); + // the coded size is a whole number of coding tree blocks; the conformance window crops it back + assert!(sps.width >= w && sps.height >= h); + let (_, _, shown_w, shown_h) = sps.crop_rect(); + assert_eq!((shown_w, shown_h), (w, h)); + assert!(sps.max_num_reorder <= enc.delay(), "SPS reorder {} but the dts shift is {}", sps.max_num_reorder, enc.delay()); + assert_eq!(&hvcc.to_bytes()[1..13], &rbsp[1..13], "the record's profile / tier / level bytes are the SPS's"); + assert_eq!(HevcConfig::parse(&hvcc.to_bytes()).unwrap(), hvcc); + assert_eq!((hvcc.general_profile_idc, hvcc.general_tier_flag, hvcc.chroma_format_idc, hvcc.bit_depth_luma, hvcc.bit_depth_chroma), (1, false, 1, 8, 8)); + assert_eq!(usize::from(hvcc.num_temporal_layers), sps.max_sub_layers_minus1 as usize + 1); + assert!(hvcc.arrays.iter().all(|a| a.completeness && a.nalus.len() == 1)); + assert_eq!(hvcc.arrays.iter().map(|a| a.nal_type).collect::>(), vec![32, 33, 34]); + eprintln!( + "hvcC: level {} compat {:#010x} constraints {:#014x} nested {} layers {}", + hvcc.general_level_idc, + hvcc.general_profile_compatibility_flags, + hvcc.general_constraint_indicator_flags, + hvcc.temporal_id_nested, + hvcc.num_temporal_layers + ); + // BT.709 limited range, timing of the frame rate (a frame count per tick, not fields) + let vui = sps.vui.clone().expect("a VUI"); + assert_eq!((vui.colour_primaries, vui.transfer_characteristics, vui.matrix_coefficients, vui.full_range), (1, 1, 1, false)); + assert_eq!(vui.timing, Some((1, 24)), "num_units_in_tick, time_scale"); + + // our own decoder decodes the samples + let entry = SampleEntry::hevc(hvcc, w as u16, h as u16); + let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap(); + let mut out = Vec::new(); + for p in &packets { + out.extend(dec.decode(&p.data, p.pts).unwrap()); + } + out.extend(dec.flush()); + assert_eq!(out.len(), 72); + let mut worst = 99.0f64; + let mut worst_chroma = 99.0f64; + for f in &out { + let src = &sources[f.pts as usize]; + let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") }; + worst = worst.min(psnr(&planes[0], &src.0)); + worst_chroma = worst_chroma.min(psnr(&planes[1], &src.1)).min(psnr(&planes[2], &src.2)); + } + eprintln!("worst luma PSNR {worst:.1} dB, worst chroma {worst_chroma:.1} dB, {} bytes", packets.iter().map(|p| p.data.len()).sum::()); + assert!(worst > 43.0, "luma PSNR {worst:.1} dB"); + assert!(worst_chroma > 45.0, "chroma PSNR {worst_chroma:.1} dB"); +} + +#[test] +fn nothing_to_encode_with_is_an_error_not_a_crash() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + // sizes NVENC cannot take, a zero frame rate, an HEVC level that does not exist + for (w, h) in [(0, 0), (641, 360), (640, 361), (16, 16), (u32::MAX, u32::MAX), (100_000, 64)] { + assert!(Nvenc::new(&config(w, h)).is_err(), "{w}x{h}"); + } + let mut c = config(640, 360); + c.fps = (0, 1); + assert!(Nvenc::new(&c).is_err()); + c.fps = (24, 0); + assert!(Nvenc::new(&c).is_err()); + c = config(640, 360); + c.level = Some(42); + assert!(Nvenc::new(&c).is_err()); + // a level that exists is taken + c.level = Some(41); + let enc = Nvenc::new(&c).expect("level 4.1"); + assert_eq!(enc.hevc_config().map(|h| h.general_level_idc), Some(123)); + // pictures smaller than the encoder's size + let mut enc = Nvenc::new(&config(640, 360)).unwrap(); + assert!(enc.encode(&[0; 10], &[0; 10], &[0; 10], 0).is_err()); +} + +#[test] +fn hostile_annex_b_never_panics() { + for s in [&[][..], &[0, 0, 1], &[1, 2, 3], &[0, 0, 1, 0x46, 0x01, 0x50], &[0, 0, 0, 1, 0x40], &[0, 0, 0, 1, 0x40, 0x01]] { + assert!(annex_b_to_length_prefixed_for(Codec::Hevc, s).is_err(), "{s:?}"); + } +} + +/// Run `f`, failing the test (not the process) if it panics. +fn calm(what: &str, f: impl FnOnce() -> T) -> T { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(v) => v, + Err(_) => panic!("{what}: panicked"), + } +} + +#[test] +fn hostile_configurations_give_errors_not_crashes() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + let base = config(640, 360); + let variants: Vec<(&str, Config)> = vec![ + ("zero bitrate", Config { bitrate_kbps: 0, max_bitrate_kbps: 0, ..base.clone() }), + ("huge bitrate", Config { bitrate_kbps: u32::MAX, max_bitrate_kbps: u32::MAX, ..base.clone() }), + ("max below target", Config { bitrate_kbps: 6000, max_bitrate_kbps: 1, ..base.clone() }), + ("zero cbr", Config { cbr: true, bitrate_kbps: 0, ..base.clone() }), + ("cbr", Config { cbr: true, bitrate_kbps: 4000, ..base.clone() }), + ("zero keyint", Config { keyint: 0, ..base.clone() }), + ("keyint 2", Config { keyint: 2, ..base.clone() }), + ("huge keyint", Config { keyint: u32::MAX, ..base.clone() }), + ("keyint 1 (all intra)", Config { keyint: 1, ..base.clone() }), + ("huge frame rate", Config { fps: (u32::MAX, 1), ..base.clone() }), + ("huge frame duration", Config { fps: (1, u32::MAX), ..base.clone() }), + ("huge aspect ratio", Config { sar: Some((u32::MAX, u32::MAX)), ..base.clone() }), + ("zero aspect ratio", Config { sar: Some((0, 0)), ..base.clone() }), + ("no B-frames", Config { bframes: false, ..base.clone() }), + ("smallest size", Config { width: 130, height: 34, ..base.clone() }), + ("odd size", Config { width: 321, height: 181, ..base.clone() }), + ]; + for (name, c) in variants { + match calm(name, || Nvenc::new(&c)) { + Ok(mut enc) => { + let (w, h) = (c.width as usize, c.height as usize); + let (cw, ch) = (w / 2, h / 2); + assert!(calm(name, || enc.encode(&[], &[], &[], 0)).is_err() || w == 0, "{name}: empty planes"); + assert!(calm(name, || enc.encode(&vec![0; w * h - 1], &vec![128; cw * ch], &vec![128; cw * ch], 0)).is_err(), "{name}: short luma"); + // a few real pictures, then the end; whatever comes out, nothing panics + for i in 0..3u8 { + let _ = calm(name, || enc.encode(&vec![16 + i; w * h], &vec![128; cw * ch], &vec![128; cw * ch], i as u64)); + } + let _ = calm(name, || enc.flush()); + } + Err(why) => eprintln!("{name}: declined ({why})"), + } + } +} + +#[test] +fn an_encoder_dropped_mid_stream_releases_everything() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + // more sessions than a consumer GPU allows at once would fail the later ones: drop each early + for round in 0..12 { + let mut enc = Nvenc::new(&config(640, 360)).unwrap_or_else(|e| panic!("round {round}: {e}")); + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + for i in 0..5 { + filmcraft_export::rgba_to_yuv420_8(&rgba(640, 360, i), 640, 360, &mut y, &mut u, &mut v); + enc.encode(&y, &u, &v, i as u64).unwrap(); + } + // dropped with pictures in flight and no flush + } +} + +#[test] +fn high_bitrates_move_up_a_level_and_stay_in_the_main_tier() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // 1080p30 at a 12 / 18 Mbit/s peak: above level 4's Main tier (12), where NVENC's own choice was + // level 4 High tier, which many hardware decoders refuse + for (w, h, fps, kbps, max_kbps, level_idc) in + [(1920u32, 1080u32, 30u32, 12_000u32, 18_000u32, 123u8), (1920, 1080, 30, 4_000, 6_000, 120), (1280, 720, 30, 5_000, 7_500, 93)] + { + let cfg = Config { fps: (fps, 1), bitrate_kbps: kbps, max_bitrate_kbps: max_kbps, keyint: 30, ..config(w, h) }; + let Some((enc, packets, _)) = encode_all(&cfg, 4) else { return }; + assert_eq!(packets.len(), 4); + let record = enc.hevc_config().expect("an hvcC record"); + assert!(!record.general_tier_flag, "{w}x{h} at {max_kbps} kbit/s: Main tier"); + assert_eq!(record.general_level_idc, level_idc, "{w}x{h} at {max_kbps} kbit/s"); + } +} + +#[test] +fn a_keyframe_every_one_or_two_pictures_is_written_without_b_frames() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // NVENC wants a GOP longer than the B-frame pattern: short GOPs drop the B-frames, they do not fail + for keyint in [1u32, 2] { + let cfg = Config { keyint, ..config(640, 360) }; + let Some((enc, packets, _)) = encode_all(&cfg, 12) else { return }; + assert_eq!(enc.delay(), 0, "keyint {keyint}"); + assert_eq!(packets.len(), 12); + assert!(packets.iter().enumerate().all(|(k, p)| p.pts == k as i64 && p.dts == p.pts), "keyint {keyint}: no reordering"); + let keys: Vec = packets.iter().filter(|p| p.key).map(|p| p.pts).collect(); + assert_eq!(keys, (0..12).step_by(keyint as usize).collect::>(), "keyint {keyint}"); + let entry = SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), 640, 360); + let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap(); + let mut n = 0; + for p in &packets { + n += dec.decode(&p.data, p.pts).unwrap().len(); + } + assert_eq!(n + dec.flush().len(), 12, "keyint {keyint}"); + } +} diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/nvenc_hevc_export.rs new file mode 100644 index 000000000..95f074bb8 --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_export.rs @@ -0,0 +1,318 @@ +//! Export as H.265 (HEVC Main, NVENC) through the real export pipeline: MP4 and QuickTime files that +//! ffprobe (an external oracle only) describes as `hvc1` Main 8-bit 4:2:0 BT.709, that ffmpeg decodes +//! without errors and that our own decoder decodes to the pictures of the software H.264 export; the +//! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test +//! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with +//! an HEVC encoder. +#![cfg(target_os = "windows")] + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use filmcraft_export::{ + BitrateMode, ColorSignal, ExportSettings, FieldOrder, Format, H264Pass, HardwareEncoding, Multiplexer, Progress, export, hw_encode_stats, +}; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{DemoScene, FrameRequest, Generator, MediaSource}; +use filmcraft_platform::nvenc::export::factory; +use filmcraft_platform::nvenc::hevc_available; +use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::{FrameRate, Tick, TimeRange}; + +/// The encoder counters are process-wide: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn project(w: u32, h: u32, frames: i64) -> (Arc, filmcraft_project::ItemId, SourceMap) { + let rate = FrameRate::FPS_24; + let mut p = Project::new("x"); + let g = GeneratorSource::new(Generator::Demo(DemoScene::OceanSunset), w, h, rate, rate.tick_of(frames)); + let info = g.info().clone(); + let clip = p.add_item( + &info.name.clone(), + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let seq = p.new_sequence("s", SequenceSettings { width: w, height: h, frame_rate: rate, ..Default::default() }, 1, 1, None); + let v = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(frames)), rate).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v); + let mut m = SourceMap::default(); + m.0.insert(clip, Arc::new(g)); + (Arc::new(p), seq, m) +} + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("fc-nvenc-hevc-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +fn decode(path: &str) -> Vec> { + let bytes: Arc<[u8]> = std::fs::read(path).unwrap().into(); + let src = filmcraft_codecs::open_bytes(path, bytes).unwrap(); + let rate = src.info().frame_rate(); + let n = rate.frame_at(src.info().duration); + (0..n).map(|i| src.video_frame(FrameRequest::full(rate.tick_of(i))).unwrap()).collect() +} + +fn luma_psnr(a: &filmcraft_frame::VideoFrame, b: &filmcraft_frame::VideoFrame) -> f64 { + let (x, y) = (a.luma8(), b.luma8()); + let mse = x.iter().zip(&y).map(|(p, q)| (f64::from(*p) - f64::from(*q)).powi(2)).sum::() / x.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() } +} + +fn hevc(path: String) -> ExportSettings { + ExportSettings { format: Format::Hevc, path, include_audio: false, bitrate_kbps: 4000, bitrate_mode: BitrateMode::Vbr1Pass, ..Default::default() } +} + +/// `key=value` lines of `ffprobe -show_entries` for the first video stream. +fn probe(ffprobe: &Path, path: &str) -> std::collections::HashMap { + let entries = "stream=codec_name,profile,codec_tag_string,pix_fmt,color_space,color_transfer,color_primaries,color_range,width,height,nb_frames,r_frame_rate,start_time,level"; + let out = Command::new(ffprobe).args(["-v", "error", "-select_streams", "v:0", "-show_entries", entries, "-of", "default=nw=1", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect() +} + +/// Presentation times (in frames at 24 fps) of the packets ffprobe flags as keyframes. +fn keyframes(ffprobe: &Path, path: &str) -> Vec { + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", path]) + .output() + .unwrap(); + let mut keys: Vec = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.split_once(',')) + .filter(|(_, flags)| flags.starts_with('K')) + .filter_map(|(t, _)| t.parse::().ok()) + .map(|t| (t * 24.0).round() as i64) + .collect(); + keys.sort_unstable(); + keys +} + +/// ffmpeg decodes the whole file with `-xerror` and says nothing. +fn assert_ffmpeg_decodes(ffmpeg: &Path, path: &str) { + let out = Command::new(ffmpeg).args(["-v", "error", "-xerror", "-i", path, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr)); +} + +fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) { + let after = hw_encode_stats(); + (after.frames - before.frames, after.sessions - before.sessions, after.declined - before.declined) +} + +#[test] +fn hevc_exports_play_everywhere() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + assert_eq!(filmcraft_export::available(Format::Hevc), hevc_available(), "the format is available iff NVENC can encode HEVC"); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + let (p, seq, m) = project(1280, 720, 72); + + // the software H.264 export of the same frames is the reference for our decoder's pictures + let soft_path = tmp("soft.mp4"); + let soft = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Off, ..hevc(soft_path.clone()) }; + let before = hw_encode_stats(); + export(&p, seq, &soft, &m, &Progress::default()).unwrap(); + assert_eq!(delta(before), (0, 0, 0), "H.264 with hardware encoding Off never touches NVENC"); + let reference = decode(&soft_path); + + let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hevc export"); + let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export"); + for (name, multiplexer, toggle) in [ + ("hevc.mp4", Multiplexer::Mp4, HardwareEncoding::Auto), + ("hevc.mov", Multiplexer::Mov, HardwareEncoding::Auto), + // HEVC has no software encoder: the toggle does not apply + ("hevc-off.mp4", Multiplexer::Mp4, HardwareEncoding::Off), + ] { + let path = tmp(name); + let s = ExportSettings { multiplexer, hardware_encoding: toggle, ..hevc(path.clone()) }; + let before = hw_encode_stats(); + let report = export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(report.frames, 72, "{name}"); + assert_eq!(delta(before), (72, 1, 0), "{name}: every picture through one NVENC session, nothing declined"); + + // our own decoder: 72 pictures close to the software export's + let pictures = decode(&path); + assert_eq!(pictures.len(), 72, "{name}"); + let worst = pictures.iter().zip(&reference).map(|(a, b)| luma_psnr(a, b)).fold(99.0, f64::min); + eprintln!( + "{name}: {} bytes (software H.264 {}), worst luma PSNR against the software export {worst:.1} dB", + std::fs::metadata(&path).unwrap().len(), + std::fs::metadata(&soft_path).unwrap().len() + ); + assert!(worst > 48.0, "{name}: {worst:.1} dB"); + + let (Some(ffprobe), Some(ffmpeg)) = (&ffprobe, &ffmpeg) else { continue }; + let v = probe(ffprobe, &path); + let get = |k: &str| v.get(k).map(String::as_str).unwrap_or("?"); + assert_eq!((get("codec_name"), get("profile"), get("codec_tag_string"), get("pix_fmt")), ("hevc", "Main", "hvc1", "yuv420p"), "{name}: {v:?}"); + assert_eq!((get("color_space"), get("color_transfer"), get("color_primaries"), get("color_range")), ("bt709", "bt709", "bt709", "tv"), "{name}: {v:?}"); + assert_eq!((get("width"), get("height"), get("nb_frames"), get("r_frame_rate")), ("1280", "720", "72", "24/1"), "{name}: {v:?}"); + assert!(get("start_time").parse::().is_ok_and(|t| t.abs() < 1e-3), "{name}: starts at {}", get("start_time")); + assert_eq!(keyframes(ffprobe, &path), vec![0, 48], "{name}: keyframes every 2 s (the default distance)"); + assert_ffmpeg_decodes(ffmpeg, &path); + eprintln!("{name}: ffprobe {v:?}, ffmpeg -xerror decodes it cleanly"); + } +} + +#[test] +fn hevc_export_with_audio() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + let (p, seq, m) = project(640, 360, 48); + let path = tmp("hevc-audio.mp4"); + let s = ExportSettings { include_audio: true, keyframe_distance: Some(24), ..hevc(path.clone()) }; + export(&p, seq, &s, &m, &Progress::default()).expect("export"); + let bytes = std::fs::read(&path).unwrap(); + let file = filmcraft_isobmff::open(bytes.as_slice()).unwrap(); + assert!(file.track_of_kind(filmcraft_isobmff::TrackKind::Video).is_some()); + let at = file.track_of_kind(filmcraft_isobmff::TrackKind::Audio).expect("an audio track"); + assert!(matches!(&file.tracks[at].entries[0].codec, filmcraft_isobmff::CodecConfig::Aac(_))); + if let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("nvenc hevc export"), filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export")) { + let out = Command::new(&ffprobe) + .args(["-v", "error", "-show_entries", "stream=codec_name,codec_type,codec_tag_string", "-of", "csv=p=0", &path]) + .output() + .unwrap(); + let streams = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(streams.contains("hevc,video,hvc1") && streams.contains("aac,audio,mp4a"), "{streams}"); + assert_ffmpeg_decodes(&ffmpeg, &path); + assert_eq!(keyframes(&ffprobe, &path), vec![0, 24]); + } +} + +#[test] +fn sizes_that_are_not_multiples_of_the_coding_block_are_cropped_back() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC here"); + return; + } + // 1080 is not a multiple of the 32-pixel coding tree block (the SPS carries a conformance window); + // 642x362 is even but a multiple of nothing + for (w, h) in [(1920u32, 1080u32), (642, 362)] { + let (p, seq, m) = project(w, h, 12); + let path = tmp(&format!("hevc-{w}x{h}.mp4")); + let before = hw_encode_stats(); + export(&p, seq, &hevc(path.clone()), &m, &Progress::default()).unwrap_or_else(|e| panic!("{w}x{h}: {e}")); + assert_eq!(delta(before), (12, 1, 0), "{w}x{h}"); + let pictures = decode(&path); + assert_eq!(pictures.len(), 12); + assert!(pictures.iter().all(|f| (f.width, f.height) == (w, h)), "{w}x{h}: decoded {}x{}", pictures[0].width, pictures[0].height); + if let (Some(ffprobe), Some(ffmpeg)) = (filmcraft_testkit::ffprobe_or_skip("nvenc hevc export"), filmcraft_testkit::ffmpeg_or_skip("nvenc hevc export")) + { + let v = probe(&ffprobe, &path); + assert_eq!((v["width"].as_str(), v["height"].as_str()), (w.to_string().as_str(), h.to_string().as_str()), "{v:?}"); + assert_ffmpeg_decodes(&ffmpeg, &path); + } + } +} + +#[test] +fn what_nvenc_cannot_do_is_an_error_that_says_why() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + let rate = FrameRate::FPS_24; + let base = hevc(tmp("declined.mp4")); + // (what, settings, size): each declined by the configuration alone, so even a machine without + // NVENC counts and explains it + let cases: Vec<(&str, ExportSettings, (u32, u32))> = vec![ + // HDR is Main 10 since the HDR round (see nvenc_hevc_hdr_export.rs); what stays declined is a colour description + // this backend does not write + ("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..base.clone() }, (1280, 720)), + ("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..base.clone() }, (1280, 720)), + ("an analysis pass", ExportSettings { h264_pass: H264Pass::First, ..base.clone() }, (1280, 720)), + ("two-pass VBR", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..base.clone() }, (1280, 720)), + ("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..base.clone() }, (1280, 720)), + ("MXF", ExportSettings { format: Format::MxfOp1a, ..base.clone() }, (1280, 720)), + ("interlaced output", ExportSettings { field_order: FieldOrder::UpperFirst, ..base.clone() }, (1280, 720)), + ("wider than an MP4 sample entry", base.clone(), (70_000, 64)), + ("taller than an MP4 sample entry", base.clone(), (64, 70_000)), + ]; + for (what, s, (w, h)) in cases { + let before = hw_encode_stats(); + let r = factory(Format::Hevc, w, h, rate, &s); + let Some(Err(e)) = r else { panic!("{what}: expected a declined request, got {}", if r.is_some() { "an encoder" } else { "None" }) }; + assert!(e.to_string().contains("NVENC"), "{what}: {e}"); + assert_eq!(delta(before), (0, 0, 1), "{what}: counted as declined, no session"); + } + // HDR (PQ / HLG) is taken as Main 10 where the GPU has 10-bit HEVC, declined (counted, with the reason) where it has not + for signal in [ColorSignal::PQ, ColorSignal::HLG] { + let before = hw_encode_stats(); + let r = factory(Format::Hevc, 640, 360, rate, &ExportSettings { signal, ..base.clone() }); + if filmcraft_platform::nvenc::hevc_hdr_available() { + assert!(matches!(r, Some(Ok(_))), "{signal:?}"); + assert_eq!(delta(before), (0, 1, 0), "{signal:?}"); + } else if hevc_available() { + let Some(Err(e)) = r else { panic!("{signal:?}: expected a declined request") }; + assert!(e.to_string().contains("10-bit"), "{signal:?}: {e}"); + assert_eq!(delta(before), (0, 0, 1), "{signal:?}"); + } + } + // interlaced output is refused by the export's own validation, before any encoder is asked + let (p, seq, m) = project(640, 360, 6); + let before = hw_encode_stats(); + let s = ExportSettings { field_order: FieldOrder::UpperFirst, ..hevc(tmp("interlaced.mp4")) }; + assert!(export(&p, seq, &s, &m, &Progress::default()).is_err()); + assert_eq!(delta(before), (0, 0, 0)); + + if !hevc_available() { + // no HEVC encoder: the export's own "encoder not available" error, nothing written, no panic + let err = export(&p, seq, &hevc(tmp("none.mp4")), &m, &Progress::default()).expect_err("no encoder").to_string(); + assert!(err.contains("encoder not available"), "{err}"); + eprintln!("SKIPPED the NVENC-limit cases: no NVENC HEVC here"); + return; + } + // sizes outside what the GPU's HEVC encoder takes, through the real export + let (p, seq, m) = project(640, 360, 6); + for (w, h) in [(64u32, 64u32), (16, 16), (10_000, 64)] { + let path = tmp(&format!("small-{w}x{h}.mp4")); + let s = ExportSettings { frame_size: Some((w, h)), ..hevc(path.clone()) }; + let before = hw_encode_stats(); + let r = export(&p, seq, &s, &m, &Progress::default()); + match r { + Err(e) => { + let e = e.to_string(); + eprintln!("{w}x{h}: {e}"); + assert!(e.contains("NVENC"), "{w}x{h}: {e}"); + assert_eq!(delta(before), (0, 0, 1), "{w}x{h}"); + } + // a GPU that does take the size: a good file + Ok(_) => assert_eq!(delta(before).1, 1, "{w}x{h}"), + } + } + // the factory itself: an odd size and a size outside the limits are declined, a good one is taken + let before = hw_encode_stats(); + assert!(matches!(factory(Format::Hevc, 641, 360, rate, &base), Some(Err(_)))); + assert!(matches!(factory(Format::Hevc, 20_000, 20_000, rate, &base), Some(Err(_)))); + assert_eq!(delta(before), (0, 0, 2)); + let before = hw_encode_stats(); + assert!(matches!(factory(Format::Hevc, 640, 360, rate, &base), Some(Ok(_)))); + assert_eq!(delta(before), (0, 1, 0)); + // H.264 with hardware encoding Off is still not NVENC's business, HEVC's toggle does not matter + let before = hw_encode_stats(); + let off = ExportSettings { format: Format::H264, hardware_encoding: HardwareEncoding::Off, ..base.clone() }; + assert!(factory(Format::H264, 640, 360, rate, &off).is_none()); + assert!(matches!(factory(Format::Hevc, 640, 360, rate, &ExportSettings { hardware_encoding: HardwareEncoding::Off, ..base }), Some(Ok(_)))); + assert_eq!(delta(before), (0, 1, 0)); +} diff --git a/crates/platform/tests/nvenc_hevc_hdr_export.rs b/crates/platform/tests/nvenc_hevc_hdr_export.rs new file mode 100644 index 000000000..8acbc1cad --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_hdr_export.rs @@ -0,0 +1,362 @@ +//! HDR export as H.265 Main 10 (NVENC) through the real export pipeline: an HDR (Rec. 2100 PQ / HLG) +//! sequence gives MP4 / QuickTime files that ffprobe (an external oracle only) describes as `hvc1` +//! Main 10, yuv420p10le, BT.2020 with the right transfer, limited range, with HDR10 static metadata for +//! PQ; that ffmpeg decodes without errors to the same 10-bit pictures our own decoder makes; that +//! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same +//! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder +//! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder. +#![cfg(target_os = "windows")] + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use filmcraft_color::{ColorPipeline, WorkingSpace}; +use filmcraft_export::{BitrateMode, ColorSignal, EncoderFrame, ExportSettings, Format, Progress, export, hw_encode_stats}; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{DemoScene, FrameRequest, Generator, MediaSource}; +use filmcraft_platform::nvenc::export::factory; +use filmcraft_platform::nvenc::{hevc_available, hevc_hdr_available}; +use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::{FrameRate, Tick, TimeRange}; + +/// The encoder counters are process-wide: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +const W: u32 = 1280; +const H: u32 = 720; +const FRAMES: i64 = 24; + +fn project(working: WorkingSpace) -> (Arc, filmcraft_project::ItemId, SourceMap) { + let rate = FrameRate::FPS_24; + let mut p = Project::new("x"); + let g = GeneratorSource::new(Generator::Demo(DemoScene::OceanSunset), W, H, rate, rate.tick_of(FRAMES)); + let info = g.info().clone(); + let clip = p.add_item( + &info.name.clone(), + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let color = ColorPipeline { working, ..ColorPipeline::REC709 }; + let seq = p.new_sequence("s", SequenceSettings { width: W, height: H, frame_rate: rate, color, ..Default::default() }, 1, 1, None); + let v = p.make_track_item(clip, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(FRAMES)), rate).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v); + let mut m = SourceMap::default(); + m.0.insert(clip, Arc::new(g)); + (Arc::new(p), seq, m) +} + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("fc-nvenc-hdr-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +fn decode(path: &str) -> Vec> { + let bytes: Arc<[u8]> = std::fs::read(path).unwrap().into(); + let src = filmcraft_codecs::open_bytes(path, bytes).unwrap(); + let rate = src.info().frame_rate(); + let n = rate.frame_at(src.info().duration); + (0..n).map(|i| src.video_frame(FrameRequest::full(rate.tick_of(i))).unwrap()).collect() +} + +fn hevc(path: String) -> ExportSettings { + ExportSettings { format: Format::Hevc, path, include_audio: false, bitrate_kbps: 10_000, bitrate_mode: BitrateMode::Vbr1Pass, ..Default::default() } +} + +fn probe(ffprobe: &Path, path: &str) -> std::collections::HashMap { + let entries = "stream=codec_name,profile,codec_tag_string,pix_fmt,color_space,color_transfer,color_primaries,color_range,width,height,nb_frames,level"; + let out = Command::new(ffprobe).args(["-v", "error", "-select_streams", "v:0", "-show_entries", entries, "-of", "default=nw=1", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect() +} + +/// The side data ffprobe reports for the first frame. +fn first_frame_side_data(ffprobe: &Path, path: &str) -> String { + let out = Command::new(ffprobe) + .args([ + "-v", + "error", + "-select_streams", + "v:0", + "-read_intervals", + "%+#1", + "-show_frames", + "-show_entries", + "frame=side_data_list", + "-of", + "default=nw=1", + path, + ]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).to_string() +} + +fn assert_ffmpeg_decodes(ffmpeg: &Path, path: &str) { + let out = Command::new(ffmpeg).args(["-v", "error", "-xerror", "-i", path, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr)); +} + +/// The first picture as ffmpeg decodes it to yuv420p10le: (Y, Cb, Cr) code values. +fn ffmpeg_first_picture(ffmpeg: &Path, path: &str, w: usize, h: usize) -> (Vec, Vec, Vec) { + let out = Command::new(ffmpeg).args(["-v", "error", "-i", path, "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "yuv420p10le", "-"]).output().unwrap(); + assert!(out.status.success(), "ffmpeg: {}", String::from_utf8_lossy(&out.stderr)); + let samples: Vec = out.stdout.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect(); + assert_eq!(samples.len(), w * h * 3 / 2, "one yuv420p10le picture"); + let (y, c) = samples.split_at(w * h); + let (u, v) = c.split_at(c.len() / 2); + (y.to_vec(), u.to_vec(), v.to_vec()) +} + +fn frame<'a>(rgba: &'a [u8], hdr: Option<&'a [f32]>, index: u64) -> EncoderFrame<'a> { + EncoderFrame { width: 640, height: 360, rgba, hdr, index } +} + +fn psnr10(a: &[u16], b: &[u16]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (1023.0f64 * 1023.0 / mse).log10() } +} + +fn delta(before: filmcraft_export::HwEncodeStats) -> (u64, u64, u64) { + let after = hw_encode_stats(); + (after.frames - before.frames, after.sessions - before.sessions, after.declined - before.declined) +} + +fn yuv16(f: &filmcraft_frame::VideoFrame) -> ([Arc>; 3], u32) { + match &f.data { + filmcraft_frame::PixelData::Yuv16 { planes, bits, .. } => (planes.clone(), *bits), + _ => panic!("16-bit planes expected, got {}", f.format_label()), + } +} + +#[test] +fn hdr_sequences_export_as_main_10() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + assert_eq!(filmcraft_export::hdr_available(Format::Hevc), hevc_hdr_available(), "HDR H.265 is available iff NVENC can encode Main 10"); + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 here (hevc: {})", hevc_available()); + return; + } + let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export"); + let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("nvenc hdr export"); + for (ws, ext, ff_transfer, pq) in [ + (WorkingSpace::Rec2100Pq, "mp4", "smpte2084", true), + (WorkingSpace::Rec2100Hlg, "mp4", "arib-std-b67", false), + (WorkingSpace::Rec2100Pq, "mov", "smpte2084", true), + ] { + let name = format!("{}.{ext}", ws.id()); + let (p, seq, m) = project(ws); + + // the ProRes HDR export of the same sequence is the reference for the pixels (10-bit BT.2020, same matrix and range) + let ref_path = tmp(&format!("ref-{}.mov", ws.id())); + let before = hw_encode_stats(); + export( + &p, + seq, + &ExportSettings { format: Format::ProRes, path: ref_path.clone(), include_audio: false, ..Default::default() }, + &m, + &Progress::default(), + ) + .unwrap(); + assert_eq!(delta(before), (0, 0, 0), "ProRes never touches NVENC"); + let reference = decode(&ref_path); + + let path = tmp(&name); + let s = hevc(path.clone()); + let before = hw_encode_stats(); + let report = export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(report.frames, FRAMES as u64, "{name}"); + assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{name}: every picture through one NVENC session, nothing declined"); + + // our importer: PQ / HLG, BT.2020, 10-bit planes + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes(&path, bytes.clone()).unwrap(); + let v = src.info().video.clone().unwrap(); + let (want_transfer, want_hdr) = if pq { (filmcraft_color::Transfer::Pq, true) } else { (filmcraft_color::Transfer::Hlg, false) }; + assert_eq!( + (v.color.transfer, v.color.primaries, v.color.matrix), + (want_transfer, filmcraft_color::Primaries::Bt2020, filmcraft_color::Matrix::Bt2020Ncl), + "{name}" + ); + if want_hdr { + let hdr = v.hdr.unwrap_or_else(|| panic!("{name}: no HDR metadata")); + assert_eq!((hdr.mastering_max_nits, hdr.max_cll, hdr.peak_nits()), (Some(1000.0), None, Some(1000.0)), "{name}"); + } + // and the sample entry boxes + let file = filmcraft_isobmff::open(&bytes[..]).unwrap(); + let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).expect("a video track"); + let ve = file.tracks[vt].entries[0].video.clone().expect("a video sample entry"); + assert!(matches!(ve.color, Some(filmcraft_isobmff::ColorInfo::Nclx { primaries: 9, matrix: 9, full_range: false, .. })), "{name}: {:?}", ve.color); + assert_eq!(ve.mastering_display.is_some() && ve.content_light == Some((0, 0)), pq, "{name}: mdcv / clli"); + + let pictures = decode(&path); + assert_eq!(pictures.len(), FRAMES as usize, "{name}"); + let (mut worst, mut best_levels) = (99.0f64, usize::MAX); + let (mut peak, mut ref_peak) = (0u16, 0u16); + for (a, b) in pictures.iter().zip(&reference) { + let (pa, bits) = yuv16(a); + let (pb, _) = yuv16(b); + assert_eq!(bits, 10, "{name}: decoded bit depth"); + assert!(pa[0].iter().all(|c| *c < 1024)); + worst = worst.min(psnr10(&pa[0], &pb[0])); + peak = peak.max(pa[0].iter().copied().max().unwrap_or(0)); + ref_peak = ref_peak.max(pb[0].iter().copied().max().unwrap_or(0)); + let mut levels: Vec = pa[0].to_vec(); + levels.sort_unstable(); + levels.dedup(); + best_levels = best_levels.min(levels.len()); + } + eprintln!( + "{name}: {} bytes, worst luma PSNR against the ProRes HDR export {worst:.1} dB, {best_levels} distinct luma levels per picture (min), peak luma code {peak} (ProRes {ref_peak})", + report.bytes + ); + assert!(worst > 35.0, "{name}: {worst:.1} dB against the ProRes HDR export"); + assert!(best_levels > 256, "{name}: {best_levels} distinct luma levels: not 10-bit"); + assert!(peak.abs_diff(ref_peak) < 24, "{name}: peak {peak} against {ref_peak}"); + + let (Some(ffprobe), Some(ffmpeg)) = (&ffprobe, &ffmpeg) else { continue }; + let f = probe(ffprobe, &path); + let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?"); + assert_eq!((get("codec_name"), get("profile"), get("codec_tag_string"), get("pix_fmt")), ("hevc", "Main 10", "hvc1", "yuv420p10le"), "{name}: {f:?}"); + assert_eq!( + (get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")), + ("bt2020", ff_transfer, "bt2020nc", "tv"), + "{name}: {f:?}" + ); + assert_eq!((get("width"), get("height"), get("nb_frames")), ("1280", "720", "24"), "{name}: {f:?}"); + let side = first_frame_side_data(ffprobe, &path); + if pq { + assert!( + side.contains("Mastering display metadata") && side.contains("max_luminance=10000000/10000") && side.contains("min_luminance=1/10000"), + "{name}: {side}" + ); + assert!(side.contains("Content light level metadata") && side.contains("max_content=0") && side.contains("max_average=0"), "{name}: {side}"); + } else { + assert!(!side.contains("Mastering display") && !side.contains("Content light level"), "{name}: {side}"); + } + assert_ffmpeg_decodes(ffmpeg, &path); + // ffmpeg's yuv420p10le picture is our decoder's picture + let (fy, fu, fv) = ffmpeg_first_picture(ffmpeg, &path, W as usize, H as usize); + let (ours, _) = yuv16(&pictures[0]); + let diff = |a: &[u16], b: &[u16]| a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + eprintln!( + "{name}: ffprobe {f:?}\n{name}: first-frame side data {:?}\n{name}: ffmpeg vs our decoder, max code difference Y {} Cb {} Cr {}", + side.lines().collect::>(), + diff(&fy, &ours[0]), + diff(&fu, &ours[1]), + diff(&fv, &ours[2]) + ); + assert_eq!( + (diff(&fy, &ours[0]), diff(&fu, &ours[1]), diff(&fv, &ours[2])), + (0, 0, 0), + "{name}: our HEVC decoder and ffmpeg disagree on the 10-bit pictures" + ); + } +} + +#[test] +fn sdr_stays_main_8_bit_and_the_sdr_setting_tone_maps_hdr_sequences() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 here"); + return; + } + let ffprobe = filmcraft_testkit::ffprobe_or_skip("nvenc hdr export"); + // an HDR sequence with SDR asked for, and a plain SDR sequence: Main, 8-bit, BT.709 + for (what, ws, sdr) in [ + ("HDR sequence, settings.sdr", WorkingSpace::Rec2100Pq, true), + ("HLG sequence, settings.sdr", WorkingSpace::Rec2100Hlg, true), + ("SDR sequence", ColorPipeline::REC709.working, false), + ] { + let (p, seq, m) = project(ws); + let path = tmp(&format!("sdr-{}.mp4", what.replace([' ', ','], "-"))); + let s = ExportSettings { sdr, ..hevc(path.clone()) }; + let before = hw_encode_stats(); + export(&p, seq, &s, &m, &Progress::default()).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(delta(before), (FRAMES as u64, 1, 0), "{what}"); + let pictures = decode(&path); + assert!(matches!(&pictures[0].data, filmcraft_frame::PixelData::Yuv8 { .. }), "{what}: 8-bit planes"); + assert_eq!(pictures[0].color.transfer, filmcraft_color::Transfer::Bt709, "{what}"); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let file = filmcraft_isobmff::open(&bytes[..]).unwrap(); + let vt = file.track_of_kind(filmcraft_isobmff::TrackKind::Video).unwrap(); + let ve = file.tracks[vt].entries[0].video.clone().unwrap(); + assert!(ve.mastering_display.is_none() && ve.content_light.is_none(), "{what}: no HDR boxes"); + if let Some(ffprobe) = &ffprobe { + let f = probe(ffprobe, &path); + let get = |k: &str| f.get(k).map(String::as_str).unwrap_or("?"); + assert_eq!( + (get("profile"), get("pix_fmt"), get("color_primaries"), get("color_transfer"), get("color_space"), get("color_range")), + ("Main", "yuv420p", "bt709", "bt709", "bt709", "tv"), + "{what}: {f:?}" + ); + assert!(!first_frame_side_data(ffprobe, &path).contains("Mastering"), "{what}"); + } + } +} + +#[test] +fn the_factory_rejects_mismatched_pictures_and_clamps_hostile_floats() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + filmcraft_platform::register(); + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 here"); + return; + } + let rate = FrameRate::FPS_24; + let (w, h) = (640u32, 360u32); + let rgba = vec![128u8; (w * h * 4) as usize]; + let hdr_settings = ExportSettings { signal: ColorSignal::PQ, ..hevc(tmp("x.mp4")) }; + let sdr_settings = hevc(tmp("y.mp4")); + let Some(Ok(mut ten)) = factory(Format::Hevc, w, h, rate, &hdr_settings) else { panic!("a Main 10 encoder") }; + let Some(Ok(mut eight)) = factory(Format::Hevc, w, h, rate, &sdr_settings) else { panic!("a Main encoder") }; + let good = vec![0.5f32; (w * h * 3) as usize]; + // the HDR encoder wants the HDR picture, the SDR encoder the RGBA one + assert!(ten.encode(&frame(&rgba, None, 0)).is_err()); + assert!(eight.encode(&frame(&rgba, Some(&good), 0)).is_err()); + // wrong-length floats (short, long, empty) and a wrong picture size: errors, nothing submitted + for bad in [&good[..good.len() - 1], &[][..], &vec![0.5f32; good.len() + 3][..]] { + assert!(ten.encode(&frame(&rgba, Some(bad), 0)).is_err(), "{} floats", bad.len()); + } + assert!(ten.encode(&EncoderFrame { width: w + 2, height: h, rgba: &rgba, hdr: Some(&good), index: 0 }).is_err()); + // NaN, infinities and out-of-range values clamp: the pictures encode + let mut hostile = vec![0.25f32; good.len()]; + for (i, x) in hostile.iter_mut().enumerate() { + *x = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 7.0, 0.5][i % 6]; + } + let mut packets = 0; + for i in 0..4 { + packets += ten.encode(&frame(&rgba, Some(&hostile), i)).unwrap().len(); + } + packets += ten.flush().unwrap().len(); + assert_eq!(packets, 4); + // the sample entry of the HDR encoder carries colr + mdcv + clli, the SDR one nothing HDR + let e = ten.sample_entry(); + let v = e.video.as_ref().unwrap(); + assert!(v.color.is_some() && v.mastering_display.is_some() && v.content_light == Some((0, 0))); + assert!(eight.sample_entry().video.as_ref().unwrap().color.is_none()); + // HLG: colr only + let Some(Ok(hlg)) = factory(Format::Hevc, w, h, rate, &ExportSettings { signal: ColorSignal::HLG, ..sdr_settings.clone() }) else { + panic!("an HLG encoder") + }; + let v = hlg.sample_entry().video.unwrap(); + assert!(v.color.is_some() && v.mastering_display.is_none() && v.content_light.is_none()); + // H.264 still declines HDR (software signalling), and says why + let h264 = ExportSettings { format: Format::H264, signal: ColorSignal::PQ, ..sdr_settings }; + assert!(factory(Format::H264, w, h, rate, &h264).is_none()); +} diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/nvenc_hevc_main10.rs new file mode 100644 index 000000000..76d0c9ccc --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_main10.rs @@ -0,0 +1,324 @@ +//! NVENC H.265 Main 10 (HDR PQ / HLG) encoding (Windows, NVIDIA GPU with 10-bit HEVC encoding): the +//! pictures that go in are the 10-bit codes `rgbf_to_yuv420_10` makes of float PQ / HLG R'G'B', the +//! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more +//! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and +//! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder. +#![cfg(target_os = "windows")] + +use filmcraft_bitstream::unescape_rbsp; +use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10}; +use filmcraft_hevc::params::Sps; +use filmcraft_isobmff::SampleEntry; +use filmcraft_platform::nvenc::export::nvenc_signal; +use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, hevc_available, hevc_hdr_available, nal_type}; + +const W: usize = 1280; +const H: usize = 720; +const FRAMES: usize = 48; + +/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn config(w: u32, h: u32) -> Config { + Config { + width: w, + height: h, + fps: (24, 1), + bitrate_kbps: 12000, + max_bitrate_kbps: 18000, + cbr: false, + keyint: 24, + profile: Profile::HevcMain10, + level: None, + sar: None, + bframes: true, + } +} + +/// A float HDR test picture (encoded R'G'B', 3 per pixel): the top half is a smooth grey ramp from black to +/// peak (all of it, so PQ passes 1000 nits near 75 % of the way), a box moves across it; the bottom half +/// is bars of saturated BT.2020 colours (the primaries and secondaries at 0.75, beyond BT.709) and dark steps. +fn picture(i: usize) -> Vec { + let mut v = vec![0f32; W * H * 3]; + let bars: [[f32; 3]; 8] = + [[0.75, 0.0, 0.0], [0.0, 0.75, 0.0], [0.0, 0.0, 0.75], [0.0, 0.75, 0.75], [0.75, 0.0, 0.75], [0.75, 0.75, 0.0], [1.0, 1.0, 1.0], [0.05, 0.05, 0.05]]; + let (bx, by) = ((i * 11) % (W - 80), (i * 5) % (H / 2 - 80)); + for y in 0..H { + for x in 0..W { + let o = (y * W + x) * 3; + let px = if y < H / 2 { + if x >= bx && x < bx + 80 && y >= by && y < by + 80 { [0.9, 0.3, 0.1] } else { [x as f32 / (W - 1) as f32; 3] } + } else { + bars[x * 8 / W] + }; + v[o..o + 3].copy_from_slice(&px); + } + } + v +} + +/// The converter's 10-bit planes of a picture. +fn planes(rgb: &[f32], signal: &ColorSignal) -> (Vec, Vec, Vec) { + let (kr, kb) = signal.kr_kb(); + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + rgbf_to_yuv420_10(rgb, W, H, kr, kb, &mut y, &mut u, &mut v).expect("convert"); + (y, u, v) +} + +type Sources = Vec<(Vec, Vec, Vec)>; + +fn encode_all(cfg: &Config, signal: &ColorSignal, frames: usize) -> Option<(Nvenc, Vec, Sources)> { + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); + return None; + } + let mut enc = Nvenc::with_signal(cfg, &nvenc_signal(signal)).unwrap_or_else(|why| panic!("NVENC Main 10 is available but refused {cfg:?}: {why}")); + let (mut packets, mut sources) = (Vec::new(), Vec::new()); + for i in 0..frames { + let src = planes(&picture(i), signal); + packets.extend(enc.encode_10(&src.0, &src.1, &src.2, i as u64).unwrap()); + sources.push(src); + } + packets.extend(enc.flush().unwrap()); + Some((enc, packets, sources)) +} + +fn psnr10(a: &[u16], b: &[u16]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (1023.0f64 * 1023.0 / mse).log10() } +} + +/// The (NAL type, bytes after the 2-byte header) of every NAL unit of a length-prefixed sample. +fn sample_nals(sample: &[u8]) -> Vec<(u8, Vec)> { + let mut out = Vec::new(); + let mut pos = 0; + while let Some(len) = sample.get(pos..pos + 4) { + let n = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize; + pos += 4; + let nal = sample.get(pos..pos + n).expect("a NAL unit inside the sample"); + out.push((nal_type(Codec::Hevc, nal).expect("a NAL header"), nal[2..].to_vec())); + pos += n; + } + assert_eq!(pos, sample.len(), "samples are made of whole NAL units"); + out +} + +/// The SEI messages (type, payload) of a prefix SEI NAL unit's RBSP. +fn sei_messages(nal_body: &[u8]) -> Vec<(u32, Vec)> { + let rbsp = unescape_rbsp(nal_body); + let (mut out, mut i) = (Vec::new(), 0usize); + // the RBSP ends with the stop bit byte 0x80 (rbsp_trailing_bits) + while i < rbsp.len() && rbsp[i] != 0x80 { + let mut t = 0u32; + while rbsp[i] == 0xff { + t += 255; + i += 1; + } + t += u32::from(rbsp[i]); + i += 1; + let mut n = 0usize; + while rbsp[i] == 0xff { + n += 255; + i += 1; + } + n += usize::from(rbsp[i]); + i += 1; + out.push((t, rbsp[i..i + n].to_vec())); + i += n; + } + out +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn round_trip(name: &str, signal: ColorSignal, want_sei: bool) { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((enc, packets, sources)) = encode_all(&config(W as u32, H as u32), &signal, FRAMES) else { return }; + assert!(enc.is_ten_bit()); + assert_eq!(packets.len(), FRAMES, "{name}: one packet per picture"); + let keys: Vec = packets.iter().filter(|p| p.key).map(|p| p.pts).collect(); + assert_eq!(keys, vec![0, 24], "{name}: IDR pictures"); + + // where NVENC put the SEI: every IDR sample has prefix SEI NAL units (type 39) before its slices; nothing else does + let expected = nvenc_signal(&signal).sei; + for (k, p) in packets.iter().enumerate() { + let nals = sample_nals(&p.data); + let types: Vec = nals.iter().map(|n| n.0).collect(); + assert!(types.iter().all(|t| !(32..=37).contains(t)), "{name}: packet {k} carries parameter sets {types:?}"); + let seis: Vec<(u32, Vec)> = nals.iter().filter(|n| n.0 == 39).flat_map(|n| sei_messages(&n.1)).collect(); + if p.key { + if k < 2 { + eprintln!("{name}: IDR sample {k} NAL types in order {types:?}"); + } + let got: Vec<(u32, Vec)> = seis.clone(); + let want: Vec<(u32, Vec)> = expected.iter().map(|m| (m.payload_type, m.payload.clone())).collect(); + assert_eq!(got, want, "{name}: SEI messages of the IDR sample {k}"); + let slice_at = types.iter().position(|t| matches!(t, 19 | 20)).expect("an IDR slice"); + assert!(types.iter().take(slice_at).filter(|t| **t == 39).count() >= usize::from(want_sei), "{name}: prefix SEI before the slice"); + assert!(types.iter().all(|t| *t != 40), "{name}: no suffix SEI"); + } else { + assert!(seis.is_empty(), "{name}: packet {k} (not an IDR) carries SEI {seis:?}"); + } + } + assert_eq!(expected.is_empty(), !want_sei); + + // the record and the SPS: Main 10, 10-bit 4:2:0, BT.2020 + the transfer, limited range + let hvcc = enc.hevc_config().expect("an hvcC record").clone(); + let sps_nal = enc.parameter_sets().0.to_vec(); + eprintln!("{name}: VPS {}\n{name}: SPS {}\n{name}: PPS {}", hex(enc.vps()), hex(&sps_nal), hex(enc.parameter_sets().1)); + assert_eq!((hvcc.general_profile_idc, hvcc.chroma_format_idc, hvcc.bit_depth_luma, hvcc.bit_depth_chroma), (2, 1, 10, 10), "{name}"); + eprintln!( + "{name}: hvcC level {} compat {:#010x} constraints {:#014x}", + hvcc.general_level_idc, hvcc.general_profile_compatibility_flags, hvcc.general_constraint_indicator_flags + ); + let rbsp = unescape_rbsp(&sps_nal[2..]); + let sps = Sps::parse(&rbsp).unwrap(); + let vui = sps.vui.clone().expect("a VUI"); + assert_eq!((vui.colour_primaries, vui.transfer_characteristics, vui.matrix_coefficients, vui.full_range), (9, signal.transfer, 9, false), "{name}"); + + // our decoder: 10-bit planes + let entry = SampleEntry::hevc(hvcc, W as u16, H as u16); + let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap(); + let mut out = Vec::new(); + for p in &packets { + out.extend(dec.decode(&p.data, p.pts).unwrap()); + } + out.extend(dec.flush()); + assert_eq!(out.len(), FRAMES, "{name}"); + let (mut worst_y, mut worst_c) = (99.0f64, 99.0f64); + let (mut max_code, mut max_ramp_code) = (0u16, 0u16); + let mut distinct_min = usize::MAX; + for f in &out { + let filmcraft_frame::PixelData::Yuv16 { planes: p, bits, .. } = &f.frame.data else { panic!("{name}: 16-bit planes, got {}", f.frame.format_label()) }; + assert_eq!(*bits, 10, "{name}: decoded bit depth"); + assert_eq!((f.frame.width as usize, f.frame.height as usize), (W, H)); + assert!(p.iter().all(|pl| pl.iter().all(|c| *c < 1024)), "{name}: codes are 10-bit values, not MSB-aligned"); + let src = &sources[f.pts as usize]; + worst_y = worst_y.min(psnr10(&p[0], &src.0)); + worst_c = worst_c.min(psnr10(&p[1], &src.1)).min(psnr10(&p[2], &src.2)); + max_code = max_code.max(p[0].iter().copied().max().unwrap_or(0)); + // the ramp: one row of the top half, away from the moving box (row 340 is below it) + let row = &p[0][340 * W..341 * W]; + max_ramp_code = max_ramp_code.max(row.iter().copied().max().unwrap_or(0)); + let mut levels: Vec = row.to_vec(); + levels.sort_unstable(); + levels.dedup(); + distinct_min = distinct_min.min(levels.len()); + } + let src_max = sources.iter().map(|s| s.0[340 * W..341 * W].iter().copied().max().unwrap_or(0)).max().unwrap_or(0); + eprintln!( + "{name}: worst luma PSNR {worst_y:.1} dB, worst chroma {worst_c:.1} dB (10-bit scale), ramp row: {distinct_min} distinct decoded levels (min over frames), \ + max decoded ramp code {max_ramp_code} (source {src_max}), max luma code {max_code}, {} bytes", + packets.iter().map(|p| p.data.len()).sum::() + ); + assert!(worst_y > 60.0, "{name}: luma PSNR {worst_y:.1} dB"); + assert!(worst_c > 60.0, "{name}: chroma PSNR {worst_c:.1} dB"); + assert!(distinct_min > 700, "{name}: only {distinct_min} distinct levels on a smooth ramp: not 10-bit"); + assert!(max_ramp_code >= src_max.saturating_sub(4) && max_ramp_code > 900, "{name}: bright codes lost: {max_ramp_code} (source {src_max})"); +} + +#[test] +fn pq_decodes_to_the_10_bit_source() { + round_trip("PQ", ColorSignal::PQ, true); +} + +#[test] +fn hlg_decodes_to_the_10_bit_source_without_static_metadata() { + round_trip("HLG", ColorSignal::HLG, false); +} + +/// Run `f`, failing the test (not the process) if it panics. +fn calm(what: &str, f: impl FnOnce() -> T) -> T { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(v) => v, + Err(_) => panic!("{what}: panicked"), + } +} + +#[test] +fn main_10_and_main_encoders_refuse_each_others_pictures() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); + return; + } + let (w, h) = (640usize, 360usize); + let mut ten = Nvenc::with_signal(&config(w as u32, h as u32), &nvenc_signal(&ColorSignal::PQ)).unwrap(); + let mut eight = Nvenc::new(&Config { profile: Profile::HevcMain, ..config(w as u32, h as u32) }).unwrap(); + assert!(ten.is_ten_bit() && !eight.is_ten_bit()); + let (y8, c8) = (vec![16u8; w * h], vec![128u8; w * h / 4]); + let (y10, c10) = (vec![64u16; w * h], vec![512u16; w * h / 4]); + assert!(calm("8-bit into Main 10", || ten.encode(&y8, &c8, &c8, 0)).err().unwrap().contains("Main 10")); + assert!(calm("10-bit into Main", || eight.encode_10(&y10, &c10, &c10, 0)).err().unwrap().contains("8-bit")); + // wrong-length planes + assert!(calm("short luma", || ten.encode_10(&y10[..w * h - 1], &c10, &c10, 0)).is_err()); + assert!(calm("short chroma", || ten.encode_10(&y10, &c10[..c10.len() - 1], &c10, 0)).is_err()); + assert!(calm("empty", || ten.encode_10(&[], &[], &[], 0)).is_err()); + // nothing was submitted by the refusals: the encoders still work + ten.encode_10(&y10, &c10, &c10, 0).unwrap(); + eight.encode(&y8, &c8, &c8, 0).unwrap(); + assert!(!ten.flush().unwrap().is_empty() && !eight.flush().unwrap().is_empty()); + // H.264 takes no colour signal, no 10-bit profile + let h264 = Config { profile: Profile::Main, ..config(w as u32, h as u32) }; + assert!(Nvenc::with_signal(&h264, &nvenc_signal(&ColorSignal::PQ)).is_err()); + assert!(Nvenc::with_signal(&h264, &Signal::default()).is_ok() || !filmcraft_platform::nvenc::available()); +} + +#[test] +fn hostile_main_10_configurations_give_errors_not_crashes() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_hdr_available() { + eprintln!("SKIPPED: no NVENC HEVC Main 10 encoder"); + return; + } + let base = config(640, 360); + let sig = nvenc_signal(&ColorSignal::PQ); + for (w, h) in [(0, 0), (641, 360), (640, 361), (16, 16), (u32::MAX, u32::MAX), (100_000, 64)] { + assert!(calm("size", || Nvenc::with_signal(&config(w, h), &sig)).is_err(), "{w}x{h}"); + } + let variants: Vec<(&str, Config, Signal)> = vec![ + ("zero bitrate", Config { bitrate_kbps: 0, max_bitrate_kbps: 0, ..base.clone() }, sig.clone()), + ("huge bitrate", Config { bitrate_kbps: u32::MAX, max_bitrate_kbps: u32::MAX, ..base.clone() }, sig.clone()), + ("keyint 1", Config { keyint: 1, ..base.clone() }, sig.clone()), + ("zero keyint", Config { keyint: 0, ..base.clone() }, sig.clone()), + ("odd signal codes", base.clone(), Signal { primaries: 255, transfer: 255, matrix: 255, sei: vec![] }), + ( + "a huge SEI payload", + base.clone(), + Signal { sei: vec![filmcraft_platform::nvenc::Sei { payload_type: 137, payload: vec![0xAB; 100_000] }], ..sig.clone() }, + ), + ("an empty SEI payload", base.clone(), Signal { sei: vec![filmcraft_platform::nvenc::Sei { payload_type: 144, payload: vec![] }], ..sig.clone() }), + ]; + for (name, c, s) in variants { + match calm(name, || Nvenc::with_signal(&c, &s)) { + Ok(mut enc) => { + let (y, u) = (vec![300u16; 640 * 360], vec![512u16; 640 * 360 / 4]); + for i in 0..3u64 { + let _ = calm(name, || enc.encode_10(&y, &u, &u, i)); + } + let _ = calm(name, || enc.flush()); + } + Err(why) => eprintln!("{name}: declined ({why})"), + } + } + // dropped mid-stream with SEI buffers in flight, repeatedly + for round in 0..8 { + let mut enc = Nvenc::with_signal(&base, &sig).unwrap_or_else(|e| panic!("round {round}: {e}")); + let (y, u) = (vec![300u16; 640 * 360], vec![512u16; 640 * 360 / 4]); + for i in 0..5 { + enc.encode_10(&y, &u, &u, i).unwrap(); + } + } +} + +#[test] +fn the_probe_follows_the_encoder() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let hdr = hevc_hdr_available(); + assert!(!hdr || hevc_available(), "Main 10 implies HEVC"); + assert_eq!(hevc_hdr_available(), hdr, "the answer is kept"); + let made = Nvenc::with_signal(&config(640, 360), &Signal::default()); + assert_eq!(made.is_ok(), hdr, "{:?}", made.err()); +} diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/nvenc_hevc_probe.rs new file mode 100644 index 000000000..e2a41cd77 --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_probe.rs @@ -0,0 +1,29 @@ +//! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own +//! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC +//! session and encoder initialisation all included). +#![cfg(target_os = "windows")] + +use std::time::{Duration, Instant}; + +use filmcraft_export::Format; +use filmcraft_platform::nvenc::hevc_available; + +#[test] +fn the_probe_runs_once_and_registers_the_format() { + let t0 = Instant::now(); + let first = hevc_available(); + let cold = t0.elapsed(); + let t1 = Instant::now(); + let again = hevc_available(); + let warm = t1.elapsed(); + eprintln!("first HEVC probe in a fresh process: {cold:?} (available: {first}); the cached answer: {warm:?}"); + assert_eq!(first, again); + assert!(warm < Duration::from_millis(50), "the answer is cached: {warm:?}"); + + // `register` makes `available(Hevc)` follow the probe; registering again changes nothing + assert!(!filmcraft_export::available(Format::Hevc) || first); + filmcraft_platform::register(); + filmcraft_platform::register(); + assert_eq!(filmcraft_export::available(Format::Hevc), first); + assert!(filmcraft_export::available(Format::H264)); +} diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs new file mode 100644 index 000000000..adc0ebb2b --- /dev/null +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -0,0 +1,32 @@ +//! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format +//! list does not open an encoder session on the UI thread. Its own test binary: anything else asking +//! `hevc_available` first would make the wait meaningless. +#![cfg(target_os = "windows")] + +use std::time::{Duration, Instant}; + +use filmcraft_platform::nvenc::{hevc_available, hevc_hdr_available, hevc_hdr_probed, hevc_probed}; + +#[test] +fn register_answers_the_hevc_question_in_the_background() { + assert!(!hevc_probed() && !hevc_hdr_probed(), "nothing has asked yet"); + filmcraft_platform::register(); + let t0 = Instant::now(); + while !hevc_probed() { + assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the probe"); + std::thread::sleep(Duration::from_millis(10)); + } + // the Main 10 probe runs on the same thread, right after + while !hevc_hdr_probed() { + assert!(t0.elapsed() < Duration::from_secs(60), "register() did not start the Main 10 probe"); + std::thread::sleep(Duration::from_millis(10)); + } + eprintln!("answered in the background after {:?}", t0.elapsed()); + let hdr = hevc_hdr_available(); + assert!(!hdr || hevc_available(), "Main 10 implies HEVC"); + assert_eq!(filmcraft_export::hdr_available(filmcraft_export::Format::Hevc), hdr); + // the answer is kept: asking and registering again change nothing + let answer = hevc_available(); + filmcraft_platform::register(); + assert_eq!(hevc_available(), answer); +} diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs new file mode 100644 index 000000000..4009f3e68 --- /dev/null +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -0,0 +1,157 @@ +//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main +//! and H.264. Skips without an NVIDIA GPU / driver. +#![cfg(target_os = "windows")] + +use filmcraft_isobmff::{AvcConfig, SampleEntry}; +use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; + +/// NVENC sessions are a limited resource on consumer GPUs: one test at a time. +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A moving test picture: gradient background, a moving box, a little texture. +fn moving(w: usize, h: usize, i: usize) -> Vec { + let mut v = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let o = (y * w + x) * 4; + let (bx, by) = ((i * 7) % (w - 64), (i * 3) % (h - 64)); + let in_box = x >= bx && x < bx + 64 && y >= by && y < by + 64; + let tex = ((x * 31 + y * 17 + i * 5) % 23) as u8; + v[o] = if in_box { 230 } else { (x * 200 / w) as u8 + tex }; + v[o + 1] = if in_box { 40 } else { (y * 200 / h) as u8 + tex }; + v[o + 2] = if in_box { 60 } else { 90 + tex }; + v[o + 3] = 255; + } + } + v +} + +fn solid(w: usize, h: usize, rgb: [u8; 3]) -> Vec { + [rgb[0], rgb[1], rgb[2], 255].repeat(w * h) +} + +fn config(w: u32, h: u32, profile: Profile) -> Config { + Config { + width: w, + height: h, + fps: (30, 1), + bitrate_kbps: 8000, + max_bitrate_kbps: 12000, + cbr: false, + keyint: 30, + profile, + level: None, + sar: None, + bframes: true, + } +} + +fn psnr(a: &[u8], b: &[u8]) -> f64 { + let mse = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2)).sum::() / a.len() as f64; + if mse == 0.0 { 99.0 } else { 10.0 * (255.0f64 * 255.0 / mse).log10() } +} + +fn mean(p: &[u8]) -> f64 { + p.iter().map(|v| f64::from(*v)).sum::() / p.len() as f64 +} + +/// Encode `frames` through the RGBA input, decode with our own decoder: the decoded planes by picture. +fn round_trip(profile: Profile, w: usize, h: usize, frames: &[Vec]) -> Vec<[Vec; 3]> { + let mut enc = + Nvenc::with_rgba_input(&config(w as u32, h as u32, profile), &Signal::default()).unwrap_or_else(|e| panic!("{profile:?} with RGBA input: {e}")); + assert!(enc.takes_rgba()); + assert!(enc.encode(&[0; 4], &[0; 1], &[0; 1], 0).is_err(), "planar pictures are refused"); + let mut packets: Vec = Vec::new(); + for (i, f) in frames.iter().enumerate() { + packets.extend(enc.encode_rgba(f, i as u64).unwrap()); + } + packets.extend(enc.flush().unwrap()); + assert_eq!(packets.len(), frames.len(), "one packet per picture"); + let entry = match enc.codec() { + Codec::Hevc => SampleEntry::hevc(enc.hevc_config().expect("an hvcC record").clone(), w as u16, h as u16), + Codec::H264 => { + let (sps, pps) = enc.parameter_sets(); + SampleEntry::avc(AvcConfig::new(vec![sps.to_vec()], vec![pps.to_vec()], 4), w as u16, h as u16) + } + }; + let mut dec = filmcraft_codecs::software_video_decoder(&entry).unwrap(); + let mut out = Vec::new(); + for p in &packets { + out.extend(dec.decode(&p.data, p.pts).unwrap()); + } + out.extend(dec.flush()); + out.sort_by_key(|f| f.pts); + assert_eq!(out.len(), frames.len()); + out.into_iter() + .map(|f| { + let filmcraft_frame::PixelData::Yuv8 { planes, .. } = &f.frame.data else { panic!("8-bit planes") }; + [planes[0].to_vec(), planes[1].to_vec(), planes[2].to_vec()] + }) + .collect() +} + +fn check(profile: Profile) { + let (w, h) = (1280usize, 720usize); + // solid colours: the decoded means are the BT.709 limited-range codes of the export's own conversion + let colours: [[u8; 3]; 6] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 0], [128, 128, 128]]; + let mut frames: Vec> = colours.iter().map(|c| solid(w, h, *c)).collect(); + frames.extend((0..24).map(|i| moving(w, h, i))); + let decoded = round_trip(profile, w, h, &frames); + let (mut y, mut u, mut v) = (Vec::new(), Vec::new(), Vec::new()); + for (k, c) in colours.iter().enumerate() { + filmcraft_export::rgba_to_yuv420_8(&frames[k], w, h, &mut y, &mut u, &mut v); + let want = [f64::from(y[0]), f64::from(u[0]), f64::from(v[0])]; + let got = [mean(&decoded[k][0]), mean(&decoded[k][1]), mean(&decoded[k][2])]; + eprintln!("{profile:?} {c:?}: Y/Cb/Cr {got:.1?}, BT.709 limited {want:?}"); + for (g, e) in got.iter().zip(want) { + assert!((g - e).abs() <= 2.0, "{profile:?} {c:?}: decoded {got:.1?}, expected {want:?}"); + } + } + // a moving picture: close to the export's own conversion encoded the planar way + let (mut worst, mut worst_chroma) = (99.0f64, 99.0f64); + for (k, f) in frames.iter().enumerate().skip(colours.len()) { + filmcraft_export::rgba_to_yuv420_8(f, w, h, &mut y, &mut u, &mut v); + worst = worst.min(psnr(&decoded[k][0], &y)); + worst_chroma = worst_chroma.min(psnr(&decoded[k][1], &u)).min(psnr(&decoded[k][2], &v)); + } + eprintln!("{profile:?}: worst luma PSNR {worst:.1} dB, chroma {worst_chroma:.1} dB"); + assert!(worst > 40.0, "{profile:?} luma PSNR {worst:.1} dB"); + assert!(worst_chroma > 40.0, "{profile:?} chroma PSNR {worst_chroma:.1} dB"); +} + +#[test] +fn hevc_rgba_input_converts_like_the_export() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC encoder"); + return; + } + check(Profile::HevcMain); +} + +#[test] +fn h264_rgba_input_converts_like_the_export() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !available() { + eprintln!("SKIPPED: no NVENC"); + return; + } + check(Profile::High); +} + +#[test] +fn main10_has_no_rgba_input_and_short_pictures_are_errors() { + let _one = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !hevc_available() { + eprintln!("SKIPPED: no NVENC HEVC encoder"); + return; + } + assert!(Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain10), &Signal::default()).is_err()); + let mut enc = Nvenc::with_rgba_input(&config(640, 360, Profile::HevcMain), &Signal::default()).unwrap(); + assert!(enc.encode_rgba(&[], 0).is_err()); + assert!(enc.encode_rgba(&vec![0; 640 * 360 * 4 - 1], 0).is_err()); + let mut planar = Nvenc::new(&config(640, 360, Profile::HevcMain)).unwrap(); + assert!(!planar.takes_rgba()); + assert!(planar.encode_rgba(&solid(640, 360, [1, 2, 3]), 0).is_err(), "RGBA pictures for a planar encoder"); +} diff --git a/crates/platform/tests/setting.rs b/crates/platform/tests/setting.rs index c177e55d7..cae45b19a 100644 --- a/crates/platform/tests/setting.rs +++ b/crates/platform/tests/setting.rs @@ -36,8 +36,14 @@ fn off_uses_the_software_decoder() { assert_eq!(d.name(), "FilmCraft HEVC"); return; } - let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; - assert_eq!(d.name(), hw_name); + let backend = if cfg!(target_os = "linux") { + assert!(d.name().starts_with("VA-API HEVC ("), "Linux Auto decoder: {}", d.name()); + "VA-API" + } else { + let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") }; + assert_eq!(d.name(), hw_name); + backend + }; assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend"); assert!(filmcraft_platform::registered(), "register() put the factory in the registry"); let h1 = filmcraft_codecs::hw::hw_stats(); diff --git a/crates/platform/tests/vaapi.rs b/crates/platform/tests/vaapi.rs new file mode 100644 index 000000000..507e7768c --- /dev/null +++ b/crates/platform/tests/vaapi.rs @@ -0,0 +1,491 @@ +//! VA-API decoding against our software decoders (Linux): every picture of H.264 and HEVC fixtures +//! covering the stream features the hardware path takes (H.264: CAVLC and CABAC, B-pyramids, +//! several references, weighted prediction, temporal direct, custom scaling matrices, several +//! slices per picture; HEVC Main and Main 10: open GOP with RASL pictures, scaling lists, several +//! slices with wavefront parallel processing, weighted prediction, transform skip and AMP; 1080p / +//! 2160p) must be identical (bit-exact planes, colour, pixel aspect, pts and presentation +//! order), also after `reset` + reseek and a mid-stream `flush`; a forced mid-stream failure must +//! continue in software with the software decoder's exact output; what the hardware path does not +//! take (interlaced and 10-bit H.264, 4:2:2 HEVC) is declined; damaged samples and parameter sets give +//! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without a +//! VA-API driver. +#![cfg(target_os = "linux")] + +mod common; + +use std::time::Duration; + +use common::*; +use filmcraft_codecs::VideoDecoder; +use filmcraft_codecs::hw::NalStreamInfo; +use filmcraft_platform::HybridDecoder; +use filmcraft_platform::vaapi::VaDecoder; + +/// The hardware decoder for a stream, or `None` (skip) when this machine has none for it. +fn hardware(s: &Stream) -> Option> { + match filmcraft_platform::vaapi_factory(&s.entry) { + Some(Ok(d)) => { + assert!(d.name().starts_with("VA-API"), "{}", d.name()); + Some(d) + } + Some(Err(e)) => panic!("factory error: {e}"), + None => { + eprintln!("SKIPPED: no VA-API hardware decoder for {}", s.entry.codec.name()); + None + } + } +} + +fn software(s: &Stream) -> Box { + filmcraft_codecs::software_video_decoder(&s.entry).unwrap() +} + +/// H.264 fixtures for the stream features the hardware path takes: (file, x264 profile, x264 +/// parameters). All 640x360 (coded 368: cropping), three seconds at 24 fps, keyframes every second. +const H264: &[(&str, &str, &str)] = &[ + ("va_h264_cbp_slices.mp4", "baseline", "keyint=24:min-keyint=24:scenecut=0:slices=3:ref=3"), + ("va_h264_main_weighted.mp4", "main", "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:ref=4:weightp=2:weightb=1"), + ("va_h264_temporal_direct.mp4", "high", "bframes=2:b-pyramid=strict:direct=temporal:keyint=24:min-keyint=24:scenecut=0:ref=3:weightb=0"), + ("va_h264_cqm_jvt.mp4", "high", "bframes=3:keyint=24:min-keyint=24:scenecut=0:cqm=jvt:8x8dct=1:slices=2"), + ("va_h264_open_gop.mp4", "high", "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:open-gop=1"), +]; + +fn h264_fixture(ff: &std::path::Path, name: &str, profile: &str, params: &str) -> Option { + let src = "testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t"; + fixture(ff, name, &["-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", profile, "-preset", "fast", "-x264-params", params, "-pix_fmt", "yuv420p"]) +} + +/// HEVC fixtures for the stream features the hardware path takes: (file, pixel format, x265 +/// parameters). 640x360, three seconds at 24 fps, keyframes every second. +const HEVC: &[(&str, &str, &str)] = &[ + ("va_hevc_scaling_lists.mp4", "yuv420p", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:scaling-list=default"), + ("va_hevc_slices_wpp.mp4", "yuv420p10le", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:slices=3:wpp=1"), + ("va_hevc_weighted.mp4", "yuv420p", "log-level=error:bframes=3:keyint=24:min-keyint=24:scenecut=0:weightp=1:weightb=1:ref=4"), + ("va_hevc_tskip_amp.mp4", "yuv420p10le", "log-level=error:bframes=4:b-pyramid=1:keyint=24:min-keyint=24:scenecut=0:tskip=1:amp=1:rect=1:open-gop=1"), +]; + +fn hevc_fixture(ff: &std::path::Path, name: &str, pix_fmt: &str, params: &str) -> Option { + let src = "testsrc2=s=640x360:r=24:d=3,noise=alls=12:allf=t"; + let profile = if pix_fmt.contains("10") { "main10" } else { "main" }; + fixture( + ff, + name, + &["-f", "lavfi", "-i", src, "-c:v", "libx265", "-preset", "fast", "-profile:v", profile, "-tag:v", "hvc1", "-x265-params", params, "-pix_fmt", pix_fmt], + ) +} + +/// The small fixtures: the shared H.264 High, HEVC Main (open GOP) and Main 10 ones and the +/// feature fixtures above. +fn small_streams(ff: &std::path::Path) -> Vec<(String, Stream)> { + let mut out = Vec::new(); + for name in ["h264_high.mp4", "hevc_main.mp4", "hevc_main10.mp4"] { + if let Some(path) = named(ff, name) { + out.push((name.to_string(), read_stream(&path))); + } + } + for (name, profile, params) in H264 { + if let Some(path) = h264_fixture(ff, name, profile, params) { + out.push((name.to_string(), read_stream(&path))); + } + } + for (name, pix_fmt, params) in HEVC { + if let Some(path) = hevc_fixture(ff, name, pix_fmt, params) { + out.push((name.to_string(), read_stream(&path))); + } + } + out +} + +/// Every picture of `s` from the hardware decoder equals the software decoder's: whole stream, +/// after `reset` + reseek to each later sync sample, after a mid-stream `flush`, and a full pass +/// after all the resets. +fn parity(name: &str, s: &Stream) { + let Some(mut hw) = hardware(s) else { return }; + let mut sw = software(s); + let a = decode_all(hw.as_mut(), &s.samples); + let b = decode_all(sw.as_mut(), &s.samples); + assert!(a.len() >= 40, "{name}: {} pictures", a.len()); + assert_same(name, &a, &b); + assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name()); + + // reset + reseek to each later sync sample, decode a stretch. After a seek to a non-IDR I picture + // (open GOP), the pictures shown before it are predicted from frames that were never decoded: + // both decoders conceal them (the software decoder from mid-gray stand-ins, the hardware also + // from motion data those stand-ins do not have), so only the pictures from the seek point on + // must match, as they must. + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + let syncs: Vec = (1..s.samples.len()).filter(|&i| s.sync[i]).collect(); + assert!(!syncs.is_empty(), "{name}: more than one GOP"); + for &k in syncs.iter().rev() { + let end = (k + 17).min(s.samples.len()); + for d in [&mut hw, &mut sw] { + d.reset(); + } + let mut a = decode_all(hw.as_mut(), &s.samples[k..end]); + let mut b = decode_all(sw.as_mut(), &s.samples[k..end]); + assert_eq!(a.len(), b.len(), "{name} from sample {k}: pictures"); + if info.codec == filmcraft_codecs::hw::NalCodec::H264 && !info.nal_types(&s.samples[k].0).contains(&5) { + let start = s.samples[k].1; + a.retain(|f| f.pts >= start); + b.retain(|f| f.pts >= start); + } + assert!(!a.is_empty(), "{name}: pictures after seeking to {k}"); + assert_same(&format!("{name} from sample {k}"), &a, &b); + } + // a flush in the middle of a GOP, then carrying on: H.264 only, where references survive a + // flush (an HEVC flush ends the stream; the GOP cache always seeks after one) + if info.codec == filmcraft_codecs::hw::NalCodec::H264 { + for d in [&mut hw, &mut sw] { + d.reset(); + } + let mid = s.samples.len() / 2; + let mut a = Vec::new(); + let mut b = Vec::new(); + for (d, out) in [(&mut hw, &mut a), (&mut sw, &mut b)] { + for (smp, pts) in &s.samples[..mid] { + out.extend(d.decode(smp, *pts).unwrap()); + } + out.extend(d.flush()); + out.extend(decode_all(d.as_mut(), &s.samples[mid..])); + } + assert_same(&format!("{name} with a flush at {mid}"), &a, &b); + } + // back to the start after seeking around: the whole stream again + hw.reset(); + sw.reset(); + assert_same(&format!("{name} after resets"), &decode_all(hw.as_mut(), &s.samples), &decode_all(sw.as_mut(), &s.samples)); + assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name()); + // random-access / disposable answers match the software decoder's + for (smp, _) in &s.samples { + assert_eq!(hw.is_random_access(smp), sw.is_random_access(smp), "{name}"); + assert_eq!(hw.is_disposable(smp), sw.is_disposable(smp), "{name}"); + } +} + +/// [`parity`] for large streams, with little memory: the two decoders run side by side and each +/// picture is compared and dropped as soon as both have output it (a 4K 10-bit stream held whole +/// takes gigabytes). The whole stream, then from the last sync sample after a reset. +fn parity_streaming(name: &str, s: &Stream) { + let Some(mut hw) = hardware(s) else { return }; + let mut sw = software(s); + let last_sync = (1..s.samples.len()).rev().find(|&i| s.sync[i]).unwrap_or(0); + for start in [0, last_sync] { + hw.reset(); + sw.reset(); + let (mut a, mut b) = (std::collections::VecDeque::new(), std::collections::VecDeque::new()); + let mut compared = 0usize; + let mut compare = |a: &mut std::collections::VecDeque, + b: &mut std::collections::VecDeque| { + while !a.is_empty() && !b.is_empty() { + let (Some(x), Some(y)) = (a.pop_front(), b.pop_front()) else { break }; + assert_same(&format!("{name} from sample {start}"), &[x], &[y]); + compared += 1; + } + }; + for (smp, pts) in &s.samples[start..] { + a.extend(hw.decode(smp, *pts).unwrap()); + b.extend(sw.decode(smp, *pts).unwrap()); + compare(&mut a, &mut b); + } + a.extend(hw.flush()); + b.extend(sw.flush()); + compare(&mut a, &mut b); + assert!(a.is_empty() && b.is_empty(), "{name} from sample {start}: picture counts differ"); + assert!(compared >= s.samples.len().saturating_sub(start) / 2, "{name} from sample {start}: {compared} pictures"); + assert!(hw.name().starts_with("VA-API"), "{name}: stayed in hardware ({})", hw.name()); + } +} + +#[test] +fn bit_exact_with_the_software_decoder() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let streams = small_streams(&ff); + assert!(streams.len() > H264.len(), "fixtures"); + for (name, s) in streams { + parity(&name, &s); + } +} + +fn large(size: &str) { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (_, h) = size.split_once('x').unwrap(); + let src = format!("testsrc2=s={size}:r=24:d=2,noise=alls=12:allf=t"); + let args = [ + "-f", + "lavfi", + "-i", + &src, + "-c:v", + "libx264", + "-profile:v", + "high", + "-preset", + "veryfast", + "-x264-params", + "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0", + "-pix_fmt", + "yuv420p", + ]; + let Some(path) = fixture(&ff, &format!("h264_{h}.mp4"), &args) else { return }; + parity_streaming(&format!("h264 {size}"), &read_stream(&path)); +} + +#[test] +fn h264_1080p_and_2160p_are_bit_exact() { + large("1920x1080"); + large("3840x2160"); +} + +fn large_hevc(size: &str, pix_fmt: &str) { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (_, h) = size.split_once('x').unwrap(); + let src = format!("testsrc2=s={size}:r=24:d=2,noise=alls=12:allf=t"); + let ten = pix_fmt.contains("10"); + let args = [ + "-f", + "lavfi", + "-i", + &src, + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-profile:v", + if ten { "main10" } else { "main" }, + "-tag:v", + "hvc1", + "-x265-params", + "log-level=error:bframes=4:keyint=24:min-keyint=24:scenecut=0:open-gop=1", + "-pix_fmt", + pix_fmt, + ]; + let name = format!("va_hevc_{}_{h}.mp4", if ten { "main10" } else { "main" }); + let Some(path) = fixture(&ff, &name, &args) else { return }; + parity_streaming(&name, &read_stream(&path)); +} + +#[test] +fn hevc_main_and_main10_1080p_and_2160p_are_bit_exact() { + for size in ["1920x1080", "3840x2160"] { + large_hevc(size, "yuv420p"); + large_hevc(size, "yuv420p10le"); + } +} + +#[test] +fn mid_stream_failure_continues_in_software() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, s) in small_streams(&ff) { + let name = name.as_str(); + let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap(); + let Ok(_) = VaDecoder::new(info.clone()) else { + eprintln!("SKIPPED: no VA-API hardware decoder for {name}"); + continue; + }; + let reference = decode_all(software(&s).as_mut(), &s.samples); + assert!(!reference.is_empty(), "{name}"); + // fail at the first sample, inside the first GOP, at a sync sample and just after one + let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap(); + for fail_at in [0, 5, k, k + 1, k + 9] { + let mut va = VaDecoder::new(info.clone()).unwrap(); + va.fail_after(fail_at as u64); + let mut d = HybridDecoder::new(Box::new(va), s.entry.clone(), info.clone()); + let before = filmcraft_codecs::hw::hw_stats().fallbacks; + let out = decode_all(&mut d, &s.samples); + assert!(!d.is_hardware(), "{name}: switched to software"); + assert!(filmcraft_codecs::hw::hw_stats().fallbacks > before, "{name}: fallback counted"); + assert_same(&format!("{name} failing at sample {fail_at}"), &out, &reference); + d.reset(); + assert_same(&format!("{name} after the fallback and a reset"), &decode_all(&mut d, &s.samples), &reference); + } + } +} + +/// What the hardware path does not take is declined (the software decoders keep it): interlaced +/// and 10-bit H.264, and HEVC (not decoded through VA-API yet). +#[test] +fn declines_what_the_hardware_path_does_not_take() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let src = "testsrc2=s=640x360:r=24:d=1"; + let cases: [(&str, Vec<&str>); 2] = [ + ("va_h264_interlaced.mp4", vec!["-f", "lavfi", "-i", src, "-c:v", "libx264", "-x264-params", "interlaced=1:keyint=12", "-pix_fmt", "yuv420p"]), + ("va_h264_10bit.mp4", vec!["-f", "lavfi", "-i", src, "-c:v", "libx264", "-profile:v", "high10", "-pix_fmt", "yuv420p10le"]), + ]; + for (name, args) in cases { + // an ffmpeg whose x264 lacks 10-bit leaves the fixture out + let Some(path) = fixture(&ff, name, &args) else { continue }; + let s = read_stream(&path); + assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "{name}: declined"); + } + let args = ["-f", "lavfi", "-i", src, "-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:keyint=12", "-pix_fmt", "yuv422p10le"]; + if let Some(path) = fixture(&ff, "va_hevc_422.mp4", &args) { + let s = read_stream(&path); + assert!(filmcraft_platform::vaapi_factory(&s.entry).is_none(), "HEVC 4:2:2: declined"); + } + // Settings ▸ Playback ▸ Hardware decoding Off + let Some(path) = named(&ff, "h264_high.mp4") else { return }; + let s = read_stream(&path); + filmcraft_codecs::hw::set_hardware_decoding(false); + let off = filmcraft_platform::vaapi_factory(&s.entry).is_none(); + filmcraft_codecs::hw::set_hardware_decoding(true); + assert!(off, "declined while hardware decoding is Off"); +} + +/// Run `f` on a thread; fail if it panics or takes longer than `limit`. +fn bounded(what: &str, limit: Duration, f: impl FnOnce() + Send + 'static) { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + let _ = tx.send(r.is_ok()); + }); + match rx.recv_timeout(limit) { + Ok(true) => {} + Ok(false) => panic!("{what}: panicked"), + Err(_) => panic!("{what}: hung"), + } +} + +#[test] +fn damaged_samples_and_parameter_sets_never_crash_or_hang() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, s) in small_streams(&ff) { + let name = name.as_str(); + if hardware(&s).is_none() { + continue; + } + // damaged samples (bit flips, truncation, corrupt length prefixes): through the hybrid, and + // straight into the hardware decoder (no software to fall back to) + for seed in 1..=8u64 { + let samples = s.samples.clone(); + let entry = s.entry.clone(); + bounded(&format!("{name} seed {seed}"), Duration::from_secs(60), move || { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut d = filmcraft_platform::vaapi_factory(&entry).unwrap().unwrap(); + let info = NalStreamInfo::from_entry(&entry).unwrap().unwrap(); + let mut raw = VaDecoder::new(info).unwrap(); + for (i, (smp, pts)) in samples.iter().enumerate().take(40) { + let mut m = smp.clone(); + match xorshift(&mut seed) % 4 { + 0 => { + for _ in 0..1 + xorshift(&mut seed) % 8 { + let at = (xorshift(&mut seed) as usize) % m.len().max(1); + if let Some(b) = m.get_mut(at) { + *b ^= 1 << (xorshift(&mut seed) % 8); + } + } + } + 1 => m.truncate((xorshift(&mut seed) as usize) % m.len().max(1)), + 2 if m.len() > 4 => m[..4].copy_from_slice(&(xorshift(&mut seed) as u32).to_be_bytes()), + _ => {} + } + let _ = d.decode(&m, *pts); + let _ = raw.decode(&m, *pts); + if i % 13 == 12 { + d.reset(); + raw.reset(); + } + } + let _ = d.flush(); + let _ = raw.flush(); + }); + } + // damaged parameter sets: the decoder declines, errors or decodes; never crashes + let filmcraft_isobmff::CodecConfig::Avc(a) = &s.entry.codec else { continue }; + let rec = a.to_bytes(); + let mut seed = 0x1234_5678u64; + for round in 0..24 { + let mut r = rec.clone(); + if round % 3 == 2 { + r.truncate(r.len() * (round + 1) / 30); + } else { + for _ in 0..1 + round % 4 { + let at = 6 + (xorshift(&mut seed) as usize) % r.len().saturating_sub(6).max(1); + if let Some(b) = r.get_mut(at) { + *b ^= 1 << (xorshift(&mut seed) % 8); + } + } + } + let samples: Vec<_> = s.samples.iter().take(12).cloned().collect(); + bounded(&format!("{name} record round {round}"), Duration::from_secs(60), move || { + let Ok(info) = NalStreamInfo::from_avcc(&r) else { return }; + let Ok(mut va) = VaDecoder::new(info) else { return }; + for (smp, pts) in &samples { + if va.decode(smp, *pts).is_err() { + break; + } + } + let _ = va.flush(); + }); + } + } +} + +/// Creating and dropping decoders again and again (every clip on a timeline makes one) neither +/// fails nor leaks VA-API objects into the next one. +#[test] +fn repeated_initialisation() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let Some(path) = named(&ff, "h264_high.mp4") else { return }; + let s = read_stream(&path); + let reference = decode_all(software(&s).as_mut(), &s.samples[..13]); + for round in 0..40 { + let Some(mut hw) = hardware(&s) else { return }; + if round % 8 == 0 { + assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..13]), &reference); + } + } +} + +/// Several decoders at once on different threads (the frame workers) give the software decoder's +/// pictures. +#[test] +fn concurrent_decoders() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut paths: Vec<_> = named(&ff, "h264_high.mp4").into_iter().collect(); + for (name, profile, params) in H264.iter().take(3) { + paths.extend(h264_fixture(&ff, name, profile, params)); + } + let jobs: Vec<_> = paths + .into_iter() + .chain(named(&ff, "h264_high.mp4")) + .map(|path| { + std::thread::spawn(move || { + let s = read_stream(&path); + let Some(mut hw) = hardware(&s) else { return }; + let n = s.samples.len().min(40); + let a = decode_all(hw.as_mut(), &s.samples[..n]); + hw.reset(); + let b = decode_all(hw.as_mut(), &s.samples[..n]); + let c = decode_all(software(&s).as_mut(), &s.samples[..n]); + assert_same("concurrent", &a, &c); + assert_same("concurrent, after a reset", &b, &c); + }) + }) + .collect(); + for j in jobs { + j.join().unwrap(); + } +} + +/// A decoder used and moved between threads (decoders migrate between frame workers). +#[test] +fn decoder_moves_between_threads() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let Some(path) = named(&ff, "h264_high.mp4") else { return }; + let s = read_stream(&path); + let Some(mut hw) = hardware(&s) else { return }; + let reference = decode_all(software(&s).as_mut(), &s.samples[..30]); + let samples = s.samples.clone(); + let (hw, out) = std::thread::spawn(move || { + let out = decode_all(hw.as_mut(), &samples[..30]); + (hw, out) + }) + .join() + .unwrap(); + assert_same("first thread", &out, &reference); + let mut hw = hw; + hw.reset(); + let samples = s.samples.clone(); + let out = std::thread::spawn(move || decode_all(hw.as_mut(), &samples[..30])).join().unwrap(); + assert_same("second thread", &out, &reference); +} diff --git a/crates/platform/tests/vaapi_displays.rs b/crates/platform/tests/vaapi_displays.rs new file mode 100644 index 000000000..8ec45334a --- /dev/null +++ b/crates/platform/tests/vaapi_displays.rs @@ -0,0 +1,68 @@ +//! VA-API decoders share one display (Linux). Opening a display takes 0.2-0.3 s with NVIDIA's +//! driver (about 100 MB of video memory each) and terminating one 60-70 ms, on the thread that +//! creates or drops the decoder (a frame worker at every clip, the UI thread when media is +//! removed), so the process opens one and every decoder makes only its own context and surfaces on +//! it, also several decoders at once on different threads. Its own test binary: the count of +//! opened displays is process-wide, and other tests running at the same time would change it. +//! Skips without ffmpeg (fixture generator) or without a VA-API driver. +#![cfg(target_os = "linux")] + +mod common; + +use common::*; +use filmcraft_codecs::VideoDecoder; +use filmcraft_platform::vaapi::va::displays_opened; + +fn hardware(s: &Stream) -> Option> { + match filmcraft_platform::vaapi_factory(&s.entry) { + Some(Ok(d)) if d.name().starts_with("VA-API") => Some(d), + _ => { + eprintln!("SKIPPED: no VA-API decoder for this stream on this machine"); + None + } + } +} + +#[test] +fn decoders_share_one_display() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let paths: Vec<_> = FIXTURES.iter().filter_map(|(name, _)| named(&ff, name)).collect(); + let streams: Vec<_> = paths.iter().map(|p| read_stream(p)).collect(); + let Some(first) = streams.first() else { return }; + let Some(hw) = hardware(first) else { return }; + drop(hw); + let opened = displays_opened(); + assert_eq!(opened, 1, "the first decoder opened one display"); + + // one decoder after another, of every fixture (H.264, HEVC Main, HEVC Main 10) + for round in 0..10 { + for s in &streams { + let Some(mut hw) = hardware(s) else { return }; + let n = s.samples.len().min(13); + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples[..n]); + assert_same(&format!("round {round}"), &decode_all(hw.as_mut(), &s.samples[..n]), &reference); + } + } + assert_eq!(displays_opened(), opened, "decoders one after another open no display"); + + // several decoders at once on different threads, each used from its own thread, repeatedly + for round in 0..3 { + let jobs: Vec<_> = (0..6) + .map(|i| { + let path = paths[i % paths.len()].clone(); + std::thread::spawn(move || { + let s = read_stream(&path); + let Some(mut hw) = hardware(&s) else { return }; + let reference = decode_all(filmcraft_codecs::software_video_decoder(&s.entry).unwrap().as_mut(), &s.samples); + assert_same(&format!("round {round}, thread {i}"), &decode_all(hw.as_mut(), &s.samples), &reference); + hw.reset(); + assert_same(&format!("round {round}, thread {i}, after a reset"), &decode_all(hw.as_mut(), &s.samples), &reference); + }) + }) + .collect(); + for j in jobs { + j.join().unwrap(); + } + } + assert_eq!(displays_opened(), opened, "decoders at once on several threads open no display"); +} diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index e23132e74..cd7bf31be 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -783,6 +783,7 @@ mod tests { hold_filters: false, field_options: None, source_channels: Vec::new(), + audio_stream: 0, graphic: None, } } diff --git a/crates/project/src/find.rs b/crates/project/src/find.rs index e9584685d..a25d3d189 100644 --- a/crates/project/src/find.rs +++ b/crates/project/src/find.rs @@ -133,7 +133,7 @@ pub fn column_text(p: &Project, it: &ProjectItem, column: &str) -> String { .and_then(|m| m.info.video.as_ref()) .map(|v| format!("{} x {} ({:.4}) {}", v.width, v.height, v.par.0 as f64 / v.par.1.max(1) as f64, v.codec)) .unwrap_or_default(), - "audio info" => media.and_then(|m| m.info.audio.as_ref()).map(|a| format!("{} Hz - {} ch {}", a.sample_rate, a.channels, a.codec)).unwrap_or_default(), + "audio info" => media.and_then(|m| m.info.audio()).map(|a| format!("{} Hz - {} ch {}", a.sample_rate, a.channels, a.codec)).unwrap_or_default(), "file path" | "path" => match media.map(|m| &m.media) { Some(MediaRef::File { path }) => path.clone(), _ => String::new(), diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index 5908d423e..978917e32 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -24,6 +24,7 @@ pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"]; pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"]; pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"]; pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"]; +pub const FILL_KIND_OPTS: &[&str] = &["Solid", "Linear Gradient"]; /// Whether an effect instance is a graphic layer. pub fn is_layer_id(id: &str) -> bool { @@ -58,6 +59,10 @@ fn appearance() -> Vec { vec![ bo("fill", "Fill", true, A), co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A), + chc("fill_kind", "Fill Type", FILL_KIND_OPTS, 0, A), + co("gradient_start", "Gradient Start", [1.0, 1.0, 1.0, 1.0], A), + co("gradient_end", "Gradient End", [0.15, 0.15, 0.15, 1.0], A), + p("gradient_angle", "Gradient Angle", ParamKind::Angle, ParamValue::Float(0.0), true, A), bo("stroke", "Stroke", false, A), co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A), fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A), @@ -144,10 +149,22 @@ pub(crate) fn layer_defs() -> Vec { ] } +/// A linear fill across the layer's local bounds. Colours are sRGB-encoded straight RGBA. +/// Angle is degrees clockwise on screen (y down); 0 runs left to right. +#[derive(Clone, Debug, PartialEq)] +pub struct LinearGradient { + pub start: [f32; 4], + pub end: [f32; 4], + pub angle: f32, +} + /// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA). #[derive(Clone, Debug, PartialEq)] pub struct Appearance { pub fill: Option<[f32; 4]>, + /// Set when Fill Type is Linear Gradient. Replaces the solid fill. A text run with its own + /// fill colour stays solid; the other characters keep the ramp. + pub gradient: Option, /// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last. pub strokes: Vec<([f32; 4], f32, u32)>, /// (colour with opacity in alpha, padding px, corner radius px). @@ -334,7 +351,17 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option bool { + *stream == 0 +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TrackItem { pub id: ClipId, @@ -549,6 +553,9 @@ pub struct TrackItem { /// left/right). Empty = the first two channels (mono sources on both sides). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_channels: Vec, + /// Audio stream within the media container (0 = the primary stream). + #[serde(default, skip_serializing_if = "is_zero_stream")] + pub audio_stream: usize, /// Graphic clips: roll / crawl, responsive time and the template the graphic came from /// ([`GraphicMeta`]). Schema v12. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1190,7 +1197,7 @@ pub struct Project { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub source_graphics: BTreeMap, /// Narrations (Text to Speech): the script and voice settings each generated WAV item was - /// made from, keyed by that item. Schema v13. + /// made from, keyed by that item. Schema v14. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub narrations: BTreeMap, } @@ -1231,9 +1238,9 @@ impl SequenceView { } } -/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one -/// and how each sequence was shown. Stored beside the project in its file, not in it: it is not -/// part of the edit and never an undo step. +/// What was open when a project was saved: the Timeline's sequence tabs in order, the active one, +/// how each sequence was shown and where its playhead was. Stored beside the project in its file, +/// not in it: it is not part of the edit and never an undo step. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ProjectView { #[serde(default)] @@ -1242,6 +1249,17 @@ pub struct ProjectView { pub active_sequence: Option, #[serde(default)] pub sequences: BTreeMap, + /// Each sequence's playhead, so a sequence reopens where it was left. Files + /// written before it existed have none, and every playhead starts at zero as it did then. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub playheads: BTreeMap, +} + +impl ProjectView { + /// The latest playhead a project file may ask for: ten million seconds (about 115 days), the + /// same bound as a sequence view's scroll position. Playheads are read from project files, so + /// nothing in them is trusted. + pub const MAX_PLAYHEAD: Tick = Tick(10_000_000 * TICKS_PER_SECOND); } /// A LUT imported into the project (`lut.import`). Lumetri refers to it as `lib:`. @@ -1533,6 +1551,7 @@ impl Project { hold_filters: false, field_options: None, source_channels, + audio_stream: 0, graphic: source_graphic.and_then(|sg| sg.meta.map(Box::new)), }) } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 45438feeb..404ee794f 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -208,7 +208,7 @@ fn mix_item( if !src.info().has_audio() { return; } - effected(item, &|m0, len| src.audio(m0, len, sr).ok(), a0, n, sr, w) + effected(item, &|m0, len| src.audio_stream(item.audio_stream, m0, len, sr).ok(), a0, n, sr, w) }; // gains: clip gain × Volume (keyframed, per 64-sample block) × channel volume × panner let clip_gain = db_to_gain(item.gain_db); @@ -328,7 +328,7 @@ pub fn clip_signal(item: &TrackItem, start: i64, frames: usize, sr: u32, sources let n = (a1 - a0) as usize; let off = (a0 - start) as usize; let g = db_to_gain(item.gain_db); - let buf = effected(item, &|m0, len| src.audio(m0, len, sr).ok(), a0, n, sr, 2); + let buf = effected(item, &|m0, len| src.audio_stream(item.audio_stream, m0, len, sr).ok(), a0, n, sr, 2); for (dst, b) in out.iter_mut().zip(&buf) { for (d, x) in dst[off..off + n].iter_mut().zip(b) { *d = x * g; @@ -338,6 +338,18 @@ pub fn clip_signal(item: &TrackItem, start: i64, frames: usize, sr: u32, sources Some(out) } +/// Whether a clip plays one source channel on both sides: a mono source, or one picked channel +/// (Modify ▸ Audio Channels, Breakout to Mono). Loudness Auto-Match measures such a clip as the one +/// channel it is, as BS.1770 (and ffmpeg's `ebur128`) measure a mono programme, not as the +/// dual-mono pair [`clip_signal`] plays, which reads 3 LU louder. `None` when the clip has no +/// audio source. +pub fn clip_is_mono(item: &TrackItem, sources: &dyn SourceProvider) -> Option { + let src = sources.source(item.item)?; + let available = src.info().audio_streams.get(item.audio_stream)?.channels as usize; + let (left, right) = source_pair(item, available); + Some(left == right) +} + /// The clip's audio (after speed/reverse) for timeline samples `[x0, x0 + len)` as `w` channels /// (2: stereo, mono sources on both sides; 6: 5.1), with silence outside the clip. fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32, w: usize) -> Vec> { diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index b88a5915a..db84a1f6c 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -66,19 +66,6 @@ pub(crate) fn mapping(id: &str) -> Option { d.set_param("mix", f(e, "mix", t)); }, }, - "parametric_eq" => Mapping { - dsp: "simple_eq", - preroll: |_, _| 0.05, - apply: |d, e, t| { - d.set_param("low_freq", f(e, "low_freq", t)); - d.set_param("low", f(e, "low_gain", t)); - d.set_param("mid_freq", f(e, "mid_freq", t)); - d.set_param("mid", f(e, "mid_gain", t)); - d.set_param("mid_q", f(e, "mid_q", t)); - d.set_param("high_freq", f(e, "high_freq", t)); - d.set_param("high", f(e, "high_gain", t)); - }, - }, // Single-band filters via band 1 of the parametric EQ (types: see FILTER_TYPE_NAMES). "highpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 4.0, f(e, "cutoff", t), 0.707) }, "lowpass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| band(d, 3.0, f(e, "cutoff", t), 0.707) }, @@ -474,4 +461,25 @@ mod tests { assert_eq!(latency("fft_filter", 48000), 2048); assert_eq!(latency("amplify", 48000), 0); } + + #[test] + fn parametric_eq_applies_every_control() { + let def = filmcraft_project::find_effect("parametric_eq").unwrap(); + let resp = |id: &str, v: filmcraft_project::ParamValue, extra: Option<(&str, filmcraft_project::ParamValue)>| { + let mut inst = def.instance(); + inst.param_mut(id).unwrap().value = v; + if let Some((k, x)) = extra { + inst.param_mut(k).unwrap().value = x; + } + configured(&inst, Tick::ZERO, 48000).unwrap().response_db(1000.0).unwrap() + }; + use filmcraft_project::ParamValue::{Bool, Float}; + assert!((resp("master_gain", Float(-30.0), None) + 30.0).abs() < 1.0); + for b in ["b1", "b2", "b4", "b5"] { + let g = resp(&format!("{b}_gain"), Float(-24.0), Some((&format!("{b}_freq"), Float(1000.0)))); + assert!((g + 24.0).abs() < 1.0, "{b}: {g}"); + } + assert!(resp("hp_on", Bool(true), Some(("hp_freq", Float(10000.0)))) < -20.0); + assert!(resp("lp_on", Bool(true), Some(("lp_freq", Float(100.0)))) < -20.0); + } } diff --git a/crates/render/src/graphic_clip.rs b/crates/render/src/graphic_clip.rs index 70df605b7..eaf1f6b9b 100644 --- a/crates/render/src/graphic_clip.rs +++ b/crates/render/src/graphic_clip.rs @@ -12,7 +12,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; use filmcraft_geom::{Affine, Vec2}; -use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer}; +use filmcraft_project::graphic::{LayerContent, LayerSpec, LinearGradient, ShapeProps, TextProps, eval_layer}; use filmcraft_project::graphic_design::{GraphicMeta, PinNode, remap_time, resolve_pins, roll_offset}; use filmcraft_project::{EffectInstance, TrackItem}; use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale}; @@ -20,7 +20,7 @@ use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, Styl use filmcraft_time::Tick; use rayon::prelude::*; -use crate::graphics::premul_linear; +use crate::graphics::{premul_linear, premul_linear_lerp}; use crate::image::Image; /// f64 affine → the text crate's f32 transform. @@ -191,6 +191,109 @@ fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) { }); } +fn invert_xform(m: &Xform) -> Option { + let det = m[0] * m[3] - m[1] * m[2]; + if det.abs() < 1e-8 { + return None; + } + let inv = 1.0 / det; + let a = m[3] * inv; + let b = -m[1] * inv; + let c = -m[2] * inv; + let d = m[0] * inv; + Some([a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])]) +} + +/// Paint `grad` through `msk`. 0° runs left to right across `lb` in layer space; the angle is +/// clockwise on screen and turns with `inv` (the device→layer transform). +fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [f32; 4], inv: &Xform, origin: (i32, i32)) { + if msk.w == 0 { + return; + } + let (cx, cy) = ((lb[0] + lb[2]) * 0.5, (lb[1] + lb[3]) * 0.5); + let (sin, cos) = grad.angle.to_radians().sin_cos(); + let (ax, ay) = (cos, sin); + let corners = [(lb[0], lb[1]), (lb[2], lb[1]), (lb[0], lb[3]), (lb[2], lb[3])]; + let half = corners.iter().map(|(x, y)| ((x - cx) * ax + (y - cy) * ay).abs()).fold(1e-3_f32, f32::max); + let w = msk.w; + let (start, end) = (grad.start, grad.end); + px.par_chunks_mut(4 * w).enumerate().for_each(|(row, dest)| { + let py = origin.1 as f32 + row as f32 + 0.5; + let mrow = &msk.a[row * w..(row + 1) * w]; + for (col, (p, &cov)) in dest.as_chunks_mut::<4>().0.iter_mut().zip(mrow).enumerate() { + if cov <= 0.0 { + continue; + } + let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py); + let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0); + let c = premul_linear_lerp(start, end, t); + let k = 1.0 - c[3] * cov; + for i in 0..4 { + p[i] = c[i] * cov + p[i] * k; + } + } + }); +} + +/// Solid fill, one mask per distinct colour when text runs carry their own. +fn paint_solid(px: &mut [f32], cover: &Mask, color: [f32; 4], text: Option<&Layout>, m: &Xform, run_fills: &[Option<[f32; 4]>], origin: (i32, i32)) { + let Some(layout) = text.filter(|_| run_fills.iter().any(Option::is_some)) else { + over_mask(px, cover, premul_linear(color)); + return; + }; + let mut groups: Vec<([f32; 4], Vec)> = Vec::new(); + for r in 0..=run_fills.len() { + let col = if r == 0 { color } else { run_fills[r - 1].unwrap_or(color) }; + match groups.iter_mut().find(|g| g.0 == col) { + Some(g) => g.1.push(r as u16), + None => groups.push((col, vec![r as u16])), + } + } + for (col, runs) in groups { + let mut m_run = Mask::new(cover.w, cover.h); + for r in runs { + render::draw_run(layout, m, &mut m_run, origin, Some(r)); + } + over_mask(px, &m_run, premul_linear(col)); + } +} + +/// Gradient for runs that have no fill of their own; runs with a fill stay that solid colour. +fn paint_mixed_gradient( + px: &mut [f32], + cover: &Mask, + grad: &LinearGradient, + lb: [f32; 4], + inv: &Xform, + origin: (i32, i32), + layout: &Layout, + m: &Xform, + run_fills: &[Option<[f32; 4]>], +) { + let mut grad_mask = Mask::new(cover.w, cover.h); + render::draw_run(layout, m, &mut grad_mask, origin, Some(0)); + let mut solids: Vec<([f32; 4], Vec)> = Vec::new(); + for (i, fill) in run_fills.iter().enumerate() { + let run = (i + 1) as u16; + if let Some(col) = fill { + match solids.iter_mut().find(|g| g.0 == *col) { + Some(g) => g.1.push(run), + None => solids.push((*col, vec![run])), + } + } else { + render::draw_run(layout, m, &mut grad_mask, origin, Some(run)); + } + } + over_linear_gradient(px, &grad_mask, grad, lb, inv, origin); + for (col, runs) in solids { + let mut m_run = Mask::new(cover.w, cover.h); + for r in runs { + render::draw_run(layout, m, &mut m_run, origin, Some(r)); + } + over_mask(px, &m_run, premul_linear(col)); + } +} + /// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas. pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option { if !spec.enabled || spec.transform.opacity <= 0.0 { @@ -294,32 +397,22 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option over_mask(&mut px, sm, premul_linear(*c)); } } - if let Some(c) = ap.fill { - // per-character fills: each style run with its own colour is drawn on its own - let run_fills: Vec> = match &spec.content { - LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(), - _ => Vec::new(), - }; - match &text { - Some(l) if run_fills.iter().any(Option::is_some) => { - let mut groups: Vec<([f32; 4], Vec)> = Vec::new(); - for r in 0..=run_fills.len() { - let col = if r == 0 { c } else { run_fills[r - 1].unwrap_or(c) }; - match groups.iter_mut().find(|g| g.0 == col) { - Some(g) => g.1.push(r as u16), - None => groups.push((col, vec![r as u16])), - } - } - for (col, runs) in groups { - let mut m_run = Mask::new(w, h); - for r in runs { - render::draw_run(l, m, &mut m_run, (x0, y0), Some(r)); - } - over_mask(&mut px, &m_run, premul_linear(col)); - } - } - _ => over_mask(&mut px, &cover, premul_linear(c)), + let run_fills: Vec> = match &spec.content { + LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(), + _ => Vec::new(), + }; + if let Some(grad) = &ap.gradient + && let Some(inv) = invert_xform(m) + { + if run_fills.iter().any(Option::is_some) + && let Some(layout) = &text + { + paint_mixed_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0), layout, m, &run_fills); + } else { + over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0)); } + } else if let Some(c) = ap.fill { + paint_solid(&mut px, &cover, c, text.as_deref(), m, &run_fills, (x0, y0)); } for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() { if !outer(*k) { @@ -702,6 +795,103 @@ mod tests { assert_eq!(img.get(20, 20)[3], 0.0); } + #[test] + fn linear_gradient_runs_left_to_right_on_a_shape_and_on_text() { + let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]); + set(&mut shape, "fill_kind", ParamValue::Choice(1)); + set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + set(&mut shape, "gradient_angle", ParamValue::Float(0.0)); + let mut img = Image::new(200, 200); + render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + let left = img.get(70, 100); + let right = img.get(130, 100); + assert!(left[3] > 0.9 && left[0] > left[2], "redder on the left {left:?}"); + assert!(right[3] > 0.9 && right[2] > right[0], "bluer on the right {right:?}"); + + let mut text = new_text_layer("HHHHHHHH", Vec2::new(16.0, 90.0), 64.0); + set(&mut text, "fill_kind", ParamValue::Choice(1)); + set(&mut text, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut text, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + let mut img = Image::new(500, 180); + render_graphic_layers(&[text], Tick::ZERO, (500, 180), &Affine::IDENTITY, &mut img); + let ink: Vec<(usize, usize)> = (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.8).collect(); + let minx = ink.iter().map(|p| p.0).min().unwrap(); + let maxx = ink.iter().map(|p| p.0).max().unwrap(); + let span = (maxx - minx).max(1); + let (mut ln, mut rn, mut ls, mut rs) = (0.0_f32, 0.0, 0.0, 0.0); + for &(x, y) in &ink { + let p = img.get(x, y)[0]; + if x < minx + span / 3 { + ls += p; + ln += 1.0; + } else if x > maxx - span / 3 { + rs += p; + rn += 1.0; + } + } + assert!(ln > 30.0 && rn > 30.0, "ink on both ends of the title: {ln} {rn} x {minx}..{maxx}"); + assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn); + } + + #[test] + fn linear_gradient_blends_in_linear_light_and_turns_with_the_layer() { + let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]); + set(&mut shape, "fill_kind", ParamValue::Choice(1)); + set(&mut shape, "gradient_start", ParamValue::Color([0.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([1.0, 1.0, 1.0, 1.0])); + let mut img = Image::new(200, 200); + render_graphic_layers(&[shape.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + let mid = img.get(100, 100)[0]; + // Linear-light midpoint is ~0.5. An sRGB midpoint converted to linear is ~0.21. + assert!(mid > 0.4 && mid < 0.6, "centre of black→white is linear 0.5, got {mid}"); + assert!(img.get(70, 100)[0] < mid && mid < img.get(130, 100)[0]); + + set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + set(&mut shape, "rotation", ParamValue::Float(90.0)); + let mut turned = Image::new(200, 200); + render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut turned); + let top = turned.get(100, 70); + let bot = turned.get(100, 130); + assert!(top[3] > 0.9 && bot[3] > 0.9, "the rotated ramp is inside the shape: {top:?} {bot:?}"); + assert!(top[0] > top[2], "clockwise 90° carries the start colour to the top {top:?}"); + assert!(bot[2] > bot[0], "and the end colour to the bottom {bot:?}"); + } + + #[test] + fn linear_gradient_keeps_a_per_character_fill_solid() { + use filmcraft_project::graphic_design::{CharStyle, LayerExtra, StyleRun}; + let mut e = new_text_layer("AAAA", Vec2::new(20.0, 100.0), 60.0); + set(&mut e, "fill_kind", ParamValue::Choice(1)); + set(&mut e, "gradient_start", ParamValue::Color([0.0, 1.0, 0.0, 1.0])); + set(&mut e, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + let red = CharStyle { fill: Some([1.0, 0.0, 0.0, 1.0]), ..Default::default() }; + e.layer = Some(Box::new(LayerExtra { runs: vec![StyleRun { start: 2, end: 4, style: red }], ..Default::default() })); + let mut img = Image::new(400, 200); + render_graphic_layers(&[e.clone()], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img); + let l = text_layout(match &eval_layer(&e, Tick::ZERO, (400, 200)).unwrap().content { + LayerContent::Text(t) => t, + _ => unreachable!(), + }); + let split = 20.0 + l.caret(2).0; + let (mut green, mut red_px) = (0, 0); + for y in 0..200 { + for x in 0..400 { + let p = img.get(x, y); + if p[3] < 0.8 { + continue; + } + if (x as f32) < split - 1.0 && p[1] > p[0] && p[1] > p[2] { + green += 1; + } else if (x as f32) > split + 1.0 && p[0] > 0.9 && p[1] < 0.05 { + red_px += 1; + } + } + } + assert!(green > 80 && red_px > 80, "gradient on the plain letters, solid red on the styled ones: {green} {red_px}"); + } + #[test] fn inner_and_centre_strokes() { for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] { diff --git a/crates/render/src/graphics.rs b/crates/render/src/graphics.rs index 3664aa01f..dd98fe744 100644 --- a/crates/render/src/graphics.rs +++ b/crates/render/src/graphics.rs @@ -14,6 +14,15 @@ pub fn premul_linear(c: [f32; 4]) -> [f32; 4] { [srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a] } +/// Blend two sRGB straight colours in linear light, then premultiply. `t` is 0 at `start`. +pub fn premul_linear_lerp(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] { + let t = t.clamp(0.0, 1.0); + let s = [srgb_to_linear(start[0]), srgb_to_linear(start[1]), srgb_to_linear(start[2]), start[3].clamp(0.0, 1.0)]; + let e = [srgb_to_linear(end[0]), srgb_to_linear(end[1]), srgb_to_linear(end[2]), end[3].clamp(0.0, 1.0)]; + let a = s[3] * (1.0 - t) + e[3] * t; + [(s[0] * (1.0 - t) + e[0] * t) * a, (s[1] * (1.0 - t) + e[1] * t) * a, (s[2] * (1.0 - t) + e[2] * t) * a, a] +} + /// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by /// `opacity`, over `img`. pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index bcf4a0dda..780042189 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -489,11 +489,24 @@ fn plain_layer(frame: &VideoFrame, n: usize, opacity: f32, blend: Blend) -> Laye /// convert it with. fn media_frame(item: &TrackItem, t: Tick, sources: &dyn SourceProvider, want: f32, src_size: (u32, u32)) -> Option<(SharedSource, Arc, usize)> { let src = sources.source(item.item)?; - let frame = src.video_frame(FrameRequest { time: item.source_time_at(t), scale: want }).ok()?; + let frame = src.video_frame(FrameRequest { time: video_source_time(item, t, src.info().frame_rate()), scale: want }).ok()?; let n = decimation(frame.width as f32, src_size.0 as f32 * want); Some((src, frame, n)) } +/// The media time to ask a source for the video frame a clip shows at timeline `t`. A reversed +/// clip's source time is the instant just before its mirrored position; readers that round the +/// request to the nearest unit of a coarse timescale (MP4) land on the next frame from there, +/// showing one frame past the clip's source range and never its In (#318). Snapped down to the +/// start of the `media_rate` frame that contains it, every reader shows that frame. +pub(crate) fn video_source_time(item: &TrackItem, t: Tick, media_rate: filmcraft_time::FrameRate) -> Tick { + let ft = item.source_time_at(t); + if !item.reverse || item.frame_hold.is_some() || media_rate.frame_duration().0 <= 0 { + return ft; + } + media_rate.tick_of(media_rate.frame_at(ft)).clamp(item.source_in.min(ft), ft) +} + /// The linear image of a media clip's `frame`, colour managed, and blended with the next frame when /// the clip's speed asks for it. #[allow(clippy::too_many_arguments)] diff --git a/crates/render/src/mixer_tests.rs b/crates/render/src/mixer_tests.rs index 0eea28c35..b9705e1e3 100644 --- a/crates/render/src/mixer_tests.rs +++ b/crates/render/src/mixer_tests.rs @@ -58,7 +58,7 @@ impl Rig { kind: MediaKind::AudioOnly, duration: Tick(10 * TICKS_PER_SECOND), video: None, - audio: Some(AudioStreamInfo { sample_rate: 48_000, channels: ch as u32, codec: "test".into(), bits_per_sample: Some(32) }), + audio_streams: vec![AudioStreamInfo { sample_rate: 48_000, channels: ch as u32, codec: "test".into(), bits_per_sample: Some(32) }], container: "test".into(), start_timecode: None, file_size: None, @@ -128,6 +128,70 @@ fn close(a: f32, b: f32, tol: f32) -> bool { (a - b).abs() <= tol } +/// The renderer must request the stream selected by each clip, rather than reading stream zero +/// twice when a container item has clips on two audio tracks. +#[test] +fn audio_clips_from_one_container_mix_distinct_streams() { + struct TwoStreams(MediaInfo); + impl MediaSource for TwoStreams { + fn info(&self) -> &MediaInfo { + &self.0 + } + fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result> { + Err(MediaError::NoStream("video")) + } + fn audio(&self, start: i64, frames: usize, sr: u32) -> filmcraft_media::Result { + self.audio_stream(0, start, frames, sr) + } + fn audio_stream(&self, stream: usize, _start: i64, frames: usize, sr: u32) -> filmcraft_media::Result { + let v = match stream { + 0 => 0.25, + 1 => -0.125, + _ => return Err(MediaError::NoStream("audio")), + }; + Ok(AudioBuffer { sample_rate: sr, channels: vec![vec![v; frames], vec![v; frames]] }) + } + } + let mut p = Project::new("streams"); + let seq = p.new_sequence("mix", SequenceSettings::default(), 0, 2, None); + let mut info = MediaInfo { + name: "two.mov".into(), + kind: MediaKind::AudioOnly, + duration: Tick(TICKS_PER_SECOND), + video: None, + audio_streams: vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "PCM".into(), bits_per_sample: Some(16) }], + container: "MOV".into(), + start_timecode: None, + file_size: None, + }; + info.audio_streams.push(info.audio_streams[0].clone()); + let id = p.add_item( + "two.mov", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::File { path: "two.mov".into() }, + info: info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let mut map = SourceMap::default(); + map.0.insert(id, Arc::new(TwoStreams(info))); + for stream in 0..2 { + let mut clip = p.make_track_item(id, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND)), FrameRate::FPS_24).unwrap(); + clip.audio_stream = stream; + p.sequence_mut(seq).unwrap().audio_tracks[stream].items.push(clip); + } + let buffer = crate::audio::mix_sequence(&p, p.sequence(seq).unwrap(), 0, 32, &map); + assert!(buffer.channels.iter().all(|c| c.iter().all(|&v| close(v, 0.125, 1e-6)))); +} + #[test] fn fader_gain_is_sample_exact() { let mut r = Rig::new(&[dc]); @@ -423,6 +487,10 @@ fn live_override_holds_and_ramps_back() { /// 24 tracks × 3 inserts (EQ, Dynamics, Studio Reverb) + a compressed submix at 48 kHz, one core. #[test] +// Off Unix there is no per-thread CPU clock (`thread_cpu_secs`), so the timing falls back to wall +// time, which a loaded CI host (Windows runners run the suite in parallel) can push under the +// threshold: genuinely timing-flaky there. On Unix it measures this thread's CPU time and stays on. +#[cfg_attr(not(unix), ignore = "wall-clock timing without a thread CPU clock is load-dependent; run with --ignored")] fn perf_24_tracks_3_effects_realtime_factor() { let r = busy_rig(24); let secs = if cfg!(debug_assertions) { 2.0 } else { 10.0 }; @@ -666,7 +734,7 @@ fn nested_tone(inner_rate: u32) -> (Project, ItemId, SourceMap, Arc) { kind: MediaKind::AudioOnly, duration: ten.duration, video: None, - audio: Some(AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "test".into(), bits_per_sample: Some(32) }), + audio_streams: vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "test".into(), bits_per_sample: Some(32) }], container: "test".into(), start_timecode: None, file_size: None, diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index bf4875e0f..628e7e102 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -7,9 +7,14 @@ //! them as a [`LayerFx`]: their parameters evaluated at the frame's time, run by the GPU on the //! clip's working image before Motion places it, in the CPU's order (effects → Motion → Opacity / //! blend). Anything the shaders don't cover yet — other standard effects, masks, adjustment -//! layers, nested sequences, non-dissolve transitions — is rendered on the CPU for that layer (or -//! the whole frame) and handed over as a pre-composited image, so the GPU path is always exact -//! with respect to the CPU reference. +//! layers, nested sequences, transitions other than dissolves, dips and Push — is rendered on the CPU +//! for that layer (or the whole frame) and handed over as a pre-composited image, so the GPU path +//! is always exact with respect to the CPU reference. +//! +//! A Push transition is planned as ordered [`PlanStep`] operations on isolated clip inputs: each +//! side is drawn into its own image, the two are pushed together, and the result is composited (Normal) over +//! the canvas of the lower tracks, which is preserved for the blends above it. [`execute_cpu`] +//! runs the same steps on the CPU and is the reference. use std::sync::Arc; @@ -65,10 +70,90 @@ impl PlanLayer { pub enum FramePlan { /// Draw these layers over black. Layers { width: usize, height: usize, layers: Vec }, + /// Ordered layer runs and two-input operations; each step composites onto the same canvas. + Composite { width: usize, height: usize, steps: Vec }, /// The CPU produced the final image (fallback). Image(crate::Image), } +#[derive(Clone)] +pub enum PlanStep { + Layers(Vec), + /// Inputs are isolated clip canvases; transition result composites Normal over lower tracks. + Transition { + inputs: [Vec; 2], + effect: filmcraft_project::EffectInstance, + progress: f32, + scale: f32, + }, +} + +#[derive(Default)] +struct PlanStack(Vec); + +impl PlanStack { + fn push(&mut self, layer: PlanLayer) { + if let Some(PlanStep::Layers(layers)) = self.0.last_mut() { + layers.push(layer); + } else { + self.0.push(PlanStep::Layers(vec![layer])); + } + } + + fn into_layers(self, width: usize, height: usize) -> Vec { + match self.finish(width, height) { + FramePlan::Layers { layers, .. } => layers, + plan => vec![PlanLayer::new(cpu_frame(execute_cpu(&plan)), Affine::IDENTITY, 1.0, Blend::Normal)], + } + } + + fn finish(mut self, width: usize, height: usize) -> FramePlan { + if self.0.len() <= 1 && self.0.first().is_none_or(|s| matches!(s, PlanStep::Layers(_))) { + let layers = match self.0.pop() { + Some(PlanStep::Layers(layers)) => layers, + _ => Vec::new(), + }; + FramePlan::Layers { width, height, layers } + } else { + FramePlan::Composite { width, height, steps: self.0 } + } + } +} + +impl FramePlan { + /// Source storage retained by the plan, before GPU preparation. + pub fn source_bytes(&self) -> usize { + let layers = |ls: &[PlanLayer]| ls.iter().map(|l| l.frame.byte_size()).sum::(); + match self { + Self::Image(img) => img.px.len() * size_of::(), + Self::Layers { layers: ls, .. } => layers(ls), + Self::Composite { steps, .. } => steps + .iter() + .map(|s| match s { + PlanStep::Layers(ls) => layers(ls), + PlanStep::Transition { inputs, .. } => inputs.iter().map(|ls| layers(ls)).sum(), + }) + .sum(), + } + } + + /// Largest output/source texture side required by the plan. + pub fn max_side(&self) -> usize { + let layer_side = |ls: &[PlanLayer]| ls.iter().map(|l| l.frame.width.max(l.frame.height) as usize).max().unwrap_or(0); + match self { + Self::Image(img) => img.w.max(img.h), + Self::Layers { width, height, layers } => (*width).max(*height).max(layer_side(layers)), + Self::Composite { width, height, steps } => steps + .iter() + .map(|s| match s { + PlanStep::Layers(ls) => layer_side(ls), + PlanStep::Transition { inputs, .. } => inputs.iter().map(|ls| layer_side(ls)).max().unwrap_or(0), + }) + .fold((*width).max(*height), usize::max), + } + } +} + fn cpu_frame(img: crate::Image) -> Arc { Arc::new(VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px)) } @@ -114,7 +199,7 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption if !layered_at(project, seq, t) { return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); } - let mut layers = Vec::new(); + let mut layers = PlanStack::default(); push_tracks(project, seq, t, opts, sources, 0, &mut layers); if opts.captions { for o in crate::caption_overlays(seq, t, w, h) { @@ -126,7 +211,7 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption )); } } - FramePlan::Layers { width: w, height: h, layers } + layers.finish(w, h) } /// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no @@ -138,6 +223,7 @@ fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { } if let Some(trn) = tr.transitions.iter().find(|x| x.range().contains(t)) && !simple_transition(&trn.effect.effect) + && trn.effect.effect != "push" { return false; } @@ -152,7 +238,7 @@ fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { /// Push the layers of every video track of `seq` at `t`, bottom track first (`seq` must be /// [`layered_at`] `t`). `nest` counts the nested sequences already followed to reach `seq`. -fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider, nest: u32, layers: &mut Vec) { +fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider, nest: u32, layers: &mut PlanStack) { let (w, h) = output_size(seq, opts.scale); for tr in &seq.video_tracks { if !tr.enabled { @@ -164,6 +250,23 @@ fn push_tracks(project: &Project, seq: &Sequence, t: Tick, opts: RenderOptions, let a = trn.from.and_then(|id| tr.item(id)).filter(|i| i.enabled); let b = trn.to.and_then(|id| tr.item(id)).filter(|i| i.enabled); match trn.effect.effect.as_str() { + "push" => { + let mut inputs = [Vec::new(), Vec::new()]; + for (slot, item) in inputs.iter_mut().zip([a, b]) { + if let Some(item) = item { + let mut input = PlanStack::default(); + push_item(project, seq, item, t, opts, sources, 1.0, Some(Blend::Normal), nest, &mut input); + *slot = input.into_layers(w, h); + } + } + let progress = if trn.reverse { + inputs.swap(0, 1); + 1.0 - p + } else { + p + }; + layers.0.push(PlanStep::Transition { inputs, effect: trn.effect.clone(), progress, scale: opts.scale }); + } "dip_to_black" | "dip_to_white" => { let col = if trn.effect.effect == "dip_to_black" { [0.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; layers.push(PlanLayer::new(Arc::new(VideoFrame::rgba_f32(1, 1, col.to_vec())), Affine::scale(w as f64, h as f64), 1.0, Blend::Normal)); @@ -201,6 +304,7 @@ fn nest_is_plain(project: &Project, seq: &Sequence, nested: &Sequence, ft: Tick) nested.settings.color == seq.settings.color && (nested.settings.width, nested.settings.height) == (seq.settings.width, seq.settings.height) && layered_at(project, nested, ft) + && !nested.video_tracks.iter().any(|tr| tr.enabled && tr.transitions.iter().any(|t| t.range().contains(ft) && !simple_transition(&t.effect.effect))) && nested.video_tracks.iter().filter(|tr| tr.enabled).all(|tr| { tr.transitions.iter().any(|x| x.range().contains(ft)) || tr.item_at(ft).filter(|i| i.enabled).is_none_or(|i| crate::opacity_blend(i, i.effect_time_at(ft)).1 == Blend::Normal) @@ -222,7 +326,7 @@ fn push_item( extra_opacity: f32, blend: Option, nest: u32, - out: &mut Vec, + out: &mut PlanStack, ) { // frame time (`ft`) vs. effect time (`mt`): they differ inside a frame hold without Hold Filters let ft = item.source_time_at(t); @@ -275,8 +379,7 @@ fn push_item( } // Graphic clips without standard effects: the layers are rasterised (cached) into one tight // image the GPU places as a layer. - if !(opts.effects && item.has_standard_effects()) - && !item.has_opacity_masks() + if !(opts.effects && item.has_standard_effects() || item.has_opacity_masks()) && project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. })) { let Some(size) = crate::source_size(project, item.item) else { return }; @@ -293,7 +396,8 @@ fn push_item( let motion = motion_matrix(seq, item, size, mt); let lin = ((motion.a * motion.a + motion.b * motion.b).sqrt()).max((motion.c * motion.c + motion.d * motion.d).sqrt()); let want = (lin * opts.scale as f64).clamp(1.0 / 64.0, 1.0) as f32; - let Ok(frame) = src.video_frame(FrameRequest { time: ft, scale: want }) else { return }; + let time = crate::video_source_time(item, t, src.info().frame_rate()); + let Ok(frame) = src.video_frame(FrameRequest { time, scale: want }) else { return }; let cs = crate::colorman::source_space(project, item.item, &frame); // log / HDR / wide-gamut media is converted on the CPU (below), and so are blended // in-between frames (Frame Blending / Optical Flow on speed-changed clips) @@ -367,21 +471,65 @@ pub fn effect_image(frame: &VideoFrame, fx: &LayerFx) -> crate::Image { pub fn execute_cpu(plan: &FramePlan) -> crate::Image { match plan { FramePlan::Image(img) => img.clone(), + FramePlan::Composite { width, height, steps } => { + let mut canvas = crate::Image::new(*width, *height); + for step in steps { + match step { + PlanStep::Layers(layers) => execute_layers(&mut canvas, layers), + PlanStep::Transition { inputs, effect, progress, scale } => { + let [a, b] = inputs.each_ref().map(|layers| { + let mut input = crate::Image::new(*width, *height); + execute_layers(&mut input, layers); + input + }); + let mixed = crate::transitions::apply_scaled(effect, &a, &b, *progress, *scale); + crate::blend::composite(&mut canvas, &mixed, 1.0, Blend::Normal); + } + } + } + canvas + } FramePlan::Layers { width, height, layers } => { let mut canvas = crate::Image::new(*width, *height); - for l in layers { - let src = match &l.fx { - Some(fx) => effect_image(&l.frame, fx), - None => crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() }, - }; - let placed = if l.matrix == Affine::scale(*width as f64, *height as f64) && src.w == 1 && src.h == 1 { - crate::Image::filled(*width, *height, src.get(0, 0)) - } else { - src.transformed(*width, *height, &l.matrix) - }; - crate::blend::composite(&mut canvas, &placed, l.opacity, l.blend); - } + execute_layers(&mut canvas, layers); canvas } } } + +fn execute_layers(canvas: &mut crate::Image, layers: &[PlanLayer]) { + let (width, height) = (canvas.w, canvas.h); + for l in layers { + let src = match &l.fx { + Some(fx) => effect_image(&l.frame, fx), + None => crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() }, + }; + let placed = if l.matrix == Affine::scale(width as f64, height as f64) && src.w == 1 && src.h == 1 { + crate::Image::filled(width, height, src.get(0, 0)) + } else { + src.transformed(width, height, &l.matrix) + }; + crate::blend::composite(canvas, &placed, l.opacity, l.blend); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flattening_an_operation_preserves_its_picture() { + let frame = Arc::new(VideoFrame::rgba_f32(1, 1, vec![0.25, 0.0, 0.0, 0.5])); + let steps = vec![PlanStep::Transition { + inputs: [vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)], vec![]], + effect: filmcraft_project::find_effect("push").unwrap().instance(), + progress: 0.0, + scale: 1.0, + }]; + let expected = execute_cpu(&FramePlan::Composite { width: 1, height: 1, steps: steps.clone() }); + let layers = PlanStack(steps).into_layers(1, 1); + let got = execute_cpu(&FramePlan::Layers { width: 1, height: 1, layers }); + assert_eq!(got.px, expected.px); + assert!(got.px.iter().any(|v| *v != 0.0)); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 20ec4fefb..31f5edfc0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -208,6 +208,48 @@ fn plan_matches_reference_renderer() { } } +#[test] +fn push_plan_preserves_reference_track_order_and_reverse() { + let (mut p, red, ocean, seq, map) = setup(); + place(&mut p, seq, 0, ocean, 0, 48); + let a = place(&mut p, seq, 1, red, 0, 24); + let b = place(&mut p, seq, 1, ocean, 24, 24); + let rate = FrameRate::FPS_24; + let s = p.sequence_mut(seq).unwrap(); + for item in &mut s.video_tracks[1].items { + item.effect_mut("opacity").unwrap().param_mut("opacity").unwrap().value = ParamValue::Float(55.0); + item.effect_mut("motion").unwrap().param_mut("scale").unwrap().value = ParamValue::Float(75.0); + } + s.video_tracks[1].transitions.push(filmcraft_project::Transition { + id: filmcraft_project::TransitionId(999), + effect: filmcraft_project::find_effect("push").unwrap().instance(), + start: rate.tick_of(18), + duration: rate.tick_of(12), + from: Some(a), + to: Some(b), + align: Default::default(), + reverse: false, + }); + for reverse in [false, true] { + for scale in [1.0, 0.5] { + for missing in [false, true] { + let tr = &mut p.sequence_mut(seq).unwrap().video_tracks[1].transitions[0]; + tr.reverse = reverse; + tr.to = if missing { None } else { Some(b) }; + for f in [18, 21, 24, 29] { + let opts = RenderOptions { scale, ..Default::default() }; + let plan = plan::plan_frame(&p, seq, rate.tick_of(f), opts, &map); + assert!(matches!(plan, plan::FramePlan::Composite { .. })); + let got = plan::execute_cpu(&plan); + let reference = render_sequence(&p, seq, rate.tick_of(f), opts, &map); + let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max); + assert!(max < 1e-5, "reverse {reverse}, scale {scale}, missing {missing}, frame {f}: {max}"); + } + } + } + } +} + /// A project with a 4 s bars-and-tone clip (1 kHz at −20 dBFS) on A1, with extra audio effects. fn tone_with(effects: &[(&str, &[(&str, f64)])]) -> (Project, ItemId, SourceMap) { let mut p = Project::new("fx"); @@ -369,3 +411,87 @@ fn a_multicam_clip_showing_itself_plans_and_renders_to_an_end() { let img = render_sequence(&p, a, Tick::ZERO, opts, &sources); assert!(img.w > 0 && img.h > 0); } + +/// A 24 fps source that picks frames like the MP4 reader: the requested time is converted to the +/// track timescale rounding to the nearest unit, then the frame at or before it is shown. It +/// records the frames it was asked for. +struct NearestUnitSource { + inner: GeneratorSource, + asked: std::sync::Mutex>, +} + +impl MediaSource for NearestUnitSource { + fn info(&self) -> &filmcraft_media::MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + // 12288 units a second: 512 a frame at 24 fps + let units = req.time.to_rational_round(1, 12_288); + self.asked.lock().unwrap().push(units.div_euclid(512)); + self.inner.video_frame(req) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio(start, frames, sample_rate) + } +} + +/// #318: a trimmed clip (source frames 10–29) played in reverse shows 29 down to 10. Its source +/// time is the instant just before the mirrored position; a reader that rounds to the nearest +/// timescale unit used to land on the next frame, showing frame 30 (outside the clip) and never 10. +#[test] +fn a_reversed_clip_shows_exactly_its_source_range() { + let r = FrameRate::FPS_24; + let mut p = Project::new("r"); + let g = GeneratorSource::new(Generator::ColorMatte { color: [0.2, 0.4, 0.6, 1.0] }, 64, 64, r, Tick(4 * TICKS_PER_SECOND)); + let info = g.info().clone(); + let item = p.add_item( + "ids", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let src = Arc::new(NearestUnitSource { inner: g, asked: Default::default() }); + let mut map = SourceMap::default(); + map.0.insert(item, src.clone() as SharedSource); + let seq = p.new_sequence("s", SequenceSettings { width: 64, height: 64, frame_rate: r, ..Default::default() }, 1, 0, None); + let ti = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(r.tick_of(10), r.tick_of(20)), r).unwrap(); + let id = ti.id; + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(ti); + let opts = RenderOptions { scale: 1.0, ..Default::default() }; + let shown = |p: &Project, f: i64, planned: bool| -> Vec { + src.asked.lock().unwrap().clear(); + if planned { + plan::execute_cpu(&plan::plan_frame(p, seq, r.tick_of(f), opts, &map)); + } else { + render_sequence(p, seq, r.tick_of(f), opts, &map); + } + let mut asked = src.asked.lock().unwrap().clone(); + asked.dedup(); + asked + }; + // forward control: 10, 17, 29 + for (f, want) in [(0, 10), (7, 17), (19, 29)] { + for planned in [false, true] { + assert_eq!(shown(&p, f, planned), [want], "forward, timeline frame {f}, planned {planned}"); + } + } + p.sequence_mut(seq).unwrap().find_item_mut(id).unwrap().1.reverse = true; + for (f, want) in [(0, 29), (7, 22), (19, 10)] { + for planned in [false, true] { + assert_eq!(shown(&p, f, planned), [want], "reversed, timeline frame {f}, planned {planned}"); + } + } + // every timeline frame stays inside the clip's source range, each source frame shown once + let all: Vec = (0..20).flat_map(|f| shown(&p, f, false)).collect(); + assert_eq!(all, (10..=29).rev().collect::>()); +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index d9f7f8e6e..00989623d 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -19,3 +19,5 @@ harfrust = { version = "0.12", default-features = false, features = ["std"] } unicode-bidi = "0.3.18" # UAX #14 line breaking (Apache-2.0). unicode-linebreak = "0.1.5" +# UAX #29 extended grapheme clusters (MIT OR Apache-2.0). +unicode-segmentation = "1.13" diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 963f28e9d..6cceff10e 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -215,6 +215,23 @@ impl Face { pub fn has_char(&self, c: char) -> bool { self.glyph(c).is_some() } + /// Height of the glyph's vertical origin above its baseline, in pixels. + /// OpenType VORG takes precedence; TrueType uses the ink top plus vmtx's top bearing. + pub(crate) fn vertical_origin(&self, gid: u32, px: f32) -> Option { + use skrifa::raw::TableProvider; + let font = self.font()?; + let glyph = skrifa::GlyphId::new(gid); + let units = match font.vorg() { + Ok(table) => f32::from(table.vertical_origin_y(glyph)), + Err(_) => { + let bounds = font.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(glyph)?; + bounds.y_max + f32::from(font.vmtx().ok()?.side_bearing(glyph)?) + } + }; + let origin = units * px / self.units_per_em(); + (origin.is_finite() && origin.abs() < px * 4.0).then_some(origin) + } + pub fn metrics(&self, px: f32) -> VMetrics { let Some(f) = self.font() else { return VMetrics { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 31281afb9..c28b8479e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -20,6 +20,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; use unicode_bidi::{BidiInfo, Level}; +use unicode_segmentation::UnicodeSegmentation; use crate::fonts::{self, FaceId, Resolved}; @@ -218,7 +219,7 @@ pub struct Layout { /// Lines were left out because they do not fit the box height. pub overflow: bool, pub text_len: usize, - /// Laid out vertically (one line per character; columns right to left). + /// Laid out vertically (one line per grapheme; columns right to left). pub vertical: bool, } @@ -319,7 +320,11 @@ fn upper_single(c: char) -> char { const SMALL_CAPS_SCALE: f32 = 0.78; -fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec { +fn uses_vertical_form(c: char) -> bool { + matches!(c as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF) +} + +fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, vertical: bool) -> Vec { let f = fonts::face(face); let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; let shaper = data.shaper(&font).build(); @@ -330,6 +335,10 @@ fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); buf.guess_segment_properties(); let mut feats = Vec::new(); + if vertical && chars.first().is_some_and(|c| uses_vertical_form(c.1)) { + feats.push(Feature::new(Tag::new(b"vert"), 1, ..)); + feats.push(Feature::new(Tag::new(b"vrt2"), 1, ..)); + } if !style.kerning { feats.push(Feature::new(Tag::new(b"kern"), 0, ..)); } @@ -416,7 +425,7 @@ fn paragraph(text: &str, base: usize, sty: &Styles, para: &ParagraphStyle) -> Ve let st = sty.list[si]; let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); let size = if small { st.size * SMALL_CAPS_SCALE } else { st.size }; - let mut glyphs = shape_item(text, &sub, rtl, face, size, st); + let mut glyphs = shape_item(text, &sub, rtl, face, size, st, para.vertical); // tracking after each cluster if st.tracking != 0.0 { let t = st.tracking * st.size / 1000.0; @@ -707,13 +716,13 @@ pub fn layout_rich_uncached(text: &str, style: &TextStyle, runs: &[StyleRun], pa lay } -/// Vertical layout: each character is its own line (so carets, hit testing and selection work +/// Vertical layout: each grapheme is its own line (so carets, hit testing and selection work /// unchanged), stacked top to bottom and centred on its column; columns run right to left. fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, mut lay: Layout) -> Layout { let style = sty.list[0]; let pm = fonts::face(sty.prim[0].face).metrics(style.size); let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0; - let flat = ParagraphStyle::default(); + let flat = ParagraphStyle { vertical: true, ..Default::default() }; lay.vertical = true; let mut base = 0usize; let mut ncols = 0usize; @@ -724,8 +733,8 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, if p_clean.is_empty() { cells.extend(paragraph("", base, sty, &flat)); } - for (off, ch) in p_clean.char_indices() { - cells.extend(paragraph(&p_clean[off..off + ch.len_utf8()], base + off, sty, &flat)); + for (off, cluster) in p_clean.grapheme_indices(true) { + cells.extend(paragraph(cluster, base + off, sty, &flat)); } let h = row * cells.len() as f32; let y0 = match para.align { @@ -740,7 +749,16 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, let g0 = lay.glyphs.len(); lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| { g.x += shift; - g.y += baseline; + // The alternate glyph hangs from its vertical origin. A horizontal ascent + // is not the top bearing of a vertical punctuation glyph (OpenType VORG/vmtx). + let font = fonts::face(g.face); + if text.get(g.cluster..).and_then(|s| s.chars().next()).is_some_and(uses_vertical_form) + && let Some(origin) = font.vertical_origin(g.id, g.size) + { + g.y += baseline - pl.ascent + origin; + } else { + g.y += baseline; + } g })); lay.lines.push(Line { @@ -910,6 +928,53 @@ mod tests { assert!(l.glyphs[3].x < l.glyphs[0].x); } + #[test] + fn japanese_vertical_punctuation_uses_alternates_at_the_top_right() { + use skrifa::{ + MetadataProvider, + instance::{LocationRef, Size}, + }; + fonts::scan_system(); + let face = fonts::all_faces().into_iter().find(|f| { + ((f.info.family.contains("Hiragino") && !f.info.family.contains(" GB") && !f.info.family.contains(" TC")) + || f.info.family.contains("Shippori") + || f.info.family.contains("BIZ UD")) + && "国、。「」ー".chars().all(|c| f.has_char(c)) + && (f.has_feature(b"vert") || f.has_feature(b"vrt2")) + }); + let Some(face) = face else { + eprintln!("SKIPPED: no Japanese font with vertical alternates available"); + return; + }; + let style = TextStyle { family: face.info.family.clone(), style: face.info.style.clone(), ..st(48.0) }; + let text = "国、。「」ーABC"; + let horizontal = layout_uncached(text, &style, &ParagraphStyle::default()); + let vertical = layout_uncached(text, &style, &ParagraphStyle { vertical: true, ..Default::default() }); + assert_eq!(vertical.glyphs.len(), 9); + assert_eq!(vertical.glyphs[0].id, horizontal.glyphs[0].id); + for i in 1..6 { + assert_ne!(vertical.glyphs[i].id, horizontal.glyphs[i].id, "{}: vertical form for glyph {i}", face.info.family); + } + for i in [1, 2] { + let g = &vertical.glyphs[i]; + let line = &vertical.lines[i]; + let font = fonts::face(g.face); + let bounds = font.font().unwrap().glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(skrifa::GlyphId::new(g.id)).unwrap(); + let scale = g.size / font.units_per_em(); + let ink_x = g.x + (bounds.x_min + bounds.x_max) * scale / 2.0; + let ink_y = g.y - (bounds.y_min + bounds.y_max) * scale / 2.0; + let centre_x = line.x + line.width / 2.0; + let cell_top = line.baseline - line.ascent; + assert!(ink_x > centre_x, "{}: punctuation ink on the right ({ink_x}, {centre_x})", face.info.family); + assert!(ink_y < cell_top + g.size / 2.0, "{}: punctuation ink at the top ({ink_y}, {cell_top})", face.info.family); + } + for i in 6..9 { + assert_eq!(vertical.glyphs[i].id, horizontal.glyphs[i].id, "Latin shaping stays unchanged"); + } + assert_eq!(layout_uncached(text, &style, &ParagraphStyle::default()), horizontal); + eprintln!("verified vertical punctuation with {} / {}", face.info.family, face.info.style); + } + #[test] fn vertical_text_stacks_characters_in_columns() { let p = ParagraphStyle { vertical: true, ..Default::default() }; @@ -933,6 +998,51 @@ mod tests { assert_eq!(h.lines.len(), 2); } + #[test] + fn vertical_cells_keep_combining_marks_with_their_base() { + let style = st(32.0); + let para = ParagraphStyle { vertical: true, ..Default::default() }; + let decomposed = layout_uncached("e\u{301}x", &style, ¶); + let composed = layout_uncached("éx", &style, ¶); + assert_eq!(decomposed.lines.len(), 2, "the combining acute must not get another vertical cell"); + assert_eq!(decomposed.lines[0].range, 0..3); + assert_eq!(decomposed.lines[1].range, 3..4); + assert_eq!(decomposed.text_len, 4, "retain the original UTF-8 source offsets"); + assert_eq!( + decomposed.glyphs.iter().map(|g| (g.id, g.x, g.y)).collect::>(), + composed.glyphs.iter().map(|g| (g.id, g.x, g.y)).collect::>() + ); + assert_eq!(decomposed.bounds, composed.bounds); + assert_eq!(decomposed.caret(0).1, decomposed.caret(1).1, "base and combining mark occupy the same cell"); + for text in ["葛\u{e0101}x", "👩\u{200d}👧x"] { + let lay = layout_uncached(text, &style, ¶); + assert_eq!(lay.lines.len(), 2, "variation selectors and ZWJ sequences stay with their base"); + assert_eq!(lay.lines[0].range, 0..text.len() - 1); + assert_eq!(lay.lines[1].range, text.len() - 1..text.len()); + } + } + + #[test] + fn vertical_kana_keep_decomposed_dakuten_in_the_same_cell() { + fonts::scan_system(); + let Some(face) = fonts::all_faces().into_iter().find(|f| { + (f.info.family.contains("Hiragino") || f.info.family.contains("Shippori") || f.info.family.contains("BIZ UD")) + && "かがはぱ\u{3099}\u{309a}".chars().all(|c| f.has_char(c)) + }) else { + eprintln!("SKIPPED: no Japanese font covering combining dakuten/handakuten"); + return; + }; + let style = TextStyle { family: face.info.family.clone(), style: face.info.style.clone(), ..st(48.0) }; + let para = ParagraphStyle { vertical: true, ..Default::default() }; + let nfd = layout_uncached("か\u{3099}は\u{309a}", &style, ¶); + let nfc = layout_uncached("がぱ", &style, ¶); + assert_eq!(nfd.lines.len(), 2); + assert_eq!(nfd.lines[0].range, 0..6); + assert_eq!(nfd.lines[1].range, 6..12); + assert_eq!(nfd.glyphs.iter().map(|g| (g.id, g.x, g.y)).collect::>(), nfc.glyphs.iter().map(|g| (g.id, g.x, g.y)).collect::>()); + assert_eq!(nfd.bounds, nfc.bounds); + } + #[test] fn carets_and_hit_testing() { let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default()); diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 231120a3e..23f328bf5 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -20,6 +20,7 @@ use std::ops::{Add, AddAssign, Neg, Sub, SubAssign}; pub const TICKS_PER_SECOND: i64 = 254_016_000_000; /// A point or duration in time, in ticks. +/// Arithmetic saturates at the i64 storage limits; representable results remain exact. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct Tick(pub i64); @@ -37,20 +38,26 @@ impl Tick { } /// Exact conversion from a count of `units` at `per_second` (e.g. samples at 48 000). pub fn from_units(units: i64, per_second: i64) -> Tick { - Tick((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0) as i64) + Tick(clamp_i64((units as i128 * TICKS_PER_SECOND as i128).checked_div(per_second as i128).unwrap_or(0))) } /// Floor conversion to a count of units at `per_second`. pub fn to_units_floor(self, per_second: i64) -> i64 { - (self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128) as i64 + clamp_i64((self.0 as i128 * per_second as i128).div_euclid(TICKS_PER_SECOND as i128)) } /// Conversion from a rational timestamp `pts * num / den` seconds (container timebases). pub fn from_rational(pts: i64, num: i64, den: i64) -> Tick { - let t = pts as i128 * num as i128 * TICKS_PER_SECOND as i128; - Tick(t.checked_div_euclid(den as i128).unwrap_or(0) as i64) + if den == 0 { + return Tick::ZERO; + } + let Some(t) = (pts as i128 * num as i128).checked_mul(TICKS_PER_SECOND as i128) else { + // A product beyond i128 divided by an i64 cannot fit back into i64. + return Tick(if (pts < 0) ^ (num < 0) ^ (den < 0) { i64::MIN } else { i64::MAX }); + }; + Tick(clamp_i64(t.div_euclid(den as i128))) } /// Inverse of [`Tick::from_rational`], floored to the timebase. pub fn to_rational_floor(self, num: i64, den: i64) -> i64 { - (self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0) as i64 + clamp_i64((self.0 as i128 * den as i128).checked_div_euclid(num as i128 * TICKS_PER_SECOND as i128).unwrap_or(0)) } /// Inverse of [`Tick::from_rational`], rounded to the nearest timebase unit (halves up). /// @@ -63,10 +70,10 @@ impl Tick { let (n, unit) = if unit < 0 { (-n, -unit) } else { (n, unit) }; let (Some(q), Some(r)) = (n.checked_div_euclid(unit), n.checked_rem_euclid(unit)) else { return 0 }; // 0 <= r < unit: round up from the halfway point - (if r * 2 >= unit { q + 1 } else { q }) as i64 + clamp_i64(if r * 2 >= unit { q + 1 } else { q }) } pub fn abs(self) -> Tick { - Tick(self.0.abs()) + Tick(self.0.saturating_abs()) } pub fn min(self, o: Tick) -> Tick { Tick(self.0.min(o.0)) @@ -80,36 +87,36 @@ impl Tick { } /// Scale by a rational factor (`num/den`), flooring. pub fn mul_ratio(self, num: i64, den: i64) -> Tick { - Tick((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0) as i64) + Tick(clamp_i64((self.0 as i128 * num as i128).checked_div_euclid(den as i128).unwrap_or(0))) } } impl Add for Tick { type Output = Tick; fn add(self, o: Tick) -> Tick { - Tick(self.0 + o.0) + Tick(self.0.saturating_add(o.0)) } } impl Sub for Tick { type Output = Tick; fn sub(self, o: Tick) -> Tick { - Tick(self.0 - o.0) + Tick(self.0.saturating_sub(o.0)) } } impl Neg for Tick { type Output = Tick; fn neg(self) -> Tick { - Tick(-self.0) + Tick(self.0.saturating_neg()) } } impl AddAssign for Tick { fn add_assign(&mut self, o: Tick) { - self.0 += o.0; + self.0 = self.0.saturating_add(o.0); } } impl SubAssign for Tick { fn sub_assign(&mut self, o: Tick) { - self.0 -= o.0; + self.0 = self.0.saturating_sub(o.0); } } @@ -188,8 +195,8 @@ impl FrameRate { ]; pub fn new(num: i64, den: i64) -> Self { - let g = gcd(num.abs(), den.abs()).max(1); - FrameRate { num: num / g, den: den / g } + let g = i128::from(gcd(num.unsigned_abs(), den.unsigned_abs()).max(1)); + FrameRate { num: (i128::from(num) / g) as i64, den: (i128::from(den) / g) as i64 } } /// Closest standard rate for a float (e.g. from a container's average rate). @@ -213,40 +220,52 @@ impl FrameRate { } /// Exact duration of one frame (rounded down only for exotic rates). + /// Values outside the integer tick range saturate at its limit. pub fn frame_duration(self) -> Tick { let r = self.sane(); - Tick(((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128) as i64) + Tick(clamp_i64((TICKS_PER_SECOND as i128 * r.den as i128) / r.num as i128)) } - /// Index of the frame containing `t` (floor). + /// Index of the frame containing `t` (floor), saturated to the integer frame range. pub fn frame_at(self, t: Tick) -> i64 { - let r = self.sane(); - (t.0 as i128 * r.num as i128).div_euclid(TICKS_PER_SECOND as i128 * r.den as i128) as i64 + clamp_i64(frame_index(self.sane(), t)) } - /// Start tick of frame `f`. + /// Start tick of frame `f` (ceiling division). + /// + /// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every + /// representable frame boundary with at least one tick per frame, even when the frame duration is not an integer number of ticks + /// (exotic rates like 37.516 fps where `TICKS_PER_SECOND * den / num` has a + /// fractional remainder). Floor division truncates the remainder, causing each + /// frame boundary to drift backward by one tick until `frame_at` returns `f - 1` + /// instead of `f` — frame stepping then stalls or moves backward. + /// Values outside the integer tick range saturate at its limit. pub fn tick_of(self, f: i64) -> Tick { - let r = self.sane(); - let n = f as i128 * TICKS_PER_SECOND as i128 * r.den as i128; - Tick(n.div_euclid(r.num as i128) as i64) + Tick(clamp_i64(frame_boundary(self.sane(), i128::from(f)))) } /// Snap `t` down to a frame boundary. pub fn snap(self, t: Tick) -> Tick { - self.tick_of(self.frame_at(t)) + let r = self.sane(); + Tick(clamp_i64(frame_boundary(r, frame_index(r, t)))) } /// Snap `t` to the nearest frame boundary. pub fn snap_nearest(self, t: Tick) -> Tick { - let a = self.snap(t); - let b = self.tick_of(self.frame_at(t) + 1); - if (t - a) <= (b - t) { a } else { b } + let r = self.sane(); + // Keep the frame index and both boundaries wide until choosing the nearest. Saturating + // an unrepresentable upper boundary first would make it seem closer than it really is. + let f = frame_index(r, t); + let a = frame_boundary(r, f); + let b = frame_boundary(r, f.saturating_add(1)); + let t = i128::from(t.0); + Tick(clamp_i64(if t - a <= b - t { a } else { b })) } /// Timecode base (frames counted per timecode second): 30 for 29.97, 24 for 23.976. pub fn timecode_base(self) -> i64 { let r = self.sane(); - ((r.num + r.den - 1) / r.den).max(1) + (r.num / r.den).saturating_add(i64::from(r.num % r.den != 0)).max(1) } /// NTSC (x/1001) rates can use drop-frame timecode. @@ -277,7 +296,29 @@ impl fmt::Display for FrameRate { } } -fn gcd(mut a: i64, mut b: i64) -> i64 { +/// Narrow only after applying the storage bounds, never by a wrapping integer cast. +fn clamp_i64(value: i128) -> i64 { + value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64 +} + +/// Callers pass a positive rate. Products of two i64 values fit in i128. +fn frame_index(rate: FrameRate, t: Tick) -> i128 { + (i128::from(t.0) * i128::from(rate.num)).div_euclid(i128::from(TICKS_PER_SECOND) * i128::from(rate.den)) +} + +/// Ceiling of the rational boundary. An overflowing numerator divided by an i64 rate cannot +/// fit in an i64 tick, so saturating that intermediate preserves the public result. Boundaries +/// derived from frame_index remain within i128 (at most one frame away from an i64 tick). +fn frame_boundary(rate: FrameRate, frame: i128) -> i128 { + let Some(n) = frame.checked_mul(i128::from(TICKS_PER_SECOND)).and_then(|n| n.checked_mul(i128::from(rate.den))) else { + return if frame < 0 { i128::MIN } else { i128::MAX }; + }; + let d = i128::from(rate.num); + // Flooring a fractional boundary puts the tick in the preceding frame. + n.div_euclid(d) + i128::from(n.rem_euclid(d) != 0) +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { while b != 0 { (a, b) = (b, a % b); } @@ -320,8 +361,9 @@ fn df_params(rate: FrameRate) -> (i64, i64) { /// Convert a frame count to SMPTE fields `(negative, h, m, s, f)`. pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, i64, i64, i64, i64) { let neg = frame < 0; - let mut n = frame.saturating_abs(); + let mut n = i128::from(frame).abs(); let (drop, base) = df_params(rate); + let (drop, base) = (i128::from(drop), i128::from(base)); if drop_frame && rate.supports_drop_frame() { let per_min = base * 60 - drop; let per_10 = per_min * 10 + drop; @@ -336,18 +378,20 @@ pub fn frames_to_fields(frame: i64, rate: FrameRate, drop_frame: bool) -> (bool, let s = (n / base) % 60; let mi = (n / (base * 60)) % 60; let h = n / (base * 3600); - (neg, h, mi, s, f) + (neg, clamp_i64(h), clamp_i64(mi), clamp_i64(s), clamp_i64(f)) } /// Convert SMPTE fields back to a frame count. pub fn fields_to_frames(h: i64, m: i64, s: i64, f: i64, rate: FrameRate, drop_frame: bool) -> i64 { let (drop, base) = df_params(rate); - let mut n = h.saturating_mul(3600).saturating_add(m.saturating_mul(60)).saturating_add(s).saturating_mul(base).saturating_add(f); + let seconds = i128::from(h) * 3600 + i128::from(m) * 60 + i128::from(s); + // Nominal drop-frame labels can exceed i64 even when the final frame count fits. + let mut n = seconds.saturating_mul(i128::from(base)).saturating_add(i128::from(f)); if drop_frame && rate.supports_drop_frame() { - let total_min = h.saturating_mul(60).saturating_add(m); - n = n.saturating_sub(drop.saturating_mul(total_min - total_min / 10)); + let total_min = i128::from(h) * 60 + i128::from(m); + n = n.saturating_sub(i128::from(drop) * (total_min - total_min / 10)); } - n + clamp_i64(n) } /// Format a frame count as SMPTE timecode (`HH:MM:SS:FF`, or `HH;MM;SS;FF` for drop-frame). @@ -368,7 +412,7 @@ pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisp TimeDisplay::Feet35 | TimeDisplay::Feet16 => { let per_ft = if display == TimeDisplay::Feet35 { 16 } else { 40 }; let neg = frame < 0; - let a = frame.abs(); + let a = frame.unsigned_abs(); format!("{}{}+{:02}", if neg { "-" } else { "" }, a / per_ft, a % per_ft) } TimeDisplay::AudioSamples => { @@ -502,6 +546,119 @@ mod tests { } } + #[test] + fn boundary_rates_saturate_without_wrapping_and_keep_representable_values_exact() { + let slow = FrameRate { num: 1, den: i64::MAX }; + assert_eq!(slow.frame_duration(), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MAX), Tick(i64::MAX)); + assert_eq!(slow.tick_of(i64::MIN), Tick(i64::MIN)); + assert_eq!(slow.snap_nearest(Tick(i64::MAX)), Tick::ZERO, "compare the real boundary before saturating"); + assert_eq!(slow.snap_nearest(Tick(i64::MIN)), Tick::ZERO); + let fast = FrameRate { num: i64::MAX, den: 1 }; + assert_eq!(fast.tick_of(i64::MAX), Tick(TICKS_PER_SECOND), "large factors can cancel exactly"); + assert_eq!(fast.tick_of(-i64::MAX), Tick(-TICKS_PER_SECOND)); + assert_eq!(fast.frame_at(Tick(TICKS_PER_SECOND)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MAX)), i64::MAX); + assert_eq!(fast.frame_at(Tick(i64::MIN)), i64::MIN); + assert_eq!(fast.timecode_base(), i64::MAX); + for t in [i64::MIN, -TICKS_PER_SECOND, -1, 0, 1, TICKS_PER_SECOND, i64::MAX] { + assert_eq!(fast.snap(Tick(t)), Tick(t), "sub-tick frames include every integer tick"); + assert_eq!(fast.snap_nearest(Tick(t)), Tick(t)); + } + let balanced = FrameRate { num: i64::MAX, den: i64::MAX }; + assert_eq!(balanced.frame_duration(), Tick(TICKS_PER_SECOND)); + assert_eq!(balanced.tick_of(2), Tick(2 * TICKS_PER_SECOND)); + assert_eq!(balanced.frame_at(Tick(2 * TICKS_PER_SECOND)), 2); + assert_eq!(balanced.timecode_base(), 1); + assert_eq!(FrameRate::new(i64::MIN, i64::MIN), FrameRate { num: -1, den: -1 }); + assert_eq!(FrameRate::new(i64::MIN, 0), FrameRate { num: -1, den: 0 }); + assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); + } + + #[test] + fn scalar_time_conversions_saturate_at_storage_limits() { + assert_eq!(Tick::from_units(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_units(i64::MIN, 1), Tick(i64::MIN)); + assert_eq!(Tick(i64::MAX).to_units_floor(i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_units_floor(i64::MAX), i64::MIN); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, 1), Tick(i64::MIN)); + assert_eq!(Tick::from_rational(i64::MIN, i64::MAX, -1), Tick(i64::MAX)); + assert_eq!(Tick::from_rational(i64::MAX, i64::MAX, 0), Tick::ZERO); + assert_eq!(Tick(i64::MAX).to_rational_floor(1, i64::MAX), i64::MAX); + assert_eq!(Tick(i64::MIN).to_rational_round(1, i64::MAX), i64::MIN); + assert_eq!(Tick(i64::MAX).mul_ratio(i64::MAX, 1), Tick(i64::MAX)); + assert_eq!(Tick(i64::MIN).abs(), Tick(i64::MAX)); + } + + #[test] + fn drop_frame_fields_keep_cancelling_terms_wide_at_storage_limits() { + for rate in [FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] { + for frame in [0, 1, 999_999, i64::MAX - 1, i64::MAX] { + let (negative, h, m, s, f) = frames_to_fields(frame, rate, true); + assert!(!negative); + assert_eq!(fields_to_frames(h, m, s, f, rate, true), frame, "{rate}: {frame}"); + assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), -frame); + } + let (negative, h, m, s, f) = frames_to_fields(i64::MIN, rate, true); + assert!(negative); + assert_eq!(fields_to_frames(-h, -m, -s, -f, rate, true), i64::MIN); + } + } + + #[test] + fn tick_arithmetic_saturates_only_outside_storage_limits() { + let values = [i64::MIN, i64::MIN + 1, Tick::MIN.0, -1, 0, 1, Tick::MAX.0, i64::MAX - 1, i64::MAX]; + for a in values { + assert_eq!(-Tick(a), Tick(clamp_i64(-i128::from(a)))); + for b in values { + let sum = Tick(clamp_i64(i128::from(a) + i128::from(b))); + let difference = Tick(clamp_i64(i128::from(a) - i128::from(b))); + assert_eq!(Tick(a) + Tick(b), sum); + assert_eq!(Tick(a) - Tick(b), difference); + let mut assigned = Tick(a); + assigned += Tick(b); + assert_eq!(assigned, sum); + assigned = Tick(a); + assigned -= Tick(b); + assert_eq!(assigned, difference); + } + } + assert_eq!(Tick::MAX + Tick(1), Tick(Tick::MAX.0 + 1), "project bounds are not storage bounds"); + assert_eq!(Tick::MIN - Tick(1), Tick(Tick::MIN.0 - 1)); + assert_eq!(TimeRange::new(Tick(i64::MAX), Tick(1)).end(), Tick(i64::MAX)); + assert_eq!(TimeRange::new(Tick(i64::MIN), Tick(-1)).end(), Tick(i64::MIN)); + assert_eq!(TimeRange::from_bounds(Tick(i64::MIN), Tick(i64::MAX)).duration, Tick(i64::MAX)); + assert_eq!(TimeRange::from_bounds(Tick(i64::MAX), Tick(i64::MIN)).duration, Tick(i64::MIN)); + let ordinary = TimeRange::from_bounds(Tick::MIN, Tick::MAX); + assert_eq!(ordinary.end(), Tick::MAX); + } + + #[test] + fn numeric_limits_are_total_across_frame_conversion_and_display() { + let values = [i64::MIN, -i64::MAX, -1, 0, 1, TICKS_PER_SECOND, i64::MAX]; + for num in values { + for den in values { + let r = FrameRate { num, den }; + let _ = FrameRate::new(num, den); + let _ = r.frame_duration(); + assert!(r.timecode_base() >= 1); + for value in values { + let tick = Tick(value); + let _ = r.tick_of(value); + let _ = r.frame_at(tick); + let _ = r.snap(tick); + let _ = r.snap_nearest(tick); + for display in TimeDisplay::ALL { + let _ = format_time(tick, r, true, display, 48_000); + } + let (_, h, m, sec, f) = frames_to_fields(value, r, true); + let _ = fields_to_frames(h, m, sec, f, r, true); + } + } + } + } + #[test] fn every_common_rate_is_exact() { for r in FrameRate::COMMON { @@ -571,7 +728,98 @@ mod tests { assert_eq!(FrameRate::FPS_23_976.label(), "23.976"); } + #[test] + fn exotic_frame_rate_roundtrip() { + // Issue #301: 37.516 fps footage. The frame duration is + // 254_016_000_000 * 250 / 9379 = 6_770_871_094.999… ticks — not an + // integer. Floor division in `tick_of` truncated to 6_770_871_094, + // losing ~1 tick/frame so `frame_at(tick_of(f))` returned `f - 1`. + // Ceiling division makes the round-trip exact for all integer frames. + let rates: &[(FrameRate, &str)] = &[ + (FrameRate::from_f64(37.516), "37.516"), + (FrameRate::from_f64(37.5161), "37.5161"), + (FrameRate::from_f64(12.345), "12.345"), + (FrameRate::from_f64(59.9401), "59.9401"), + (FrameRate::from_f64(23.9761), "23.9761"), + ]; + for &(r, name) in rates { + // Round-trip over a wide range including negatives. + let bad: Vec = (-20_000..20_000).filter(|&f| r.frame_at(r.tick_of(f)) != f).collect(); + assert!(bad.is_empty(), "{name}: {} round-trip failures, first {bad:?}", r); + + // snap(tick_of(f)) == tick_of(f): snapping a frame boundary is a no-op. + for f in -100i64..100 { + assert_eq!(r.snap(r.tick_of(f)), r.tick_of(f), "{name}: snap(tick_of({f}))"); + } + + // Step-back: the last tick of frame f is one tick before tick_of(f+1), + // and frame_at of that is still f. Also check the frame_duration path: + // frame_at(tick_of(f) - frame_duration()) == f - 1. + for f in 1..2000i64 { + let last_tick = r.tick_of(f + 1) - Tick(1); + assert_eq!(r.frame_at(last_tick), f, "{name}: last tick of frame {f}"); + let prev = r.tick_of(f) - r.frame_duration(); + assert_eq!(r.frame_at(prev), f - 1, "{name}: tick_of({f}) - frame_duration"); + } + } + + // Stepping simulation: frame_at(playhead) + 1, tick_of, snap. + // Before the fix this stalled at frame 0 (step forward did nothing). + let r = FrameRate::from_f64(37.516); + let mut tick = Tick::ZERO; + for expected in 0..50 { + let frame = r.frame_at(tick); + assert_eq!(frame, expected, "stepping forward at frame {expected}"); + tick = r.tick_of(frame + 1); + tick = r.snap(tick); // set_playhead calls snap + } + + // Stepping backward from frame 50 should land on 49, 48, … 0 + tick = r.tick_of(50); + let mut last = 50; + while last > 0 { + let frame = r.frame_at(tick); + let target = frame - 1; + if target < 0 { + break; + } + tick = r.snap(r.tick_of(target)); + let landed = r.frame_at(tick); + assert_eq!(landed, target, "stepping backward to frame {target}"); + last = landed; + } + } + + #[test] + fn exotic_frame_rate_durations() { + // frame_duration uses floor (not round) so that `end - frame_duration()` + // lands on the previous frame, not two frames back, for exotic rates. + let r = FrameRate::from_f64(37.516); + // 254_016_000_000 * 250 / 9379 = 6_770_871_094.999… → floor = 6_770_871_094 + assert_eq!(r.frame_duration().0, 6_770_871_094); + + // Standard rates must remain exact (unchanged). + assert_eq!(FrameRate::FPS_23_976.frame_duration().0, 10_594_584_000); + assert_eq!(FrameRate::FPS_24.frame_duration().0, 10_584_000_000); + assert_eq!(FrameRate::FPS_25.frame_duration().0, 10_160_640_000); + assert_eq!(FrameRate::FPS_29_97.frame_duration().0, 8_475_667_200); + assert_eq!(FrameRate::FPS_30.frame_duration().0, 8_467_200_000); + assert_eq!(FrameRate::FPS_60.frame_duration().0, 4_233_600_000); + } + proptest! { + #[test] + fn extreme_frame_math_stays_monotonic(num in 1i64..=i64::MAX, den in 1i64..=i64::MAX, a in any::(), b in any::()) { + let r = FrameRate { num, den }; + let (lo, hi) = (a.min(b), a.max(b)); + prop_assert!(r.tick_of(lo) <= r.tick_of(hi)); + prop_assert!(r.frame_at(Tick(lo)) <= r.frame_at(Tick(hi))); + prop_assert!(r.snap(Tick(lo)) <= r.snap(Tick(hi))); + prop_assert!(r.snap_nearest(Tick(lo)) <= r.snap_nearest(Tick(hi))); + let snapped = r.snap_nearest(Tick(a)); + prop_assert_eq!(r.snap_nearest(snapped), snapped); + } + #[test] fn frame_tick_roundtrip(f in -1_000_000i64..10_000_000, ri in 0usize..11) { let r = FrameRate::COMMON[ri]; @@ -579,6 +827,16 @@ mod tests { prop_assert_eq!(r.frame_at(r.tick_of(f) + r.frame_duration() - Tick(1)), f); } + #[test] + fn exotic_frame_tick_roundtrip(f in -100_000i64..100_000, num in 1u64..200_000, den in 1u64..2_000) { + // Skip rates that divide evenly (those are covered by COMMON above). + let r = FrameRate::new(num as i64, den as i64); + if (TICKS_PER_SECOND as i128 * r.den as i128) % r.num as i128 == 0 { + return Ok(()); + } + prop_assert_eq!(r.frame_at(r.tick_of(f)), f, "rate={}/{}, f={}", r.num, r.den, f); + } + #[test] fn drop_frame_bijection(f in 0i64..(24 * 107_892)) { let r = FrameRate::FPS_29_97; diff --git a/crates/tts-text/src/phonemize.rs b/crates/tts-text/src/phonemize.rs index 8d3e7437c..6d6c1f33d 100644 --- a/crates/tts-text/src/phonemize.rs +++ b/crates/tts-text/src/phonemize.rs @@ -1487,44 +1487,3 @@ PERCENT P ER0 S EH1 N T prev[b.len()] } } - -#[cfg(test)] -mod probe2 { - use super::tests::test_lex; - use super::*; - #[test] - fn probe2_outputs() { - let lex = test_lex(); - println!("DICT_NINETY {:?}", lex.pronunciations("NINETY")); - println!("DICT_LEN {}", lex.len()); - println!("W1999 {:?}", phonemize("in 1999.", Lang::EnUs, &lex, &Overrides::default())); - println!("WNINETY {:?}", word_phonemes("ninety", &lex, None, Lang::EnUs)); - println!("WHYPHEN {:?}", word_phonemes("ninety-nine", &lex, None, Lang::EnUs)); - println!("WVERSION {:?}", word_phonemes("version", &lex, None, Lang::EnUs)); - println!("WSEVENTH {:?}", word_phonemes("seventh", &lex, None, Lang::EnUs)); - println!("WOFF {:?}", word_phonemes("off", &lex, None, Lang::EnUs)); - println!("WCALL {:?}", word_phonemes("call", &lex, None, Lang::EnUs)); - println!("WPERCENT {:?}", word_phonemes("percent", &lex, None, Lang::EnUs)); - println!("WOCTOBER {:?}", word_phonemes("october", &lex, None, Lang::EnUs)); - println!("WAPPLE {:?}", word_phonemes("apple", &lex, None, Lang::EnUs)); - println!("WTOM {:?}", word_phonemes("tom", &lex, None, Lang::EnUs)); - println!("WJERRY {:?}", word_phonemes("jerry", &lex, None, Lang::EnUs)); - println!("WREALLY {:?}", word_phonemes("really", &lex, None, Lang::EnUs)); - println!("WWOW {:?}", word_phonemes("wow", &lex, None, Lang::EnUs)); - println!("WEND {:?}", word_phonemes("end", &lex, None, Lang::EnUs)); - println!("WVERSIONPUNCT {:?}", phonemize("Version 2. Next.", Lang::EnUs, &lex, &Overrides::default())); - println!("WWAS {:?}", word_phonemes("was", &lex, None, Lang::EnUs)); - println!("WITWAS {:?}", phonemize("It was 3.5 percent.", Lang::EnUs, &lex, &Overrides::default())); - println!("WUP {:?}", phonemize("Up 20%.", Lang::EnUs, &lex, &Overrides::default())); - println!("WONDATE {:?}", phonemize("On 10/7/2026.", Lang::EnUs, &lex, &Overrides::default())); - println!("WCALLPHONE {:?}", phonemize("Call 555-0134.", Lang::EnUs, &lex, &Overrides::default())); - println!("WATOFL {:?}", word_phonemes("smile", &lex, None, Lang::EnUs)); - println!("WWORLD {:?}", word_phonemes("worlds", &lex, None, Lang::EnUs)); - println!("WBRIDGE {:?}", letter_to_sound("bridge")); - println!("WWRIST {:?}", letter_to_sound("wrist")); - println!("WKNOCK {:?}", letter_to_sound("knock")); - println!("WSMILE {:?}", letter_to_sound("smile")); - println!("WBUSINESS {:?}", letter_to_sound("business")); - println!("WCOLONEL {:?}", letter_to_sound("colonel")); - } -} diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md new file mode 100644 index 000000000..699a95d49 --- /dev/null +++ b/crates/ui-egui/README.md @@ -0,0 +1,59 @@ +# FilmCraft interface + +The egui frontend draws panels and dialogs, and dispatches project changes through the engine. +Interactive controls keep stable automation ids regardless of the interface language. + +## Transition drop previews + +Dragging a video or audio transition over a compatible clip shows its actual time span, the +selected cut and an In/Out label. Shared cuts center the span; isolated edges use a one-sided +span. The preview uses the engine's read-only transition planner, including Timeline duration +preferences, and release dispatches `effects.apply` with the same edge. Locked tracks and +incompatible clip types show no valid transition preview. Ordinary effects retain their clip +outline. `timeline.transitionDropPreview` exposes the span and edge to automation. + +`transition_drop_ui` covers both edges of short video/audio clips, hover without project edits, +the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screenshots with wgpu. + +## Localisation + +Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's +`general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. +Its default, System Language (`system`), follows the operating system: the first of the user's +preferred languages that the interface has (the host supplies them through +`HostHooks::system_languages`: `sys-locale` on the desktop, `navigator.languages` on the web), +otherwise English. +The `app.language.*` UI commands and `prefs.set` reach it through the control channel. +Japanese requires a craft-fonts build or a suitable installed font. + +English source strings are lookup keys in `src/i18n/.tsv`. `tl!` translates literals, `tlf!` +fills translated templates, and `i18n::t` translates names from registries. Placeholder values +(including user filenames containing braces) are inserted literally. Catalog translations are +original work using ordinary language, without proprietary localisation resources. + +Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the +translated label and its English source, including Unicode capitals. Project content, command ids +and preference values retain their original values. Engine errors, CLI and MCP messages remain +English; Brazilian Portuguese currently covers core menus and falls back to English elsewhere. + +Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, +literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual +checks use the control channel to switch language and capture the resulting panels. + +## Source playback + +`source_playback.rs` auditions media independently of Program playback. It reuses MediaSource +decoding, output mapping, the device clock and the desktop play-ahead buffer. Only one monitor +owns the device at a time. Source frame jobs use the existing frame server and Source resolution. +The live project, undo stack and timeline playhead are unchanged. See [monitor documentation](../../docs/monitors.md) +for behavior, commands, limitations and regression tests. Source playback tests use synthetic +demo media and fake audio outputs; no third-party assets are introduced. + +Source drag controls snapshot the selected span and dispatch `timeline.place` for video, audio, +or linked video/audio. Full-clip marks are implicit until set by the user. The engine owns edits, +range validation and undo; the UI owns the drag gesture. See `source_drag_ui` and the engine's +`source_placement_tests` for regression coverage. + +Source range handles use transient gesture previews and the engine's integer-frame +`source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing +keeps Source navigation, markers and marked-range playback separate from Program. diff --git a/crates/ui-egui/examples/thumb_burst.rs b/crates/ui-egui/examples/thumb_burst.rs new file mode 100644 index 000000000..87b18f421 --- /dev/null +++ b/crates/ui-egui/examples/thumb_burst.rs @@ -0,0 +1,88 @@ +//! Memory of the thumbnail burst that follows an import (issue: 6+ GB while importing 42 4K H.264 +//! clips with hardware decoding off). +//! +//! Imports every `*.mp4` of a directory one by one, as the Project panel does, and asks for each +//! clip's poster thumbnail (priority 50, 160 px) through the real [`FrameServer`] right after its +//! import. Prints the wall time until every thumbnail is ready and the peak number of live +//! decoders. Measure the process' private bytes from outside (`stress.py`'s sampler, or any +//! process monitor). +//! +//! ```sh +//! cargo run --release -p filmcraft-ui-egui --example thumb_burst -- [--hw off|auto] [--limit N] +//! ``` + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use filmcraft_engine::Session; +use filmcraft_project::ItemId; +use filmcraft_ui_egui::frames::{FrameKey, FrameServer, Target}; +use serde_json::json; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let value = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + let Some(dir) = args.first().filter(|a| !a.starts_with("--")).map(PathBuf::from) else { + eprintln!("usage: thumb_burst [--hw off|auto] [--limit N]"); + return; + }; + filmcraft_codecs::hw::set_hardware_decoding(value("--hw").as_deref() != Some("off")); + let limit = value("--limit").and_then(|v| v.parse().ok()).unwrap_or(usize::MAX); + let mut clips: Vec = std::fs::read_dir(&dir).map(|d| d.filter_map(|e| e.ok().map(|e| e.path())).collect()).unwrap_or_default(); + clips.retain(|p| p.extension().is_some_and(|e| e == "mp4")); + clips.sort(); + clips.truncate(limit); + + let mut s = Session::default(); + let server = FrameServer::new(s.media.clone(), s.services.clone(), s.previews.clone(), FrameServer::default_workers()); + + let peak = std::sync::Arc::new(AtomicUsize::new(0)); + let stop = std::sync::Arc::new(AtomicBool::new(false)); + let sampler = { + let (peak, stop) = (peak.clone(), stop.clone()); + std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + peak.fetch_max(filmcraft_codecs::live_decoders(), Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(5)); + } + }) + }; + + let t0 = Instant::now(); + let mut keys = Vec::new(); + for path in &clips { + let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).expect("import"); + let id = ItemId(r["items"][0].as_u64().expect("item")); + let (rate, width) = match s.project.item(id).map(|i| (i.frame_rate(), &i.kind)) { + Some((rate, filmcraft_project::ItemKind::Media(m))) => (rate, m.info.video.as_ref().map_or(1920, |v| v.width)), + _ => continue, + }; + let rev = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + if let Some(filmcraft_project::ItemKind::Media(m)) = s.project.item(id).map(|i| &i.kind) { + filmcraft_engine::media_pool::media_key(m).hash(&mut h); + false.hash(&mut h); + } + h.finish() + }; + let key = FrameKey { target: Target::Item(id), frame: 0, size: 160, revision: rev, draft: false }; + server.request(key, rate.tick_of(0), 160.0 / width.max(1) as f32, &s.project.clone(), 50); + keys.push(key); + } + let imported = t0.elapsed(); + while keys.iter().any(|k| !server.is_ready(k)) && t0.elapsed() < Duration::from_secs(300) { + std::thread::sleep(Duration::from_millis(10)); + } + stop.store(true, Ordering::Relaxed); + let _ = sampler.join(); + println!( + "clips {} imported in {:.2}s, all thumbnails ready after {:.2}s, peak live decoders {}, gop frames cached {:.0} MB", + keys.len(), + imported.as_secs_f64(), + t0.elapsed().as_secs_f64(), + peak.load(Ordering::Relaxed), + filmcraft_codecs::cached_bytes() as f64 / 1048576.0 + ); +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs new file mode 100644 index 000000000..b7c8811ce --- /dev/null +++ b/crates/ui-egui/src/cjk.rs @@ -0,0 +1,69 @@ +//! Chinese fallback fonts for document text in the interface, independent of UI language. +//! Optional craft-fonts are preferred; native builds without them reuse one installed face. + +use std::sync::{Arc, OnceLock}; + +use egui::{FontData, FontDefinitions, FontFamily}; + +const SAMPLE: &str = "中文轨道配乐音效简体汉语繁體漢語车"; +const PREFERRED: &[&str] = &[ + "Microsoft YaHei UI", + "Microsoft YaHei", + "PingFang SC", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "Source Han Sans CN", + "WenQuanYi Micro Hei", + "Microsoft JhengHei UI", + "Microsoft JhengHei", +]; + +pub(crate) fn craft_fonts() -> impl Iterator { + filmcraft_text::fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| matches!(*s, "Hans" | "Hant"))) +} + +/// A font collection's face index matters: Windows Chinese UI fonts commonly live in TTC files. +/// Keep the owned bytes behind an Arc so theme reinstalls do not reread or leak a font file. +fn system_font() -> Option> { + static FONT: OnceLock>> = OnceLock::new(); + FONT.get_or_init(|| { + if cfg!(target_arch = "wasm32") { + return None; + } + filmcraft_text::fonts::scan_system(); + let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); + let covers = |f: &filmcraft_text::fonts::Face| SAMPLE.chars().all(|c| f.has_char(c)); + let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); + let preferred = PREFERRED.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); + let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; + Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() })) + }) + .clone() +} + +/// Append Chinese faces after the Latin UI fonts and the Japanese fallbacks. This keeps the +/// interface's Latin typography and Japanese glyph forms, and covers the simplified and +/// traditional hanzi in media and track names that Japanese fonts lack. +pub(crate) fn install(fonts: &mut FontDefinitions) { + let chinese: Vec<_> = craft_fonts().collect(); + if chinese.is_empty() { + if let Some(font) = system_font() { + fonts.font_data.insert("system-chinese".into(), font); + for stack in fonts.families.values_mut() { + stack.push("system-chinese".into()); + } + } + return; + } + let name = |f: &filmcraft_text::fonts::CraftFont| format!("craft:{} {}", f.family, f.style); + for f in &chinese { + fonts.font_data.insert(name(f), Arc::new(FontData::from_static(f.bytes))); + } + for (family, stack) in &mut fonts.families { + let heavy = matches!(family, FontFamily::Name(n) if matches!(n.as_ref(), "semibold" | "medium")); + let mut order = chinese.clone(); + order.sort_by_key(|f| (f.family.contains("Serif") && !f.family.contains("Sans"), (f.style == "Bold") != heavy)); + stack.extend(order.iter().map(|f| name(f))); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0b222773f..162602581 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -14,7 +14,8 @@ //! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` -//! - `ui.timeline.hit {x, y}`: what the timeline shows at a point (track, clip, edge, time) +//! - `ui.timeline.hit {x, y, modifiers?}`: what the timeline shows at a point (track, clip, edge, +//! time) and `kind`, the trim a press of the current tool with `modifiers` starts there //! - `ui.timeline.locate {clip, edge?}`: screen point of a clip body/edge (for drags) //! - `ui.playback {action: play|stop|toggle, speed?}` //! - `ui.screenshot {path?, panel?}`: PNG of the window (or one panel) @@ -63,10 +64,23 @@ fn err(e: impl std::fmt::Display) -> Outcome { Outcome::Done(json!({"ok": false, "error": e.to_string()})) } +/// Modifier flags from `{modifiers: {ctrl, command, shift, alt}}` (or the same keys at the top +/// level), as the OS would report them pressed. fn modifiers(p: &Value) -> egui::Modifiers { let m = p.get("modifiers").unwrap_or(p); let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: cfg!(target_os = "macos") && b("command"), command: b("command") } + as_pressed(egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: cfg!(target_os = "macos") && b("command"), command: b("command") }) +} + +/// `m` as egui-winit reports a physical press: off macOS Control is the primary modifier, so +/// `ctrl` and `command` are one key and a press of either carries both. A synthetic `Ctrl+Z` then +/// matches a `Cmd+Z` binding exactly as the real key does (#245). +fn as_pressed(mut m: egui::Modifiers) -> egui::Modifiers { + if !cfg!(target_os = "macos") && (m.ctrl || m.command) { + m.ctrl = true; + m.command = true; + } + m } /// Resolve a point from `{id}` (element centre) or `{x, y}`. @@ -342,9 +356,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) } "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; - let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; - let extra = modifiers(p); - m |= extra; + let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; + let m = as_pressed(m | modifiers(p)); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); Outcome::AfterInput @@ -360,7 +373,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Ok(p) => p, Err(e) => return err(e), }; - ok(crate::panels::timeline::hit_json(app, pos)) + ok(crate::panels::timeline::hit_json(app, pos, modifiers(p))) } "ui.timeline.locate" => { let Some(c) = p.get("clip").and_then(Value::as_u64) else { return err("need `clip`") }; @@ -427,6 +440,7 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, "ui": app.ui, + "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0}, "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()}, "playhead": app.session.playhead().0, "activeSequence": app.session.state.active_sequence.map(|i| i.0), diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index fe6b7ac81..c1976eb80 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -234,37 +234,37 @@ pub fn contributors_ui(ui: &mut egui::Ui, t: &Tokens, auto: &mut Registry) { let id = egui::Id::new("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label("Show"); + ui.label(tl!("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, crate::i18n::t(m.label())); auto.add(&format!("about.credits.names.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; } } ui.separator(); - ui.label("Sort"); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + ui.label(tl!("Sort")); + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { + if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; v.ascending = k.default_ascending(); } } }); auto.add("about.credits.sort", combo.response.rect, "Sort"); - let r = ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order"); + let r = ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")); auto.add("about.credits.reverse", r.rect, "Reverse the order"); if r.clicked() { v.ascending = !v.ascending; } ui.separator(); - let r = ui.selectable_label(!v.table, "Grab bag"); + let r = ui.selectable_label(!v.table, tl!("Grab bag")); auto.add("about.credits.grabBag", r.rect, "Grab bag"); if r.clicked() { v.table = false; } - let r = ui.selectable_label(v.table, "Table"); + let r = ui.selectable_label(v.table, tl!("Table")); auto.add("about.credits.table", r.rect, "Table"); if r.clicked() { v.table = true; @@ -275,7 +275,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, t: &Tokens, auto: &mut Registry) { ui.separator(); egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { - ui.label("No contributor data was built into this copy."); + ui.label(tl!("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v, auto); } else { @@ -296,7 +296,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for (i, k) in SortKey::ALL.into_iter().enumerate() { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(crate::i18n::t(k.label().0)); auto.add(&format!("about.credits.header.{i}"), r.rect, k.label().0); if r.clicked() { if v.key == k { @@ -327,13 +327,13 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi /// About ▸ Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { - ui.label("No model credits were built into this copy."); + ui.label(tl!("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { + for h in [tl!("Company"), tl!("Model"), tl!("Version"), tl!("Commits"), tl!("% of all commits"), tl!("Lines +/−")] { ui.label(RichText::new(h).strong()); } ui.end_row(); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5e254f766..05acf45b7 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -539,9 +539,9 @@ pub fn draw_group_chrome( tabs.extend(seqs.open.iter().map(|(id, name)| Tab { panel: *p, seq: Some(*id), title: name.clone(), active: seqs.active == Some(*id) })); } else if *p == PanelKind::Timeline { let name = seqs.open.iter().find(|(id, _)| seqs.active == Some(*id)).map(|(_, n)| n.clone()); - tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| "Timeline: (no sequences)".into()), active }); + tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| tl!("Timeline: (no sequences)").into()), active }); } else { - tabs.push(Tab { panel: *p, seq: None, title: p.title().to_string(), active }); + tabs.push(Tab { panel: *p, seq: None, title: crate::i18n::t(p.title()).to_string(), active }); } } let activate = |tab: &Tab, actions: &mut Vec| match tab.seq { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 6e0ef1ef0..c8db19cdd 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -35,6 +35,22 @@ pub enum Target { MulticamAngle(ItemId, u32), } +/// Item jobs at this priority or a larger number are background work: thumbnails of the Project +/// panel and the timeline (50) and the Media Browser (40). Monitors and playback use 0..=3. Their +/// decoders are small and are not kept (`filmcraft_media::cancel::with_background`). +pub const BACKGROUND_PRIO: u32 = 40; + +/// Whether a job is a thumbnail: a single item at a background priority. Sequence frames are never +/// background, whatever their priority (the scopes ask for the program frame at 40 while playing, +/// and must not turn the program's decoders into small ones). +fn is_background(job: &Job) -> bool { + background_work(job.prio, job.key.target) +} + +fn background_work(prio: u32, target: Target) -> bool { + prio >= BACKGROUND_PRIO && matches!(target, Target::Item(_)) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct FrameKey { pub target: Target, @@ -234,6 +250,9 @@ impl filmcraft_media::MediaSource for TimedSource { fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { self.0.audio(start, frames, sample_rate) } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.0.audio_stream(stream, start, frames, sample_rate) + } } /// The job's source provider: the pool, with source fetches timed (the time feeds @@ -258,10 +277,7 @@ pub struct GpuPlan { impl GpuPlan { /// Bytes this plan keeps alive (layer frames + converted texels). fn bytes(&self) -> usize { - let frames = match &self.plan { - filmcraft_render::plan::FramePlan::Layers { layers, .. } => layers.iter().map(|l| l.frame.byte_size()).sum(), - filmcraft_render::plan::FramePlan::Image(img) => img.px.len() * 4, - }; + let frames = self.plan.source_bytes(); frames + self.prepared.bytes() } } @@ -535,10 +551,15 @@ impl FrameServer { // A new frame on screen for this view replaces the one asked for before: scrubbing // asks for one per refresh, and without this the oldest position would decode // first (or keep a decoder busy seeking to it) while the newest waits. + // + // A job already running for the same frame at an older revision (a value being + // dragged asks for a new revision per refresh) is left to finish: it needs the same + // source frames, so it holds no decoder back, and when frames take longer than a + // refresh, cancelling each one for the next would show nothing until the mouse rests. let same_view = |k: &FrameKey| k.target == key.target && k.size == key.size; q.retain(|j| !(j.prio == 0 && !j.prefetch && same_view(&j.key))); for (k, c, prefetch) in self.shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).iter() { - if !prefetch && *k != key && same_view(k) { + if !prefetch && same_view(k) && k.frame != key.frame { c.store(true, Ordering::Relaxed); } } @@ -804,23 +825,25 @@ fn run_job(sh: &Shared, job: &Job, pool: &Arc, services: &Arc, services: &Arc, pr #[cfg(test)] mod tests { - use super::{PlaybackMeter, playback_plan}; + use super::{PlaybackMeter, Target, background_work, playback_plan}; + use filmcraft_project::ItemId; + + #[test] + fn only_item_thumbnails_are_background_work() { + let item = Target::Item(ItemId(1)); + // thumbnails (50) and the Media Browser (40) + assert!(background_work(50, item)); + assert!(background_work(40, item)); + // the Source monitor shows an item at a foreground priority + assert!(!background_work(1, item)); + // the scopes ask for the program frame at 40 while playing: never background + assert!(!background_work(40, Target::Sequence(ItemId(1)))); + assert!(!background_work(50, Target::SequencePlan(ItemId(1)))); + } #[test] fn draft_decoding_only_for_reduced_resolution_playback() { diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index ed1fd3886..8615b827b 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -26,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 }; // Home let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0)); - let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text("Home"); + let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); app.auto.add("header.home", home, "Home"); if hresp.hovered() { p.rect_filled(home, 4.0, t.hover); @@ -37,9 +37,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } x = home.max.x + 16.0; // Mode tabs (14 pt; active = primary text with a 2 pt underline under the label) - for (m, label) in [(Mode::Import, "Import"), (Mode::Edit, "Edit"), (Mode::Export, "Export")] { + for (m, label, shown) in [(Mode::Import, "Import", tl!("Import")), (Mode::Edit, "Edit", tl!("Edit")), (Mode::Export, "Export", tl!("Export"))] { let active = app.ui.mode == m; - let galley = p.layout_no_wrap(label.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); + let galley = p.layout_no_wrap(shown.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0)); let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click()); app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label); @@ -66,7 +66,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { left_end = mu.min_rect().max.x; } // Document title, centred - let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " - Edited" } else { "" }); + let title = if app.session.is_dirty() { tlf!("{name} - Edited", name = app.session.project.name) } else { app.session.project.name.clone() }; // Right cluster: icons at ~38 pt pitch, then workspace name in caps. let mut rx = rect.max.x - 14.0; let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response { @@ -80,29 +80,29 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { icons::paint(ui.painter(), r.shrink(6.0), icon, if resp.hovered() { t.text } else { t.text_dim }); resp }; - if btn(ui, Icon::Fullscreen, "fullscreen", "Full screen", app).clicked() { + if btn(ui, Icon::Fullscreen, "fullscreen", tl!("Full screen"), app).clicked() { let fs = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fs)); } - if btn(ui, Icon::Speaker, "volume", "Volume", app).clicked() { - app.ui.status = "Master volume: use the Audio Track Mixer".into(); + if btn(ui, Icon::Speaker, "volume", tl!("Volume"), app).clicked() { + app.ui.status = tl!("Master volume: use the Audio Track Mixer").into(); } - if btn(ui, Icon::Search, "search", "Search", app).clicked() { + if btn(ui, Icon::Search, "search", tl!("Search"), app).clicked() { app.show_panel(crate::dock::PanelKind::Effects); } - if btn(ui, Icon::Bell, "notifications", "Progress", app).clicked() { + if btn(ui, Icon::Bell, "notifications", tl!("Progress"), app).clicked() { app.ui.mode = Mode::Export; } // Quick Export: a popup with File Name & Location, a preset list and Export (Premiere 26) let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right - if btn(ui, Icon::Export, "quickExport", "Quick Export", app).clicked() { + if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() { app.ui.export.quick_open = !app.ui.export.quick_open; ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); - let ws_resp = btn(ui, Icon::Workspaces, "workspaces", "Workspaces", app); + let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app); // workspace name (caps) - let ws = app.ui.workspace.to_uppercase(); + let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim); let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0)); let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click()); @@ -114,7 +114,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE); let w = g.size().x + 34.0; let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text("Join the ArtCraft Discord (discord.gg/artcraft)"); + let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); app.auto.add("header.discord", r, "Join the ArtCraft Discord"); ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent }); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); @@ -152,13 +152,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_min_width(220.0); for w in crate::dock::names(&app.workspaces) { let sel = app.ui.workspace == w; - if ui.selectable_label(sel, &w).clicked() { + if ui.selectable_label(sel, crate::i18n::t(&w)).clicked() { app.set_workspace(&w); ui.ctx().data_mut(|d| d.insert_temp(popup_id, false)); } } ui.separator(); - if ui.button("Reset to Saved Layout").clicked() { + if ui.button(tl!("Reset to Saved Layout")).clicked() { let n = app.ui.workspace.clone(); app.set_workspace(&n); ui.ctx().data_mut(|d| d.insert_temp(popup_id, false)); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs deleted file mode 100644 index 3514cd041..000000000 --- a/crates/ui-egui/src/i18n.rs +++ /dev/null @@ -1,1051 +0,0 @@ -//! Interface translations. Command ids, document text and file names remain stable. -//! Untranslated labels fall back to English so coverage can grow incrementally. -//! -//! Japanese text uses the Japanese craft-fonts when FilmCraft was built with them (`CRAFT_FONTS_DIR`; -//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise -//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to -//! Japanese is refused with a message. - -use std::sync::{Arc, OnceLock}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Language { - #[default] - En, - Ja, - Es, - /// Persisted as `pt-br` (the blanket `rename_all` would produce `ptbr`). - #[serde(rename = "pt-br")] - PtBr, -} - -impl Language { - pub const ALL: [Self; 4] = [Self::En, Self::Ja, Self::Es, Self::PtBr]; - - pub fn name(self) -> &'static str { - match self { - Self::En => "English", - Self::Ja => "日本語", - Self::Es => "Español", - Self::PtBr => "Português (Brasil)", - } - } - - /// Stable code persisted in the app config (`Language::parse` reads it back). - pub fn code(self) -> &'static str { - match self { - Self::En => "en", - Self::Ja => "ja", - Self::Es => "es", - Self::PtBr => "pt-br", - } - } - - pub fn parse(code: &str) -> Option { - match code { - "en" => Some(Self::En), - "ja" => Some(Self::Ja), - "es" => Some(Self::Es), - "pt-br" => Some(Self::PtBr), - _ => None, - } - } - - pub fn tr(self, text: &str) -> &str { - let table = match self { - Self::En => return text, - Self::Ja => JAPANESE, - Self::Es => SPANISH, - Self::PtBr => PORTUGUESE, - }; - table.iter().find(|(english, _)| *english == text).map_or(text, |(_, translated)| translated) - } -} - -/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana). -const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; - -/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces -/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if -/// none of these is present. -const PREFERRED_JAPANESE: &[&str] = &[ - "Hiragino Sans", - "Hiragino Kaku Gothic ProN", - "Hiragino Kaku Gothic Pro", - "Yu Gothic UI", - "Yu Gothic", - "Meiryo UI", - "Meiryo", - "Noto Sans CJK JP", - "Noto Sans JP", - "Source Han Sans JP", - "Source Han Sans", - "IPAexGothic", - "IPAGothic", - "TakaoGothic", - "VL Gothic", -]; - -const JAPANESE_FONT: &str = "system-japanese"; - -/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up -/// once per process: the system font folders are scanned on first use (name tables only), then the -/// chosen face's file is read. `None` on the web and on systems without a Japanese font. -pub fn system_japanese_font() -> Option> { - static FONT: OnceLock>> = OnceLock::new(); - FONT.get_or_init(|| { - filmcraft_text::fonts::scan_system(); - let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); - // within a family, the face closest to regular weight - let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); - let preferred = - PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); - let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; - // kept for the life of the process (one font, read once) so installing it again after a - // theme change shares the bytes instead of copying the whole file - let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); - Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) - }) - .clone() -} - -/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a -/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds -/// these faces to every font family, so nothing else needs installing (and no system scan runs). -pub fn craft_japanese_font() -> bool { - filmcraft_text::fonts::craft_japanese().next().is_some() - && filmcraft_text::fonts::all_faces() - .iter() - .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) -} - -/// Japanese for the interface: true when built with the craft-fonts (already installed by -/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next -/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again -/// after `theme::install`, which replaces the font definitions. -pub fn install_japanese_font(ctx: &egui::Context) -> bool { - if craft_japanese_font() { - return true; - } - let Some(font) = system_japanese_font() else { return false }; - let families = crate::theme::font_families() - .into_iter() - .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) - .collect(); - // queued for the next pass (works before the first frame); a no-op when already installed - ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); - true -} - -const SPANISH: &[(&str, &str)] = &[ - ("About FilmCraft", "Acerca de FilmCraft"), - ("Add Audio Submix Track", "Añadir pista de submezcla de audio"), - ("Add Caption at Playhead", "Añadir subtítulo en el cabezal"), - ("Add Chapter Marker…", "Añadir marcador de capítulo…"), - ("Add Edit", "Añadir edición"), - ("Add Edit to All Tracks", "Añadir edición a todas las pistas"), - ("Add Flash Cue Marker…", "Añadir marcador de referencia Flash…"), - ("Add Frame Hold", "Añadir fotograma congelado"), - ("Add Guide…", "Añadir guía…"), - ("Add Marker", "Añadir marcador"), - ("Add New Caption Track…", "Añadir nueva pista de subtítulos…"), - ("Add Range Marker", "Añadir marcador de rango"), - ("Add Range Marker to In and Out", "Añadir marcador de rango de entrada a salida"), - ("Add Tracks…", "Añadir pistas…"), - ("Adjustment Layer…", "Capa de ajuste…"), - ("Align to Selection", "Alinear con la selección"), - ("Align to Video Frame", "Alinear con el fotograma de video"), - ("Align to Video Frame as Group", "Alinear con el fotograma de video como grupo"), - ("All Panels", "Todos los paneles"), - ("Alpha", "Alfa"), - ("Appearance", "Apariencia"), - ("Appearance…", "Apariencia…"), - ("Apply Audio Transition", "Aplicar transición de audio"), - ("Apply Default Transitions to Selection", "Aplicar transiciones predeterminadas a la selección"), - ("Apply Video Transition", "Aplicar transición de video"), - ("Arrange", "Organizar"), - ("ArtCraft Website", "Sitio web de ArtCraft"), - ("Assembly", "Ensamblaje"), - ("Attach Proxies…", "Adjuntar proxies…"), - ("Audio Channels…", "Canales de audio…"), - ("Audio Clip Mixer", "Mezclador de clips de audio"), - ("Audio Gain…", "Ganancia de audio…"), - ("Audio Hardware…", "Hardware de audio…"), - ("Audio In", "Entrada de audio"), - ("Audio Meters", "Medidores de audio"), - ("Audio Options", "Opciones de audio"), - ("Audio Out", "Salida de audio"), - ("Audio Track Mixer", "Mezclador de pistas de audio"), - ("Audio Waveform", "Forma de onda de audio"), - ("Auto Save…", "Guardado automático…"), - ("Automate to Sequence…", "Automatizar a secuencia…"), - ("Bars and Tone…", "Barras y tono…"), - ("Bin", "Bandeja"), - ("Bin From Selection", "Bandeja a partir de la selección"), - ("Black Video…", "Video negro…"), - ("Blue", "Azul"), - ("Bottom", "Abajo"), - ("Breakout to Mono", "Separar en mono"), - ("Bring Forward", "Traer adelante"), - ("Bring to Front", "Traer al frente"), - ("Brown", "Marrón"), - ("Captions", "Subtítulos"), - ("Captions and Graphics", "Subtítulos y gráficos"), - ("Captions…", "Subtítulos…"), - ("Caribbean", "Caribe"), - ("Center Horizontally", "Centrar horizontalmente"), - ("Center Vertically", "Centrar verticalmente"), - ("Cerulean", "Cerúleo"), - ("Clear", "Borrar"), - ("Clear Guides", "Borrar guías"), - ("Clear In", "Borrar entrada"), - ("Clear In and Out", "Borrar entrada y salida"), - ("Clear Markers", "Borrar marcadores"), - ("Clear Out", "Borrar salida"), - ("Clear Selected Marker", "Borrar marcador seleccionado"), - ("Close", "Cerrar"), - ("Close All Other Projects", "Cerrar todos los demás proyectos"), - ("Close All Projects", "Cerrar todos los proyectos"), - ("Close Gap", "Cerrar hueco"), - ("Close Project", "Cerrar proyecto"), - ("Color Management…", "Gestión del color…"), - ("Color Matte…", "Mate de color…"), - ("Comparison View", "Vista de comparación"), - ("Composite Video", "Video compuesto"), - ("Consolidate Duplicates", "Consolidar duplicados"), - ("Copy", "Copiar"), - ("Copy Paste Includes Sequence Markers", "Copiar y pegar incluye marcadores de secuencia"), - ("Create Captions from Transcript…", "Crear subtítulos a partir de la transcripción…"), - ("Create Multi-Camera Source Sequence…", "Crear secuencia de origen multicámara…"), - ("Create Proxies…", "Crear proxies…"), - ("Cut", "Cortar"), - ("Darkest", "Más oscuro"), - ("Delete Render Files", "Eliminar archivos de procesamiento"), - ("Delete Render Files In to Out", "Eliminar archivos de procesamiento de entrada a salida"), - ("Delete Tracks…", "Eliminar pistas…"), - ("Delete Transcript", "Eliminar transcripción"), - ("Demo Footage", "Material de demostración"), - ("Demo Project", "Proyecto de demostración"), - ("Deselect All", "Deseleccionar todo"), - ("Detach Proxies", "Separar proxies"), - ("Display Mode", "Modo de visualización"), - ("Distribute", "Distribuir"), - ("Distribute Horizontally", "Distribuir horizontalmente"), - ("Distribute Space Horizontally", "Distribuir espacio horizontalmente"), - ("Distribute Space Vertically", "Distribuir espacio verticalmente"), - ("Distribute Vertically", "Distribuir verticalmente"), - ("Duplicate", "Duplicar"), - ("Dynamic Audio Waveforms", "Formas de onda de audio dinámicas"), - ("Edit", "Edición"), - ("Edit Offline…", "Editar sin conexión…"), - ("Edit Original", "Editar original"), - ("Edit Subclip…", "Editar subclip…"), - ("Editing", "Edición"), - ("Effect Controls", "Controles de efectos"), - ("Effects", "Efectos"), - ("Ellipse", "Elipse"), - ("Enable", "Activar"), - ("Enable Remix", "Activar remezcla"), - ("Essential Graphics", "Gráficos esenciales"), - ("Essential Sound", "Sonido esencial"), - ("Events", "Eventos"), - ("Export", "Exportar"), - ("Export As Motion Graphics Template…", "Exportar como plantilla de animación…"), - ("Extract", "Extraer"), - ("Extract Audio", "Extraer audio"), - ("Field Options…", "Opciones de campo…"), - ("File", "Archivo"), - ("File…", "Archivo…"), - ("Fill frame", "Rellenar fotograma"), - ("FilmCraft Help…", "Ayuda de FilmCraft…"), - ("FilmCraft on GitHub", "FilmCraft en GitHub"), - ("FilmCraft on getartcraft.com", "FilmCraft en getartcraft.com"), - ("Find Next", "Buscar siguiente"), - ("Find…", "Buscar…"), - ("Fit", "Ajustar"), - ("Fit to frame", "Ajustar al fotograma"), - ("Flatten", "Acoplar"), - ("Forest", "Bosque"), - ("Frame Blending", "Fusión de fotogramas"), - ("Frame Hold Options…", "Opciones de fotograma congelado…"), - ("Frame Sampling", "Muestreo de fotogramas"), - ("From Bin", "Desde la bandeja"), - ("From Source Monitor", "Desde el monitor de origen"), - ("From Source Monitor, Match Frame", "Desde el monitor de origen, fotograma coincidente"), - ("From file…", "Desde archivo…"), - ("Full", "Completa"), - ("Generate Audio Waveform", "Generar forma de onda de audio"), - ("Get Media File Properties for", "Obtener propiedades del archivo multimedia de"), - ("Go to Gap", "Ir al hueco"), - ("Go to In", "Ir a la entrada"), - ("Go to Next Caption Segment", "Ir al siguiente segmento de subtítulo"), - ("Go to Next Marker", "Ir al siguiente marcador"), - ("Go to Out", "Ir a la salida"), - ("Go to Previous Caption Segment", "Ir al segmento de subtítulo anterior"), - ("Go to Previous Marker", "Ir al marcador anterior"), - ("Go to Split", "Ir a la división"), - ("Graphics and Titles", "Gráficos y títulos"), - ("Graphics…", "Gráficos…"), - ("Green", "Verde"), - ("Group", "Agrupar"), - ("Guide Templates", "Plantillas de guías"), - ("Help", "Ayuda"), - ("Hide All Caption Tracks", "Ocultar todas las pistas de subtítulos"), - ("High Quality Playback", "Reproducción de alta calidad"), - ("History", "Historial"), - ("Import From", "Importar desde"), - ("Import Image Sequence…", "Importar secuencia de imágenes…"), - ("Import from Media Browser", "Importar desde el navegador de medios"), - ("Import…", "Importar…"), - ("Info", "Información"), - ("Ingest Settings…", "Ajustes de ingesta…"), - ("Insert", "Insertar"), - ("Insert Frame Hold Segment", "Insertar segmento de fotograma congelado"), - ("Install Motion Graphics Template…", "Instalar plantilla de animación…"), - ("Interpret Footage…", "Interpretar material…"), - ("Join the ArtCraft Discord…", "Únete al Discord de ArtCraft…"), - ("Keyboard Shortcuts…", "Atajos de teclado…"), - ("Label", "Etiqueta"), - ("Labels…", "Etiquetas…"), - ("Language", "Idioma"), - ("Lavender", "Lavanda"), - ("Learning", "Aprendizaje"), - ("Left", "Izquierda"), - ("Libraries", "Bibliotecas"), - ("Lift", "Levantar"), - ("Light", "Claro"), - ("Link", "Vincular"), - ("Link Media…", "Vincular medios…"), - ("Linked Selection", "Selección vinculada"), - ("Lock Guides", "Bloquear guías"), - ("Lumetri Color", "Color Lumetri"), - ("Lumetri Scopes", "Ámbitos Lumetri"), - ("Magnification", "Ampliación"), - ("Make Offline…", "Desconectar…"), - ("Make Subclip…", "Crear subclip…"), - ("Make Subsequence", "Crear subsecuencia"), - ("Manage Guides…", "Gestionar guías…"), - ("Mark Clip", "Marcar clip"), - ("Mark In", "Marcar entrada"), - ("Mark Out", "Marcar salida"), - ("Mark Selection", "Marcar selección"), - ("Mark Split", "Marcar división"), - ("Markers", "Marcadores"), - ("Match Frame", "Fotograma coincidente"), - ("Media Analysis & Transcription…", "Análisis y transcripción de medios…"), - ("Media Browser", "Navegador de medios"), - ("Media Cache…", "Caché de medios…"), - ("Media…", "Medios…"), - ("Medium", "Medio"), - ("Memory…", "Memoria…"), - ("Merge Clips…", "Combinar clips…"), - ("Metadata", "Metadatos"), - ("Modify", "Modificar"), - ("Motion Graphics Template…", "Plantilla de animación…"), - ("Multi-Camera", "Multicámara"), - ("Multi-Camera View", "Vista multicámara"), - ("Nest…", "Anidar…"), - ("New", "Nuevo"), - ("New Layer", "Nueva capa"), - ("New Project Panel", "Nuevo panel de proyecto"), - ("Next in Sequence", "Siguiente en la secuencia"), - ("Next in Track", "Siguiente en la pista"), - ("Normalize Mix Track…", "Normalizar pista de mezcla…"), - ("Offline File…", "Archivo sin conexión…"), - ("Open Project…", "Abrir proyecto…"), - ("Optical Flow", "Flujo óptico"), - ("Overwrite", "Sobrescribir"), - ("Paste", "Pegar"), - ("Paste Attributes…", "Pegar atributos…"), - ("Paste Insert", "Pegar e insertar"), - ("Paused Resolution", "Resolución en pausa"), - ("Playback Resolution", "Resolución de reproducción"), - ("Playback…", "Reproducción…"), - ("Plugins…", "Complementos…"), - ("Polygon", "Polígono"), - ("Preferences", "Preferencias"), - ("Previous in Sequence", "Anterior en la secuencia"), - ("Previous in Track", "Anterior en la pista"), - ("Program", "Programa"), - ("Progress", "Progreso"), - ("Project", "Proyecto"), - ("Project Manager…", "Administrador de proyectos…"), - ("Project Settings", "Ajustes del proyecto"), - ("Project…", "Proyecto…"), - ("Properties", "Propiedades"), - ("Purple", "Púrpura"), - ("Reconnect Full Resolution Media…", "Reconectar medios de resolución completa…"), - ("Recover Unsaved Changes…", "Recuperar cambios no guardados…"), - ("Rectangle", "Rectángulo"), - ("Red", "Rojo"), - ("Redo", "Rehacer"), - ("Reference Monitor", "Monitor de referencia"), - ("Remix", "Remezcla"), - ("Remix Properties…", "Propiedades de remezcla…"), - ("Remove Attributes…", "Quitar atributos…"), - ("Remove Filler Words", "Quitar muletillas"), - ("Remove Pauses", "Quitar pausas"), - ("Remove Unused", "Quitar no utilizados"), - ("Rename…", "Cambiar nombre…"), - ("Render Audio", "Procesar audio"), - ("Render Effects In to Out", "Procesar efectos de entrada a salida"), - ("Render In to Out", "Procesar de entrada a salida"), - ("Render Selection", "Procesar selección"), - ("Replace Fonts in Projects…", "Reemplazar fuentes en proyectos…"), - ("Replace With Clip", "Reemplazar con clip"), - ("Report an Issue…", "Informar de un problema…"), - ("Reset All Parameters", "Restablecer todos los parámetros"), - ("Reset Duration", "Restablecer duración"), - ("Reset to Saved Layout", "Restablecer al diseño guardado"), - ("Restore Captions from Source Clip", "Restaurar subtítulos del clip de origen"), - ("Reveal Log Files…", "Mostrar archivos de registro…"), - ("Reverse Match Frame", "Fotograma coincidente inverso"), - ("Revert", "Revertir"), - ("Revert Remix", "Revertir remezcla"), - ("Review", "Revisión"), - ("Right", "Derecha"), - ("Ripple Delete", "Eliminar y cerrar hueco"), - ("Ripple Sequence Markers", "Desplazar marcadores de secuencia"), - ("Rose", "Rosa"), - ("Safe Margins", "Márgenes seguros"), - ("Save", "Guardar"), - ("Save All", "Guardar todo"), - ("Save As…", "Guardar como…"), - ("Save Guides as Template…", "Guardar guías como plantilla…"), - ("Save a Copy…", "Guardar una copia…"), - ("Save as Template…", "Guardar como plantilla…"), - ("Scale to Frame Size", "Escalar al tamaño del fotograma"), - ("Scene Edit Detection…", "Detección de cambios de escena…"), - ("Scratch Disks…", "Discos de memoria virtual…"), - ("Search Bin", "Bandeja de búsqueda"), - ("Select", "Seleccionar"), - ("Select All", "Seleccionar todo"), - ("Select All Matching", "Seleccionar todos los coincidentes"), - ("Select Label Group", "Seleccionar grupo de etiquetas"), - ("Select Next Graphic", "Seleccionar siguiente gráfico"), - ("Select Next Layer", "Seleccionar siguiente capa"), - ("Select Previous Graphic", "Seleccionar gráfico anterior"), - ("Select Previous Layer", "Seleccionar capa anterior"), - ("Selection Follows Playhead", "La selección sigue al cabezal"), - ("Selection as FilmCraft Project…", "Selección como proyecto de FilmCraft…"), - ("Selection…", "Selección…"), - ("Send Backward", "Enviar atrás"), - ("Send to Back", "Enviar al fondo"), - ("Sequence", "Secuencia"), - ("Sequence From Clip", "Secuencia a partir de clip"), - ("Sequence Settings…", "Ajustes de secuencia…"), - ("Sequence…", "Secuencia…"), - ("Show Active Caption Tracks Only", "Mostrar solo pistas de subtítulos activas"), - ("Show All Caption Tracks", "Mostrar todas las pistas de subtítulos"), - ("Show All Marker Colors", "Mostrar todos los colores de marcador"), - ("Show Guides", "Mostrar guías"), - ("Show Rulers", "Mostrar reglas"), - ("Show Through Edits", "Mostrar ediciones pasantes"), - ("Simplify Sequence…", "Simplificar secuencia…"), - ("Snap in Program Monitor", "Ajustar en el monitor de programa"), - ("Snap in Timeline", "Ajustar en la línea de tiempo"), - ("Source", "Origen"), - ("Source Settings…", "Ajustes de origen…"), - ("Speed/Duration…", "Velocidad/duración…"), - ("Synchronize…", "Sincronizar…"), - ("System Compatibility Report…", "Informe de compatibilidad del sistema…"), - ("Tan", "Canela"), - ("Teal", "Verde azulado"), - ("Text", "Texto"), - ("Time Interpolation", "Interpolación de tiempo"), - ("Timecode", "Código de tiempo"), - ("Timecode…", "Código de tiempo…"), - ("Timeline", "Línea de tiempo"), - ("Timeline…", "Línea de tiempo…"), - ("Toggle Proxies", "Alternar proxies"), - ("Tools", "Herramientas"), - ("Top", "Arriba"), - ("Transcribe Sequence…", "Transcribir secuencia…"), - ("Transcribe…", "Transcribir…"), - ("Transcript", "Transcripción"), - ("Transparent Video…", "Video transparente…"), - ("Trim Edit", "Recortar edición"), - ("Trim…", "Recorte…"), - ("Undo", "Deshacer"), - ("Ungroup", "Desagrupar"), - ("Universal Counting Leader…", "Cuenta atrás universal…"), - ("Update Metadata…", "Actualizar metadatos…"), - ("Upgrade Caption to Graphic", "Convertir subtítulo en gráfico"), - ("Upgrade to Source Graphic", "Convertir en gráfico de origen"), - ("Vertical Text", "Texto vertical"), - ("Video In", "Entrada de video"), - ("Video Options", "Opciones de video"), - ("Video Out", "Salida de video"), - ("Video and Audio Waveform Split", "Video y forma de onda de audio divididos"), - ("View", "Ver"), - ("Violet", "Violeta"), - ("Window", "Ventana"), - ("Workspaces", "Espacios de trabajo"), - ("Yellow", "Amarillo"), - ("Zoom In", "Acercar"), - ("Zoom Out", "Alejar"), - ("Zoom to Sequence", "Ajustar zoom a la secuencia"), -]; - -const PORTUGUESE: &[(&str, &str)] = &[ - ("About FilmCraft", "Sobre o FilmCraft"), - ("Add Audio Submix Track", "Adicionar faixa de submixagem de áudio"), - ("Add Caption at Playhead", "Adicionar legenda no cursor de reprodução"), - ("Add Chapter Marker…", "Adicionar marcador de capítulo…"), - ("Add Edit", "Adicionar edição"), - ("Add Edit to All Tracks", "Adicionar edição em todas as faixas"), - ("Add Flash Cue Marker…", "Adicionar marcador de referência Flash…"), - ("Add Frame Hold", "Adicionar quadro congelado"), - ("Add Guide…", "Adicionar guia…"), - ("Add Marker", "Adicionar marcador"), - ("Add New Caption Track…", "Adicionar nova faixa de legendas…"), - ("Add Range Marker", "Adicionar marcador de intervalo"), - ("Add Range Marker to In and Out", "Adicionar marcador de intervalo da entrada à saída"), - ("Add Tracks…", "Adicionar faixas…"), - ("Adjustment Layer…", "Camada de ajuste…"), - ("Align to Selection", "Alinhar à seleção"), - ("Align to Video Frame", "Alinhar ao quadro de vídeo"), - ("Align to Video Frame as Group", "Alinhar ao quadro de vídeo como grupo"), - ("All Panels", "Todos os painéis"), - ("Alpha", "Alfa"), - ("Appearance", "Aparência"), - ("Appearance…", "Aparência…"), - ("Apply Audio Transition", "Aplicar transição de áudio"), - ("Apply Default Transitions to Selection", "Aplicar transições padrão à seleção"), - ("Apply Video Transition", "Aplicar transição de vídeo"), - ("Arrange", "Organizar"), - ("ArtCraft Website", "Site do ArtCraft"), - ("Assembly", "Montagem"), - ("Attach Proxies…", "Anexar proxies…"), - ("Audio Channels…", "Canais de áudio…"), - ("Audio Clip Mixer", "Misturador de clipes de áudio"), - ("Audio Gain…", "Ganho de áudio…"), - ("Audio Hardware…", "Hardware de áudio…"), - ("Audio In", "Entrada de áudio"), - ("Audio Meters", "Medidores de áudio"), - ("Audio Options", "Opções de áudio"), - ("Audio Out", "Saída de áudio"), - ("Audio Track Mixer", "Misturador de faixas de áudio"), - ("Audio Waveform", "Forma de onda de áudio"), - ("Auto Save…", "Salvamento automático…"), - ("Automate to Sequence…", "Automatizar para a sequência…"), - ("Bars and Tone…", "Barras e tom…"), - ("Bin", "Pasta"), - ("Bin From Selection", "Pasta a partir da seleção"), - ("Black Video…", "Vídeo preto…"), - ("Blue", "Azul"), - ("Bottom", "Inferior"), - ("Breakout to Mono", "Separar em mono"), - ("Bring Forward", "Trazer para a frente"), - ("Bring to Front", "Trazer ao topo"), - ("Brown", "Marrom"), - ("Captions", "Legendas"), - ("Captions and Graphics", "Legendas e gráficos"), - ("Captions…", "Legendas…"), - ("Caribbean", "Caribe"), - ("Center Horizontally", "Centralizar horizontalmente"), - ("Center Vertically", "Centralizar verticalmente"), - ("Cerulean", "Cerúleo"), - ("Clear", "Limpar"), - ("Clear Guides", "Limpar guias"), - ("Clear In", "Limpar entrada"), - ("Clear In and Out", "Limpar entrada e saída"), - ("Clear Markers", "Limpar marcadores"), - ("Clear Out", "Limpar saída"), - ("Clear Selected Marker", "Limpar marcador selecionado"), - ("Close", "Fechar"), - ("Close All Other Projects", "Fechar todos os outros projetos"), - ("Close All Projects", "Fechar todos os projetos"), - ("Close Gap", "Fechar lacuna"), - ("Close Project", "Fechar projeto"), - ("Color Management…", "Gerenciamento de cores…"), - ("Color Matte…", "Matte de cor…"), - ("Comparison View", "Visualização de comparação"), - ("Composite Video", "Vídeo composto"), - ("Consolidate Duplicates", "Consolidar duplicatas"), - ("Copy", "Copiar"), - ("Copy Paste Includes Sequence Markers", "Copiar e colar inclui marcadores da sequência"), - ("Create Captions from Transcript…", "Criar legendas a partir da transcrição…"), - ("Create Multi-Camera Source Sequence…", "Criar sequência de origem multicâmera…"), - ("Create Proxies…", "Criar proxies…"), - ("Cut", "Recortar"), - ("Darkest", "Mais escuro"), - ("Delete Render Files", "Excluir arquivos de renderização"), - ("Delete Render Files In to Out", "Excluir arquivos de renderização da entrada à saída"), - ("Delete Tracks…", "Excluir faixas…"), - ("Delete Transcript", "Excluir transcrição"), - ("Demo Footage", "Clipes de demonstração"), - ("Demo Project", "Projeto de demonstração"), - ("Deselect All", "Desmarcar tudo"), - ("Detach Proxies", "Desanexar proxies"), - ("Display Mode", "Modo de exibição"), - ("Distribute", "Distribuir"), - ("Distribute Horizontally", "Distribuir horizontalmente"), - ("Distribute Space Horizontally", "Distribuir espaço horizontalmente"), - ("Distribute Space Vertically", "Distribuir espaço verticalmente"), - ("Distribute Vertically", "Distribuir verticalmente"), - ("Duplicate", "Duplicar"), - ("Dynamic Audio Waveforms", "Formas de onda de áudio dinâmicas"), - ("Edit", "Editar"), - ("Edit Offline…", "Editar offline…"), - ("Edit Original", "Editar original"), - ("Edit Subclip…", "Editar subclipe…"), - ("Editing", "Edição"), - ("Effect Controls", "Controles de efeito"), - ("Effects", "Efeitos"), - ("Ellipse", "Elipse"), - ("Enable", "Ativar"), - ("Enable Remix", "Ativar remixagem"), - ("Essential Graphics", "Gráficos essenciais"), - ("Essential Sound", "Som essencial"), - ("Events", "Eventos"), - ("Export", "Exportar"), - ("Export As Motion Graphics Template…", "Exportar como modelo de gráficos de movimento…"), - ("Extract", "Extrair"), - ("Extract Audio", "Extrair áudio"), - ("Field Options…", "Opções de campo…"), - ("File", "Arquivo"), - ("File…", "Arquivo…"), - ("Fill frame", "Preencher quadro"), - ("FilmCraft Help…", "Ajuda do FilmCraft…"), - ("FilmCraft on GitHub", "FilmCraft no GitHub"), - ("FilmCraft on getartcraft.com", "FilmCraft no getartcraft.com"), - ("Find Next", "Localizar próximo"), - ("Find…", "Localizar…"), - ("Fit", "Ajustar"), - ("Fit to frame", "Ajustar ao quadro"), - ("Flatten", "Achatar"), - ("Forest", "Floresta"), - ("Frame Blending", "Emenda de quadros"), - ("Frame Hold Options…", "Opções de quadro congelado…"), - ("Frame Sampling", "Amostragem de quadros"), - ("From Bin", "Da pasta"), - ("From Source Monitor", "Do monitor de origem"), - ("From Source Monitor, Match Frame", "Do monitor de origem, quadro correspondente"), - ("From file…", "Do arquivo…"), - ("Full", "Total"), - ("Generate Audio Waveform", "Gerar forma de onda de áudio"), - ("Get Media File Properties for", "Obter propriedades do arquivo de mídia de"), - ("Go to Gap", "Ir para a lacuna"), - ("Go to In", "Ir para a entrada"), - ("Go to Next Caption Segment", "Ir para o próximo segmento de legenda"), - ("Go to Next Marker", "Ir para o próximo marcador"), - ("Go to Out", "Ir para a saída"), - ("Go to Previous Caption Segment", "Ir para o segmento de legenda anterior"), - ("Go to Previous Marker", "Ir para o marcador anterior"), - ("Go to Split", "Ir para a divisão"), - ("Graphics and Titles", "Gráficos e títulos"), - ("Graphics…", "Gráficos…"), - ("Green", "Verde"), - ("Group", "Agrupar"), - ("Guide Templates", "Modelos de guias"), - ("Help", "Ajuda"), - ("Hide All Caption Tracks", "Ocultar todas as faixas de legendas"), - ("High Quality Playback", "Reprodução em alta qualidade"), - ("History", "Histórico"), - ("Import From", "Importar de"), - ("Import Image Sequence…", "Importar sequência de imagens…"), - ("Import from Media Browser", "Importar do navegador de mídia"), - ("Import…", "Importar…"), - ("Info", "Informações"), - ("Ingest Settings…", "Configurações de ingestão…"), - ("Insert", "Inserir"), - ("Insert Frame Hold Segment", "Inserir segmento de quadro congelado"), - ("Install Motion Graphics Template…", "Instalar modelo de gráficos de movimento…"), - ("Interpret Footage…", "Interpretar clipes…"), - ("Join the ArtCraft Discord…", "Entre no Discord do ArtCraft…"), - ("Keyboard Shortcuts…", "Atalhos de teclado…"), - ("Label", "Rótulo"), - ("Labels…", "Rótulos…"), - ("Language", "Idioma"), - ("Lavender", "Lavanda"), - ("Learning", "Aprendizado"), - ("Left", "Esquerda"), - ("Libraries", "Bibliotecas"), - ("Lift", "Levantar"), - ("Light", "Claro"), - ("Link", "Vincular"), - ("Link Media…", "Vincular mídia…"), - ("Linked Selection", "Seleção vinculada"), - ("Lock Guides", "Bloquear guias"), - ("Lumetri Color", "Cor Lumetri"), - ("Lumetri Scopes", "Escopos Lumetri"), - ("Magnification", "Ampliação"), - ("Make Offline…", "Tornar offline…"), - ("Make Subclip…", "Criar subclipe…"), - ("Make Subsequence", "Criar subsequência"), - ("Manage Guides…", "Gerenciar guias…"), - ("Mark Clip", "Marcar clipe"), - ("Mark In", "Marcar entrada"), - ("Mark Out", "Marcar saída"), - ("Mark Selection", "Marcar seleção"), - ("Mark Split", "Marcar divisão"), - ("Markers", "Marcadores"), - ("Match Frame", "Quadro correspondente"), - ("Media Analysis & Transcription…", "Análise e transcrição de mídia…"), - ("Media Browser", "Navegador de mídia"), - ("Media Cache…", "Cache de mídia…"), - ("Media…", "Mídia…"), - ("Medium", "Médio"), - ("Memory…", "Memória…"), - ("Merge Clips…", "Mesclar clipes…"), - ("Metadata", "Metadados"), - ("Modify", "Modificar"), - ("Motion Graphics Template…", "Modelo de gráficos de movimento…"), - ("Multi-Camera", "Multicâmera"), - ("Multi-Camera View", "Visualização multicâmera"), - ("Nest…", "Aninhar…"), - ("New", "Novo"), - ("New Layer", "Nova camada"), - ("New Project Panel", "Novo painel de projeto"), - ("Next in Sequence", "Próximo na sequência"), - ("Next in Track", "Próximo na faixa"), - ("Normalize Mix Track…", "Normalizar faixa de mixagem…"), - ("Offline File…", "Arquivo offline…"), - ("Open Project…", "Abrir projeto…"), - ("Optical Flow", "Fluxo óptico"), - ("Overwrite", "Sobrescrever"), - ("Paste", "Colar"), - ("Paste Attributes…", "Colar atributos…"), - ("Paste Insert", "Inserir colando"), - ("Paused Resolution", "Resolução pausada"), - ("Playback Resolution", "Resolução de reprodução"), - ("Playback…", "Reprodução…"), - ("Plugins…", "Plug-ins…"), - ("Polygon", "Polígono"), - ("Preferences", "Preferências"), - ("Previous in Sequence", "Anterior na sequência"), - ("Previous in Track", "Anterior na faixa"), - ("Program", "Programa"), - ("Progress", "Progresso"), - ("Project", "Projeto"), - ("Project Manager…", "Gerenciador de projetos…"), - ("Project Settings", "Configurações do projeto"), - ("Project…", "Projeto…"), - ("Properties", "Propriedades"), - ("Purple", "Roxo"), - ("Reconnect Full Resolution Media…", "Reconectar mídia em resolução total…"), - ("Recover Unsaved Changes…", "Recuperar alterações não salvas…"), - ("Rectangle", "Retângulo"), - ("Red", "Vermelho"), - ("Redo", "Refazer"), - ("Reference Monitor", "Monitor de referência"), - ("Remix", "Remixagem"), - ("Remix Properties…", "Propriedades da remixagem…"), - ("Remove Attributes…", "Remover atributos…"), - ("Remove Filler Words", "Remover muletas"), - ("Remove Pauses", "Remover pausas"), - ("Remove Unused", "Remover não usados"), - ("Rename…", "Renomear…"), - ("Render Audio", "Renderizar áudio"), - ("Render Effects In to Out", "Renderizar efeitos da entrada à saída"), - ("Render In to Out", "Renderizar da entrada à saída"), - ("Render Selection", "Renderizar seleção"), - ("Replace Fonts in Projects…", "Substituir fontes em projetos…"), - ("Replace With Clip", "Substituir por clipe"), - ("Report an Issue…", "Relatar um problema…"), - ("Reset All Parameters", "Redefinir todos os parâmetros"), - ("Reset Duration", "Redefinir duração"), - ("Reset to Saved Layout", "Redefinir para o layout salvo"), - ("Restore Captions from Source Clip", "Restaurar legendas do clipe de origem"), - ("Reveal Log Files…", "Mostrar arquivos de log…"), - ("Reverse Match Frame", "Quadro correspondente inverso"), - ("Revert", "Reverter"), - ("Revert Remix", "Reverter remixagem"), - ("Review", "Revisão"), - ("Right", "Direita"), - ("Ripple Delete", "Excluir e fechar lacuna"), - ("Ripple Sequence Markers", "Deslocar marcadores da sequência"), - ("Rose", "Rosa"), - ("Safe Margins", "Margens de segurança"), - ("Save", "Salvar"), - ("Save All", "Salvar tudo"), - ("Save As…", "Salvar como…"), - ("Save Guides as Template…", "Salvar guias como modelo…"), - ("Save a Copy…", "Salvar uma cópia…"), - ("Save as Template…", "Salvar como modelo…"), - ("Scale to Frame Size", "Escalar para o tamanho do quadro"), - ("Scene Edit Detection…", "Detecção de edição de cena…"), - ("Scratch Disks…", "Discos de trabalho…"), - ("Search Bin", "Pasta de pesquisa"), - ("Select", "Selecionar"), - ("Select All", "Selecionar tudo"), - ("Select All Matching", "Selecionar todos correspondentes"), - ("Select Label Group", "Selecionar grupo de rótulos"), - ("Select Next Graphic", "Selecionar próximo gráfico"), - ("Select Next Layer", "Selecionar próxima camada"), - ("Select Previous Graphic", "Selecionar gráfico anterior"), - ("Select Previous Layer", "Selecionar camada anterior"), - ("Selection Follows Playhead", "A seleção segue o cursor de reprodução"), - ("Selection as FilmCraft Project…", "Seleção como projeto do FilmCraft…"), - ("Selection…", "Seleção…"), - ("Send Backward", "Enviar para trás"), - ("Send to Back", "Enviar para o fundo"), - ("Sequence", "Sequência"), - ("Sequence From Clip", "Sequência a partir do clipe"), - ("Sequence Settings…", "Configurações da sequência…"), - ("Sequence…", "Sequência…"), - ("Show Active Caption Tracks Only", "Mostrar apenas faixas de legendas ativas"), - ("Show All Caption Tracks", "Mostrar todas as faixas de legendas"), - ("Show All Marker Colors", "Mostrar todas as cores de marcador"), - ("Show Guides", "Mostrar guias"), - ("Show Rulers", "Mostrar réguas"), - ("Show Through Edits", "Mostrar edições contíguas"), - ("Simplify Sequence…", "Simplificar sequência…"), - ("Snap in Program Monitor", "Encaixar no monitor do programa"), - ("Snap in Timeline", "Encaixar na linha do tempo"), - ("Source", "Origem"), - ("Source Settings…", "Configurações de origem…"), - ("Speed/Duration…", "Velocidade/duração…"), - ("Synchronize…", "Sincronizar…"), - ("System Compatibility Report…", "Relatório de compatibilidade do sistema…"), - ("Tan", "Canela"), - ("Teal", "Verde-azulado"), - ("Text", "Texto"), - ("Time Interpolation", "Interpolação de tempo"), - ("Timecode", "Timecode"), - ("Timecode…", "Timecode…"), - ("Timeline", "Linha do tempo"), - ("Timeline…", "Linha do tempo…"), - ("Toggle Proxies", "Alternar proxies"), - ("Tools", "Ferramentas"), - ("Top", "Superior"), - ("Transcribe Sequence…", "Transcrever sequência…"), - ("Transcribe…", "Transcrever…"), - ("Transcript", "Transcrição"), - ("Transparent Video…", "Vídeo transparente…"), - ("Trim Edit", "Edição de aparagem"), - ("Trim…", "Aparar…"), - ("Undo", "Desfazer"), - ("Ungroup", "Desagrupar"), - ("Universal Counting Leader…", "Cabeçote de contagem universal…"), - ("Update Metadata…", "Atualizar metadados…"), - ("Upgrade Caption to Graphic", "Converter legenda em gráfico"), - ("Upgrade to Source Graphic", "Converter em gráfico de origem"), - ("Vertical Text", "Texto vertical"), - ("Video In", "Entrada de vídeo"), - ("Video Options", "Opções de vídeo"), - ("Video Out", "Saída de vídeo"), - ("Video and Audio Waveform Split", "Vídeo e forma de onda de áudio separados"), - ("View", "Exibir"), - ("Violet", "Violeta"), - ("Window", "Janela"), - ("Workspaces", "Áreas de trabalho"), - ("Yellow", "Amarelo"), - ("Zoom In", "Ampliar"), - ("Zoom Out", "Reduzir"), - ("Zoom to Sequence", "Zoom para a sequência"), -]; - -const JAPANESE: &[(&str, &str)] = &[ - ("Type Tool", "文字ツール"), - ("Vertical Type Tool", "縦書き文字ツール"), - ("Clip", "クリップ"), - ("Sequence", "シーケンス"), - ("Markers", "マーカー"), - ("Graphics and Titles", "グラフィックスとタイトル"), - ("Settings", "環境設定"), - ("Object", "オブジェクト"), - ("Effect", "効果"), - ("Settings…", "環境設定…"), - ("Image", "画像"), - ("Layer", "レイヤー"), - ("Type", "書式"), - ("Select", "選択"), - ("Filter", "フィルター"), - ("Window", "ウィンドウ"), - ("Language", "表示言語"), - ("Save As…", "別名で保存…"), - ("Exit", "終了"), - ("New…", "新規…"), - ("New", "新規"), - ("Horizontal", "横書き"), - ("Vertical", "縦書き"), - ("Orientation", "組み方向"), - ("Layers", "レイヤー"), - ("History", "履歴"), - ("Properties", "プロパティ"), - ("Color", "カラー"), - ("Brush Settings", "ブラシ設定"), - ("Tools", "ツール"), - ("Options", "オプション"), - ("Zoom In", "ズームイン"), - ("Zoom Out", "ズームアウト"), - ("Fit on Screen", "画面に合わせる"), - ("Copy", "コピー"), - ("Cut", "切り取り"), - ("Paste", "貼り付け"), - ("Select All", "すべて選択"), - ("Deselect", "選択を解除"), - ("Export", "書き出し"), - ("Export As…", "形式を指定して書き出し…"), - ("Search…", "検索…"), - ("Theme", "テーマ"), - ("Menu", "メニュー"), - ("File", "ファイル"), - ("Edit", "編集"), - ("Pages", "ページ"), - ("View", "表示"), - ("Help", "ヘルプ"), - ("Preferences", "環境設定"), - ("Preferences…", "環境設定…"), - ("Interface language", "表示言語"), - ("Open…", "開く…"), - ("New blank PDF", "空白の PDF を作成"), - ("Create PDF from file…", "ファイルから PDF を作成…"), - ("Create PDF from images…", "画像から PDF を作成…"), - ("Create PDF from clipboard", "クリップボードから PDF を作成"), - ("Combine files…", "ファイルを結合…"), - ("Save", "保存"), - ("Save as…", "別名で保存…"), - ("Close file", "ファイルを閉じる"), - ("Close all", "すべて閉じる"), - ("Revert", "保存済みの状態に戻す"), - ("Print…", "印刷…"), - ("Document properties…", "文書のプロパティ…"), - ("Undo", "取り消し"), - ("Redo", "やり直し"), - ("Find…", "検索…"), - ("Advanced search…", "高度な検索…"), - ("Copy pages", "ページをコピー"), - ("Cut pages", "ページを切り取り"), - ("Paste pages", "ページを貼り付け"), - ("Fit visible", "表示範囲に合わせる"), - ("Marquee zoom", "範囲指定ズーム"), - ("Take a snapshot", "スナップショットを作成"), - ("Full screen mode", "全画面表示"), - ("Read mode", "閲覧モード"), - ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), - ("Comments panel", "コメントパネル"), - ("Form fields panel", "フォームフィールドパネル"), - ("Clear form", "フォームをクリア"), - ("Find tools and commands…", "ツールとコマンドを検索…"), - ("Zoom", "ズーム"), - ("Actual size", "実際のサイズ"), - ("Zoom to page level", "ページ全体を表示"), - ("Fit to width", "幅に合わせる"), - ("Display theme", "表示テーマ"), - ("Side panels", "サイドパネル"), - ("OK", "OK"), -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in JAPANESE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), *en); - } - assert_eq!(Language::Ja.tr("File"), "ファイル"); - for (i, (en, es)) in SPANISH.iter().enumerate() { - assert!(!es.is_empty()); - assert!(SPANISH.iter().take(i).all(|(other, _)| en != other)); - } - assert_eq!(Language::Es.tr("File"), "Archivo"); - assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); - assert_eq!(Language::parse("es"), Some(Language::Es)); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("xx"), None); - // pt-br: same coverage as Spanish, unknown text untouched, stable code round-trips - for (i, (en, pt)) in PORTUGUESE.iter().enumerate() { - assert!(!pt.is_empty()); - assert!(PORTUGUESE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::PtBr.tr(en), *pt); - } - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); - assert_eq!(Language::PtBr.tr("meu video.mp4"), "meu video.mp4"); - assert_eq!(Language::PtBr.name(), "Português (Brasil)"); - assert_eq!(Language::PtBr.code(), "pt-br"); - assert_eq!(Language::parse("pt-br"), Some(Language::PtBr)); - assert_eq!(serde_json::to_string(&Language::PtBr).unwrap(), "\"pt-br\""); - assert_eq!(serde_json::from_str::("\"pt-br\"").unwrap(), Language::PtBr); - assert_eq!(Language::ALL.len(), 4); - } - - #[test] - fn spanish_and_portuguese_entries_are_menu_labels() { - // a renamed command would otherwise leave a dead entry and an untranslated menu item - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - for (en, _) in SPANISH { - assert!(known(en), "not a menu label: {en}"); - } - // pt-br reuses exactly the Spanish keys, so the same menu labels must still exist - assert_eq!(PORTUGUESE.len(), SPANISH.len()); - for ((en, _), (pt_en, _)) in SPANISH.iter().zip(PORTUGUESE) { - assert_eq!(en, pt_en); - assert!(known(pt_en), "not a menu label: {pt_en}"); - } - } - - #[test] - fn language_commands_switch_and_persist_without_a_document() { - let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let ctx = egui::Context::default(); - crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Ja); - let saved = serde_json::to_string(&app.ui).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); - crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Es); - assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.spanish" && it.checked == Some(true))); - crate::menus::invoke(&mut app, &ctx, "app.language.portuguese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::PtBr); - assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.portuguese" && it.checked == Some(true))); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains("\"language\":\"pt-br\""), "{saved}"); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::PtBr); - crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::En); - } - - #[test] - fn japanese_needs_an_installed_font() { - let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let ctx = egui::Context::default(); - crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); - let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})); - let craft = craft_japanese_font(); - if !craft && system_japanese_font().is_none() { - // no craft-fonts and no system font: refused, and the interface stays English - assert!(r.is_err(), "{r:?}"); - assert_eq!(app.ui.language, Language::En); - return; - } - assert!(r.is_ok(), "{r:?}"); - let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); - output.textures_delta.clear(); - ctx.fonts_mut(|fonts| { - // every theme family falls back to the craft-fonts (when built with them; no system - // font is added then) or to the system Japanese font - for family in crate::theme::font_families() { - let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); - if craft { - assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); - assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); - } else { - assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}"); - } - } - // and the glyphs resolve. (Only families whose replacement-box face is another font: - // egui's `has_glyph` reports false for any character served by the face it also uses - // for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the - // Japanese font itself.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - let font = egui::FontId::new(13.0, family); - for ch in JAPANESE_SAMPLE.chars() { - assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); - } - } - }); - } -} diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs new file mode 100644 index 000000000..6422eeaf5 --- /dev/null +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -0,0 +1,97 @@ +//! Parser and lookup tables for translation catalogs (`*.tsv`, format documented in `es.tsv`). + +use std::collections::HashMap; + +/// A parsed catalog. Built once per language and kept for the life of the process. +#[derive(Debug, Default)] +pub struct Catalog { + /// context-free strings: English source → translation (looked up every frame) + plain: HashMap, + /// strings with a disambiguating context, keyed `context \u{1} source` + contextual: HashMap, +} + +/// A catalog entry as read from the file: (context, source, translation). +pub type Entry = (String, String, String); + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('\\') => out.push('\\'), + Some(o) => { + out.push('\\'); + out.push(o); + } + None => out.push('\\'), + } + } + out +} + +/// Read the entries of a catalog file. Malformed lines are returned as errors (and skipped), so a +/// bad translation never breaks the UI; the tests insist the bundled catalogs have none. +pub fn parse_entries(text: &str) -> (Vec, Vec) { + let mut entries = Vec::new(); + let mut errors = Vec::new(); + for (n, line) in text.lines().enumerate() { + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + let mut cols = line.split('\t'); + match (cols.next(), cols.next(), cols.next(), cols.next()) { + (Some(ctx), Some(src), Some(tr), None) if !src.is_empty() && !tr.is_empty() => { + entries.push((unescape(ctx), unescape(src), unescape(tr))); + } + _ => errors.push(format!("line {}: expected `contextsourcetranslation`", n + 1)), + } + } + (entries, errors) +} + +impl Catalog { + pub fn parse(text: &str) -> Catalog { + let mut c = Catalog::default(); + for (ctx, src, tr) in parse_entries(text).0 { + if ctx.is_empty() { + c.plain.insert(src, tr); + } else { + c.contextual.insert(format!("{ctx}\u{1}{src}"), tr); + } + } + c + } + + pub fn plain(&self, s: &str) -> Option<&str> { + self.plain.get(s).map(String::as_str) + } + + pub fn contextual(&self, ctx: &str, s: &str) -> Option<&str> { + self.contextual.get(&format!("{ctx}\u{1}{s}")).map(String::as_str) + } +} + +/// `{name}` placeholders of a template, in order of appearance. +#[cfg(test)] +pub fn placeholders(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut rest = s; + while let Some(a) = rest.find('{') { + let after = &rest[a + 1..]; + match after.find('}') { + Some(b) => { + out.push(&after[..b]); + rest = &after[b + 1..]; + } + None => break, + } + } + out +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv new file mode 100644 index 000000000..5a85456b4 --- /dev/null +++ b/crates/ui-egui/src/i18n/es.tsv @@ -0,0 +1,3498 @@ +# Spanish (es) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code (`tl!("…")`, +# a menu label, or a name the UI translates with `i18n::t`) +# anything else a disambiguating context passed to `tr_ctx` (one English word that +# needs two Spanish translations) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns; +# translators may reorder them. A trailing "…" (dialog commands) is kept. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Spanish video-editing vocabulary (pista, cabezal, secuencia, bandeja, procesar…). No proprietary +# localisation resource, string table or product UI was consulted or copied; add entries the same +# way. Product and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS…) keep their +# spelling. "video" is written without an accent, as in the rest of this catalog. + + About FilmCraft Acerca de FilmCraft + Add Audio Submix Track Añadir pista de submezcla de audio + Add Caption at Playhead Añadir subtítulo en el cabezal + Add Chapter Marker… Añadir marcador de capítulo… + Add Edit Añadir edición + Add Edit to All Tracks Añadir edición a todas las pistas + Add Flash Cue Marker… Añadir marcador de referencia Flash… + Add Frame Hold Añadir fotograma congelado + Add Guide… Añadir guía… + Add Marker Añadir marcador + Add New Caption Track… Añadir nueva pista de subtítulos… + Add Range Marker Añadir marcador de rango + Add Range Marker to In and Out Añadir marcador de rango de entrada a salida + Add Tracks… Añadir pistas… + Adjustment Layer… Capa de ajuste… + Align to Selection Alinear con la selección + Align to Video Frame Alinear con el fotograma de video + Align to Video Frame as Group Alinear con el fotograma de video como grupo + All Panels Todos los paneles + Alpha Alfa + Appearance Apariencia + Appearance… Apariencia… + Apply Audio Transition Aplicar transición de audio + Apply Default Transitions to Selection Aplicar transiciones predeterminadas a la selección + Apply Video Transition Aplicar transición de video + Arrange Organizar + ArtCraft Website Sitio web de ArtCraft + Assembly Ensamblaje + Attach Proxies… Adjuntar proxies… + Audio Channels… Canales de audio… + Audio Clip Mixer Mezclador de clips de audio + Audio Gain… Ganancia de audio… + Audio Hardware… Hardware de audio… + Audio In Entrada de audio + Audio Meters Medidores de audio + Audio Options Opciones de audio + Audio Out Salida de audio + Audio Track Mixer Mezclador de pistas de audio + Audio Waveform Forma de onda de audio + Auto Save… Guardado automático… + Automate to Sequence… Automatizar a secuencia… + Bars and Tone… Barras y tono… + Bin Bandeja + Bin From Selection Bandeja a partir de la selección + Black Video… Video negro… + Blue Azul + Bottom Abajo + Breakout to Mono Separar en mono + Bring Forward Traer adelante + Bring to Front Traer al frente + Brown Marrón + Captions Subtítulos + Captions and Graphics Subtítulos y gráficos + Captions… Subtítulos… + Caribbean Caribe + Center Horizontally Centrar horizontalmente + Center Vertically Centrar verticalmente + Cerulean Cerúleo + Clear Borrar + Clear Guides Borrar guías + Clear In Borrar entrada + Clear In and Out Borrar entrada y salida + Clear Markers Borrar marcadores + Clear Out Borrar salida + Clear Selected Marker Borrar marcador seleccionado + Close Cerrar + Close All Other Projects Cerrar todos los demás proyectos + Close All Projects Cerrar todos los proyectos + Close Gap Cerrar hueco + Close Project Cerrar proyecto + Color Management… Gestión del color… + Color Matte… Mate de color… + Comparison View Vista de comparación + Composite Video Video compuesto + Consolidate Duplicates Consolidar duplicados + Copy Copiar + Copy Paste Includes Sequence Markers Copiar y pegar incluye marcadores de secuencia + Create Captions from Transcript… Crear subtítulos a partir de la transcripción… + Create Multi-Camera Source Sequence… Crear secuencia de origen multicámara… + Create Proxies… Crear proxies… + Cut Cortar + Darkest Más oscuro + Delete Render Files Eliminar archivos de procesamiento + Delete Render Files In to Out Eliminar archivos de procesamiento de entrada a salida + Delete Tracks… Eliminar pistas… + Delete Transcript Eliminar transcripción + Demo Footage Material de demostración + Demo Project Proyecto de demostración + Deselect All Deseleccionar todo + Detach Proxies Separar proxies + Display Mode Modo de visualización + Distribute Distribuir + Distribute Horizontally Distribuir horizontalmente + Distribute Space Horizontally Distribuir espacio horizontalmente + Distribute Space Vertically Distribuir espacio verticalmente + Distribute Vertically Distribuir verticalmente + Duplicate Duplicar + Dynamic Audio Waveforms Formas de onda de audio dinámicas + Edit Edición + Edit Offline… Editar sin conexión… + Edit Original Editar original + Edit Subclip… Editar subclip… + Editing Edición + Effect Controls Controles de efectos + Effects Efectos + Ellipse Elipse + Enable Activar + Enable Remix Activar remezcla + Essential Graphics Gráficos esenciales + Essential Sound Sonido esencial + Events Eventos + Export Exportar + Export As Motion Graphics Template… Exportar como plantilla de animación… + Extract Extraer + Extract Audio Extraer audio + Field Options… Opciones de campo… + File Archivo + File… Archivo… + Fill frame Rellenar fotograma + FilmCraft Help… Ayuda de FilmCraft… + FilmCraft on GitHub FilmCraft en GitHub + FilmCraft on getartcraft.com FilmCraft en getartcraft.com + Find Next Buscar siguiente + Find… Buscar… + Fit Ajustar + Fit to frame Ajustar al fotograma + Flatten Acoplar + Forest Bosque + Frame Blending Fusión de fotogramas + Frame Hold Options… Opciones de fotograma congelado… + Frame Sampling Muestreo de fotogramas + From Bin Desde la bandeja + From Source Monitor Desde el monitor de origen + From Source Monitor, Match Frame Desde el monitor de origen, fotograma coincidente + From file… Desde archivo… + Full Completa + Generate Audio Waveform Generar forma de onda de audio + Get Media File Properties for Obtener propiedades del archivo multimedia de + Go to Gap Ir al hueco + Go to In Ir a la entrada + Go to Next Caption Segment Ir al siguiente segmento de subtítulo + Go to Next Marker Ir al siguiente marcador + Go to Out Ir a la salida + Go to Previous Caption Segment Ir al segmento de subtítulo anterior + Go to Previous Marker Ir al marcador anterior + Go to Split Ir a la división + Graphics and Titles Gráficos y títulos + Graphics… Gráficos… + Green Verde + Group Agrupar + Guide Templates Plantillas de guías + Help Ayuda + Hide All Caption Tracks Ocultar todas las pistas de subtítulos + High Quality Playback Reproducción de alta calidad + History Historial + Import From Importar desde + Import Image Sequence… Importar secuencia de imágenes… + Import from Media Browser Importar desde el navegador de medios + Import… Importar… + Info Información + Ingest Settings… Ajustes de ingesta… + Insert Insertar + Insert Frame Hold Segment Insertar segmento de fotograma congelado + Install Motion Graphics Template… Instalar plantilla de animación… + Interpret Footage… Interpretar material… + Join the ArtCraft Discord… Únete al Discord de ArtCraft… + Keyboard Shortcuts… Atajos de teclado… + Label Etiqueta + Labels… Etiquetas… + Language Idioma + Lavender Lavanda + Learning Aprendizaje + Left Izquierda + Libraries Bibliotecas + Lift Levantar + Light Claro + Link Vincular + Link Media… Vincular medios… + Linked Selection Selección vinculada + Lock Guides Bloquear guías + Lumetri Color Color Lumetri + Lumetri Scopes Ámbitos Lumetri + Magnification Ampliación + Make Offline… Desconectar… + Make Subclip… Crear subclip… + Make Subsequence Crear subsecuencia + Manage Guides… Gestionar guías… + Mark Clip Marcar clip + Mark In Marcar entrada + Mark Out Marcar salida + Mark Selection Marcar selección + Mark Split Marcar división + Markers Marcadores + Match Frame Fotograma coincidente + Media Analysis & Transcription… Análisis y transcripción de medios… + Media Browser Navegador de medios + Media Cache… Caché de medios… + Media… Medios… + Medium Medio + Memory… Memoria… + Merge Clips… Combinar clips… + Metadata Metadatos + Modify Modificar + Motion Graphics Template… Plantilla de animación… + Multi-Camera Multicámara + Multi-Camera View Vista multicámara + Nest… Anidar… + New Nuevo + New Layer Nueva capa + New Project Panel Nuevo panel de proyecto + Next in Sequence Siguiente en la secuencia + Next in Track Siguiente en la pista + Normalize Mix Track… Normalizar pista de mezcla… + Offline File… Archivo sin conexión… + Open Project… Abrir proyecto… + Optical Flow Flujo óptico + Overwrite Sobrescribir + Paste Pegar + Paste Attributes… Pegar atributos… + Paste Insert Pegar e insertar + Paused Resolution Resolución en pausa + Playback Resolution Resolución de reproducción + Playback… Reproducción… + Plugins… Complementos… + Polygon Polígono + Preferences Preferencias + Previous in Sequence Anterior en la secuencia + Previous in Track Anterior en la pista + Program Programa + Progress Progreso + Project Proyecto + Project Manager… Administrador de proyectos… + Project Settings Ajustes del proyecto + Project… Proyecto… + Properties Propiedades + Purple Púrpura + Reconnect Full Resolution Media… Reconectar medios de resolución completa… + Recover Unsaved Changes… Recuperar cambios no guardados… + Rectangle Rectángulo + Red Rojo + Redo Rehacer + Reference Monitor Monitor de referencia + Remix Remezcla + Remix Properties… Propiedades de remezcla… + Remove Attributes… Quitar atributos… + Remove Filler Words Quitar muletillas + Remove Pauses Quitar pausas + Remove Unused Quitar no utilizados + Rename… Cambiar nombre… + Render Audio Procesar audio + Render Effects In to Out Procesar efectos de entrada a salida + Render In to Out Procesar de entrada a salida + Render Selection Procesar selección + Replace Fonts in Projects… Reemplazar fuentes en proyectos… + Replace With Clip Reemplazar con clip + Report an Issue… Informar de un problema… + Reset All Parameters Restablecer todos los parámetros + Reset Duration Restablecer duración + Reset to Saved Layout Restablecer al diseño guardado + Restore Captions from Source Clip Restaurar subtítulos del clip de origen + Reveal Log Files… Mostrar archivos de registro… + Reverse Match Frame Fotograma coincidente inverso + Revert Revertir + Revert Remix Revertir remezcla + Review Revisión + Right Derecha + Ripple Delete Eliminar y cerrar hueco + Ripple Sequence Markers Desplazar marcadores de secuencia + Rose Rosa + Safe Margins Márgenes seguros + Save Guardar + Save All Guardar todo + Save As… Guardar como… + Save Guides as Template… Guardar guías como plantilla… + Save a Copy… Guardar una copia… + Save as Template… Guardar como plantilla… + Scale to Frame Size Escalar al tamaño del fotograma + Scene Edit Detection… Detección de cambios de escena… + Scratch Disks… Discos de memoria virtual… + Search Bin Bandeja de búsqueda + Select Seleccionar + Select All Seleccionar todo + Select All Matching Seleccionar todos los coincidentes + Select Label Group Seleccionar grupo de etiquetas + Select Next Graphic Seleccionar siguiente gráfico + Select Next Layer Seleccionar siguiente capa + Select Previous Graphic Seleccionar gráfico anterior + Select Previous Layer Seleccionar capa anterior + Selection Follows Playhead La selección sigue al cabezal + Selection as FilmCraft Project… Selección como proyecto de FilmCraft… + Selection… Selección… + Send Backward Enviar atrás + Send to Back Enviar al fondo + Sequence Secuencia + Sequence From Clip Secuencia a partir de clip + Sequence Settings… Ajustes de secuencia… + Sequence… Secuencia… + Show Active Caption Tracks Only Mostrar solo pistas de subtítulos activas + Show All Caption Tracks Mostrar todas las pistas de subtítulos + Show All Marker Colors Mostrar todos los colores de marcador + Show Guides Mostrar guías + Show Rulers Mostrar reglas + Show Through Edits Mostrar ediciones pasantes + Simplify Sequence… Simplificar secuencia… + Snap in Program Monitor Ajustar en el monitor de programa + Snap in Timeline Ajustar en la línea de tiempo + Source Origen + Source Settings… Ajustes de origen… + Speed/Duration… Velocidad/duración… + Synchronize… Sincronizar… + System Compatibility Report… Informe de compatibilidad del sistema… + Tan Canela + Teal Verde azulado + Text Texto + Time Interpolation Interpolación de tiempo + Timecode Código de tiempo + Timecode… Código de tiempo… + Timeline Línea de tiempo + Timeline… Línea de tiempo… + Toggle Proxies Alternar proxies + Tools Herramientas + Top Arriba + Transcribe Sequence… Transcribir secuencia… + Transcribe… Transcribir… + Transcript Transcripción + Transparent Video… Video transparente… + Trim Edit Recortar edición + Trim… Recorte… + Undo Deshacer + Ungroup Desagrupar + Universal Counting Leader… Cuenta atrás universal… + Update Metadata… Actualizar metadatos… + Upgrade Caption to Graphic Convertir subtítulo en gráfico + Upgrade to Source Graphic Convertir en gráfico de origen + Vertical Text Texto vertical + Video In Entrada de video + Video Options Opciones de video + Video Out Salida de video + Video and Audio Waveform Split Video y forma de onda de audio divididos + View Ver + Violet Violeta + Window Ventana + Workspaces Espacios de trabajo + Yellow Amarillo + Zoom In Acercar + Zoom Out Alejar + Zoom to Sequence Ajustar zoom a la secuencia + Home Inicio + Import Importar + {name} - Edited {name} - Editado + Full screen Pantalla completa + Volume Volumen + Master volume: use the Audio Track Mixer Volumen maestro: usa el mezclador de pistas de audio + Search Buscar + Quick Export Exportación rápida + Join the ArtCraft Discord (discord.gg/artcraft) Únete al Discord de ArtCraft (discord.gg/artcraft) + Cancel Cancelar + OK Aceptar + Interpret Footage Interpretar material + Color Management · {n} item Gestión del color · {n} elemento + Color Management · {n} items Gestión del color · {n} elementos + Media colour space (from file metadata): {detected} Espacio de color del medio (según los metadatos del archivo): {detected} + Override media colour space: Sustituir el espacio de color del medio: + Use file metadata Usar los metadatos del archivo + Sequence Color Management Gestión del color de la secuencia + Working Color Space Espacio de color de trabajo + Wide gamut color (composite in BT.2020) Color de gama amplia (componer en BT.2020) + Auto tone map media (HDR and log into SDR) Mapeo de tonos automático de los medios (HDR y log a SDR) + Audio Gain Ganancia de audio + Set Gain to: Establecer ganancia en: + Adjust Gain by: Ajustar ganancia en: + Normalize Max Peak to: Normalizar pico máximo a: + Normalize All Peaks to: Normalizar todos los picos a: + Peak Amplitude: {peak} Amplitud de pico: {peak} + Standard Estándar + Adaptive Adaptable + Mono Mono + Stereo Estéreo + Add Tracks Añadir pistas + Amount Cantidad + Placement Ubicación + Track type Tipo de pista + Add video tracks Añadir pistas de video + Add audio tracks Añadir pistas de audio + Add audio submix tracks Añadir pistas de submezcla de audio + After {name} Después de {name} + Before First Track Antes de la primera pista + Delete Tracks Eliminar pistas + Video Tracks Pistas de video + Audio Tracks Pistas de audio + Delete Video Tracks Eliminar pistas de video + Delete Audio Tracks Eliminar pistas de audio + All Empty Tracks Todas las pistas vacías + Version {version} Versión {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Un editor de video no lineal en Rust puro, desarrollado en sala limpia. Parte de la familia ArtCraft. + Join the ArtCraft Discord Únete al Discord de ArtCraft + Source code on GitHub Código fuente en GitHub + Report an issue Informar de un problema + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Fuentes: Inter, Noto Serif y JetBrains Mono (SIL OFL 1.1). + just now ahora mismo + {n} s ago hace {n} s + {n} min ago hace {n} min + {n} h ago hace {n} h + All Events Todos los eventos + Warnings and Errors Advertencias y errores + Errors Only Solo errores + Clear All Borrar todo + No events. No hay eventos. + Show finished jobs Mostrar tareas terminadas + Cancelled Cancelada + Failed: {e} Error: {e} + Done Terminada + No background jobs. No hay tareas en segundo plano. + Recover Unsaved Changes Recuperar cambios no guardados + Untitled Sin título + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft se cerró mientras “{name}” tenía cambios sin guardar. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft se cerró de forma inesperada mientras “{name}” tenía cambios sin guardar. + Recover unsaved changes from {when}? ¿Recuperar los cambios no guardados de {when}? + The project had not been saved yet. El proyecto aún no se había guardado. + {n} sessions have unsaved changes: {n} sesiones tienen cambios sin guardar: + Discard Descartar + Recover Recuperar + Not Now Ahora no + Are you sure you want to discard your changes to '{file}'? ¿Seguro que quieres descartar los cambios en '{file}'? + Yes Sí + No No + Add media to your project. Drop files anywhere in the window, or choose a source below. Añade medios a tu proyecto. Suelta archivos en cualquier parte de la ventana o elige un origen abajo. + Browse files… Examinar archivos… + Movies, audio and stills from disk Películas, audio e imágenes fijas del disco + Demo footage Material de demostración + Six procedural 1080p clips with sound Seis clips procedurales 1080p con sonido + Demo project Proyecto de demostración + A cut sequence with transitions, effects and music Una secuencia montada con transiciones, efectos y música + Bars and Tone Barras y tono + SMPTE HD bars with 1 kHz reference tone Barras SMPTE HD con tono de referencia de 1 kHz + Community Comunidad + Join us on Discord Únete a nosotros en Discord + OMF Export Settings Ajustes de exportación OMF + AAF Export Settings Ajustes de exportación AAF + OMF Title: Título OMF: + Mixdown video Mezclar video + Audio: Audio: + Encapsulate Encapsular + Separate Audio Audio independiente + Embed Incrustar + Separate Files Archivos independientes + Link to Media Vincular a los medios + Audio File Format: Formato de archivo de audio: + Sample Rate: Frecuencia de muestreo: + Bits per Sample: Bits por muestra: + Rendering: Procesamiento: + Trim audio files Recortar archivos de audio + Handle Frames: Fotogramas de margen: + Render audio clip effects Procesar efectos de clips de audio + Breakout to mono Separar en mono + Exported {path} Exportado: {path} + Select a clip to view its metadata. Selecciona un clip para ver sus metadatos. + Clip: {name} Clip: {name} + Open Abrir + (no sequence) (sin secuencia) + Show or hide {color} markers Mostrar u ocultar los marcadores de color {color} + Marker Marcador + {tc} Chapter {tc} Capítulo + No markers. Press M to add one. No hay marcadores. Pulsa M para añadir uno. + All markers are hidden by the colour filter. El filtro de color oculta todos los marcadores. + Type: Tipo: + Video: Video: + Codec: Códec: + Duration: Duración: + Settings: Ajustes: + Playhead: Cabezal: + Video {n}: Video {n}: + Audio {n}: Audio {n}: + UI: Interfaz: + {fps} fps · {n} frames queued {fps} fps · {n} fotogramas en cola + {panel} — coming in a later milestone {panel}: llegará en una versión posterior + Close Panel Cerrar panel + Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo + Maximize Frame Maximizar marco + Restore Workspace Restaurar espacio de trabajo + Reveal Sequence in Project Mostrar secuencia en el proyecto + Video Thumbnails Miniaturas de video + Audio Waveforms Formas de onda de audio + Delete Preset Eliminar ajuste preestablecido + Export Preset… Exportar ajuste preestablecido… + Import Presets… Importar ajustes preestablecidos… + Effect presets Ajustes preestablecidos de efectos + Save Preset Guardar ajuste preestablecido + Name: Nombre: + Type Tipo + Scale Escala + Anchor to In Point Anclar al punto de entrada + Anchor to Out Point Anclar al punto de salida + Without keyframes Sin fotogramas clave + Description: Descripción: + Saved preset “{name}” Ajuste preestablecido “{name}” guardado + Current duration {seconds} s Duración actual: {seconds} s + Remix Properties Propiedades de remezcla + Target Duration: Duración objetivo: + Segments: Segmentos: + Fewer · More Menos · Más + Variations: Variaciones: + Remixed to {seconds} s Remezclado a {seconds} s + {mode} · no clip {mode} · sin clip + {mode} · no sequence {mode} · sin secuencia + Remove Row Quitar fila + Add a timecode row Añadir una fila de código de tiempo + Panner Panoramizador + Mute Silenciar + Send {n} Level Nivel del envío {n} + Show Keyframes Mostrar fotogramas clave + Clip Keyframes Fotogramas clave del clip + Track Keyframes Fotogramas clave de la pista + Delete Eliminar + Untitled Workspace Espacio de trabajo sin título + New Workspace Nuevo espacio de trabajo + Edit Workspaces Editar espacios de trabajo + Rename Cambiar nombre + Restore Original Restaurar original + Search effects Buscar efectos + Apply to Selected Clips Aplicar a los clips seleccionados + Metadata Display Visualización de metadatos + Save As New View Preset Guardar como nuevo ajuste de vista + Manage Saved View Presets Gestionar ajustes de vista guardados + Freeform View Options Opciones de la vista de forma libre + Save Arrangement Guardar disposición + Saves the view, columns, sort, thumbnail and font size. Guarda la vista, las columnas, el orden, la miniatura y el tamaño de fuente. + Grid spacing: Espaciado de la cuadrícula: + Default clip size: Tamaño de clip predeterminado: + Snap to grid Ajustar a la cuadrícula + Show clip names Mostrar nombres de clip + Show durations Mostrar duraciones + Show: Mostrar: + Search fields Buscar campos + Project Metadata Metadatos del proyecto + Add Property… Añadir propiedad… + Column order Orden de columnas + {n}. (empty) {n}. (vacío) + Restore Restaurar + Overwrite with Current View Sobrescribir con la vista actual + Graphics text search arrives with M10.1–M10.2 La búsqueda de texto en gráficos llegará con M10.1–M10.2 + Open a sequence to work with captions Abre una secuencia para trabajar con subtítulos + Add captions Añadir subtítulos + Create a caption track or import a caption file. Crea una pista de subtítulos o importa un archivo de subtítulos. + Create new caption track Crear nueva pista de subtítulos + Import captions file… Importar archivo de subtítulos… + New {format} track Nueva pista {format} + Add caption at playhead Añadir subtítulo en el cabezal + Split caption at playhead Dividir subtítulo en el cabezal + Merge selected captions Combinar subtítulos seleccionados + Delete selected captions Eliminar subtítulos seleccionados + No captions on this track. Press + to add one at the playhead. No hay subtítulos en esta pista. Pulsa + para añadir uno en el cabezal. + No matching captions. Ningún subtítulo coincide. + Open a sequence to see its transcript Abre una secuencia para ver su transcripción + Transcribe sequence Transcribir secuencia + Speech-to-text turns the dialogue into editable text. La conversión de voz a texto convierte el diálogo en texto editable. + This build has no speech-to-text; import a transcript with transcript.set. Esta compilación no incluye conversión de voz a texto; importa una transcripción con transcript.set. + Transcribe Transcribir + Extract selected text Extraer texto seleccionado + Lift selected text Levantar texto seleccionado + Remove filler words Quitar muletillas + Create captions Crear subtítulos + Speaker Hablante + Box Recuadro + Align Alinear + Align left Alinear a la izquierda + Align center Centrar + Align right Alinear a la derecha + Position Posición + Position: top Posición: arriba + Position: middle Posición: centro + Position: bottom Posición: abajo + (gap) (hueco) + Out: {name} Salida: {name} + In: {name} Entrada: {name} + Play Around Edit (Space loops, Shift+K once) Reproducir alrededor de la edición (Espacio en bucle, Mayús+K una vez) + Trim Backward Many Recortar muchos hacia atrás + Trim Backward Recortar hacia atrás + Apply Default Transitions Aplicar transiciones predeterminadas + Apply Default Transitions to Selection (Shift+D) Aplicar transiciones predeterminadas a la selección (Mayús+D) + Trim Forward Recortar hacia delante + Trim Forward Many Recortar muchos hacia delante + Exit Trim Mode Salir del modo de recorte + Voice-over record (right-click: Voice-Over Record Settings…) Grabar voz en off (clic derecho: ajustes de grabación de voz en off…) + Voice-Over Record Settings… Ajustes de grabación de voz en off… + Recording voice-over… Grabando voz en off… + Voice-over recorded: {p} Voz en off grabada: {p} + Voice-over recording stopped (nothing recorded) Grabación de voz en off detenida (no se grabó nada) + Post-roll Margen posterior + ● Recording ● Grabando + Voice-Over Record Settings Ajustes de grabación de voz en off + Source: Origen: + Default Input Entrada predeterminada + Input: Entrada: + Channel {n} Canal {n} + Countdown Sound Cues Pitidos de cuenta atrás + Pre-roll: Margen previo: + seconds segundos + Post-roll: Margen posterior: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. La reproducción empieza con el margen previo antes del cabezal (o del punto de entrada); con entrada y salida marcadas, la grabación entra y sale en esos puntos. + Effect Efecto + Clip Fx Editor Editor de efectos del clip + Track Fx Editor Editor de efectos de la pista + Master Gain Ganancia maestra + Reset Restablecer + Band {n} Banda {n} + Solo Solo + Bypass Omitir + Thr Umbral + Ratio Relación + Att Ataque + Rel Liberación + Gain Ganancia + Output Gain Ganancia de salida + Limiter Limitador + Link Channels Vincular canales + Limiter Threshold Umbral del limitador + Limiter Release Liberación del limitador + Auto Gate Puerta automática + Threshold Umbral + Attack Ataque + Release Liberación + Hold Retención + Compressor Compresor + Auto Makeup Compensación automática + Makeup Compensación + Expander Expansor + Soft Clip Recorte suave + (no sequences) (sin secuencias) + (no clips) (sin clips) + Show Transport Controls Mostrar controles de transporte + Loop Bucle + Multi-Camera Audio Follows Video El audio multicámara sigue al video + Multi-Camera Record Grabación multicámara + Multi-Camera Selection Top Down Selección multicámara de arriba abajo + Show Multi-Camera Preview Monitor Mostrar monitor de previsualización multicámara + Auto-Adjust Multi-Camera Playback Quality Ajustar automáticamente la calidad de reproducción multicámara + Transmit Multi-Camera View Transmitir vista multicámara + Multi-Camera Layout Disposición multicámara + Automatic Automática + Edit Cameras… Editar cámaras… + Add Marker (M) Añadir marcador (M) + Mark In (I) Marcar entrada (I) + Mark Out (O) Marcar salida (O) + Go to In (Shift+I) Ir a la entrada (Mayús+I) + Step Back 1 Frame (Left) Retroceder 1 fotograma (Izquierda) + Play-Stop Toggle (Space) Reproducir/Detener (Espacio) + Step Forward 1 Frame (Right) Avanzar 1 fotograma (Derecha) + Go to Out (Shift+O) Ir a la salida (Mayús+O) + Insert (,) Insertar (,) + Overwrite (.) Sobrescribir (.) + Export Frame (Shift+E) Exportar fotograma (Mayús+E) + Lift (;) Levantar (;) + Extract (') Extraer (') + Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) + Button Editor Editor de botones + Multi-Camera Record on Grabación multicámara activada + Multi-Camera Record off Grabación multicámara desactivada + {first} - Merged {first} - Combinado + {first} Multicam {first} Multicámara + In Points Puntos de entrada + Out Points Puntos de salida + Clip Marker Marcador de clip + Audio Audio + Merge Clips Combinar clips + Create Multi-Camera Source Sequence Crear secuencia de origen multicámara + Synchronize Clips Sincronizar clips + Clip Name: Nombre del clip: + Sequence Name: Nombre de la secuencia: + {n} clip(s): {names} {n} clip(s): {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clip(s) seleccionado(s); el clip de la pista de referencia no se mueve. + Synchronize Point Punto de sincronización + Ignore Hours Ignorar horas + first marker primer marcador + Offset Audio by: Desplazar el audio: + Offset: Desplazamiento: + frames fotogramas + Reference track: Pista de referencia: + lowest (V1…) la más baja (V1…) + Remove Audio from AV Clip Quitar el audio del clip AV + Camera 1 Cámara 1 + All Cameras Todas las cámaras + Switch Audio Cambiar audio + Camera Names Nombres de cámara + Clip Names Nombres de clip + Enumerate Cameras Numerar cámaras + Camera Angle Metadata Metadatos de ángulo de cámara + Move source clips to Processed Clips bin Mover los clips de origen a la bandeja Clips procesados + Synchronized {n} clip group(s) Sincronizado(s) {n} grupo(s) de clips + Merged clip created Clip combinado creado + Multi-camera source sequence with {n} camera(s) Secuencia de origen multicámara con {n} cámara(s) + No multi-camera clip at the playhead No hay ningún clip multicámara en el cabezal + Camera {n} — {name} Cámara {n} — {name} + Page {page} of {pages} Página {page} de {pages} + Previous page Página anterior + Next page Página siguiente + Multi-Camera Record On/Off (0) Grabación multicámara activada/desactivada (0) + Record Grabar + Edit Cameras Editar cámaras + Cameras shown in the Multi-Camera view (uncheck to hide). Cámaras mostradas en la vista multicámara (desmarca para ocultar). + Recorded {n} multi-camera cut(s) Grabado(s) {n} corte(s) multicámara + Clamp Signal Limitar señal + Presets Ajustes preestablecidos + Parade ({kind}) Desfile ({kind}) + Waveform ({kind}) Forma de onda ({kind}) + Parade Type Tipo de desfile + Waveform Type Tipo de forma de onda + Vectorscope Targets Objetivos del vectorscopio + Colour Space Espacio de color + Brightness Brillo + Signal Scale Escala de la señal + Histogram Histograma + Paste Attributes Pegar atributos + Remove Attributes Quitar atributos + New Offline File Nuevo archivo sin conexión + Nested Sequence Name Nombre de la secuencia anidada + Make Subclip Crear subclip + Edit Subclip Editar subclip + Modify Clip: Audio Channels Modificar clip: canales de audio + Modify Clip: Timecode Modificar clip: código de tiempo + Frame Hold Options Opciones de fotograma congelado + Field Options Opciones de campo + Save Project Guardar proyecto + Motion Movimiento + Opacity Opacidad + Time Remapping Reasignación de tiempo + Channel Volume Volumen del canal + In Point Punto de entrada + Out Point Punto de salida + Playhead Cabezal + Source Timecode Código de tiempo de origen + Sequence Time Tiempo de la secuencia + Video Attributes Atributos de video + Audio Attributes Atributos de audio + No effects Sin efectos + Scale Attribute Times Escalar los tiempos de los atributos + File Name: Nombre de archivo: + Tape Name: Nombre de la cinta: + Contains: Contiene: + Video Video + Width: Anchura: + Height: Altura: + Frame Rate: Velocidad de fotogramas: + Media Start: Inicio del medio: + Subclip: {name} Subclip: {name} + Start: Inicio: + End: Fin: + Restrict Trims To Subclip Boundaries Limitar los recortes a los límites del subclip + Convert to Master Clip Convertir en clip maestro + Clip Channel Format: Formato de canales del clip: + Number of Audio Clips: Número de clips de audio: + Media Source Channels Canales de origen del medio + Ch. {n} Can. {n} + Clip {n} Clip {n} + Source channels the selected audio clips play: Canales de origen que reproducen los clips de audio seleccionados: + Set Timecode: Establecer código de tiempo: + Use the file's timecode Usar el código de tiempo del archivo + Hold On Congelar en + Timecode: Código de tiempo: + Hold Filters Congelar filtros + Reverse Field Dominance Invertir dominancia de campo + Processing Options Opciones de procesamiento + None Ninguno + Always Deinterlace Desentrelazar siempre + Flicker Removal Eliminar parpadeo + Save changes to “{project_name}” before closing? ¿Guardar los cambios de “{project_name}” antes de cerrar? + Don't Save No guardar + All media is online. Todos los medios están conectados. + {name} (copy) {name} (copia) + Offline Sin conexión + Missing No encontrado + Link Media Vincular medios + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) no encuentran sus medios. Localízalos, busca en una carpeta o déjalos sin conexión. + Clip Name Nombre del clip + File Name Nombre de archivo + File Path Ruta del archivo + Status Estado + Match file properties: Propiedades de archivo que deben coincidir: + File Extension Extensión de archivo + Clip ID (fingerprint) ID del clip (huella) + Duration Duración + Media Start Inicio del medio + Frame Size / Rate Tamaño y velocidad de fotogramas + Align Timecode Alinear código de tiempo + Relink others automatically Volver a vincular los demás automáticamente + Search in: Buscar en: + Browse… Examinar… + Exact name matches only Solo coincidencias exactas de nombre + No candidates yet — Search a folder or Locate the file. Aún no hay candidatos: busca en una carpeta o localiza el archivo. + ✔ same file ✔ mismo archivo + ✔ matches ✔ coincide + ✖ different file ✖ archivo distinto + Preview Vista previa + Offline All Todo sin conexión + Locate… Localizar… + Linked {n} clip(s) {n} clip(s) vinculado(s) + Nothing found with that name. No se ha encontrado nada con ese nombre. + Make Offline Desconectar + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Desconectar {n} clip(s) seleccionado(s). Sus clips mostrarán la claqueta sin conexión hasta que los vuelvas a vincular. + Media files remain on disk Los archivos multimedia se conservan en el disco + Media files are deleted Los archivos multimedia se eliminan + Create Proxies Crear proxies + {n} clip(s) selected. {n} clip(s) seleccionado(s). + Format and size Formato y tamaño + Destination Destino + Next to the original media, in a Proxies folder Junto a los medios originales, en una carpeta Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Los proxies se crean en segundo plano y se adjuntan al terminar. La exportación siempre usa los medios a resolución completa. + Creating {n} proxy file(s)… Creando {n} archivo(s) proxy… + Project Manager Administrador de proyectos + Sequences Secuencias + Resulting Project Proyecto resultante + Collect Files and Copy to New Location Recopilar archivos y copiar en una nueva ubicación + Consolidate and Transcode Consolidar y transcodificar + Options Opciones + Exclude Unused Clips Excluir clips no utilizados + Include Handles: Incluir márgenes: + Include Proxies Incluir proxies + Include Preview Files Incluir archivos de previsualización + Destination Path Ruta de destino + Disk space: original {original} · resulting {result} ({n} files) Espacio en disco: original {original} · resultante {result} ({n} archivos) + Disk space: — Espacio en disco: — + Calculate Calcular + Project Manager: writing {path} Administrador de proyectos: escribiendo {path} + Timeline: (no sequences) Línea de tiempo: (sin secuencias) + Browse Examinar + Select audio clips, then click a preset to apply it. Selecciona clips de audio y haz clic en un ajuste preestablecido para aplicarlo. + {name} (user) {name} (usuario) + Select audio clips to edit them here Selecciona clips de audio para editarlos aquí + Multiple Clips Selected Varios clips seleccionados + The selected clips have different audio types. Assign one type to edit them together. Los clips seleccionados tienen tipos de audio distintos. Asigna un tipo para editarlos juntos. + Assign an audio type to the selection: Asigna un tipo de audio a la selección: + Clear Audio Type Borrar tipo de audio + Preset: Ajuste preestablecido: + (Custom) (Personalizado) + Preset name Nombre del ajuste preestablecido + Reduce Noise Reducir ruido + Reduce Rumble Reducir retumbo + DeHum Eliminar zumbido + DeEss Eliminar sibilancias + Reduce Reverb Reducir reverberación + Dynamics Dinámica + EQ Ecualizador + Enhance Speech Realzar voz + Low Tone Tono grave + High Tone Tono agudo + Mix Mezcla + Reverb Reverberación + Stereo Width Amplitud estéreo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Aún no se puede remezclar la música a una duración objetivo. Recorta el clip y añade un fundido cruzado. + Pan Panorámica + Auto-Match Igualar automáticamente + Not matched yet Aún sin igualar + Matches each of the {n} clips on its own. Iguala cada uno de los {n} clips por separado. + Duck against: Atenuar frente a: + Untagged Sin etiquetar + Sensitivity Sensibilidad + Reduce By Reducir en + Fades Fundidos + Generate Keyframes Generar fotogramas clave + Clip Volume Volumen del clip + (no clip selected) (ningún clip seleccionado) + Master · {name} Maestro · {name} + Add Lumetri Color to clip Añadir Color Lumetri al clip + Input LUT LUT de entrada + Color Color + Temperature Temperatura + Tint Matiz + Saturation Saturación + Exposure Exposición + Contrast Contraste + Highlights Iluminaciones + Shadows Sombras + Whites Blancos + Blacks Negros + HDR HDR + HDR White Blanco HDR + HDR Specular Especular HDR + Look Aspecto + Look LUT LUT de aspecto + Intensity Intensidad + Adjustments Ajustes + Faded Film Película desvaída + Sharpen Enfocar + Vibrance Intensidad del color + RGB Curves Curvas RGB + HDR Range Rango HDR + Hue Saturation Curves Curvas de tono y saturación + Hue vs Sat Tono frente a saturación + Hue vs Hue Tono frente a tono + Hue vs Luma Tono frente a luminancia + Luma vs Sat Luminancia frente a saturación + Sat vs Sat Saturación frente a saturación + Midtones Medios tonos + Key Clave + Hue Tono + Hue Range Rango de tono + Saturation Min Saturación mínima + Luma Min Luminancia mínima + Luma Max Luminancia máxima + Soften Suavizar + Show Mask Mostrar máscara + Off Desactivado + Color/Gray Color/Gris + Color/Black Color/Negro + White/Black Blanco/Negro + Refine Refinar + Denoise Reducir ruido + Blur Desenfoque + Correction Corrección + Hue Shift Cambio de tono + Midpoint Punto medio + Roundness Redondez + Feather Desvanecer + LUT LUT + Color Match Coincidencia de color + Reference Referencia + Face Detection (protect skin tones) Detección de caras (protege los tonos de piel) + Apply Match Aplicar coincidencia + Create ellipse mask Crear máscara de elipse + Create 4-point polygon mask Crear máscara de polígono de 4 puntos + Free draw bezier Bézier a mano alzada + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Haz clic en el monitor de programa para colocar puntos de máscara (arrastra para curvas); haz clic en el primer punto o pulsa Intro para cerrar + Delete Mask Eliminar máscara + Inverted Invertida + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Cmd+clic: eliminar + Track selected mask backward Seguir la máscara seleccionada hacia atrás + Track selected mask backward 1 frame Seguir la máscara seleccionada 1 fotograma hacia atrás + Track selected mask forward 1 frame Seguir la máscara seleccionada 1 fotograma hacia delante + Track selected mask forward Seguir la máscara seleccionada hacia delante + Stop tracking Detener seguimiento + Tracking method: {method} Método de seguimiento: {method} + Composite Compuesto + Comparison Comparación + Video and Waveform Video y forma de onda + System Default Predeterminado del sistema + Default location Ubicación predeterminada + Font family Familia de fuentes + Label Colors Colores de etiqueta + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Fotogramas descodificados en memoria para los monitores y miniaturas: {used} de {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft deja el resto de la RAM libre para otras aplicaciones. + not available in this session no disponible en esta sesión + Currently: {loc} — {files} file(s), {size} Actualmente: {loc} — {files} archivo(s), {size} + ({ch} output channels) ({ch} canales de salida) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programa izquierdo → salida {l} del dispositivo, derecho → salida {r}{dev}. Las salidas que superen los canales del dispositivo vuelven a 1 y 2. + Auto-saves go to: {dir} Los guardados automáticos van a: {dir} + Recovery copy: {dir} Copia de recuperación: {dir} + Last auto-save {a} · last recovery copy {j} Último guardado automático {a} · última copia de recuperación {j} + Last auto-save {a} Último guardado automático {a} + Last recovery copy {j} Última copia de recuperación {j} + Remove Media Cache Files Eliminar archivos de caché de medios + Deleted {n} file(s), {size}. Se eliminaron {n} archivo(s), {size}. + Reset… Restablecer… + Source · {name} Origen · {name} + Reset Effect Restablecer efecto + Save Preset… Guardar ajuste preestablecido… + Custom Setup Configuración personalizada + Edit… Editar… + Show graphs Mostrar gráficos + Toggle animation Activar/desactivar animación + Eyedropper (pick a color from the Program monitor) Cuentagotas (tomar un color del monitor de programa) + Eyedropper: no picture to sample Cuentagotas: no hay imagen de la que tomar el color + Add/Remove Keyframe Añadir/quitar fotograma clave + Temporal Interpolation Interpolación temporal + Linear Lineal + Bezier Bézier + Auto Bezier Bézier automático + Continuous Bezier Bézier continuo + Ease In Suavizar entrada + Ease Out Suavizar salida + Select a clip to grade Selecciona un clip para etalonarlo + Select a clip to see its properties Selecciona un clip para ver sus propiedades + Anchor point Punto de anclaje + Rotation Rotación + Level Nivel + Speed {n}% Velocidad {n}% + Value Valor + Velocity Velocidad + Default Predeterminado + Custom ({n} points) Personalizada ({n} puntos) + Select a graphic clip, or use the Type tool (T) on the Program monitor Selecciona un clip de gráfico o usa la herramienta Texto (T) en el monitor de programa + Create new graphic Crear gráfico nuevo + New Text Layer Nueva capa de texto + New Text Texto nuevo + New Rectangle Nuevo rectángulo + New Ellipse Nueva elipse + Delete Layer Eliminar capa + Select a layer to edit its properties. Selecciona una capa para editar sus propiedades. + Align Left Alinear a la izquierda + Align Center Horizontally Centrar horizontalmente + Align Right Alinear a la derecha + Align Top Alinear arriba + Align Center Vertically Centrar verticalmente + Align Bottom Alinear abajo + Font Fuente + Font Style Estilo de fuente + Font Size Tamaño de fuente + Alignment Alineación + Left Align Text Alinear texto a la izquierda + Center Align Text Centrar texto + Right Align Text Alinear texto a la derecha + Justify Justificar + Tracking Espaciado entre letras + Leading Interlineado + Baseline Shift Desplazamiento de línea de base + Text Box Width Anchura del cuadro de texto + Style Estilo + Faux Bold Negrita simulada + Faux Italic Cursiva simulada + All Caps Todo mayúsculas + Small Caps Versalitas + Underline Subrayado + OpenType OpenType + Kerning Ajuste entre pares + Ligatures Ligaduras + Shape Forma + Size Tamaño + Corner Radius Radio de esquina + Polygon Sides Lados del polígono + Fill Relleno + Stroke Trazo + Stroke 2 Trazo 2 + Background Fondo + Opacity Opacidad + Size Tamaño + Corner Radius Radio de esquina + Shadow Sombra + Angle Ángulo + Distance Distancia + Blur Desenfoque + Search templates Buscar plantillas + All Categories Todas las categorías + Install… Instalar… + Install Motion Graphics Template (.fcgt) Instalar plantilla de animación (.fcgt) + No templates match. Ninguna plantilla coincide. + {category} · My Template {category} · Mi plantilla + Apply Aplicar + Remove Quitar + {n} editable properties {n} propiedades editables + Template: {name} Plantilla: {name} + Intro Duration Duración de la entrada + Outro Duration Duración de la salida + Roll Desplazamiento + Start Off Screen Empezar fuera de la pantalla + End Off Screen Terminar fuera de la pantalla + Preroll Margen previo + Postroll Margen posterior + Video Frame Fotograma de video + Pin To Fijar a + Pinned Edges Bordes fijados + FilmCraft Graphics Template Plantilla de gráficos de FilmCraft + {n} Font {n} Fuente + {n} Size {n} Tamaño + {n} Color {n} Color + {n} Position {n} Posición + Show {n} Mostrar {n} + My Templates Mis plantillas + Export As Motion Graphics Template Exportar como plantilla de animación + Category: Categoría: + Editable properties: Propiedades editables: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Se guarda como plantilla de gráficos de FilmCraft (.fcgt) en tu carpeta de plantillas. + Exported graphics template to {path} Plantilla de gráficos exportada a {path} + Replace Fonts in Projects Reemplazar fuentes en proyectos + Fonts used in the project: Fuentes usadas en el proyecto: + {f} (missing) {f} (no encontrada) + Replace: Reemplazar: + Replaced {n} font uses Se reemplazaron {n} usos de fuentes + Show/Hide Effects and Sends Mostrar/ocultar efectos y envíos + Audio Track Mixer menu Menú del mezclador de pistas de audio + Show/Hide Tracks… Mostrar/ocultar pistas… + Meter Input(s) Only Medir solo entradas + Show/Hide Tracks Mostrar/ocultar pistas + Show All Mostrar todo + Post-Fader Post-fader + Remove Effect Quitar efecto + Fill the slots above first Rellena primero las ranuras de arriba + pre pre + Pre-Fader Pre-fader + Mute Send Silenciar envío + Remove Send Quitar envío + New Submix Nueva submezcla + Pan / balance (drag; double-click: centre) Panorámica / balance (arrastra; doble clic: centro) + Solo safe Protección de solo + Enable track for recording Activar la pista para grabar + Volume (drag; double-click: 0 dB) Volumen (arrastra; doble clic: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panoramizador 5.1 (arrastra el punto; doble clic: centro delantero) + Center Centro + Go to In Point Ir al punto de entrada + Go to Out Point Ir al punto de salida + Play In to Out Reproducir de entrada a salida + Record (voice-over on the record-enabled track) Grabar (voz en off en la pista activada para grabar) + Write keyframes Escribir fotogramas clave + Something went wrong while drawing the window. Your project is still open; save it to be safe. Algo ha fallado al dibujar la ventana. Tu proyecto sigue abierto; guárdalo por si acaso. + Continue Continuar + Go Back Atrás + Go Forward Adelante + Up One Level Subir un nivel + File Types Displayed Tipos de archivo mostrados + List View Vista de lista + Thumbnail View Vista de miniaturas + Ingest Ingesta + Open Ingest Settings Abrir ajustes de ingesta + Import as Image Sequence Importar como secuencia de imágenes + {sel} of {total} items selected {sel} de {total} elementos seleccionados + {total} items {total} elementos + Cannot open {dir}: {e} No se puede abrir {dir}: {e} + Favorites Favoritos + Local Drives Unidades locales + Network Red + Recent Directories Carpetas recientes + (none) (ninguna) + Remove from Favorites Quitar de favoritos + Add to Favorites Añadir a favoritos + Clear Recent Directories Borrar carpetas recientes + Open In Source Monitor Abrir en el monitor de origen + Reveal in Finder Mostrar en el explorador de archivos + Edit Columns… Editar columnas… + Hover Scrub Arrastre al pasar el ratón + New Media Browser Panel Nuevo panel de navegador de medios + Create Folder For Imported Projects Crear carpeta para los proyectos importados + Allow Duplicate Media During Project Import Permitir medios duplicados al importar proyectos + Refresh Actualizar + Edit Columns Editar columnas + Guides Guías + (no audio) (sin audio) + Generating waveform… Generando forma de onda… + Reference {tc} Referencia {tc} + Set Fijar + Set the reference to the playhead Fijar la referencia en el cabezal + Reference: next frame Referencia: fotograma siguiente + Reference: previous frame Referencia: fotograma anterior + RGBA Channels Canales RGBA + Add Guide Añadir guía + Save Guides as Template Guardar guías como plantilla + Manage Guides Gestionar guías + Orientation: Orientación: + Vertical Vertical + Horizontal Horizontal + Position: Posición: + No saved guide templates. No hay plantillas de guías guardadas. + Project: {name} Proyecto: {name} + Bin: {name} Bandeja: {name} + Up one level Subir un nivel + Video Usage: {vu} Audio Usage: {au} Uso de video: {vu} Uso de audio: {au} + Icon View Vista de iconos + Freeform View Vista de forma libre + Sort Icons Ordenar iconos + Clear (Delete) Borrar (Supr) + New Item Nuevo elemento + New Bin Nueva bandeja + Find Buscar + Automate to Sequence Automatizar a secuencia + Select items to clear Selecciona los elementos que quieres borrar + User Order Orden del usuario + Descending Descendente + Refresh Project Actualizar proyecto + Reveal Project in Finder… Mostrar el proyecto en el explorador de archivos… + New Bin From Selection Nueva bandeja a partir de la selección + New Search Bin Nueva bandeja de búsqueda + New Project Shortcut Nuevo acceso directo al proyecto + List Lista + Icon Icono + Freeform Forma libre + Preview Area Área de previsualización + Thumbnails Miniaturas + Thumbnails show effects applied Las miniaturas muestran los efectos aplicados + Thumbnail controls for all pointing devices Controles de miniaturas para todos los dispositivos señaladores + Refresh Sort Order Actualizar el orden + Metadata Display… Visualización de metadatos… + Save Current View Preset Guardar el ajuste de vista actual + Restore View Preset Restaurar ajuste de vista + Project View Preset {n} Ajuste de vista del proyecto {n} + Freeform View Options… Opciones de la vista de forma libre… + Media missing Medio no encontrado + Unreadable Ilegible + Open in Source Monitor Abrir en el monitor de origen + Open in Timeline Abrir en la línea de tiempo + New Sequence From Clip Nueva secuencia a partir del clip + Set Poster Frame Establecer fotograma de póster + Proxy Proxy + Open in Place Abrir en el mismo lugar + Open in New Tab Abrir en una pestaña nueva + Open in New Window Abrir en una ventana nueva + {n} items {n} elementos + Stack Apilar + Unstack Desapilar + Clip Size Tamaño del clip + Larger Más grande + Smaller Más pequeño + Align to Grid Alinear a la cuadrícula + Reset to Grid Restablecer a la cuadrícula + Save Arrangement… Guardar disposición… + Restore Arrangement Restaurar disposición + Delete Arrangement Eliminar disposición + Drop media here to create sequence. Suelta medios aquí para crear una secuencia. + Double-click a sequence in the Project panel to open it here. Haz doble clic en una secuencia del panel Proyecto para abrirla aquí. + Open Demo Project Abrir proyecto de demostración + Insert and overwrite sequences as nests or individual clips Insertar y sobrescribir secuencias como anidadas o como clips individuales + Snap in Timeline (S) Ajustar en la línea de tiempo (S) + Caption track options Opciones de pista de subtítulos + Timeline Display Settings Ajustes de visualización de la línea de tiempo + Show Video Thumbnails Mostrar miniaturas de video + Show Audio Waveform Mostrar forma de onda de audio + Expand All Tracks Expandir todas las pistas + Minimize All Tracks Minimizar todas las pistas + Unrendered: should play back in real time Sin procesar: debería reproducirse en tiempo real + Unrendered: render to play back in real time Sin procesar: procésalo para reproducirlo en tiempo real + Rendered preview Previsualización procesada + ✓ Enable ✓ Activar + Unlink Desvincular + (Default) (Predeterminado) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Nivel de negro de entrada + (RGB) Input White Level (RGB) Nivel de blanco de entrada + (RGB) Output Black Level (RGB) Nivel de negro de salida + (RGB) Output White Level (RGB) Nivel de blanco de salida + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Punto negro de 10 bits + 10 Bit White Point Punto blanco de 10 bits + 105mm Prime Objetivo fijo de 105 mm + 29.97 fps Drop-Frame 29,97 fps con eliminación de fotogramas + 29.97 fps Non Drop-Frame 29,97 fps sin eliminación de fotogramas + 35mm Prime Objetivo fijo de 35 mm + 3D Roll Rodar en 3D + 3D Rotate Rotar en 3D + 3D Spin Girar en 3D + 3D Spinback Giro inverso en 3D + 4-Color Gradient Degradado de 4 colores + 5.1 Media Medios 5.1 + 5.1 Mixdown Type Tipo de mezcla 5.1 + 50-300mm Zoom Zoom de 50-300 mm + 59.94 fps Drop-Frame 59,94 fps con eliminación de fotogramas + 59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas + ASC CDL ASC CDL + Accessible color contrast Contraste de color accesible + Action-Safe Margin Margen seguro de acción + Active Monitor Monitor activo + Add Añadir + Add tracks automatically when editing source clips onto the Timeline Añadir pistas automáticamente al editar clips de origen en la línea de tiempo + Additional Scale Escala adicional + Additive Dissolve Disolución aditiva + Additive Dissolve (Legacy) Disolución aditiva (heredada) + Adjust Ajustar + Advanced Avanzado + Aggressive Agresivo + Align and Transform Alinear y transformar + All lines Todas las líneas + Allow Selection tool to choose Roll and Ripple trims without modifier key Permitir que la herramienta de selección elija recortes de rodillo y de rizo sin tecla modificadora + Allow current tool to change trim type of previously selected edit point Permitir que la herramienta actual cambie el tipo de recorte del punto de edición seleccionado + Allow duplicate media during project import Permitir medios duplicados al importar proyectos + Alpha Adjust Ajuste alfa + Alpha Blurriness Desenfoque alfa + Alpha Channel Canal alfa + Alpha Glow Resplandor alfa + Always import embedded CEA-708 closed captions with wide aspect. Importar siempre los subtítulos CEA-708 incrustados con aspecto panorámico. + Always override audio tags Sustituir siempre las etiquetas de audio + Ambience Ambiente + Ambience Intensity Intensidad del ambiente + Ambience Light Color Color de la luz ambiente + Amount of Noise Cantidad de ruido + Amount to Decolor Cantidad de decoloración + Amount to Tint Cantidad de tinte + Amplifier Amplificador + Amplify Amplificar + Amplitude Amplitud + Amplitude and Compression Amplitud y compresión + Analog Analógico + Analog Delay Retardo analógico + Analyze all imported media to search for visuals or audio Analizar todos los medios importados para buscar imágenes o audio + Anamorphic Anamórfico + Anchor Ancla + Anchor Point Punto de anclaje + Angle Ángulo + Animation Animación + Anti-aliasing Quality Calidad del suavizado + Anti-flicker Filter Filtro antiparpadeo + Antialiasing for Best Quality Suavizado para la mejor calidad + Asymmetry Asimetría + At Startup Al iniciar + At playback end, return to beginning when restarting playback Al terminar la reproducción, volver al principio al reiniciarla + Attempt to force hardware to document sample rate Intentar forzar el hardware a la frecuencia de muestreo del documento + Audio Codec Códec de audio + Audio Duration Duración del audio + Audio Effects Efectos de audio + Audio Hardware Hardware de audio + Audio In Point Punto de entrada de audio + Audio Info Información de audio + Audio Out Point Punto de salida de audio + Audio Samples Muestras de audio + Audio Time Units Unidades de tiempo de audio + Audio Transition Default Duration Duración predeterminada de las transiciones de audio + Audio Transitions Transiciones de audio + Audio Usage Uso de audio + Auto Automático + Auto Align Alineación automática + Auto Reframe Reencuadre automático + Auto Save Guardado automático + Auto Save also saves the current project(s) El guardado automático también guarda los proyectos actuales + Auto-tag audio types in the timeline Etiquetar automáticamente los tipos de audio en la línea de tiempo + Auto-transcribe all imported clips Transcribir automáticamente todos los clips importados + Auto-transcribe only clips in sequences Transcribir automáticamente solo los clips de secuencias + Automatch Time Tiempo de igualación automática + Automatic Click Remover Eliminador automático de clics + Automatically Hide Dependent Clips Ocultar automáticamente los clips dependientes + Automatically Save Every Guardar automáticamente cada + Automatically delete cache files older than Eliminar automáticamente los archivos de caché con más de + Automatically delete oldest cache files when cache exceeds Eliminar automáticamente los archivos de caché más antiguos cuando la caché supere + Automatically refresh growing files Actualizar automáticamente los archivos en crecimiento + Automatically resume playback for growing files in Source Monitor Reanudar automáticamente la reproducción de archivos en crecimiento en el monitor de origen + Automatically save projects Guardar proyectos automáticamente + Automatically transcribe clips Transcribir clips automáticamente + Automation Keyframe Optimization Optimización de fotogramas clave de automatización + Background Color Color de fondo + Background Corner Radius Radio de esquina del fondo + Background Opacity Opacidad del fondo + Background Size Tamaño del fondo + Background fill Relleno de fondo + Balance Balance + Balanced Voice Voz equilibrada + Band 1 Banda 1 + Band 2 Banda 2 + Band 3 Banda 3 + Band 4 Banda 4 + Band 5 Banda 5 + Band Pass Paso de banda + Band Slide Deslizamiento de bandas + Band Stop Rechazo de banda + Bandpass Paso de banda + Barn Doors Puertas de granero + Basic Básico + Basic 3D 3D básico + Basic Correction Corrección básica + Bass Graves + Behind Detrás + Bessel Bessel + Bevel Alpha Bisel alfa + Bevel join Unión biselada + Binauralizer - Ambisonics Binauralizador - Ambisonics + Bins Bandejas + Black Negro + Black & White Blanco y negro + Black Input Level Nivel de entrada de negro + Black and white Blanco y negro + Black and white with crushed blacks and strong contrast Blanco y negro con negros aplastados y contraste fuerte + Bleach Bypass Blanqueo omitido + Bleach-bypass style: desaturated with hard contrast Estilo blanqueo omitido: desaturado y con contraste duro + Blend Mezclar + Blend Mode Modo de fusión + Blend With Original Mezclar con el original + Blending Mode Modo de fusión + Block Dissolve Disolución en bloques + Block Height Altura del bloque + Block Motion Movimiento de bloques + Block Size Tamaño del bloque + Block Width Anchura del bloque + Blocks Bloques + Blue Blurriness Desenfoque del azul + Blue Curve Curva del azul + Blue Offset Desplazamiento del azul + Blue Power Potencia del azul + Blue Slope Pendiente del azul + Blue-Blue Azul-azul + Blue-Const Azul-constante + Blue-Green Azul-verde + Blue-Red Azul-rojo + Blur & Sharpen Desenfocar y enfocar + Blur Amount Cantidad de desenfoque + Blur Angle Ángulo de desenfoque + Blur Dimensions Dimensiones de desenfoque + Blur Dissolve Disolución con desenfoque + Blur Layer Capa de desenfoque + Blur Length Longitud del desenfoque + Blurriness Desenfoque + Bokeh Blur Desenfoque bokeh + Boost Realce + Border Borde + Border Color Color del borde + Border Width Anchura del borde + Borders Bordes + Bottom Center Abajo en el centro + Bottom Left Abajo a la izquierda + Bottom Right Abajo a la derecha + Bounce Rebote + Branching Ramificación + Bright Brillante + Bright Air Aire brillante + Bright Studio Estudio brillante + Brightness & Contrast Brillo y contraste + British Stack Pila británica + Brush Hardness Dureza del pincel + Brush Opacity Opacidad del pincel + Brush Position Posición del pincel + Brush Size Tamaño del pincel + Brush Spacing (secs) Espaciado del pincel (s) + Brush Strokes Trazos de pincel + Bubbles Burbujas + Bulge Abultamiento + Bulge Smoother Suavizado del abultamiento + Bump Height Altura del relieve + Burn Alpha Quemar alfa + Burn Chroma Quemar croma + Burn Color Quemar color + Butt cap Extremo cuadrado + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Guardar en caché los resultados del análisis para reutilizarlos + Camera Angle Ángulo de cámara + Camera Blur Desenfoque de cámara + Camera Shake Movimiento de cámara + Capitalisation Uso de mayúsculas + Cathedral Catedral + Cell Pattern Patrón de celdas + Center Channel Pan Panorámica del canal central + Center Gradient Degradado central + Center Input Entrada central + Center Out Salida central + Center Point (Latitude) Punto central (latitud) + Center Point (Longitude) Punto central (longitud) + Center Split División central + Center of Sphere Centro de la esfera + Cents Centésimas + Change to Color Cambiar a color + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Los cambios se copian en segundo plano poco después de cada edición. Si FilmCraft se cierra de forma inesperada, se ofrecen la próxima vez que se inicie. + Channel Canal + Channel Blur Desenfoque de canal + Channel Mix Mezcla de canales + Channel Mixer Mezclador de canales + Chaos Caos + Chebyshev Chebyshev + Checker Wipe Barrido en damero + Checkerboard Tablero de damas + Chinese Chino + Choke Estrechar + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Croma + Chroma Leak Fuga de croma + Chromatic Aberration (Blue) Aberración cromática (azul) + Chromatic Aberration (Green) Aberración cromática (verde) + Chromatic Aberration (Red) Aberración cromática (rojo) + Cineon Converter Conversor Cineon + Circle Círculo + Clarity Claridad + Clean Combo Combo limpio + Clean Up Noisy Dialogue Limpiar diálogo con ruido + Clear Muddiness Eliminar turbiedad + Client Cliente + Clip Clip + Clip Level Nivel del clip + Clipping Recorte + Clock Wipe Barrido de reloj + Clock Wipe (Legacy) Barrido de reloj (heredado) + Clockwise En el sentido de las agujas del reloj + Clone Clonar + Closed Captions Subtítulos opcionales + Color & Alpha Color y alfa + Color 1 Color 1 + Color 2 Color 2 + Color 3 Color 3 + Color 4 Color 4 + Color Balance Equilibrio de color + Color Burn Subexponer color + Color Channel Canal de color + Color Correction Corrección de color + Color Distortion Distorsión de color + Color Dodge Sobreexponer color + Color Emboss Relieve en color + Color Key Incrustación de color + Color Pass Paso de color + Color Replace Sustitución de color + Color Space Espacio de color + Color Theme Tema de color + Color To Leave Color que se conserva + Color Tolerance Tolerancia de color + Color Wheels & Match Ruedas de color y coincidencia + Colorize Colorear + Columns Columnas + Comment Comentario + Complexity Complejidad + Composite In Back Componer detrás + Composite In Front Componer delante + Composite Using Componer usando + Composite on Original Componer sobre el original + Compound Blur Desenfoque compuesto + Compression Before Clipping Compresión antes del recorte + Constant Gain Ganancia constante + Constant Power Potencia constante + Conversion Type Tipo de conversión + Convolution Reverb Reverberación por convolución + Cool Blue Azul frío + Cool, desaturated and punchy Frío, desaturado y contundente + Core Width Anchura del núcleo + Corner Esquina + Corner Pin Anclaje de esquinas + Count Recuento + Counter-clockwise En sentido contrario a las agujas del reloj + Crash Recovery Recuperación tras un fallo + Crawl Left Desplazar a la izquierda + Crawl Right Desplazar a la derecha + Create folder for imported projects Crear carpeta para los proyectos importados + Creative Creativo + Crop Recortar + Crop Less <-> Smooth More Recortar menos <-> Suavizar más + Cross Displacement Desplazamiento cruzado + Cross Dissolve Disolución cruzada + Cross Dissolve (Legacy) Disolución cruzada (heredada) + Cross Zoom Zoom cruzado + Cross Zoom (Legacy) Zoom cruzado (heredado) + Crossfade Fundido cruzado + Crossovers Frecuencias de cruce + Crystallize Cristalizar + Crystals Cristales + Cube Spin Giro de cubo + Curl Radius Radio de curvatura + Current Time Tiempo actual + Cursor Cursor + Curvature Curvatura + Curve Curva + Curves Curvas + Custom Personalizado + Custom Color Color personalizado + Cutoff Corte + Cyan-teal shadows and warm highlights with a little more contrast Sombras cian verdosas e iluminaciones cálidas con algo más de contraste + Cycle (in Revolutions) Ciclo (en revoluciones) + Cycle Evolution Evolución cíclica + Damping Amortiguación + Damping HF Amortiguación de agudos + Damping LF Amortiguación de graves + Dark Oscuro + Darken Oscurecer + Darker Color Color más oscuro + Date Created Fecha de creación + Date Modified Fecha de modificación + Day for night: darker, cooler and less saturated Noche americana: más oscuro, más frío y menos saturado + DeEsser Eliminador de sibilancias + DeHummer Eliminador de zumbido + DeNoise Reductor de ruido + DeReverb Reductor de reverberación + Decay Caída + Decay Time (RT60) Tiempo de caída (RT60) + Default Audio Tracks Pistas de audio predeterminadas + Default Emoji font Fuente de emoji predeterminada + Default Media Scaling Escalado de medios predeterminado + Default Output Salida predeterminada + Default closed caption font Fuente predeterminada de subtítulos opcionales + Default language Idioma predeterminado + Default paragraph direction Dirección de párrafo predeterminada + Default subtitle and text font Fuente predeterminada de subtítulos y texto + Delay Retardo + Delay 1 Retardo 1 + Delay 2 Retardo 2 + Delay 3 Retardo 3 + Delay 4 Retardo 4 + Delay and Echo Retardo y eco + Delete files older than Eliminar archivos con más de + Delete oldest files when the cache exceeds Eliminar los archivos más antiguos cuando la caché supere + Delete… Eliminar… + Depth Profundidad + Desaturate Desaturar + Description Descripción + Detail Amplitude Amplitud del detalle + Detail Level Nivel de detalle + Detailed Analysis Análisis detallado + Device Class Clase de dispositivo + Dialogue Diálogo + Diamond Diamante + Difference Diferencia + Dimensions Dimensiones + Dimmed Atenuado + Dip to Black Fundido a negro + Dip to Black (Legacy) Fundido a negro (heredado) + Dip to Color Fundido a color + Dip to White Fundido a blanco + Dip to White (Legacy) Fundido a blanco (heredado) + Direction Dirección + Directional Direccional + Directional Blur Desenfoque direccional + Directional Blur (Legacy) Desenfoque direccional (heredado) + Disable video output when in the background Desactivar la salida de video en segundo plano + Disperse Dispersar + Displacement Desplazamiento + Display Color Management Gestión del color de la pantalla + Display Color Settings Ajustes de color de la pantalla + Display out of sync indicators for unlinked clips Mostrar indicadores de desincronización en clips desvinculados + Dissolve Disolución + Dissolve Blocks Bloques de disolución + Distance Distancia + Distance to Image Distancia a la imagen + Distort Distorsionar + Distortion Distorsión + Distortion Complexity Complejidad de la distorsión + Distortion Rate Velocidad de la distorsión + Distortion Type Tipo de distorsión + Divide Dividir + Do not delete cache files automatically No eliminar automáticamente los archivos de caché + Don't label speakers No etiquetar hablantes + Double-click Doble clic + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Descodificación de borrador a resolución de reproducción reducida (H.264: más rápida, algunos fotogramas menos filtrados) + Drive Saturación + Drop Shadow Sombra paralela + Dry Seco + Dry Out Salida seca + Dry Up a Roomy Voice Secar una voz con mucha sala + Duck Deep Under Dialogue Atenuar mucho bajo el diálogo + Duck Under Dialogue Atenuar bajo el diálogo + Duck Under Voice and Effects Atenuar bajo la voz y los efectos + Ducking Atenuación + Dutch Holandés + Dynamic Link Dynamic Link + Dynamics Processing Procesamiento de dinámica + Early Reflections Reflexiones tempranas + Earthquake Terremoto + Ease In and Out Suavizar entrada y salida + Easing Suavizado + Echo Eco + Echo Glow Resplandor de eco + Echo Operator Operador de eco + Echo Time (seconds) Tiempo de eco (segundos) + Echoes Ecos + Edge Color Color del borde + Edge Feather Desvanecimiento del borde + Edge Glow Resplandor del borde + Edge Sharpness Nitidez del borde + Edge Softness Suavidad del borde + Edge Thickness Grosor del borde + Edge Thin Adelgazar borde + Edge Threshold Umbral del borde + Edge Type Tipo de borde + Edge Width Anchura del borde + Elliptical Elíptico + Emboss Relieve + Enable HSL Secondary Activar HSL secundaria + Enable Mercury Transmit Activar Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Activar la codificación ProRes acelerada por hardware, si está disponible + Enable clip and XMP metadata linking Activar la vinculación de metadatos de clip y XMP + Enable developer mode Activar el modo de desarrollador + Enable hardware accelerated decoding (requires restart) Activar la descodificación acelerada por hardware (requiere reiniciar) + Enable language auto-detection Activar la detección automática del idioma + Enable proxies Activar proxies + End Color Color final + End Point Punto final + End of Ramp Final de la rampa + Error Error + Evolution Evolución + Evolution Options Opciones de evolución + Exciter Amount Cantidad de excitador + Exciter Mode Modo del excitador + Exclusion Exclusión + Exponential Fade Fundido exponencial + Extended Dynamic Range Monitoring Monitorización de rango dinámico ampliado + FFT Filter Filtro FFT + Fade Fundido + Fade Out Fundido de salida + Faded Matte Mate desvaído + Falloff Atenuación + Far Away Lejano + Fast Rápido + Faster Motion Movimiento más rápido + Feedback Realimentación + Feedback 1 Realimentación 1 + Feedback 2 Realimentación 2 + Feedback 3 Realimentación 3 + Feedback 4 Realimentación 4 + Feet + Frames 16mm Pies + fotogramas 16 mm + Feet + Frames 35mm Pies + fotogramas 35 mm + Female Femenina + File Size Tamaño del archivo + Fill Background Rellenar fondo + Fill Color Color de relleno + Fill Left with Right Rellenar izquierda con derecha + Fill Point Punto de relleno + Fill Right with Left Rellenar derecha con izquierda + Fill Selector Selector de relleno + Film Dissolve Disolución de película + Film Dissolve (Legacy) Disolución de película (heredada) + Film Roll Rollo de película + Filter Filtro + Filter Frequency Frecuencia del filtro + Filter Resonance Resonancia del filtro + Filter and EQ Filtro y ecualizador + Final Delay Retardo final + Find Edges Buscar bordes + Flanger Flanger + Flare Destello + Flare Brightness Brillo del destello + Flare Center Centro del destello + Flare Color Color del destello + Flash Destello + Flicker Parpadeo + Flickers Parpadeos + Flip Motion Movimiento de volteo + Flip Over Voltear + Float Coma flotante + Focus Foco + Focus Blur Desenfoque de foco + Focus Point Punto de enfoque + Focus Shape Forma del foco + Focus Size Tamaño del foco + Fold Motion Movimiento de plegado + Foldback Retorno + For color management parameters, go to the Settings tab in the Lumetri Color panel. Para los parámetros de gestión del color, ve a la pestaña Ajustes del panel Color Lumetri. + Format Formato + Four Scopes (RGB, 8 bit) Cuatro ámbitos (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Cuatro ámbitos (YUV, coma flotante, sin limitar) + Fractal Influence Influencia fractal + Fractal Type Tipo de fractal + Frame Fotograma + Frame Color Color del marco + Frame Count Número de fotogramas + Frame Layout Disposición del fotograma + Frame Rate Velocidad de fotogramas + Frame Width Anchura del marco + Frame cache for monitors and thumbnails Caché de fotogramas para monitores y miniaturas + Frames Fotogramas + Framing Encuadre + French Francés + Frequency Frecuencia + From Desde + From East Desde el este + From North Desde el norte + From South Desde el sur + From West Desde el oeste + From the Left Desde la izquierda + From the Right Desde la derecha + Front + LFE Frontales + LFE + Front + Rear Frontales + traseros + Front + Rear + LFE Frontales + traseros + LFE + Front Only Solo frontales + Fuzz Fuzz + GB GB + Gamma Gamma + Gamma Correction Corrección de gamma + Gamut Warning Aviso de gama + Gamut Warning Color Color del aviso de gama + Gap Hueco + Gap Color Color del hueco + Gaussian Blur Desenfoque gaussiano + Gaussian Blur (Legacy) Desenfoque gaussiano (heredado) + General General + Generate Generar + Generate Timecode Generar código de tiempo + Generate waveforms and conform audio using multithreading Generar formas de onda y conformar audio con varios hilos + Generate waveforms automatically during import Generar formas de onda automáticamente al importar + German Alemán + Ghosts Fantasmas + Glass Cristal + Glint Destello + Glitch Glitch + Glow Resplandor + Glow Brightness Brillo del resplandor + Glow Color Color del resplandor + Glow Only Solo resplandor + Glow Radius Radio del resplandor + Glow Saturation Saturación del resplandor + Glow Width Anchura del resplandor + Golden Hour Hora dorada + Good Buena + Gradient Degradado + Gradient End Final del degradado + Gradient Layer Capa de degradado + Gradient Placement Colocación del degradado + Gradient Scatter Dispersión del degradado + Gradient Shape Forma del degradado + Gradient Start Inicio del degradado + Gradient Wipe Barrido con degradado + Gradient Wipe (Legacy) Barrido con degradado (heredado) + Grain Size Tamaño del grano + Graphic Equalizer (10 Bands) Ecualizador gráfico (10 bandas) + Graphic Equalizer (20 Bands) Ecualizador gráfico (20 bandas) + Graphic Equalizer (30 Bands) Ecualizador gráfico (30 bandas) + Graphics Gráficos + Green Blurriness Desenfoque del verde + Green Curve Curva del verde + Green Offset Desplazamiento del verde + Green Power Potencia del verde + Green Slope Pendiente del verde + Green-Blue Verde-azul + Green-Const Verde-constante + Green-Green Verde-verde + Green-Red Verde-rojo + Grid Cuadrícula + Grow Crecer + Growing Files Archivos en crecimiento + Grunge Grunge + Grunge & Distort Grunge y distorsión + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Blanco de gráficos HDR (nits) + HLS Vectorscope + Histogram Vectorscopio HLS + histograma + HSL Secondary HSL secundaria + Hard Duro + Hard Clip Recorte duro + Hard Light Luz fuerte + Hard Limiter Limitador duro + Hard Mix Mezcla definida + Hardware decoding Descodificación por hardware + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. + Head Size Tamaño de la cabeza + Heart Corazón + Height Altura + Hexagon Hexágono + Hide Back Face Ocultar la cara posterior + High Alta + High Cutoff Corte de agudos + High Frequency Cut Corte de altas frecuencias + High Frequency Damping Amortiguación de altas frecuencias + High Pass Paso alto + High Shelf Estante de agudos + High Shelf Gain Ganancia del estante de agudos + High contrast with held highlights and deep shadows Alto contraste con iluminaciones contenidas y sombras profundas + Highlight Iluminación + Highlight Blue Balance Balance de azul en iluminaciones + Highlight Brightness Brillo de las iluminaciones + Highlight Color Color de resaltado + Highlight Green Balance Balance de verde en iluminaciones + Highlight Red Balance Balance de rojo en iluminaciones + Highlight Rolloff Atenuación de iluminaciones + Highlight Threshold Umbral de iluminaciones + Highlight Tint Matiz de iluminaciones + Highlights Lightness Luminosidad de iluminaciones + Highpass Paso alto + Hindi Hindi + Hindi digits Dígitos hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alineación horizontal + Horizontal Blocks Bloques horizontales + Horizontal Decentering Descentrado horizontal + Horizontal Displacement Desplazamiento horizontal + Horizontal Flip Volteo horizontal + Horizontal Margin Margen horizontal + Horizontal and Vertical Horizontal y vertical + Hue Center Centro de tono + Hue Tolerance Tolerancia de tono + I/O Buffer Size Tamaño del búfer de E/S + Ignore Alpha Ignorar alfa + Image Control Control de imagen + Immersive Video Video inmersivo + Import numbered stills as image sequences Importar imágenes fijas numeradas como secuencias de imágenes + Impulse Impulso + In a Room En una sala + In the Media Cache En la caché de medios + In the project En el proyecto + In/Out Duration Duración de entrada a salida + Include captions on import Incluir subtítulos al importar + Incoming Entrante + Indeterminate Media Timebase Base de tiempo de medios indeterminada + Initial Delay Retardo inicial + Inner Interior + Inner Radius Radio interior + Input Boost Realce de entrada + Inset Recuadro + Inside Dentro + Inside Color Color interior + Inter Inter + Interior Room Tone Tono de sala interior + Internal Black Point Punto negro interno + Internal White Point Punto blanco interno + Interpolation Interpolación + Intersect Intersecar + Inverse Falloff Atenuación inversa + Invert Invertir + Invert Alpha Invertir alfa + Invert Blur Invertir desenfoque + Invert Fill Invertir relleno + Invert Gradient Invertir degradado + Invert Left Invertir izquierda + Invert Right Invertir derecha + Iris Iris + Iris Box Iris en caja + Iris Cross Iris en cruz + Iris Diamond Iris en diamante + Iris Radius Radio del iris + Iris Rotation Rotación del iris + Iris Round Iris redondo + Iris Shape Forma del iris + Italian Italiano + Japanese Japonés + JetBrains Mono JetBrains Mono + Join Through Edits Unir ediciones pasantes + Ripple Trim In Recorte de rizo de entrada + Ripple Trim Out Recorte de rizo de salida + Roll Edit Edición de rodillo + Trim In Recortar entrada + Trim Out Recortar salida + Kaleidoscope Caleidoscopio + Keep a recovery copy of unsaved changes Mantener una copia de recuperación de los cambios sin guardar + Key Color Color clave + Keying Incrustación + Korean Coreano + Label Defaults Etiquetas predeterminadas + Label speakers Etiquetar hablantes + Labels Etiquetas + Large Hall Sala grande + Large Trim Offset Desplazamiento de recorte grande + Large Volume Adjustment Ajuste de volumen grande + Latch Retener + Layer Name Nombre de la capa + Layers Capas + Leave Color Conservar color + Left - Left Izquierda - izquierda + Left - Right Izquierda - derecha + Left channel to device output Canal izquierdo a la salida del dispositivo + Left to Right De izquierda a derecha + Left to right De izquierda a derecha + Legacy Heredados + Length Longitud + Lens Blur Desenfoque de lente + Lens Distortion Distorsión de lente + Lens Flare Destello de lente + Lens Type Tipo de lente + Level 1 Nivel 1 + Level 2 Nivel 2 + Level 3 Nivel 3 + Level 4 Nivel 4 + Levels Niveles + Lifted blacks, softer contrast, muted colour Negros elevados, contraste más suave y color apagado + Light 1 Luz 1 + Light 2 Luz 2 + Light 3 Luz 3 + Light 4 Luz 4 + Light 5 Luz 5 + Light Angle Ángulo de la luz + Light Color Color de la luz + Light Intensity Intensidad de la luz + Light Leak Fuga de luz + Light Leaks Fugas de luz + Light Sweep Barrido de luz + Light Type Tipo de luz + Lighten Aclarar + Lighter Color Color más claro + Lighting Effects Efectos de iluminación + Lightning Rayo + Lights & Blurs Luces y desenfoques + Lights & Glows Luces y resplandores + Line Thickness Grosor de línea + Linear Burn Subexposición lineal + Linear Dodge (Add) Sobreexposición lineal (añadir) + Linear Light Luz lineal + Linear Ramp Rampa lineal + Linear Wipe Barrido lineal + Linear Wipe (Legacy) Barrido lineal (heredado) + Linear keyframe thinning Reducción lineal de fotogramas clave + Linear to Log De lineal a logarítmico + Liquid Distortion Distorsión líquida + Local Projects Proyectos locales + Location Ubicación + Log Note Nota de registro + Log to Linear De logarítmico a lineal + Log to Log De logarítmico a logarítmico + Logo Cutout Recorte de logotipo + Long Shadow Sombra alargada + Look-Ahead Time Tiempo de anticipación + Loudness Sonoridad + Loudness Maximizer Maximizador de sonoridad + Loudness Meter Medidor de sonoridad + Louver Persiana + Low Baja + Low Frequency Cut Corte de bajas frecuencias + Low Pass Paso bajo + Low Shelf Estante de graves + Low Shelf Gain Ganancia del estante de graves + Lower Left Abajo a la izquierda + Lower Right Abajo a la derecha + Lowpass Paso bajo + Luma Luminancia + Luma Curve Curva de luminancia + Luma Fade Fundido por luminancia + Luma Key Incrustación de luminancia + Luma Source Origen de luminancia + Luma and Chroma Luminancia y croma + Lumetri Presets Ajustes preestablecidos de Lumetri + Luminance Luminancia + Luminance Threshold Umbral de luminancia + Luminosity Luminosidad + MB MB + Magenta Magenta + Magnify Ampliar + Magnify (Legacy) Ampliar (heredado) + Maintain pitch while shuttling Mantener el tono al desplazarse con lanzadera + Major Radius Radio mayor + Male Masculina + Mango Mango + Map Black To Asignar negro a + Map White To Asignar blanco a + Maps full-range levels into 16–235 (video range) with a luma curve Convierte niveles de rango completo a 16–235 (rango de video) con una curva de luminancia + Mask Only Solo máscara + Master Amplitude Amplitud maestra + Mastering Masterización + Match frame sets in point El fotograma coincidente establece el punto de entrada + Matte Mate + Matte Alpha Mate alfa + Matte Cleanup Limpieza del mate + Matte Generation Generación del mate + Matte Luma Mate de luminancia + Maximum Máximo + Maximum Amplitude Amplitud máxima + Maximum Block Size Tamaño máximo de bloque + Maximum Blur Desenfoque máximo + Maximum Project Versions Número máximo de versiones del proyecto + Maximum Reduction Reducción máxima + Maximum Scale Escala máxima + Media Medios + Media Analysis Análisis de medios + Media Analysis & Transcription Análisis y transcripción de medios + Media Cache Caché de medios + Media Cache Database Base de datos de la caché de medios + Media Cache Files Archivos de caché de medios + Media Cache Management Gestión de la caché de medios + Media Duration Duración del medio + Media End Fin del medio + Media Time Tiempo del medio + Media Timecode Código de tiempo del medio + Media Type Tipo de medio + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. Los análisis y transcripciones de medios se guardarán en la carpeta de caché de medios. No hará falta volver a analizar si los medios se importan en otro proyecto de este ordenador. + Median Mediana + Medium Room Sala mediana + Memory Memoria + Metadata & Timecode Burn-in Impresión de metadatos y código de tiempo + Method Método + Metrics Kerning Ajuste entre pares métrico + Mid Medios + Mid Point Punto medio + Midtone Blue Balance Balance de azul en medios tonos + Midtone Green Balance Balance de verde en medios tonos + Midtone Red Balance Balance de rojo en medios tonos + Midtones Lightness Luminosidad de medios tonos + Minimum Mínimo + Minimum time Tiempo mínimo + Minimum time interval thinning Reducción por intervalo de tiempo mínimo + Minor Radius Radio menor + Mirror Espejo + Mirror Alternate Espejo alterno + Missing font replacement Sustitución de fuentes que faltan + Miter join Unión en inglete + Miter limit Límite de inglete + Mixed Crystals Cristales mezclados + Mode Modo + Modern High Gain Ganancia alta moderna + Modulation Modulación + Modulation Rate Velocidad de modulación + Mono Media Medios mono + Monochrome Monocromo + Monochrome toned cyan-blue Monocromo virado a azul cian + Monochrome toned warm brown Monocromo virado a marrón cálido + Monoscopic Monoscópico + Monospace only Solo monoespaciadas + More colour in the less saturated areas Más color en las zonas menos saturadas + Morph Cut Corte de transformación + Mosaic Mosaico + Mosaic (Legacy) Mosaico (heredado) + Motion Blur Desenfoque de movimiento + Motion Camera Cámara en movimiento + Motion Preset Ajuste preestablecido de movimiento + Motion Tween Interpolación de movimiento + Move Mover + Movie Película + Movie (audio and video) Película (audio y video) + Multiband Compressor Compresor multibanda + Multichannel Mono Media Medios mono multicanal + Multiply Multiplicar + Multitap Delay Retardo multitoma + Music Música + Mute input during timeline recording Silenciar la entrada al grabar en la línea de tiempo + Name Nombre + Narrow Estrecho + Neon Color Color neón + Neon Wipe Barrido neón + Next to original media files Junto a los archivos multimedia originales + Night Noche + No Motion Sin movimiento + No Scroll Sin desplazamiento + No plugins are installed. Changes will take effect the next time you start FilmCraft. No hay complementos instalados. Los cambios se aplicarán la próxima vez que inicies FilmCraft. + Noise Ruido + Noise & Grain Ruido y grano + Noise (Legacy) Ruido (heredado) + Noise Level Nivel de ruido + Noise Reduction/Restoration Reducción de ruido/restauración + Noise Type Tipo de ruido + Non-Additive Dissolve Disolución no aditiva + Normal Normal + Notch 1 Muesca 1 + Notch 2 Muesca 2 + Notch 3 Muesca 3 + Notch 4 Muesca 4 + Notch 5 Muesca 5 + Notch 6 Muesca 6 + Notch Filter Filtro de muesca + Notch Width Anchura de la muesca + Number Of Echoes Número de ecos + Number of Harmonics Número de armónicos + Obsolete Obsoletos + Octagon Octágono + Offset Desplazamiento + Offset (Turbulence) Desplazamiento (turbulencia) + Offset From Desplazar desde + Offset To Desplazar hasta + Omni Omni + On Activado + On Original Image Sobre la imagen original + On Transparent Sobre transparente + Open Air Aire libre + Open Most Recent Abrir el más reciente + Open in new window Abrir en una ventana nueva + Open in place Abrir en el mismo lugar + Open new tab Abrir una pestaña nueva + Opens up the shadows Abre las sombras + Order Orden + Orientation Orientación + Outer Exterior + Outgoing Saliente + Output Salida + Output Mapping Asignación de salidas + Outside Fuera + Outside Color Color exterior + Overflow Desbordamiento + Overlay Superponer + Overshoot Rebasamiento + Page Peel Despegar página + Page Scroll Desplazar página + Page Turn Pasar página + Paint Bucket Bote de pintura + Paint On Black Pintar sobre negro + Paint On Original Image Pintar sobre la imagen original + Paint On Transparent Pintar sobre transparente + Paint On White Pintar sobre blanco + Paint Style Estilo de pintura + Paint Surface Superficie de pintura + Pan (Y Axis) Panorámica (eje Y) + Panel Wipe Barrido de paneles + Panels Paneles + Panner - Ambisonics Panoramizador - Ambisonics + Parade Desfile + Parade (RGB) Desfile (RGB) + Parametric Equalizer Ecualizador paramétrico + Passband Ripple Ondulación de la banda de paso + Path Trazado + Path Points Puntos del trazado + Pause Media Encoder queue during playback Pausar la cola de codificación durante la reproducción + Peak Frequency Frecuencia de pico + Peak Gain Ganancia de pico + Pedestal Pedestal + Pentagon Pentágono + Per line Por línea + Percent Blur Porcentaje de desenfoque + Perspective Perspectiva + Phase Difference Diferencia de fase + Phaser Phaser + Phone Call Llamada telefónica + Phosphor Fósforo + Phosphor Color Color del fósforo + Photocopy Fotocopia + Photocopy Color Color de fotocopia + Pillow Almohada + Pin All Fijar todo + Pin Horizontal Fijar horizontal + Pin Light Luz puntual + Pin Vertical Fijar vertical + Pinning Fijación + Pitch Shifter Cambiador de tono + Plate Placa + Plateau Wipe Barrido en mesetas + Plateaus Mesetas + Plates Placas + Play after rendering previews Reproducir después de procesar las previsualizaciones + Play audio while scrubbing in Source and Program Monitors Reproducir audio al arrastrar en los monitores de origen y de programa + Playback Reproducción + Playhead position determines trim monitor loop playback La posición del cabezal determina la reproducción en bucle del monitor de recorte + Plugins Complementos + Podcast Voice Voz de pódcast + Point 1 Punto 1 + Point 1 Color Color del punto 1 + Point 1 Latitude Latitud del punto 1 + Point 1 Longitude Longitud del punto 1 + Point 2 Punto 2 + Point 2 Color Color del punto 2 + Point 2 Latitude Latitud del punto 2 + Point 2 Longitude Longitud del punto 2 + Point 3 Punto 3 + Point 3 Color Color del punto 3 + Point 3 Latitude Latitud del punto 3 + Point 3 Longitude Longitud del punto 3 + Point 4 Punto 4 + Point 5 Punto 5 + Point 6 Punto 6 + Point 7 Punto 7 + Point 8 Punto 8 + Points Puntos + Policy Política + Pop Motion Movimiento emergente + Portuguese Portugués + Position & Rotation Posición y rotación + Position Amount Cantidad de posición + Position, Scale & Rotation Posición, escala y rotación + Position, Scale, Rotation Posición, escala, rotación + Posterize Posterizar + Posterize Time Posterizar tiempo + Pre-Delay Pre-retardo + Prefix Prefijo + Presence Lift Realce de presencia + Preserve Luminosity Conservar luminosidad + Preserve Scale Conservar escala + ProcAmp ProcAmp + Projecting cap Extremo proyectado + Projects Proyectos + Pull Motion Movimiento de arrastre + Pulls highlights and whites down Baja las iluminaciones y los blancos + Push Empujar + Push (Legacy) Empujar (heredado) + Q Q + RAM reserved for other applications RAM reservada para otras aplicaciones + RGB RGB + RGB Split División RGB + RGB-White RGB-blanco + Radial Radial + Radial Blur Desenfoque radial + Radial Ramp Rampa radial + Radial Wipe Barrido radial + Radial Wipe (Legacy) Barrido radial (heredado) + Radio Announcer Locutor de radio + Radio Voice Voz de radio + Radius Radio + Ramp Rampa + Ramp Shape Forma de la rampa + Random Aleatorio + Random Seed Semilla aleatoria + Range Rango + Ray Rayo + Ray Color Color del rayo + Ray Length Longitud del rayo + Rays Rayos + Rays Only Solo rayos + Read Lectura + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Desenfoque del rojo + Red Curve Curva del rojo + Red Offset Desplazamiento del rojo + Red Power Potencia del rojo + Red Slope Pendiente del rojo + Red-Blue Rojo-azul + Red-Const Rojo-constante + Red-Green Rojo-verde + Red-Red Rojo-rojo + Reduce Axis Reducir eje + Reduce Interlace Flicker Reducir parpadeo de entrelazado + Reflected Reflejado + Reflection Angle Ángulo de reflexión + Reflection Center Centro de reflexión + Refraction Refracción + Reframe Offset Desplazamiento del reencuadre + Reframe Zoom Zoom del reencuadre + Refresh growing Files Every Actualizar los archivos en crecimiento cada + Relaxed Relajado + Release Time Tiempo de liberación + Relief Relieve + Remaining Restante + Remove Fringe Eliminar halo + Render Edit in Audition files to: Procesar los archivos editados en Audition en: + Render audio when rendering video Procesar el audio al procesar el video + Repair Reparar + Repeat Edge Pixels Repetir píxeles del borde + Replace Color Sustituir color + Replace With Clip From Source Monitor Reemplazar con el clip del monitor de origen + Replicate Replicar + Responsive Design - Position Diseño adaptable - posición + Responsive Design - Time Diseño adaptable - tiempo + Restore open sequences when opening projects Restaurar las secuencias abiertas al abrir proyectos + Result Resultado + Retro Retro + Reveal Nested Sequence Mostrar secuencia anidada + Reveal Original Image Mostrar la imagen original + Reveal in Project Mostrar en el proyecto + Reverb Amount Cantidad de reverberación + Reverse Invertir + Right - Left Derecha - izquierda + Right - Right Derecha - derecha + Right channel to device output Canal derecho a la salida del dispositivo + Right to left De derecha a izquierda + Ripple trim adds edits to keep both sides of trim in sync El recorte de rizo añade ediciones para mantener sincronizados ambos lados + Roll (Z Axis) Alabeo (eje Z) + Room Size Tamaño de la sala + Rotation Amount Cantidad de rotación + Roughen Rugosidad + Roughen Color Color rugoso + Roughen Edges Bordes rugosos + Round cap Extremo redondeado + Round join Unión redondeada + Rounded Crop Recorte redondeado + Rows Filas + Russian Ruso + Rusty Oxidado + Rusty Color Color oxidado + SFX Efectos de sonido + Sample Rate Frecuencia de muestreo + Saturation at 50 % Saturación al 50 % + Save .cfa and .pek media cache files next to original media files when possible Guardar los archivos de caché .cfa y .pek junto a los medios originales cuando sea posible + Scale (Degrees) Escala (grados) + Scale Amount Cantidad de escala + Scale From Escalar desde + Scale Height Escalar altura + Scale Match Igualar escala + Scale To Escalar a + Scale Width Escalar anchura + Scale to frame size Escalar al tamaño del fotograma + Scan Lines Líneas de exploración + Scene Escena + Scientific Filter Filtro científico + Scratch disk location for Captured Audio Ubicación del disco de memoria virtual para el audio capturado + Screen Trama + Seconds Segundos + Segments Segmentos + Semi-tones Semitonos + Sequence Name Nombre de la secuencia + Sequence Timecode Código de tiempo de la secuencia + Set focus on the Timeline when performing Insert/Overwrite edits Enfocar la línea de tiempo al insertar o sobrescribir + Set to frame size Ajustar al tamaño del fotograma + Setting Ajuste + Shadow Angle Ángulo de la sombra + Shadow Blue Balance Balance de azul en sombras + Shadow Blur Desenfoque de la sombra + Shadow Color Color de la sombra + Shadow Distance Distancia de la sombra + Shadow Green Balance Balance de verde en sombras + Shadow Only Solo sombra + Shadow Opacity Opacidad de la sombra + Shadow Red Balance Balance de rojo en sombras + Shadow Size Tamaño de la sombra + Shadow Tint Matiz de sombras + Shadows Lightness Luminosidad de sombras + Shake Sacudida + Shape Dissolve Disolución con forma + Shape Flow Flujo de formas + Shape Size Tamaño de la forma + Shapes Formas + Shard Size Tamaño de los fragmentos + Sharp Colors Colores nítidos + Sharpen Amount Cantidad de enfoque + Shift Center To Desplazar el centro a + Shift clips that overlap trim point during ripple trimming Desplazar los clips que se solapan con el punto de recorte en el recorte de rizo + Shot Plano + Show "Project:" and "Bin:" in Project panel tab Mostrar "Proyecto:" y "Bandeja:" en la pestaña del panel Proyecto + Show Clip Mismatch Warning dialog Mostrar el aviso de discrepancia de clip + Show Event Indicator Mostrar el indicador de eventos + Show Focus Area Mostrar zona de enfoque + Show Home Mostrar Inicio + Show Mask Tracker Preview Mostrar la previsualización del seguimiento de máscara + Show Only Noise Mostrar solo el ruido + Show Open Dialog Mostrar el diálogo Abrir + Show Project Load Error Dialog Mostrar el diálogo de error al cargar proyectos + Show Rich Tool Tips Mostrar sugerencias detalladas + Show Tool Tips Mostrar sugerencias + Show Workspace Reset Warning dialog on double click Mostrar el aviso de restablecer el espacio de trabajo al hacer doble clic + Show system compatibility issues at startup Mostrar problemas de compatibilidad del sistema al iniciar + Shrink Encoger + Shutter Angle Ángulo de obturación + Similarity Similitud + Simple Notch Filter Filtro de muesca simple + Simple Parametric EQ Ecualizador paramétrico simple + Simple Text Texto simple + Single-band Compressor Compresor de una banda + Sinusoidal Sinusoidal + Skew Sesgo + Skew Axis Eje del sesgo + Slats Listones + Slice Corte + Slices Cortes + Slide Deslizar + Slide (Legacy) Deslizar (heredado) + Slope Pendiente + Slow Lento + Slower Motion Movimiento más lento + Small Room Sala pequeña + Smart Limit Límite inteligente + Smart Tools Herramientas inteligentes + Smart quotes Comillas tipográficas + Smooth Suave + Smooth Motion Movimiento suave + Smooth Scroll Desplazamiento suave + Smoothness Suavidad + Snap playhead in Timeline when Snap is enabled Ajustar el cabezal en la línea de tiempo cuando el ajuste está activado + Soft Suave + Soft Clamp Limitación suave + Soft Edges (Best Quality) Bordes suaves (mejor calidad) + Soft Light Luz suave + Soft Wipe Barrido suave + Soften Harshness Suavizar la aspereza + Softness Suavidad + Solarize Solarizar + Solid Colors Colores sólidos + Source Point Punto de origen + Source Text Texto de origen + Spacer Espaciador + Spanish Español + Speaker Angle Ángulo del altavoz + Speaker Labeling Etiquetado de hablantes + Special Especial + Special Effects Efectos especiales + Speech model Modelo de voz + Speed Velocidad + Spherize Esferizar + Spiky Puntiagudo + Spill Derrame + Spill Suppression Supresión de derrame + Spin Girar + Spin Direction Dirección del giro + Spin Motion Movimiento de giro + Split Dividir + Split (Legacy) Dividir (heredado) + Spotlight Foco + Spread Extensión + Spring Motion Movimiento elástico + Square Cuadrado + Square (Width Only) Cuadrado (solo anchura) + Square 1:1 Cuadrado 1:1 + Stabilization Estabilización + Stabilize Only Solo estabilizar + Stabilize, Crop Estabilizar, recortar + Stabilize, Crop, Auto-scale Estabilizar, recortar, escalar automáticamente + Stabilize, Synthesize Edges Estabilizar, sintetizar bordes + Stages Etapas + Star Estrella + Star Wipe Barrido en estrella + Start Angle Ángulo inicial + Start Color Color inicial + Start Point Punto inicial + Start at 0 Empezar en 0 + Start at 1 Empezar en 1 + Start of Ramp Inicio de la rampa + Start with an Empty Project Empezar con un proyecto vacío + Starting Intensity Intensidad inicial + Static Plates Placas estáticas + Step forward/back many Avanzar/retroceder muchos + Stereo Expand Expansión estéreo + Stereo Expander Expansor estéreo + Stereo Imagery Imagen estéreo + Stereo Media Medios estéreo + Stereo Phasing Fase estéreo + Stereoscopic - Over/Under Estereoscópico - arriba/abajo + Stereoscopic - Side by Side Estereoscópico - lado a lado + Still Imagen fija + Still Image Default Duration Duración predeterminada de imágenes fijas + Stone Church Iglesia de piedra + Stopband Attenuation Atenuación de la banda de rechazo + Straight Color Color directo + Strength Intensidad + Stretch Estirar + Stretch Gradient to Fit Estirar degradado para ajustar + Stretch Map to Fit Estirar mapa para ajustar + Stretch Width or Height Estirar anchura o altura + Stretch Wipe Barrido estirado + Stretch to Fit Frame Estirar para ajustar al fotograma + Stripe Franja + Stripes Franjas + Strobe Color Color estroboscópico + Strobe Duration (secs) Duración del estroboscopio (s) + Strobe Light Luz estroboscópica + Strobe Period (secs) Periodo del estroboscopio (s) + Stroke 2 Color Color del trazo 2 + Stroke 2 Type Tipo del trazo 2 + Stroke 2 Width Anchura del trazo 2 + Stroke Angle Ángulo del trazo + Stroke Color Color del trazo + Stroke Density Densidad del trazo + Stroke Length Longitud del trazo + Stroke Length (secs) Longitud del trazo (s) + Stroke Only Solo trazo + Stroke Randomness Aleatoriedad del trazo + Stroke Type Tipo de trazo + Stroke Width Anchura del trazo + Stroke line cap Extremo de línea del trazo + Stroke line join Unión de línea del trazo + Studio Reverb Reverberación de estudio + Stylize Estilizar + Subclip End Fin del subclip + Subclip Start Inicio del subclip + Subspace Warp Deformación de subespacio + Subtitle Subtítulo + Subtract Restar + Sum multichannel outputs to mono in Source Monitor Sumar las salidas multicanal en mono en el monitor de origen + Super Narrow Superestrecho + Surface Gloss Brillo de la superficie + Surface Material Material de la superficie + Surround Reverb Reverberación envolvente + Swap Intercambiar + Swap Channels Intercambiar canales + Swivel Pivotar + Synthetic Sintético + TV Power Encendido de TV + Tape Cinta + Tape Name Nombre de la cinta + Tape/Tube Cinta/válvula + Target Aspect Aspecto de destino + Target Color Color de destino + Target Field of View Campo de visión de destino + Target Loudness Sonoridad de destino + Teal & Orange Verde azulado y naranja + Telephone Teléfono + Teletext Teletexto + Template Properties Propiedades de la plantilla + Text Animator Animador de texto + Text Color Color del texto + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. La caché de medios guarda previsualizaciones procesadas de proyectos sin guardar y otros archivos que FilmCraft puede volver a crear. Eliminarlos siempre es seguro; FilmCraft los regenera cuando hace falta. + Thickness Grosor + Tile Gradient Degradado en mosaico + Tilt Inclinación + Tilt (X Axis) Inclinación (eje X) + Time Tiempo + Time and Pitch Tiempo y tono + Timecode Conversion Conversión de código de tiempo + Timeline Mouse Scrolling Desplazamiento con el ratón en la línea de tiempo + Timeline Playback Auto-Scrolling Desplazamiento automático de la línea de tiempo al reproducir + Tint Color Color del matiz + To Hasta + Tolerance Tolerancia + Tone Tono + Top Center Arriba en el centro + Top Left Arriba a la izquierda + Top Right Arriba a la derecha + Touch Tocar + Track Matte Key Incrustación de mate de pista + Transcription Transcripción + Transcription preferences Preferencias de transcripción + Transform Transformar + Transformers Transformadores + Transience Transitoriedad + Transition Completion Finalización de la transición + Transition Softness Suavidad de la transición + Transparency Transparencia + Trash Papelera + Travel Motion Movimiento de viaje + Treble Agudos + Triangle Triángulo + Trim Recortar + Tube Válvula + Tube-modeled Compressor Compresor de modelado de válvulas + Tubular Tubular + Turbulent Turbulento + Turbulent Displace Desplazamiento turbulento + Turbulent Sharp Turbulento nítido + Turbulent Smooth Turbulento suave + Turbulent Smoother Turbulento más suave + Tweed Tweed + Twirl Remolino + Twirl (Legacy) Remolino (heredado) + Twirl Center Centro del remolino + Twirl Radius Radio del remolino + Twist Torsión + Twist Smoother Suavizado de la torsión + Typewriter Máquina de escribir + Ultra Key Ultra Key + Uniform Scale Escala uniforme + Uniform Spacing Espaciado uniforme + Unsharp Mask Máscara de enfoque + Update it at least every Actualizarla al menos cada + Update timeline dynamically during ripple edits. Actualizar la línea de tiempo dinámicamente durante las ediciones de rizo. + Upper Frequency Frecuencia superior + Upper Left Arriba a la izquierda + Upper Right Arriba a la derecha + Use Color Noise Usar ruido de color + Use End Color Usar color final + Use File Usar archivo + Use Glow Color Usar color de resplandor + Use Media Source Usar el origen del medio + Use Tint Color Usar color de matiz + Utility Utilidad + VHS Damage Daño de VHS + VR Blur Desenfoque VR + VR Chroma Leaks Fugas de croma VR + VR Chromatic Aberrations Aberraciones cromáticas VR + VR Color Gradients Degradados de color VR + VR De-Noise Reducción de ruido VR + VR Digital Glitch Fallo digital VR + VR Fractal Noise Ruido fractal VR + VR Glow Resplandor VR + VR Gradient Wipe Barrido con degradado VR + VR Iris Wipe Barrido de iris VR + VR Light Leaks Fugas de luz VR + VR Light Rays Rayos de luz VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plano a esfera VR + VR Projection Proyección VR + VR Random Blocks Bloques aleatorios VR + VR Rotate Sphere Rotar esfera VR + VR Sharpen Enfocar VR + VR Spherical Blur Desenfoque esférico VR + Validate Content Credentials on import Validar las credenciales de contenido al importar + Vectorscope (YUV) Vectorscopio (YUV) + Vectorscope + Waveform YC Vectorscopio + forma de onda YC + Vectorscope HLS Vectorscopio HLS + Vectorscope YUV Vectorscopio YUV + Venetian Blinds Persianas venecianas + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alineación vertical + Vertical Blocks Bloques verticales + Vertical Decentering Descentrado vertical + Vertical Displacement Desplazamiento vertical + Vertical Flip Volteo vertical + Vertical Margin Margen vertical + Very Narrow Muy estrecho + Video 1 Video 1 + Video 2 Video 2 + Video 3 Video 3 + Video 4 Video 4 + Video 5 Video 5 + Video 6 Video 6 + Video 7 Video 7 + Video 8 Video 8 + Video Codec Códec de video + Video Duration Duración del video + Video Effects Efectos de video + Video In Point Punto de entrada de video + Video Info Información de video + Video Limiter Limitador de video + Video Out Point Punto de salida de video + Video Transition Default Duration Duración predeterminada de las transiciones de video + Video Transitions Transiciones de video + Video Usage Uso de video + Vignette Viñeta + Vivid Light Luz intensa + Vocal Booth Cabina vocal + Vocal Enhancer Realzador vocal + Voice Voz + Voice in a Hall Voz en una sala + Volumetric Rays Rayos volumétricos + Warm Film Película cálida + Warm Room Sala cálida + Warm Voice Voz cálida + Warm print stock with gentle roll-off Copia de película cálida con atenuación suave + Warm, soft late-afternoon light Luz cálida y suave de última hora de la tarde + Warning Advertencia + Warp Stabilizer Estabilizador de deformación + Wave Onda + Wave Height Altura de la onda + Wave Speed Velocidad de la onda + Wave Warp Deformación de onda + Wave Width Anchura de la onda + Waveform Forma de onda + Waveform (Luma) Forma de onda (luminancia) + Waveform (RGB) Forma de onda (RGB) + Wavelength Longitud de onda + Wet Húmedo + Wet Out Salida húmeda + When Opening a Project Al abrir un proyecto + Whip Latigazo + Whip (Legacy) Latigazo (heredado) + Whisper base (balanced) Whisper base (equilibrado) + Whisper small (most accurate) Whisper small (más preciso) + Whisper tiny (fastest) Whisper tiny (más rápido) + White Blanco + White Input Level Nivel de entrada de blanco + White Is High El blanco es alto + Wide Outdoor Exterior amplio + Widener Ensanchador + Width Anchura + Wiggle Oscilación + Wipe Barrido + Wipe Angle Ángulo del barrido + Wonder Glow Resplandor maravilloso + Wrap Back Envolver hacia atrás + Write Escritura + Write XMP ID to files on import Escribir el ID XMP en los archivos al importar + Write clip markers to XMP Escribir los marcadores de clip en XMP + Write-on Escritura progresiva + X Rotation Rotación X + Y Rotation Rotación Y + YC YC + YC no Chroma YC sin croma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Iluminaciones amarillo verdosas y sombras elevadas hacia el azul mediante curvas de canal cruzadas + Z Position Posición Z + Z Rotation Rotación Z + Zoom Zoom + Zoom Amount Cantidad de zoom + Zoom Blur Desenfoque de zoom + Zoom Strength Intensidad del zoom + days días + frames fotogramas + milliseconds milisegundos + minute(s) minuto(s) + second(s) segundo(s) + seconds segundos + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Color… + EDL… EDL… + Edit Workspaces… Editar espacios de trabajo… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… General… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Guardar los cambios en este espacio de trabajo + Save as New Workspace… Guardar como nuevo espacio de trabajo… + Create Search Bin Crear bandeja de búsqueda + Edit Offline File Editar archivo sin conexión + Source Settings Ajustes de origen + Update Metadata Actualizar metadatos + Automate To Sequence Automatizar a secuencia + Scene Edit Detection Detección de cambios de escena + Analyze Analizar + Normalize Mix Track Normalizar pista de mezcla + Simplify Sequence Simplificar secuencia + Simplify Simplificar + Transcribe Sequence Transcribir secuencia + Save as Template Guardar como plantilla + Flash Cue Marker Marcador de referencia Flash + System Compatibility Report Informe de compatibilidad del sistema + no GPU adapter: the CPU compositor is used no hay adaptador de GPU: se usa el compositor de CPU + Avid Log Exchange Avid Log Exchange + FilmCraft Project Proyecto de FilmCraft + {name} Selection.fcproj {name} Selección.fcproj + {name} (Simplified) {name} (simplificada) + Find in: Buscar en: + Find: Buscar: + Search: Buscar: + Clip Names and Markers Nombres de clip y marcadores + Match: Coincidir: + All Todos + Any Cualquiera + Case Sensitive Distinguir mayúsculas y minúsculas + Scratch Disks Discos de memoria virtual + Ingest Settings Ajustes de ingesta + Captured and Generated: Capturado y generado: + Video Previews: Previsualizaciones de video: + Audio Previews: Previsualizaciones de audio: + Project Auto Save: Guardado automático del proyecto: + Same as Project Igual que el proyecto + Path: {path} Ruta: {path} + Transcode Transcodificar + Copy and Create Proxies Copiar y crear proxies + Action: Acción: + Destination: Destino: + Next to the media Junto a los medios + Video Rendering and Playback Procesamiento y reproducción de video + Renderer: Procesador: + Display Format: Formato de visualización: + Milliseconds Milisegundos + Capture Captura + Capture Format: Formato de captura: + Action and Title Safe Areas Zonas seguras de acción y título + Title Safe Area Zona segura de título + Action Safe Area Zona segura de acción + Total Duration Duración total + Average Data Rate Velocidad de datos media + Colour Color + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(es) + Media Name: Nombre del medio: + Scene: Escena: + Shot/Take: Plano/toma: + Log Note: Nota de registro: + Codec: {codec} Códec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? ¿Escribir los metadatos de los clips seleccionados ({n}) en archivos XMP junto a sus medios? + Existing XMP files written by other applications are left unchanged. Los archivos XMP existentes escritos por otras aplicaciones no se modifican. + To {name} A {name} + Ordering: Orden: + Sort Order Orden de clasificación + Selection Order Orden de selección + Placement: Ubicación: + Sequentially Secuencialmente + At Unnumbered Markers En marcadores sin numerar + Method: Método: + Insert Edit Edición de inserción + Overwrite Edit Edición de sobrescritura + Clip Overlap: Solapamiento de clips: + Frames per Still: Fotogramas por imagen fija: + Transitions Transiciones + Apply Default Audio Transition Aplicar la transición de audio predeterminada + Apply Default Video Transition Aplicar la transición de video predeterminada + Ignore Options Opciones de omisión + Ignore Audio Ignorar audio + Ignore Video Ignorar video + Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado + Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado + Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado + Sensitivity: Sensibilidad: + Normalize Mix Track to: Normalizar pista de mezcla a: + Disabled clips Clips desactivados + Empty tracks Pistas vacías + Gaps Huecos + Video effects Efectos de video + Audio effects Efectos de audio + Text (graphics and captions) Texto (gráficos y subtítulos) + Clips Clips + Move clips to the lowest track possible Mover los clips a la pista más baja posible + Keep: Conservar: + Video and Audio Video y audio + Video Only Solo video + Audio Only Solo audio + Language: Idioma: + Audio on track {t} Audio de la pista {t} + Audio analysis: Análisis de audio: + Recognize when different speakers are talking Reconocer cuándo hablan personas distintas + Template Name: Nombre de la plantilla: + Comments: Comentarios: + (unavailable) (no disponible) + Decoders Descodificadores + Containers Contenedores + Export formats Formatos de exportación + Match {n} of {total} Coincidencia {n} de {total} + Saved template {path} Plantilla guardada en {path} + Scene Edit Detection: analysing in the background (see Progress) Detección de cambios de escena: analizando en segundo plano (ver Progreso) + Delete Search Bin Eliminar bandeja de búsqueda + (removed from {list}) (quitado de {list}) + {key} can't be used as a shortcut {key} no se puede usar como atajo + Keyboard Shortcuts Atajos de teclado + {active} (modified) {active} (modificado) + Loaded “{name}” Cargado “{name}” + My Shortcuts Mis atajos + Deleted preset “{active}” Ajuste preestablecido “{active}” eliminado + Export… Exportar… + Exported to {path} Exportado a {path} + Export: no file chosen (agents: shortcuts.export {path}) Exportar: no se eligió ningún archivo (agentes: shortcuts.export {path}) + Imported “{name}” Importado “{name}” + Import: no file chosen (agents: shortcuts.import {path}) Importar: no se eligió ningún archivo (agentes: shortcuts.import {path}) + Keyboard: US (macOS) Teclado: EE. UU. (macOS) + Keyboard: US Teclado: EE. UU. + Save preset as: Guardar ajuste preestablecido como: + Commands: Comandos: + {panel} Panel Panel {panel} + Application shortcuts work whatever panel has focus Los atajos de la aplicación funcionan con cualquier panel enfocado + Panel shortcuts override application shortcuts while that panel has focus Los atajos de panel sustituyen a los de la aplicación mientras ese panel está enfocado + Modifier held / selected Modificador pulsado / seleccionado + Search commands or keys Buscar comandos o teclas + Selected: {command} Seleccionado: {command} + Command Comando + Shortcut Atajo + Type a shortcut… (Esc cancels) Pulsa un atajo… (Esc cancela) + Add another shortcut Añadir otro atajo + No matching commands Ningún comando coincide + Key: Tecla: + Modifiers Modificadores + Nothing is assigned to this key No hay nada asignado a esta tecla + Click a key on the keyboard to see its shortcuts Haz clic en una tecla del teclado para ver sus atajos + Assign {key} to {command} Asignar {key} a {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflicto: {key} está asignada a {commands} ({ctx}) + and y + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Haz clic en la celda Atajo de un comando y pulsa las teclas para asignarlo; + añade otro atajo. + Cleared {n} shortcut(s) of {command} Se borraron {n} atajo(s) de {command} + No export preset named `{name}` No hay ningún ajuste preestablecido de exportación llamado `{name}` + Open a sequence to export Abre una secuencia para exportar + Destinations Destinos + Media File Archivo multimedia + Queue ({n}) Cola ({n}) + Send exports here with Send to Queue, then start the queue. Envía exportaciones aquí con Enviar a la cola y después inicia la cola. + Ready Lista + Encoding {n}% Codificando {n}% + Start Queue Iniciar cola + Stop Detener + Clear Finished Borrar terminadas + Not supported by FilmCraft's encoders yet Los codificadores de FilmCraft aún no lo admiten + Sequence name Nombre de la secuencia + Folder Carpeta + Preset Ajuste preestablecido + More presets… Más ajustes preestablecidos… + Multiplexer Multiplexor + The sequence has no caption track. La secuencia no tiene pista de subtítulos. + Export Options Opciones de exportación + Burn Captions Into Video Grabar los subtítulos en el video + Create Sidecar File Crear archivo adjunto + File Format Formato de archivo + SubRip Subtitle (.srt) Subtítulo SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Título + Creator Autor + Copyright Copyright + Image Imagen + Frame Size Tamaño del fotograma + Match Source Igual que el origen + Field Order Orden de campos + Aspect Aspecto + Profile Perfil + Bitrate Encoding Codificación de velocidad de bits + Target Bitrate Velocidad de bits objetivo + Adaptive ({bpp} bits per pixel) Adaptable ({bpp} bits por píxel) + Maximum Bitrate Velocidad de bits máxima + Key Frame Distance Distancia entre fotogramas clave + Quality Calidad + Render at Maximum Depth Procesar a la máxima profundidad + Use Maximum Render Quality Usar la máxima calidad de procesamiento + Export Audio Exportar audio + Audio Format Formato de audio + Uncompressed (PCM) Sin comprimir (PCM) + Match Source ({seq_sr} Hz) Igual que el origen ({seq_sr} Hz) + Channels Canales + Audio Bitrate Velocidad de bits de audio + Sample Size Tamaño de muestra + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Tamaño / opacidad + Image Overlay Superposición de imagen + PNG or JPEG file Archivo PNG o JPEG + Name Overlay Superposición de nombre + Timecode Overlay Superposición de código de tiempo + Luma Min / Max Luminancia mín. / máx. + Loudness Normalization Normalización de sonoridad + True Peak Limit Límite de pico real + Audio only Solo audio + Scaling Escalado + Scale To Fit Escalar para ajustar + Scale To Fill Escalar para rellenar + Stretch To Fill Estirar para rellenar + Summary Resumen + Sequence, {w}x{h}, {fps} fps, {rate} Hz Secuencia, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Tamaño de archivo estimado: {size} + Send to Queue Enviar a la cola + Exporting {path} Exportando {path} + Added to the export queue Añadido a la cola de exportación + Preset Manager Administrador de ajustes preestablecidos + Search presets Buscar ajustes preestablecidos + Favorites only Solo favoritos + Show in the Preset menu Mostrar en el menú de ajustes preestablecidos + User Usuario + Save current settings as: Guardar los ajustes actuales como: + Export presets Ajustes preestablecidos de exportación + Import: no file chosen (agents: export.presets.import {path}) Importar: no se eligió ningún archivo (agentes: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exportar: no se eligió ningún archivo (agentes: export.presets.export {path, names}) + Quick Export: open a sequence first Exportación rápida: abre antes una secuencia + File Name & Location Nombre y ubicación del archivo + Quick Export: {path} Exportación rápida: {path} + Graphics problem: switched to software compositing Problema de gráficos: se ha cambiado a la composición por software + Exporting Exportando + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Opción y Cmd para otras opciones. + Click to select all clips to the right in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la derecha en todas las pistas. Mayús+clic para una sola pista. + Click to select all clips to the left in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la izquierda en todas las pistas. Mayús+clic para una sola pista. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Arrastra un punto de edición para hacer un recorte de rizo; los clips posteriores se mueven para no dejar hueco. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Arrastra un punto de edición para rodarlo: la salida de un clip y la entrada del siguiente se mueven juntas. + Drag an edge to change the clip's speed so it fills the new duration. Arrastra un borde para cambiar la velocidad del clip y que ocupe la nueva duración. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Arrastra el borde de un clip de música para remezclarlo a la nueva duración en tiempos que encajen musicalmente. + Click to split a clip. Shift-click to split all tracks. Haz clic para dividir un clip. Mayús+clic para dividir todas las pistas. + Drag a clip to slip its source in/out without moving it. Arrastra un clip para desplazar su entrada y salida de origen sin moverlo. + Drag a clip to slide it between its neighbours. Arrastra un clip para deslizarlo entre sus vecinos. + Drag to scroll the timeline. Arrastra para desplazar la línea de tiempo. + Click to zoom in; Opt-click to zoom out. Haz clic para acercar; Opción+clic para alejar. + FilmCraft hit an error FilmCraft ha encontrado un error + Details: {path} Detalles: {path} + Opened {url} Abierto {url} + (empty) (vacío) + Step Back 1 Frame Retroceder 1 fotograma + Step Forward 1 Frame Avanzar 1 fotograma + Match Playhead Igualar cabezal + Gang to Program Vincular al programa + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamórfico 2:1 (2.0) + Animated GIF GIF animado + Animated GIF 640×360 GIF animado 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Application Aplicación + Apply Guide Template Aplicar plantilla de guías + Auto Save Now Guardar automáticamente ahora + Auto-Match Loudness Igualar sonoridad automáticamente + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom al estilo de Avid: archivo de imagen DNxHR HQ más un archivo PCM de 24 bits y 48 kHz por canal (_A1, _A2…) + BMP BMP + BMP Sequence Secuencia BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Empezar a editar el texto de una capa de gráfico + Begins With Empieza por + Blur In Entrada con desenfoque + Blur Out Salida con desenfoque + Brightens inside a feathered circular mask Aclara el interior de una máscara circular desvanecida + Broadcast Difusión + CBR CBR + Cancel Dynamic Trim Cancelar recorte dinámico + Cancel Queued Export Cancelar exportación en cola + Center Left Centro a la izquierda + Center Right Centro a la derecha + Center align text Centrar texto + Change Mask Cambiar máscara + Clear All Events Borrar todos los eventos + Clear Edit Point Selection Borrar la selección de puntos de edición + Clear Finished Exports Borrar exportaciones terminadas + Clear Poster Frame Borrar fotograma de póster + Close Bin Cerrar bandeja + Close Sequence Cerrar secuencia + Contains Contiene + Cut to Camera 1 Cortar a la cámara 1 + Cut to Camera 2 Cortar a la cámara 2 + Cut to Camera 3 Cortar a la cámara 3 + Cut to Camera 4 Cortar a la cámara 4 + Cut to Camera 5 Cortar a la cámara 5 + Cut to Camera 6 Cortar a la cámara 6 + Cut to Camera 7 Cortar a la cámara 7 + Cut to Camera 8 Cortar a la cámara 8 + Cut to Camera 9 Cortar a la cámara 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Oscurece el exterior de una máscara ovalada desvanecida + Decrease Audio Tracks Height Reducir la altura de las pistas de audio + Decrease Clip Volume Bajar el volumen del clip + Decrease Clip Volume Many Bajar mucho el volumen del clip + Decrease Font Size by Five Units Reducir el tamaño de fuente cinco unidades + Decrease Font Size by One Unit Reducir el tamaño de fuente una unidad + Decrease Leading by Five Units Reducir el interlineado cinco unidades + Decrease Leading by One Unit Reducir el interlineado una unidad + Decrease Video Tracks Height Reducir la altura de las pistas de video + Delete Captions Eliminar subtítulos + Delete Graphic Layer Eliminar capa de gráfico + Delete Guide Template Eliminar plantilla de guías + Delete Media Cache Files Eliminar archivos de caché de medios + Discard Unsaved Changes Descartar cambios no guardados + Does Not Contain No contiene + Download Speech Model Descargar modelo de voz + Dynamic Trim Stop Detener recorte dinámico + Edit Caption Text Editar el texto del subtítulo + Ellipse Tool Herramienta Elipse + Ends With Termina en + Entire Source Todo el origen + EssentialSound Sonido esencial + Export Frame Exportar fotograma + Extend Next Edit To Playhead Extender la siguiente edición hasta el cabezal + Extend Previous Edit To Playhead Extender la edición anterior hasta el cabezal + Extend Selected Edit to Playhead Extender la edición seleccionada hasta el cabezal + Extend Selection Down Extender la selección hacia abajo + Extend Selection Left Extender la selección hacia la izquierda + Extend Selection Right Extender la selección hacia la derecha + Extend Selection Up Extender la selección hacia arriba + Fade In Fundido de entrada + Gaussian Blur from 0 to 60 over the last second Desenfoque gaussiano de 0 a 60 durante el último segundo + Gaussian Blur from 60 to 0 over the first second Desenfoque gaussiano de 60 a 0 durante el primer segundo + Generate Ducking Keyframes Generar fotogramas clave de atenuación + Go to Next Edit Point Ir al siguiente punto de edición + Go to Next Edit Point on Any Track Ir al siguiente punto de edición en cualquier pista + Go to Previous Edit Point Ir al punto de edición anterior + Go to Previous Edit Point on Any Track Ir al punto de edición anterior en cualquier pista + Go to Selected Clip End Ir al final del clip seleccionado + Go to Selected Clip Start Ir al inicio del clip seleccionado + Go to Sequence End Ir al final de la secuencia + Go to Sequence Start Ir al inicio de la secuencia + H.264 H.264 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sitios de video, VBR 1 pasada, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pasada, 20 Mbps objetivo, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pasadas, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 para sitios de video, VBR 1 pasada, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 pasada, 60 Mbps objetivo, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 pasadas, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 vertical para videos cortos en redes sociales, escalado para rellenar, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 con el tamaño y la velocidad de fotogramas de la secuencia; 0,05 bits por píxel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 con el tamaño y la velocidad de fotogramas de la secuencia; 0,1 bits por píxel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 con el tamaño y la velocidad de fotogramas de la secuencia; la velocidad de bits se adapta al tamaño (0,2 bits por píxel) + HD Anamorphic 1080 (1.333) HD anamórfico 1080 (1.333) + Hand Tool Herramienta Mano + High Quality 1080p HD Alta calidad 1080p HD + High Quality 2160p 4K Alta calidad 2160p 4K + Image Sequence Secuencia de imágenes + Increase Audio Tracks Height Aumentar la altura de las pistas de audio + Increase Clip Volume Subir el volumen del clip + Increase Clip Volume Many Subir mucho el volumen del clip + Increase Font Size by Five Units Aumentar el tamaño de fuente cinco unidades + Increase Font Size by One Unit Aumentar el tamaño de fuente una unidad + Increase Leading by Five Units Aumentar el interlineado cinco unidades + Increase Leading by One Unit Aumentar el interlineado una unidad + Increase Video Tracks Height Aumentar la altura de las pistas de video + Large blocks resolving to the picture over the first second Bloques grandes que se resuelven en la imagen durante el primer segundo + Left align text Alinear texto a la izquierda + Looping GIF, 640×360 at 15 fps GIF en bucle, 640×360 a 15 fps + Lower First Inferior primero + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz, código de tiempo inicial + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP largo (0,3 bits por píxel), PCM de 24 bits y 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM de 24 bits y 48 kHz, código de tiempo inicial + Main Main + Mark In (Hover Scrub) Marcar entrada (arrastre al pasar el ratón) + Mark Out (Hover Scrub) Marcar salida (arrastre al pasar el ratón) + Masks Máscaras + Match Source – Adaptive High Bitrate Igual que el origen – velocidad de bits adaptable alta + Match Source – Adaptive Low Bitrate Igual que el origen – velocidad de bits adaptable baja + Match Source – Adaptive Medium Bitrate Igual que el origen – velocidad de bits adaptable media + Matches Coincide con + Maximize or Restore Active Frame Maximizar o restaurar el marco activo + Maximize or Restore Frame Under Cursor Maximizar o restaurar el marco bajo el cursor + Media Browser Settings Ajustes del navegador de medios + MediaBrowser Navegador de medios + MediaCache Caché de medios + Merge Captions Combinar subtítulos + Mixer Mezclador + Mosaic In Entrada en mosaico + Move All Audio Targets Down Bajar todos los destinos de audio + Move All Audio Targets Up Subir todos los destinos de audio + Move All Video Targets Down Bajar todos los destinos de video + Move All Video Targets Up Subir todos los destinos de video + Move Selection Down Mover la selección hacia abajo + Move Selection End Mover la selección al final + Move Selection Home Mover la selección al inicio + Move Selection Left Mover la selección a la izquierda + Move Selection Page Down Mover la selección una página abajo + Move Selection Page Up Mover la selección una página arriba + Move Selection Right Mover la selección a la derecha + Move Selection Up Mover la selección hacia arriba + Multi-Camera Record On/Off Toggle Activar/desactivar la grabación multicámara + Multicam Multicámara + Navigate to End of Segment Ir al final del segmento + Navigate to Next Line Ir a la línea siguiente + Navigate to Next Word Ir a la palabra siguiente + Navigate to Previous Line Ir a la línea anterior + Navigate to Previous Word Ir a la palabra anterior + Navigate to Start of Segment Ir al inicio del segmento + Navigation Navegación + Next Multi-Camera Page Siguiente página multicámara + Nudge Clip Selection Down Desplazar la selección de clips hacia abajo + Nudge Clip Selection Left Five Frames Desplazar la selección de clips cinco fotogramas a la izquierda + Nudge Clip Selection Left One Frame Desplazar la selección de clips un fotograma a la izquierda + Nudge Clip Selection Right Five Frames Desplazar la selección de clips cinco fotogramas a la derecha + Nudge Clip Selection Right One Frame Desplazar la selección de clips un fotograma a la derecha + Nudge Clip Selection Up Desplazar la selección de clips hacia arriba + Nudge Selected Object down by five Desplazar el objeto seleccionado cinco hacia abajo + Nudge Selected Object down by one Desplazar el objeto seleccionado uno hacia abajo + Nudge Selected Object to left by five Desplazar el objeto seleccionado cinco a la izquierda + Nudge Selected Object to left by one Desplazar el objeto seleccionado uno a la izquierda + Nudge Selected Object to right by five Desplazar el objeto seleccionado cinco a la derecha + Nudge Selected Object to right by one Desplazar el objeto seleccionado uno a la derecha + Nudge Selected Object up by five Desplazar el objeto seleccionado cinco hacia arriba + Nudge Selected Object up by one Desplazar el objeto seleccionado uno hacia arriba + Nudge Volume +1dB Ajustar volumen +1 dB + Nudge Volume +3dB Ajustar volumen +3 dB + Nudge Volume -1dB Ajustar volumen -1 dB + Nudge Volume -3dB Ajustar volumen -3 dB + Numbered BMP stills (24-bit) Imágenes fijas BMP numeradas (24 bits) + Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia + Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo + Opacity 100 → 0 % over the last second Opacidad de 100 a 0 % durante el último segundo + Open Bin Abrir bandeja + Open Search Abrir búsqueda + PNG PNG + PNG Sequence Secuencia PNG + Panel Panel + Pen Tool Herramienta Pluma + PiP 35% Upper Right Imagen en imagen al 35 % arriba a la derecha + Picture-in-picture: 35 % scale in the upper right Imagen en imagen: escala del 35 % arriba a la derecha + Play Around Reproducir alrededor + Play In to Out with Preroll/Postroll Reproducir de entrada a salida con márgenes previo y posterior + Play from Playhead to Out Point Reproducir del cabezal al punto de salida + Play/Stop Reproducir/Detener + Previous Multi-Camera Page Página multicámara anterior + Progressive Progresivo + ProjectPanel Panel Proyecto + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz + Quit FilmCraft Salir de FilmCraft + Rate Stretch Tool Herramienta Ajuste de velocidad + Razor Tool Herramienta Cuchilla + Rectangle Tool Herramienta Rectángulo + Remix Tool Herramienta Remezcla + Right align text Alinear texto a la derecha + Ripple Edit Tool Herramienta Edición de rizo + Ripple Trim Next Edit to Playhead Recorte de rizo de la siguiente edición hasta el cabezal + Ripple Trim Previous Edit to Playhead Recorte de rizo de la edición anterior hasta el cabezal + Rolling Edit Tool Herramienta Edición de rodillo + Select Camera 1 Seleccionar cámara 1 + Select Camera 2 Seleccionar cámara 2 + Select Camera 3 Seleccionar cámara 3 + Select Camera 4 Seleccionar cámara 4 + Select Camera 5 Seleccionar cámara 5 + Select Camera 6 Seleccionar cámara 6 + Select Camera 7 Seleccionar cámara 7 + Select Camera 8 Seleccionar cámara 8 + Select Camera 9 Seleccionar cámara 9 + Select Clip at Playhead Seleccionar el clip en el cabezal + Select Find Box Seleccionar el cuadro de búsqueda + Select Next Clip Seleccionar el clip siguiente + Select Next Panel Seleccionar el panel siguiente + Select Previous Clip Seleccionar el clip anterior + Select Previous Panel Seleccionar el panel anterior + Select to Next Line Seleccionar hasta la línea siguiente + Select to Next Word Seleccionar hasta la palabra siguiente + Select to Previous Line Seleccionar hasta la línea anterior + Select to Previous Word Seleccionar hasta la palabra anterior + Select to Segment End Seleccionar hasta el final del segmento + Select to Segment Start Seleccionar hasta el inicio del segmento + Selection Tool Herramienta Selección + Set Comparison Reference Fijar la referencia de comparación + Set Creative Look Fijar el aspecto creativo + Set Input LUT Fijar la LUT de entrada + Set Project Panel View Fijar la vista del panel Proyecto + Show Next Screen Mostrar la pantalla siguiente + Show Previous Screen Mostrar la pantalla anterior + Show Program Transcript Mostrar la transcripción del programa + Shuttle Left Lanzadera a la izquierda + Shuttle Right Lanzadera a la derecha + Shuttle Slow Left Lanzadera lenta a la izquierda + Shuttle Slow Right Lanzadera lenta a la derecha + Shuttle Stop Detener lanzadera + Slide Clip Selection Left Five Frames Deslizar la selección de clips cinco fotogramas a la izquierda + Slide Clip Selection Left One Frame Deslizar la selección de clips un fotograma a la izquierda + Slide Clip Selection Right Five Frames Deslizar la selección de clips cinco fotogramas a la derecha + Slide Clip Selection Right One Frame Deslizar la selección de clips un fotograma a la derecha + Slide Tool Herramienta Deslizar + Slip Clip Selection Left Five Frames Desplazar el contenido de la selección de clips cinco fotogramas a la izquierda + Slip Clip Selection Left One Frame Desplazar el contenido de la selección de clips un fotograma a la izquierda + Slip Clip Selection Right Five Frames Desplazar el contenido de la selección de clips cinco fotogramas a la derecha + Slip Clip Selection Right One Frame Desplazar el contenido de la selección de clips un fotograma a la derecha + Slip Tool Herramienta Desplazar contenido + Social Vertical 1080×1920 Vertical para redes sociales 1080×1920 + Soft Vignette Viñeta suave + Source In/Out Entrada/salida del origen + Split Caption Dividir subtítulo + Square Pixels (1.0) Píxeles cuadrados (1.0) + Stack Clips Apilar clips + Start Export Queue Iniciar la cola de exportación + Start Voice-over Recording Iniciar la grabación de voz en off + Step Back Five Frames Retroceder cinco fotogramas + Step Back One Frame Retroceder un fotograma + Step Forward Five Frames Avanzar cinco fotogramas + Step Forward One Frame Avanzar un fotograma + Stop Export Queue Detener la cola de exportación + Stop Multi-Camera Recording Detener la grabación multicámara + Stop Voice-over Recording Detener la grabación de voz en off + Sync Voice-over Capture Sincronizar la captura de voz en off + TIFF TIFF + TIFF Sequence Secuencia TIFF + TextPanel Panel Texto + Thumbnail Size Next Siguiente tamaño de miniatura + Thumbnail Size Previous Tamaño de miniatura anterior + Toggle All Audio Targets Alternar todos los destinos de audio + Toggle All Source Audio Alternar todo el audio de origen + Toggle All Source Video Alternar todo el video de origen + Toggle All Video Targets Alternar todos los destinos de video + Toggle Audio During Scrubbing Alternar audio al arrastrar + Toggle Full Screen Alternar pantalla completa + Toggle Mute for All Targeted Audio Tracks Alternar silencio en todas las pistas de audio de destino + Toggle Solo for All Targeted Audio Tracks Alternar solo en todas las pistas de audio de destino + Toggle Source/Program Monitor Focus Alternar el foco entre los monitores de origen y programa + Toggle Target Audio 1 Alternar destino de audio 1 + Toggle Target Audio 2 Alternar destino de audio 2 + Toggle Target Audio 3 Alternar destino de audio 3 + Toggle Target Audio 4 Alternar destino de audio 4 + Toggle Target Audio 5 Alternar destino de audio 5 + Toggle Target Audio 6 Alternar destino de audio 6 + Toggle Target Audio 7 Alternar destino de audio 7 + Toggle Target Audio 8 Alternar destino de audio 8 + Toggle Target Video 1 Alternar destino de video 1 + Toggle Target Video 2 Alternar destino de video 2 + Toggle Target Video 3 Alternar destino de video 3 + Toggle Target Video 4 Alternar destino de video 4 + Toggle Target Video 5 Alternar destino de video 5 + Toggle Target Video 6 Alternar destino de video 6 + Toggle Target Video 7 Alternar destino de video 7 + Toggle Target Video 8 Alternar destino de video 8 + Toggle Track Output for All Targeted Video Tracks Alternar la salida de todas las pistas de video de destino + Toggle Trim Type Alternar tipo de recorte + Toggle View Alternar vista + Track Select Backward Tool Herramienta Seleccionar pista hacia atrás + Track Select Forward Tool Herramienta Seleccionar pista hacia delante + Track Selected Mask Seguir la máscara seleccionada + Trim Next Edit to Playhead Recortar la siguiente edición hasta el cabezal + Trim Previous Edit to Playhead Recortar la edición anterior hasta el cabezal + Trimming Recorte + Type Tool Herramienta Texto + Unstack Clips Desapilar clips + Upper First Superior primero + VBR, 1 pass VBR, 1 pasada + VBR, 2 pass VBR, 2 pasadas + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Grabación de voz en off + Voiceover Voz en off + WAV, stereo 48 kHz 16-bit PCM WAV, estéreo, PCM de 48 kHz y 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, estéreo, PCM de 48 kHz y 24 bits + Warm Tint Matiz cálido + Warm highlights, brown shadows Iluminaciones cálidas, sombras marrones + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web y redes sociales + Work Area Área de trabajo + Write Automation Escribir automatización + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Ampliar el monitor de programa al 100% + Zoom Program Monitor to Fit Ajustar el monitor de programa a la ventana + Zoom Source Monitor to 100% Ampliar el monitor de origen al 100% + Zoom Source Monitor to Fit Ajustar el monitor de origen a la ventana + Zoom Tool Herramienta Zoom + with: por: + Interface Language Idioma de la interfaz + System Language Idioma del sistema + Cinematic Cinematográfico + Film Emulation Emulación de película + Technical Técnico + Teal Shadows, Warm Skin Sombras turquesa, piel cálida + Night Exterior Exterior nocturno + Golden Dusk Atardecer dorado + Steel Blue Azul acero + Hard Sun Sol intenso + Faded Print Copia desvanecida + Silver Retention Retención de plata + Cross Processed Proceso cruzado + Warm Negative Negativo cálido + Neutral Mono Monocromo neutro + Hard Mono Monocromo intenso + Sepia Tone Tono sepia + Cold Mono Monocromo frío + Lift Shadows Levantar sombras + Protect Highlights Proteger luces + Half Saturation Saturación al 50 % + Boost Vibrance Aumentar intensidad + Legal Range Squeeze Compresión al rango de vídeo + A boxed label with a round pointer; move the label and the pointer follows. Una etiqueta enmarcada con un indicador redondo; al mover la etiqueta, el indicador la sigue. + A rounded tag that fits its text, with a status dot. Una etiqueta redondeada que se ajusta al texto, con un punto de estado. + A title on a rounded card with a soft shadow. Un título en una tarjeta redondeada con una sombra suave. + Accent Color Color de acento + Bar Color Color de la barra + Box Color Color del recuadro + Box Opacity Opacidad del recuadro + Callout – Pointer Llamada – Indicador + Callout – Tag Llamada – Etiqueta + Callouts Llamadas + Credits Créditos + Dot Color Color del punto + Heading Encabezado + Large centred title, accent rule and subtitle. Un título grande centrado, una línea de acento y un subtítulo. + Lower Third – Rule Rótulo inferior – Línea + Lower Third – Slab Rótulo inferior – Bloque + Lower Thirds Rótulos inferiores + Name Color Color del nombre + Name Size Tamaño del nombre + Name and role on a coloured slab that widens with the name. Nombre y cargo sobre un bloque de color que se ensancha con el nombre. + Name over a thin rule that always matches the name's width. Nombre sobre una línea fina que siempre se ajusta a su anchura. + Pointer Color Color del indicador + Role Cargo + Rule Color Color de la línea + Show Dot Mostrar punto + Show Role Mostrar cargo + Slab Color Color del bloque + Tag Color Color de la etiqueta + Title Color Color del título + Title Size Tamaño del título + Title – Boxed Título – Enmarcado + Title – Centered Título – Centrado + Titles Títulos + Select an item to preview it Selecciona un elemento para previsualizarlo + End Credits Créditos finales + End Credits – Roll Créditos finales – Desplazamiento vertical + Ticker – Crawl Rótulo informativo – Desplazamiento horizontal + Centred credits that roll up from below the frame and leave at the top. Créditos centrados que suben desde debajo del fotograma y salen por la parte superior. + A news-style line that crawls in from the right and leaves at the left. Una línea de estilo informativo que entra desde la derecha y sale por la izquierda. + out salida + in entrada + The language changed but could not be saved: {e} El idioma ha cambiado, pero no se ha podido guardar: {e} + no Japanese font is installed on this system; the interface stays in English no hay ninguna fuente japonesa instalada en este sistema; la interfaz sigue en inglés + Show Mostrar + Sort Ordenar + Reverse the order Invertir el orden + Grab bag Mosaico + Table Tabla + No contributor data was built into this copy. Esta copia no incluye datos de colaboradores. + No model credits were built into this copy. Esta copia no incluye créditos de modelos. + Company Empresa + Model Modelo + Version Versión + Commits Commits + % of all commits % de todos los commits + Lines +/− Líneas +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) + Clip Speed / Duration Velocidad/duración del clip + Speed: Velocidad: + Reverse Speed Invertir velocidad + Ripple Edit, Shifting Trailing Clips Edición de rizo, desplazando los clips posteriores + About Acerca de + Contributors Colaboradores + Models Modelos + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para secuencias HDR) + Encoder Codificador + Hardware (H.265 has no software encoder) Hardware (H.265 no tiene codificador por software) + Text Properties Propiedades de texto + Text Box Height Altura del cuadro de texto + Paragraph Text Texto de párrafo + Point Text Texto de punto + Text Layer Type Tipo de capa de texto + Text Styling Estilo de texto + New Color Matte Nuevo mate de color + Color Matte Color Color del mate de color + Color: Color: + · {time} left · quedan {time} + Discard take Descartar toma + Voice-over take discarded Toma de voz en off descartada + Binary assets added Recursos binarios añadidos + Binary assets removed Recursos binarios eliminados + Deselect All Project Items Deseleccionar todos los elementos del proyecto + Display name Nombre visible + First commit Primer commit + H.265 (HEVC) H.265 (HEVC) + Last commit Último commit + Line delta Diferencia de líneas + Lines added Líneas añadidas + Lines deleted Líneas eliminadas + Merged PRs PR fusionados + Name (A–Z) Nombre (A–Z) + Real name Nombre real + Select All Project Items Seleccionar todos los elementos del proyecto + Username Nombre de usuario + Vertical Type Tool Herramienta Texto vertical + Go to previous keyframe Ir al fotograma clave anterior + Go to next keyframe Ir al fotograma clave siguiente + Add keyframe Añadir fotograma clave + Remove keyframe Quitar fotograma clave + Turn on animation and add keyframe Activar la animación y añadir un fotograma clave + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones. + Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Include Alpha Channel Incluir canal alfa + Sequence Settings Ajustes de secuencia + Color Management Gestión del color + VR Properties Propiedades de RV + Editing Mode: Modo de edición: + Timebase: Base de tiempo: + Frame Size: Tamaño del fotograma: + Pixel Aspect Ratio: Proporción de aspecto de píxeles: + Fields: Campos: + Channel Format: Formato de canales: + Number of Channels: Número de canales: + Preview File Format: Formato de archivo de previsualización: + Working Color Space: Espacio de color de trabajo: + Projection: Proyección: + Layout: Disposición: + Captured View: Vista capturada: + Video Previews Previsualizaciones de video + No Fields (Progressive Scan) Sin campos (exploración progresiva) + Custom ({ratio}) Personalizado ({ratio}) + {rate} frames/second {rate} fotogramas/segundo + {rate} fps Drop-Frame Timecode Código de tiempo con omisión de fotogramas a {rate} fps + {rate} fps Non Drop-Frame Timecode Código de tiempo sin omisión de fotogramas a {rate} fps + {rate} fps Timecode Código de tiempo a {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Escalar los efectos de movimiento proporcionalmente al cambiar el tamaño del fotograma + Maximum Bit Depth Profundidad de bits máxima + Maximum Render Quality Calidad de procesamiento máxima + Composite in Linear Color Componer en color lineal + Auto Tone Map Media (HDR and log into SDR) Mapeo de tonos automático de medios (HDR y log a SDR) + Configure… Configurar… + Not supported in FilmCraft yet FilmCraft aún no lo admite + FilmCraft always composites in linear light FilmCraft siempre compone en luz lineal + Loop marked range Bucle del intervalo marcado + Play Source Reproducir origen + Source Play/Stop Reproducir/Detener origen + Stop Source Detener origen + New Sequence Nueva secuencia + Tracks Pistas + tracks pistas + In Entrada + Out Salida + Export Markers as CSV… Exportar marcadores como CSV… + Fill Type Tipo de relleno + Gradient Angle Ángulo del degradado + Linear Gradient Degradado lineal + Solid Sólido + Text to Speech Texto a voz + Edit Narration Editar narración diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv new file mode 100644 index 000000000..d597a6e93 --- /dev/null +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -0,0 +1,3491 @@ +# Japanese (ja) catalog. Same format as `es.tsv`: context source translation. It covers the +# whole interface (the same entries as `es.tsv`); a string without an entry is shown in English. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Japanese computing and video-editing vocabulary (クリップ, シーケンス, トラック, 再生ヘッド, ぼかし ...). No +# proprietary localisation resource, string table or product UI was consulted or copied; add entries +# the same way. Product and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS ...) keep +# their spelling. +# +# House style, so new entries read like the rest: +# - Menu commands and buttons are short noun phrases or "〜を + verb" with no する/します ending +# (別名で保存…, 編集点を追加). Tooltips and messages are です・ます sentences ending in 。 +# - One half-width space between Japanese text and an adjacent Latin word, number or {placeholder} +# (Lumetri カラー, {n} 個). Half-width ":" and "( )", full-width "?". Always "…", never "...". +# - The long vowel mark is kept (フォルダー, モニター). Terms: 既定 (not デフォルト), リセット, エフェクト +# (not 効果), ぼかし, 音量, ビデオ, 再生ヘッド, イン点/アウト点. +# - "Horizontal"/"Vertical" are 水平/垂直 (guides, blur, axes); vertical text is "Vertical Text". + + About FilmCraft FilmCraft について + Add Audio Submix Track オーディオサブミックストラックを追加 + Add Caption at Playhead 再生ヘッドの位置にキャプションを追加 + Add Chapter Marker… チャプターマーカーを追加… + Add Edit 編集点を追加 + Add Edit to All Tracks すべてのトラックに編集点を追加 + Add Flash Cue Marker… フラッシュキューマーカーを追加… + Add Frame Hold フレームホールドを追加 + Add Guide… ガイドを追加… + Add Marker マーカーを追加 + Add New Caption Track… 新規キャプショントラックを追加… + Add Range Marker 範囲マーカーを追加 + Add Range Marker to In and Out イン点からアウト点までの範囲マーカーを追加 + Add Tracks… トラックを追加… + Adjustment Layer… 調整レイヤー… + Align to Selection 選択範囲に整列 + Align to Video Frame ビデオフレームに整列 + Align to Video Frame as Group グループとしてビデオフレームに整列 + All Panels すべてのパネル + Alpha アルファ + Appearance 外観 + Appearance… 外観… + Apply Audio Transition オーディオトランジションを適用 + Apply Default Transitions to Selection 選択範囲に既定のトランジションを適用 + Apply Video Transition ビデオトランジションを適用 + Arrange 重ね順 + ArtCraft Website ArtCraft の Web サイト + Assembly アセンブリ + Attach Proxies… プロキシを関連付け… + Audio Channels… オーディオチャンネル… + Audio Clip Mixer オーディオクリップミキサー + Audio Gain… オーディオゲイン… + Audio Hardware… オーディオハードウェア… + Audio In オーディオのイン点 + Audio Meters オーディオメーター + Audio Options オーディオオプション + Audio Out オーディオのアウト点 + Audio Track Mixer オーディオトラックミキサー + Audio Waveform オーディオ波形 + Auto Save… 自動保存… + Automate to Sequence… シーケンスに自動配置… + Bars and Tone… カラーバーとトーン… + Bin ビン + Bin From Selection 選択項目からビンを作成 + Black Video… ブラックビデオ… + Blue ブルー + Bottom 下 + Breakout to Mono モノラルに分割 + Bring Forward 前面へ + Bring to Front 最前面へ + Brown ブラウン + Captions キャプション + Captions and Graphics キャプションとグラフィックス + Captions… キャプション… + Caribbean カリビアン + Center Horizontally 水平方向の中央に整列 + Center Vertically 垂直方向の中央に整列 + Cerulean セルリアン + Clear 消去 + Clear Guides ガイドをクリア + Clear In イン点をクリア + Clear In and Out イン点とアウト点をクリア + Clear Markers マーカーをクリア + Clear Out アウト点をクリア + Clear Selected Marker 選択したマーカーをクリア + Close 閉じる + Close All Other Projects 他のプロジェクトをすべて閉じる + Close All Projects すべてのプロジェクトを閉じる + Close Gap ギャップを詰める + Close Project プロジェクトを閉じる + Color Management… カラーマネジメント… + Color Matte… カラーマット… + Comparison View 比較表示 + Composite Video コンポジットビデオ + Consolidate Duplicates 重複を統合 + Copy コピー + Copy Paste Includes Sequence Markers コピーと貼り付けにシーケンスマーカーを含める + Create Captions from Transcript… 文字起こしからキャプションを作成… + Create Multi-Camera Source Sequence… マルチカメラソースシーケンスを作成… + Create Proxies… プロキシを作成… + Cut 切り取り + Darkest 最も暗い + Delete Render Files レンダリングファイルを削除 + Delete Render Files In to Out イン点からアウト点までのレンダリングファイルを削除 + Delete Tracks… トラックを削除… + Delete Transcript 文字起こしを削除 + Demo Footage デモ素材 + Demo Project デモプロジェクト + Deselect All すべての選択を解除 + Detach Proxies プロキシの関連付けを解除 + Display Mode 表示モード + Distribute 均等配置 + Distribute Horizontally 水平方向に均等配置 + Distribute Space Horizontally 水平方向の間隔を均等化 + Distribute Space Vertically 垂直方向の間隔を均等化 + Distribute Vertically 垂直方向に均等配置 + Duplicate 複製 + Dynamic Audio Waveforms 動的なオーディオ波形 + Edit 編集 + Edit Offline… オフラインファイルを編集… + Edit Original オリジナルを編集 + Edit Subclip… サブクリップを編集… + Editing 編集 + Effect Controls エフェクトコントロール + Effects エフェクト + Ellipse 楕円 + Enable 有効 + Enable Remix リミックスを有効にする + Essential Graphics 基本グラフィックス + Essential Sound 基本サウンド + Events イベント + Export 書き出し + Export As Motion Graphics Template… モーショングラフィックステンプレートとして書き出し… + Extract 抽出 + Extract Audio オーディオを抽出 + Field Options… フィールドオプション… + File ファイル + File… ファイル… + Fill frame フレームを埋める + FilmCraft Help… FilmCraft ヘルプ… + FilmCraft on GitHub GitHub の FilmCraft + FilmCraft on getartcraft.com getartcraft.com の FilmCraft + Find Next 次を検索 + Find… 検索… + Fit 合わせる + Fit to frame フレームに合わせる + Flatten フラット化 + Forest フォレスト + Frame Blending フレームブレンド + Frame Hold Options… フレームホールドオプション… + Frame Sampling フレームサンプリング + From Bin ビンから + From Source Monitor ソースモニターから + From Source Monitor, Match Frame ソースモニターから (マッチフレーム) + From file… ファイルから… + Full フル + Generate Audio Waveform オーディオ波形を生成 + Get Media File Properties for メディアファイルのプロパティを取得 + Go to Gap ギャップへ移動 + Go to In イン点へ移動 + Go to Next Caption Segment 次のキャプションセグメントへ移動 + Go to Next Marker 次のマーカーへ移動 + Go to Out アウト点へ移動 + Go to Previous Caption Segment 前のキャプションセグメントへ移動 + Go to Previous Marker 前のマーカーへ移動 + Go to Split スプリットへ移動 + Graphics and Titles グラフィックスとタイトル + Graphics… グラフィックス… + Green グリーン + Group グループ + Guide Templates ガイドテンプレート + Help ヘルプ + Hide All Caption Tracks すべてのキャプショントラックを非表示 + High Quality Playback 高画質再生 + History 履歴 + Import From 読み込み元 + Import Image Sequence… 連番画像を読み込み… + Import from Media Browser メディアブラウザーから読み込み + Import… 読み込み… + Info 情報 + Ingest Settings… 取り込み設定… + Insert 挿入 + Insert Frame Hold Segment フレームホールドセグメントを挿入 + Install Motion Graphics Template… モーショングラフィックステンプレートをインストール… + Interpret Footage… 素材を解釈… + Join the ArtCraft Discord… ArtCraft の Discord に参加… + Keyboard Shortcuts… キーボードショートカット… + Label ラベル + Labels… ラベル… + Language 表示言語 + Lavender ラベンダー + Learning 学習 + Left 左 + Libraries ライブラリ + Lift リフト + Light ライト + Link リンク + Link Media… メディアをリンク… + Linked Selection リンクされた選択 + Lock Guides ガイドをロック + Lumetri Color Lumetri カラー + Lumetri Scopes Lumetri スコープ + Magnification 倍率 + Make Offline… オフラインに設定… + Make Subclip… サブクリップを作成… + Make Subsequence サブシーケンスを作成 + Manage Guides… ガイドを管理… + Mark Clip クリップをマーク + Mark In イン点をマーク + Mark Out アウト点をマーク + Mark Selection 選択範囲をマーク + Mark Split スプリットをマーク + Markers マーカー + Match Frame マッチフレーム + Media Analysis & Transcription… メディア分析と文字起こし… + Media Browser メディアブラウザー + Media Cache… メディアキャッシュ… + Media… メディア… + Medium 中 + Memory… メモリー… + Merge Clips… クリップを結合… + Metadata メタデータ + Modify 変更 + Motion Graphics Template… モーショングラフィックステンプレート… + Multi-Camera マルチカメラ + Multi-Camera View マルチカメラ表示 + Nest… ネスト… + New 新規 + New Layer 新規レイヤー + New Project Panel 新規プロジェクトパネル + Next in Sequence シーケンス内の次 + Next in Track トラック内の次 + Normalize Mix Track… ミックストラックをノーマライズ… + Offline File… オフラインファイル… + Open Project… プロジェクトを開く… + Optical Flow オプティカルフロー + Overwrite 上書き + Paste 貼り付け + Paste Attributes… 属性を貼り付け… + Paste Insert 挿入して貼り付け + Paused Resolution 一時停止時の解像度 + Playback Resolution 再生時の解像度 + Playback… 再生… + Plugins… プラグイン… + Polygon 多角形 + Preferences 環境設定 + Previous in Sequence シーケンス内の前 + Previous in Track トラック内の前 + Program プログラム + Progress 進行状況 + Project プロジェクト + Project Manager… プロジェクトマネージャー… + Project Settings プロジェクト設定 + Project… プロジェクト… + Properties プロパティ + Purple パープル + Reconnect Full Resolution Media… フル解像度メディアに再接続… + Recover Unsaved Changes… 未保存の変更を復元… + Rectangle 長方形 + Red レッド + Redo やり直し + Reference Monitor リファレンスモニター + Remix リミックス + Remix Properties… リミックスのプロパティ… + Remove Attributes… 属性を削除… + Remove Filler Words フィラーワードを削除 + Remove Pauses 無音部分を削除 + Remove Unused 未使用の項目を削除 + Rename… 名前を変更… + Render Audio オーディオをレンダリング + Render Effects In to Out イン点からアウト点までのエフェクトをレンダリング + Render In to Out イン点からアウト点までをレンダリング + Render Selection 選択範囲をレンダリング + Replace Fonts in Projects… プロジェクト内のフォントを置換… + Replace With Clip クリップで置換 + Report an Issue… 問題を報告… + Reset All Parameters すべてのパラメーターをリセット + Reset Duration 長さをリセット + Reset to Saved Layout 保存したレイアウトにリセット + Restore Captions from Source Clip ソースクリップからキャプションを復元 + Reveal Log Files… ログファイルを表示… + Reverse Match Frame 逆マッチフレーム + Revert 保存済みの状態に戻す + Revert Remix リミックスを元に戻す + Review レビュー + Right 右 + Ripple Delete リップル削除 + Ripple Sequence Markers シーケンスマーカーをリップル + Rose ローズ + Safe Margins セーフマージン + Save 保存 + Save All すべて保存 + Save As… 別名で保存… + Save Guides as Template… ガイドをテンプレートとして保存… + Save a Copy… コピーを保存… + Save as Template… テンプレートとして保存… + Scale to Frame Size フレームサイズに合わせてスケール + Scene Edit Detection… シーンの編集点を検出… + Scratch Disks… スクラッチディスク… + Search Bin 検索ビン + Select 選択 + Select All すべて選択 + Select All Matching 一致するクリップをすべて選択 + Select Label Group ラベルグループを選択 + Select Next Graphic 次のグラフィックを選択 + Select Next Layer 次のレイヤーを選択 + Select Previous Graphic 前のグラフィックを選択 + Select Previous Layer 前のレイヤーを選択 + Selection Follows Playhead 選択範囲が再生ヘッドに追従 + Selection as FilmCraft Project… 選択項目を FilmCraft プロジェクトとして書き出し… + Selection… 選択項目… + Send Backward 背面へ + Send to Back 最背面へ + Sequence シーケンス + Sequence From Clip クリップからシーケンスを作成 + Sequence Settings… シーケンス設定… + Sequence… シーケンス… + Show Active Caption Tracks Only アクティブなキャプショントラックのみ表示 + Show All Caption Tracks すべてのキャプショントラックを表示 + Show All Marker Colors すべての色のマーカーを表示 + Show Guides ガイドを表示 + Show Rulers ルーラーを表示 + Show Through Edits スルー編集点を表示 + Simplify Sequence… シーケンスを簡略化… + Snap in Program Monitor プログラムモニターでスナップ + Snap in Timeline タイムラインでスナップ + Source ソース + Source Settings… ソース設定… + Speed/Duration… 速度・長さ… + Synchronize… 同期… + System Compatibility Report… システム互換性レポート… + Tan タン + Teal ティール + Text テキスト + Time Interpolation 時間補間 + Timecode タイムコード + Timecode… タイムコード… + Timeline タイムライン + Timeline… タイムライン… + Toggle Proxies プロキシを切り替え + Tools ツール + Top 上 + Transcribe Sequence… シーケンスを文字起こし… + Transcribe… 文字起こし… + Transcript 文字起こし + Transparent Video… 透明ビデオ… + Trim Edit トリム編集 + Trim… トリム… + Undo 取り消し + Ungroup グループ解除 + Universal Counting Leader… ユニバーサルカウントリーダー… + Update Metadata… メタデータを更新… + Upgrade Caption to Graphic キャプションをグラフィックに変換 + Upgrade to Source Graphic ソースグラフィックに変換 + Vertical Text 縦書き + Video In ビデオのイン点 + Video Options ビデオオプション + Video Out ビデオのアウト点 + Video and Audio Waveform Split ビデオとオーディオ波形の分割表示 + View 表示 + Violet バイオレット + Window ウィンドウ + Workspaces ワークスペース + Yellow イエロー + Zoom In ズームイン + Zoom Out ズームアウト + Zoom to Sequence シーケンス全体にズーム + Home ホーム + Import 読み込み + {name} - Edited {name} - 編集済み + Full screen 全画面表示 + Volume 音量 + Master volume: use the Audio Track Mixer マスター音量はオーディオトラックミキサーで調整してください + Search 検索 + Quick Export クイック書き出し + Join the ArtCraft Discord (discord.gg/artcraft) ArtCraft の Discord に参加 (discord.gg/artcraft) + Cancel キャンセル + OK OK + Interpret Footage 素材を解釈 + Color Management · {n} item カラーマネジメント · {n} 項目 + Color Management · {n} items カラーマネジメント · {n} 項目 + Media colour space (from file metadata): {detected} メディアのカラースペース (ファイルのメタデータから): {detected} + Override media colour space: メディアのカラースペースを上書き: + Use file metadata ファイルのメタデータを使用 + Sequence Color Management シーケンスのカラーマネジメント + Working Color Space 作業用カラースペース + Wide gamut color (composite in BT.2020) 広色域カラー (BT.2020 で合成) + Auto tone map media (HDR and log into SDR) メディアを自動的にトーンマッピングする (HDR と Log を SDR に) + Audio Gain オーディオゲイン + Set Gain to: ゲインを設定: + Adjust Gain by: ゲインを調整: + Normalize Max Peak to: 最大ピークをノーマライズ: + Normalize All Peaks to: すべてのピークをノーマライズ: + Peak Amplitude: {peak} ピーク振幅: {peak} + Standard 標準 + Adaptive アダプティブ + Mono モノラル + Stereo ステレオ + Add Tracks トラックを追加 + Amount 量 + Placement 配置 + Track type トラックの種類 + Add video tracks ビデオトラックを追加 + Add audio tracks オーディオトラックを追加 + Add audio submix tracks オーディオサブミックストラックを追加 + After {name} {name} の後 + Before First Track 最初のトラックの前 + Delete Tracks トラックを削除 + Video Tracks ビデオトラック + Audio Tracks オーディオトラック + Delete Video Tracks ビデオトラックを削除する + Delete Audio Tracks オーディオトラックを削除する + All Empty Tracks すべての空のトラック + Version {version} バージョン {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Rust だけで書かれた、クリーンルーム設計のノンリニアビデオ編集ソフトです。ArtCraft ファミリーの一員です。 + Join the ArtCraft Discord ArtCraft の Discord に参加 + Source code on GitHub GitHub のソースコード + Report an issue 問題を報告 + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0。フォント: Inter、Noto Serif、JetBrains Mono (SIL OFL 1.1)。 + just now たった今 + {n} s ago {n} 秒前 + {n} min ago {n} 分前 + {n} h ago {n} 時間前 + All Events すべてのイベント + Warnings and Errors 警告とエラー + Errors Only エラーのみ + Clear All すべてクリア + No events. イベントはありません。 + Show finished jobs 終了したジョブを表示 + Cancelled キャンセル済み + Failed: {e} 失敗しました: {e} + Done 完了 + No background jobs. バックグラウンドジョブはありません。 + Recover Unsaved Changes 未保存の変更を復元 + Untitled 名称未設定 + FilmCraft was closed while “{name}” had unsaved changes. 「{name}」に未保存の変更がある状態で FilmCraft が終了しました。 + FilmCraft quit unexpectedly while “{name}” had unsaved changes. 「{name}」に未保存の変更がある状態で FilmCraft が予期せず終了しました。 + Recover unsaved changes from {when}? {when} の未保存の変更を復元しますか? + The project had not been saved yet. このプロジェクトはまだ保存されていませんでした。 + {n} sessions have unsaved changes: {n} 件のセッションに未保存の変更があります: + Discard 破棄 + Recover 復元 + Not Now 後で + Are you sure you want to discard your changes to '{file}'? 「{file}」への変更を破棄してもよろしいですか? + Yes はい + No いいえ + Add media to your project. Drop files anywhere in the window, or choose a source below. プロジェクトにメディアを追加します。ウィンドウ内の任意の場所にファイルをドロップするか、下のソースを選択してください。 + Browse files… ファイルを参照… + Movies, audio and stills from disk ディスク上のムービー、オーディオ、静止画 + Demo footage デモ素材 + Six procedural 1080p clips with sound サウンド付きの 1080p クリップ 6 本 (プロシージャル生成) + Demo project デモプロジェクト + A cut sequence with transitions, effects and music トランジション、エフェクト、音楽を含む編集済みのシーケンス + Bars and Tone カラーバーとトーン + SMPTE HD bars with 1 kHz reference tone 1 kHz 基準トーン付きの SMPTE HD カラーバー + Community コミュニティ + Join us on Discord Discord に参加 + OMF Export Settings OMF 書き出し設定 + AAF Export Settings AAF 書き出し設定 + OMF Title: OMF タイトル: + Mixdown video ビデオをミックスダウンする + Audio: オーディオ: + Encapsulate カプセル化 + Separate Audio オーディオを分離 + Embed 埋め込み + Separate Files 個別のファイル + Link to Media メディアにリンク + Audio File Format: オーディオファイル形式: + Sample Rate: サンプルレート: + Bits per Sample: サンプルあたりのビット数: + Rendering: レンダリング: + Trim audio files オーディオファイルをトリムする + Handle Frames: ハンドルフレーム: + Render audio clip effects オーディオクリップのエフェクトをレンダリングする + Breakout to mono モノラルに分割する + Exported {path} {path} を書き出しました + Select a clip to view its metadata. クリップを選択すると、メタデータを表示します。 + Clip: {name} クリップ: {name} + Open 開く + (no sequence) (シーケンスなし) + Show or hide {color} markers {color} のマーカーの表示/非表示 + Marker マーカー + {tc} Chapter {tc} チャプター + No markers. Press M to add one. マーカーはありません。M キーを押すと追加できます。 + All markers are hidden by the colour filter. 色フィルターにより、すべてのマーカーが非表示になっています。 + Type: 種類: + Video: ビデオ: + Codec: コーデック: + Duration: 長さ: + Settings: 設定: + Playhead: 再生ヘッド: + Video {n}: ビデオ {n}: + Audio {n}: オーディオ {n}: + UI: UI: + {fps} fps · {n} frames queued {fps} fps · {n} フレーム待機中 + {panel} — coming in a later milestone {panel} — 今後のマイルストーンで実装予定 + Close Panel パネルを閉じる + Close Other Timeline Panels 他のタイムラインパネルを閉じる + Maximize Frame パネルを最大化 + Restore Workspace ワークスペースを復元 + Reveal Sequence in Project プロジェクトでシーケンスを表示 + Video Thumbnails ビデオサムネイル + Audio Waveforms オーディオ波形 + Delete Preset プリセットを削除 + Export Preset… プリセットを書き出し… + Import Presets… プリセットを読み込み… + Effect presets エフェクトプリセット + Save Preset プリセットを保存 + Name: 名前: + Type 種類 + Scale スケール + Anchor to In Point イン点に固定 + Anchor to Out Point アウト点に固定 + Without keyframes キーフレームなし + Description: 説明: + Saved preset “{name}” プリセット「{name}」を保存しました + Current duration {seconds} s 現在の長さ {seconds} s + Remix Properties リミックスのプロパティ + Target Duration: 目標の長さ: + Segments: セグメント: + Fewer · More 少なく · 多く + Variations: バリエーション: + Remixed to {seconds} s {seconds} s にリミックスしました + {mode} · no clip {mode} · クリップなし + {mode} · no sequence {mode} · シーケンスなし + Remove Row 行を削除 + Add a timecode row タイムコードの行を追加 + Panner パンナー + Mute ミュート + Send {n} Level センド {n} のレベル + Show Keyframes キーフレームを表示 + Clip Keyframes クリップのキーフレーム + Track Keyframes トラックのキーフレーム + Delete 削除 + Untitled Workspace 名称未設定のワークスペース + New Workspace 新規ワークスペース + Edit Workspaces ワークスペースを編集 + Rename 名前を変更 + Restore Original 元の状態に復元 + Search effects エフェクトを検索 + Apply to Selected Clips 選択したクリップに適用 + Metadata Display メタデータ表示 + Save As New View Preset 新規表示プリセットとして保存 + Manage Saved View Presets 保存した表示プリセットを管理 + Freeform View Options フリーフォーム表示のオプション + Save Arrangement 配置を保存 + Saves the view, columns, sort, thumbnail and font size. 表示モード、列、並べ替え、サムネイルサイズ、フォントサイズを保存します。 + Grid spacing: グリッド間隔: + Default clip size: 既定のクリップサイズ: + Snap to grid グリッドにスナップ + Show clip names クリップ名を表示 + Show durations 長さを表示 + Show: 表示: + Search fields フィールドを検索 + Project Metadata プロジェクトのメタデータ + Add Property… プロパティを追加… + Column order 列の順序 + {n}. (empty) {n}. (空き) + Restore 復元 + Overwrite with Current View 現在の表示で上書き + Graphics text search arrives with M10.1–M10.2 グラフィックスのテキスト検索は M10.1–M10.2 で対応予定です + Open a sequence to work with captions キャプションを扱うにはシーケンスを開いてください + Add captions キャプションを追加 + Create a caption track or import a caption file. キャプショントラックを作成するか、キャプションファイルを読み込んでください。 + Create new caption track 新規キャプショントラックを作成 + Import captions file… キャプションファイルを読み込み… + New {format} track 新規 {format} トラック + Add caption at playhead 再生ヘッドの位置にキャプションを追加 + Split caption at playhead 再生ヘッドの位置でキャプションを分割 + Merge selected captions 選択したキャプションを結合 + Delete selected captions 選択したキャプションを削除 + No captions on this track. Press + to add one at the playhead. このトラックにはキャプションがありません。+ を押すと、再生ヘッドの位置に追加できます。 + No matching captions. 一致するキャプションはありません。 + Open a sequence to see its transcript 文字起こしを表示するにはシーケンスを開いてください + Transcribe sequence シーケンスを文字起こし + Speech-to-text turns the dialogue into editable text. 音声認識で、セリフを編集できるテキストに変換します。 + This build has no speech-to-text; import a transcript with transcript.set. このビルドには音声認識機能がありません。transcript.set で文字起こしを読み込んでください。 + Transcribe 文字起こし + Extract selected text 選択したテキストを抽出 + Lift selected text 選択したテキストをリフト + Remove filler words フィラーワードを削除 + Create captions キャプションを作成 + Speaker 話者 + Box ボックス + Align 整列 + Align left 左揃え + Align center 中央揃え + Align right 右揃え + Position 位置 + Position: top 位置: 上 + Position: middle 位置: 中央 + Position: bottom 位置: 下 + (gap) (ギャップ) + Out: {name} アウト: {name} + In: {name} イン: {name} + Play Around Edit (Space loops, Shift+K once) 編集点の前後を再生 (Space でループ、Shift+K で 1 回) + Trim Backward Many 後方へ複数フレームトリム + Trim Backward 後方へトリム + Apply Default Transitions 既定のトランジションを適用 + Apply Default Transitions to Selection (Shift+D) 選択項目に既定のトランジションを適用 (Shift+D) + Trim Forward 前方へトリム + Trim Forward Many 前方へ複数フレームトリム + Exit Trim Mode トリムモードを終了 + Voice-over record (right-click: Voice-Over Record Settings…) ナレーションを録音 (右クリック: ナレーション録音設定…) + Voice-Over Record Settings… ナレーション録音設定… + Recording voice-over… ナレーションを録音中… + Voice-over recorded: {p} ナレーションを録音しました: {p} + Voice-over recording stopped (nothing recorded) ナレーションの録音を停止しました (何も録音されていません) + Post-roll ポストロール + ● Recording ● 録音中 + Voice-Over Record Settings ナレーション録音設定 + Source: ソース: + Default Input 既定の入力 + Input: 入力: + Channel {n} チャンネル {n} + Countdown Sound Cues カウントダウン音 + Pre-roll: プリロール: + seconds 秒 + Post-roll: ポストロール: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. 再生は、再生ヘッド (またはイン点) のプリロール分手前から始まります。イン点とアウト点を設定している場合は、その位置でパンチイン・パンチアウトして録音します。 + Effect エフェクト + Clip Fx Editor クリップ Fx エディター + Track Fx Editor トラック Fx エディター + Master Gain マスターゲイン + Reset リセット + Band {n} バンド {n} + Solo ソロ + Bypass バイパス + Thr しきい値 + Ratio レシオ + Att アタック + Rel リリース + Gain ゲイン + Output Gain 出力ゲイン + Limiter リミッター + Link Channels チャンネルをリンク + Limiter Threshold リミッターのしきい値 + Limiter Release リミッターのリリース + Auto Gate 自動ゲート + Threshold しきい値 + Attack アタック + Release リリース + Hold ホールド + Compressor コンプレッサー + Auto Makeup 自動メイクアップ + Makeup メイクアップ + Expander エキスパンダー + Soft Clip ソフトクリップ + (no sequences) (シーケンスなし) + (no clips) (クリップなし) + Show Transport Controls トランスポートコントロールを表示 + Loop ループ + Multi-Camera Audio Follows Video マルチカメラでオーディオをビデオに追従させる + Multi-Camera Record マルチカメラ記録 + Multi-Camera Selection Top Down マルチカメラの選択を上から順に + Show Multi-Camera Preview Monitor マルチカメラプレビューモニターを表示 + Auto-Adjust Multi-Camera Playback Quality マルチカメラの再生画質を自動調整 + Transmit Multi-Camera View マルチカメラ表示を送出 + Multi-Camera Layout マルチカメラのレイアウト + Automatic 自動 + Edit Cameras… カメラを編集… + Add Marker (M) マーカーを追加 (M) + Mark In (I) イン点をマーク (I) + Mark Out (O) アウト点をマーク (O) + Go to In (Shift+I) イン点へ移動 (Shift+I) + Step Back 1 Frame (Left) 1 フレーム戻る (Left) + Play-Stop Toggle (Space) 再生/停止を切り替え (Space) + Step Forward 1 Frame (Right) 1 フレーム進む (Right) + Go to Out (Shift+O) アウト点へ移動 (Shift+O) + Insert (,) 挿入 (,) + Overwrite (.) 上書き (.) + Export Frame (Shift+E) フレームを書き出し (Shift+E) + Lift (;) リフト (;) + Extract (') 抽出 (') + Export Frame: use Export mode (M6) フレームの書き出し: 書き出しモードを使用してください (M6) + Button Editor ボタンエディター + Multi-Camera Record on マルチカメラ記録: オン + Multi-Camera Record off マルチカメラ記録: オフ + {first} - Merged {first} - 結合済み + {first} Multicam {first} マルチカメラ + In Points イン点 + Out Points アウト点 + Clip Marker クリップマーカー + Audio オーディオ + Merge Clips クリップを結合 + Create Multi-Camera Source Sequence マルチカメラソースシーケンスを作成 + Synchronize Clips クリップを同期 + Clip Name: クリップ名: + Sequence Name: シーケンス名: + {n} clip(s): {names} {n} 個のクリップ: {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} 個のクリップを選択中。基準トラックのクリップは移動しません。 + Synchronize Point 同期ポイント + Ignore Hours 時間の桁を無視 + first marker 最初のマーカー + Offset Audio by: オーディオのオフセット: + Offset: オフセット: + frames フレーム + Reference track: 基準トラック: + lowest (V1…) 最下段 (V1…) + Remove Audio from AV Clip AV クリップからオーディオを削除 + Camera 1 カメラ 1 + All Cameras すべてのカメラ + Switch Audio オーディオを切り替え + Camera Names カメラ名 + Clip Names クリップ名 + Enumerate Cameras カメラの連番 + Camera Angle Metadata カメラアングルのメタデータ + Move source clips to Processed Clips bin ソースクリップを「Processed Clips」ビンに移動 + Synchronized {n} clip group(s) {n} 個のクリップグループを同期しました + Merged clip created 結合クリップを作成しました + Multi-camera source sequence with {n} camera(s) マルチカメラソースシーケンス (カメラ {n} 台) + No multi-camera clip at the playhead 再生ヘッドの位置にマルチカメラクリップがありません + Camera {n} — {name} カメラ {n} — {name} + Page {page} of {pages} {page} / {pages} ページ + Previous page 前のページ + Next page 次のページ + Multi-Camera Record On/Off (0) マルチカメラ記録のオン/オフ (0) + Record 記録 + Edit Cameras カメラを編集 + Cameras shown in the Multi-Camera view (uncheck to hide). マルチカメラ表示に含めるカメラです (チェックを外すと非表示になります)。 + Recorded {n} multi-camera cut(s) {n} 個のマルチカメラカットを記録しました + Clamp Signal 信号をクランプ + Presets プリセット + Parade ({kind}) パレード ({kind}) + Waveform ({kind}) 波形 ({kind}) + Parade Type パレードの種類 + Waveform Type 波形の種類 + Vectorscope Targets ベクトルスコープのターゲット + Colour Space カラースペース + Brightness 明るさ + Signal Scale 信号スケール + Histogram ヒストグラム + Paste Attributes 属性を貼り付け + Remove Attributes 属性を削除 + New Offline File 新規オフラインファイル + Nested Sequence Name ネストされたシーケンス名 + Make Subclip サブクリップを作成 + Edit Subclip サブクリップを編集 + Modify Clip: Audio Channels クリップの変更: オーディオチャンネル + Modify Clip: Timecode クリップの変更: タイムコード + Frame Hold Options フレームホールドのオプション + Field Options フィールドのオプション + Save Project プロジェクトを保存 + Motion モーション + Opacity 不透明度 + Time Remapping タイムリマップ + Channel Volume チャンネル音量 + In Point イン点 + Out Point アウト点 + Playhead 再生ヘッド + Source Timecode ソースタイムコード + Sequence Time シーケンス時間 + Video Attributes ビデオ属性 + Audio Attributes オーディオ属性 + No effects エフェクトはありません + Scale Attribute Times 属性の時間を伸縮する + File Name: ファイル名: + Tape Name: テープ名: + Contains: 内容: + Video ビデオ + Width: 幅: + Height: 高さ: + Frame Rate: フレームレート: + Media Start: メディアの開始: + Subclip: {name} サブクリップ: {name} + Start: 開始: + End: 終了: + Restrict Trims To Subclip Boundaries トリムをサブクリップの範囲内に制限する + Convert to Master Clip マスタークリップに変換 + Clip Channel Format: クリップのチャンネル形式: + Number of Audio Clips: オーディオクリップ数: + Media Source Channels メディアソースのチャンネル + Ch. {n} Ch {n} + Clip {n} クリップ {n} + Source channels the selected audio clips play: 選択したオーディオクリップで再生するソースチャンネル: + Set Timecode: タイムコードを設定: + Use the file's timecode ファイルのタイムコードを使用する + Hold On ホールド位置 + Timecode: タイムコード: + Hold Filters フィルターをホールドする + Reverse Field Dominance フィールドオーダーを反転する + Processing Options 処理オプション + None なし + Always Deinterlace 常にインターレース解除 + Flicker Removal フリッカー除去 + Save changes to “{project_name}” before closing? 閉じる前に「{project_name}」への変更を保存しますか? + Don't Save 保存しない + All media is online. すべてのメディアがオンラインです。 + {name} (copy) {name} (コピー) + Offline オフライン + Missing 見つかりません + Link Media メディアをリンク + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} 個のクリップのメディアが見つかりません。場所を指定するか、フォルダーを検索するか、オフラインのままにしてください。 + Clip Name クリップ名 + File Name ファイル名 + File Path ファイルパス + Status 状態 + Match file properties: 照合するファイルのプロパティ: + File Extension ファイル拡張子 + Clip ID (fingerprint) クリップ ID (フィンガープリント) + Duration 長さ + Media Start メディア開始 + Frame Size / Rate フレームサイズ / レート + Align Timecode タイムコードを揃える + Relink others automatically 他のクリップを自動的に再リンク + Search in: 検索場所: + Browse… 参照… + Exact name matches only 名前の完全一致のみ + No candidates yet — Search a folder or Locate the file. 候補はまだありません。フォルダーを検索するか、ファイルの場所を指定してください。 + ✔ same file ✔ 同じファイル + ✔ matches ✔ 一致 + ✖ different file ✖ 別のファイル + Preview プレビュー + Offline All すべてオフライン + Locate… 場所を指定… + Linked {n} clip(s) {n} 個のクリップをリンクしました + Nothing found with that name. その名前に一致するものは見つかりませんでした。 + Make Offline オフラインに設定 + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. 選択した {n} 個のクリップをオフラインにします。再びリンクするまで、クリップにはオフライン画面が表示されます。 + Media files remain on disk メディアファイルをディスクに残す + Media files are deleted メディアファイルを削除する + Create Proxies プロキシを作成 + {n} clip(s) selected. {n} 個のクリップが選択されています。 + Format and size 形式とサイズ + Destination 出力先 + Next to the original media, in a Proxies folder 元のメディアと同じ場所の Proxies フォルダー + Proxies are made in the background and attached when done. Export always uses full-resolution media. プロキシはバックグラウンドで作成され、完了すると関連付けられます。書き出しには常にフル解像度のメディアが使われます。 + Creating {n} proxy file(s)… {n} 個のプロキシファイルを作成中… + Project Manager プロジェクトマネージャー + Sequences シーケンス + Resulting Project 処理後のプロジェクト + Collect Files and Copy to New Location ファイルを収集して新しい場所にコピー + Consolidate and Transcode 統合してトランスコード + Options オプション + Exclude Unused Clips 未使用のクリップを除外 + Include Handles: ハンドルを含める: + Include Proxies プロキシを含める + Include Preview Files プレビューファイルを含める + Destination Path 出力先のパス + Disk space: original {original} · resulting {result} ({n} files) ディスク容量: 処理前 {original} · 処理後 {result} ({n} ファイル) + Disk space: — ディスク容量: — + Calculate 計算 + Project Manager: writing {path} プロジェクトマネージャー: {path} を書き込み中 + Timeline: (no sequences) タイムライン: (シーケンスなし) + Browse 参照 + Select audio clips, then click a preset to apply it. オーディオクリップを選択し、プリセットをクリックして適用します。 + {name} (user) {name} (ユーザー) + Select audio clips to edit them here オーディオクリップを選択すると、ここで編集できます + Multiple Clips Selected 複数のクリップを選択中 + The selected clips have different audio types. Assign one type to edit them together. 選択したクリップはオーディオの種類が異なります。1 つの種類を割り当てると、まとめて編集できます。 + Assign an audio type to the selection: 選択したクリップにオーディオの種類を割り当て: + Clear Audio Type オーディオの種類をクリア + Preset: プリセット: + (Custom) (カスタム) + Preset name プリセット名 + Reduce Noise ノイズを軽減 + Reduce Rumble ランブルを軽減 + DeHum ハム除去 + DeEss 歯擦音除去 + Reduce Reverb リバーブを軽減 + Dynamics ダイナミクス + EQ EQ + Enhance Speech 音声を強調 + Low Tone 低い声 + High Tone 高い声 + Mix ミックス + Reverb リバーブ + Stereo Width ステレオ幅 + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. 音楽を目標の長さにリミックスする機能は、まだ利用できません。代わりにクリップをトリムして、クロスフェードを追加してください。 + Pan パン + Auto-Match 自動マッチ + Not matched yet まだマッチしていません + Matches each of the {n} clips on its own. {n} 個のクリップをそれぞれ個別にマッチさせます。 + Duck against: ダッキングのトリガー: + Untagged タグなし + Sensitivity 感度 + Reduce By 減衰量 + Fades フェード + Generate Keyframes キーフレームを生成 + Clip Volume クリップの音量 + (no clip selected) (クリップが選択されていません) + Master · {name} マスター · {name} + Add Lumetri Color to clip クリップに Lumetri カラーを追加 + Input LUT 入力 LUT + Color カラー + Temperature 色温度 + Tint 色合い + Saturation 彩度 + Exposure 露出 + Contrast コントラスト + Highlights ハイライト + Shadows シャドウ + Whites ホワイト + Blacks ブラック + HDR HDR + HDR White HDR ホワイト + HDR Specular HDR スペキュラー + Look ルック + Look LUT ルック LUT + Intensity 強さ + Adjustments 調整 + Faded Film 色あせたフィルム + Sharpen シャープ + Vibrance 自然な彩度 + RGB Curves RGB カーブ + HDR Range HDR 範囲 + Hue Saturation Curves 色相/彩度カーブ + Hue vs Sat 色相 vs 彩度 + Hue vs Hue 色相 vs 色相 + Hue vs Luma 色相 vs 輝度 + Luma vs Sat 輝度 vs 彩度 + Sat vs Sat 彩度 vs 彩度 + Midtones 中間調 + Key キー + Hue 色相 + Hue Range 色相の範囲 + Saturation Min 最小彩度 + Luma Min 最小輝度 + Luma Max 最大輝度 + Soften ソフト化 + Show Mask マスク表示 + Off オフ + Color/Gray カラー/グレー + Color/Black カラー/ブラック + White/Black ホワイト/ブラック + Refine リファイン + Denoise ノイズ除去 + Blur ぼかし + Correction 補正 + Hue Shift 色相シフト + Midpoint 中間点 + Roundness 丸み + Feather ぼかし + LUT LUT + Color Match カラーマッチ + Reference 参照 + Face Detection (protect skin tones) 顔検出 (肌の色を保護) + Apply Match マッチを適用 + Create ellipse mask 楕円マスクを作成 + Create 4-point polygon mask 4 点の多角形マスクを作成 + Free draw bezier ベジェで自由に描画 + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close プログラムモニターをクリックしてマスクのポイントを配置します (ドラッグで曲線)。最初のポイントをクリックするか Enter キーを押すと、マスクが閉じます + Delete Mask マスクを削除 + Inverted 反転 + Drag: move · Alt-click: smooth/corner · Cmd-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Cmd+クリック: 削除 + Track selected mask backward 選択したマスクを逆方向にトラッキング + Track selected mask backward 1 frame 選択したマスクを 1 フレーム逆方向にトラッキング + Track selected mask forward 1 frame 選択したマスクを 1 フレーム順方向にトラッキング + Track selected mask forward 選択したマスクを順方向にトラッキング + Stop tracking トラッキングを停止 + Tracking method: {method} トラッキング方法: {method} + Composite コンポジット + Comparison 比較 + Video and Waveform ビデオと波形 + System Default システムの既定 + Default location 既定の場所 + Font family フォントファミリー + Label Colors ラベルカラー + Decoded frames held for the monitors and thumbnails: {used} of {budget}. モニターとサムネイル用に保持するデコード済みフレームに、{budget} のうち {used} を使用しています。 + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft は残りの RAM をほかのアプリケーション用に空けておきます。 + not available in this session このセッションでは利用できません + Currently: {loc} — {files} file(s), {size} 現在: {loc} — {files} 個のファイル、{size} + ({ch} output channels) (出力 {ch} チャンネル) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. プログラムの左 → デバイス出力 {l}、右 → デバイス出力 {r}{dev}。デバイスのチャンネル数を超える出力は、代わりに 1 と 2 に送られます。 + Auto-saves go to: {dir} 自動保存先: {dir} + Recovery copy: {dir} 復旧用コピー: {dir} + Last auto-save {a} · last recovery copy {j} 前回の自動保存 {a} · 前回の復旧用コピー {j} + Last auto-save {a} 前回の自動保存 {a} + Last recovery copy {j} 前回の復旧用コピー {j} + Remove Media Cache Files メディアキャッシュファイルの削除 + Deleted {n} file(s), {size}. {n} 個のファイル、{size} を削除しました。 + Reset… リセット… + Source · {name} ソース · {name} + Reset Effect エフェクトをリセット + Save Preset… プリセットを保存… + Custom Setup カスタム設定 + Edit… 編集… + Show graphs グラフを表示 + Toggle animation アニメーションのオン / オフを切り替え + Add/Remove Keyframe キーフレームを追加/削除 + Temporal Interpolation 時間補間 + Linear リニア + Bezier ベジェ + Auto Bezier 自動ベジェ + Continuous Bezier 連続ベジェ + Ease In イーズイン + Ease Out イーズアウト + Select a clip to grade クリップを選択すると、グレーディングできます + Select a clip to see its properties クリップを選択すると、プロパティを表示します + Anchor point アンカーポイント + Rotation 回転 + Level レベル + Speed {n}% 速度 {n}% + Value 値 + Velocity 速度 + Default 既定 + Custom ({n} points) カスタム ({n} ポイント) + Select a graphic clip, or use the Type tool (T) on the Program monitor グラフィッククリップを選択するか、プログラムモニターでテキストツール (T) を使用してください + Create new graphic 新規グラフィックを作成 + New Text Layer 新規テキストレイヤー + New Text 新規テキスト + New Rectangle 新規長方形 + New Ellipse 新規楕円 + Delete Layer レイヤーを削除 + Select a layer to edit its properties. レイヤーを選択すると、プロパティを編集できます。 + Align Left 左揃え + Align Center Horizontally 水平方向の中央揃え + Align Right 右揃え + Align Top 上揃え + Align Center Vertically 垂直方向の中央揃え + Align Bottom 下揃え + Font フォント + Font Style フォントスタイル + Font Size フォントサイズ + Alignment 整列 + Left Align Text テキストを左揃え + Center Align Text テキストを中央揃え + Right Align Text テキストを右揃え + Justify 両端揃え + Tracking トラッキング + Leading 行送り + Baseline Shift ベースラインシフト + Text Box Width テキストボックスの幅 + Style スタイル + Faux Bold 疑似ボールド + Faux Italic 疑似イタリック + All Caps すべて大文字 + Small Caps スモールキャップ + Underline 下線 + OpenType OpenType + Kerning カーニング + Ligatures 合字 + Shape シェイプ + Size サイズ + Corner Radius 角丸の半径 + Polygon Sides 多角形の辺の数 + Fill 塗り + Stroke 線 + Stroke 2 線 2 + Background 背景 + Opacity 不透明度 + Size サイズ + Corner Radius 角丸の半径 + Shadow シャドウ + Angle 角度 + Distance 距離 + Blur ぼかし + Search templates テンプレートを検索 + All Categories すべてのカテゴリ + Install… インストール… + Install Motion Graphics Template (.fcgt) モーショングラフィックステンプレートをインストール (.fcgt) + No templates match. 一致するテンプレートはありません。 + {category} · My Template {category} · マイテンプレート + Apply 適用 + Remove 削除 + {n} editable properties {n} 個の編集可能なプロパティ + Template: {name} テンプレート: {name} + Intro Duration イントロの長さ + Outro Duration アウトロの長さ + Roll ロール + Start Off Screen 画面外から開始 + End Off Screen 画面外で終了 + Preroll プリロール + Postroll ポストロール + Video Frame ビデオフレーム + Pin To 固定先 + Pinned Edges 固定する辺 + FilmCraft Graphics Template FilmCraft グラフィックステンプレート + {n} Font {n} のフォント + {n} Size {n} のサイズ + {n} Color {n} の色 + {n} Position {n} の位置 + Show {n} {n} を表示 + My Templates マイテンプレート + Export As Motion Graphics Template モーショングラフィックステンプレートとして書き出し + Category: カテゴリ: + Editable properties: 編集可能なプロパティ: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. テンプレートフォルダーに FilmCraft グラフィックステンプレート (.fcgt) として保存されます。 + Exported graphics template to {path} グラフィックステンプレートを {path} に書き出しました + Replace Fonts in Projects プロジェクト内のフォントを置換 + Fonts used in the project: プロジェクトで使用されているフォント: + {f} (missing) {f} (見つかりません) + Replace: 置換前: + Replaced {n} font uses {n} 箇所のフォントを置換しました + Show/Hide Effects and Sends エフェクトとセンドの表示/非表示 + Audio Track Mixer menu オーディオトラックミキサーのメニュー + Show/Hide Tracks… トラックの表示/非表示… + Meter Input(s) Only 入力のみをメーター表示 + Show/Hide Tracks トラックの表示/非表示 + Show All すべてを表示 + Post-Fader ポストフェーダー + Remove Effect エフェクトを削除 + Fill the slots above first 先に上のスロットを埋めてください + pre プリ + Pre-Fader プリフェーダー + Mute Send センドをミュート + Remove Send センドを削除 + New Submix 新規サブミックス + Pan / balance (drag; double-click: centre) パン / バランス (ドラッグで調整、ダブルクリックで中央) + Solo safe ソロセーフ + Enable track for recording トラックの録音を有効にする + Volume (drag; double-click: 0 dB) 音量 (ドラッグで調整、ダブルクリックで 0 dB) + 5.1 panner (drag the puck; double-click: front centre) 5.1 パンナー (ポイントをドラッグ、ダブルクリックで前方中央) + Center 中央 + Go to In Point イン点へ移動 + Go to Out Point アウト点へ移動 + Play In to Out イン点からアウト点まで再生 + Record (voice-over on the record-enabled track) 録音 (録音が有効なトラックにナレーションを収録) + Write keyframes キーフレームを書き込み + Something went wrong while drawing the window. Your project is still open; save it to be safe. ウィンドウの描画中に問題が発生しました。プロジェクトは開いたままです。念のため保存してください。 + Continue 続行 + Go Back 戻る + Go Forward 進む + Up One Level 上の階層へ移動 + File Types Displayed 表示するファイルの種類 + List View リスト表示 + Thumbnail View サムネイル表示 + Ingest 取り込み + Open Ingest Settings 取り込み設定を開く + Import as Image Sequence 連番画像として読み込む + {sel} of {total} items selected {total} 項目中 {sel} 項目を選択中 + {total} items {total} 項目 + Cannot open {dir}: {e} {dir} を開けません: {e} + Favorites お気に入り + Local Drives ローカルドライブ + Network ネットワーク + Recent Directories 最近使用したディレクトリ + (none) (なし) + Remove from Favorites お気に入りから削除 + Add to Favorites お気に入りに追加 + Clear Recent Directories 最近使用したディレクトリをクリア + Open In Source Monitor ソースモニターで開く + Reveal in Finder Finder で表示 + Edit Columns… 列を編集… + Hover Scrub ホバースクラブ + New Media Browser Panel 新規メディアブラウザーパネル + Create Folder For Imported Projects 読み込んだプロジェクト用のフォルダーを作成 + Allow Duplicate Media During Project Import プロジェクトの読み込み時にメディアの重複を許可 + Refresh 更新 + Edit Columns 列を編集 + Guides ガイド + (no audio) (オーディオなし) + Generating waveform… 波形を生成中… + Reference {tc} 参照 {tc} + Set 設定 + Set the reference to the playhead 参照位置を再生ヘッドに設定 + Reference: next frame 参照: 次のフレーム + Reference: previous frame 参照: 前のフレーム + RGBA Channels RGBA チャンネル + Add Guide ガイドを追加 + Save Guides as Template ガイドをテンプレートとして保存 + Manage Guides ガイドを管理 + Orientation: 向き: + Vertical 垂直 + Horizontal 水平 + Position: 位置: + No saved guide templates. 保存されたガイドテンプレートはありません。 + Project: {name} プロジェクト: {name} + Bin: {name} ビン: {name} + Up one level 上の階層へ移動 + Video Usage: {vu} Audio Usage: {au} ビデオ使用: {vu} オーディオ使用: {au} + Icon View アイコン表示 + Freeform View フリーフォーム表示 + Sort Icons アイコンを並べ替え + Clear (Delete) 消去 (Delete) + New Item 新規項目 + New Bin 新規ビン + Find 検索 + Automate to Sequence シーケンスに自動配置 + Select items to clear 消去する項目を選択してください + User Order ユーザー定義の順序 + Descending 降順 + Refresh Project プロジェクトを更新 + Reveal Project in Finder… Finder でプロジェクトを表示… + New Bin From Selection 選択項目から新規ビン + New Search Bin 新規検索ビン + New Project Shortcut 新規プロジェクトショートカット + List リスト + Icon アイコン + Freeform フリーフォーム + Preview Area プレビューエリア + Thumbnails サムネイル + Thumbnails show effects applied サムネイルに適用済みのエフェクトを表示 + Thumbnail controls for all pointing devices すべてのポインティングデバイスでサムネイルを操作 + Refresh Sort Order 並べ替え順を更新 + Metadata Display… メタデータ表示… + Save Current View Preset 現在の表示をプリセットとして保存 + Restore View Preset 表示プリセットを復元 + Project View Preset {n} プロジェクト表示プリセット {n} + Freeform View Options… フリーフォーム表示のオプション… + Media missing メディアが見つかりません + Unreadable 読み取れません + Open in Source Monitor ソースモニターで開く + Open in Timeline タイムラインで開く + New Sequence From Clip クリップから新規シーケンス + Set Poster Frame ポスターフレームを設定 + Proxy プロキシ + Open in Place その場で開く + Open in New Tab 新しいタブで開く + Open in New Window 新しいウィンドウで開く + {n} items {n} 項目 + Stack スタック + Unstack スタックを解除 + Clip Size クリップサイズ + Larger 大きく + Smaller 小さく + Align to Grid グリッドに整列 + Reset to Grid グリッドにリセット + Save Arrangement… 配置を保存… + Restore Arrangement 配置を復元 + Delete Arrangement 配置を削除 + Drop media here to create sequence. ここにメディアをドロップすると、シーケンスを作成します。 + Double-click a sequence in the Project panel to open it here. プロジェクトパネルでシーケンスをダブルクリックすると、ここで開きます。 + Open Demo Project デモプロジェクトを開く + Insert and overwrite sequences as nests or individual clips シーケンスをネストまたは個別のクリップとして挿入・上書き + Snap in Timeline (S) タイムラインでスナップ (S) + Caption track options キャプショントラックのオプション + Timeline Display Settings タイムライン表示設定 + Show Video Thumbnails ビデオのサムネイルを表示 + Show Audio Waveform オーディオ波形を表示 + Expand All Tracks すべてのトラックを展開 + Minimize All Tracks すべてのトラックを最小化 + Unrendered: should play back in real time 未レンダリング: リアルタイムで再生できるはずです + Unrendered: render to play back in real time 未レンダリング: リアルタイム再生にはレンダリングが必要です + Rendered preview レンダリング済みプレビュー + ✓ Enable ✓ 有効 + Unlink リンク解除 + (Default) (既定) + (RGB) Gamma (RGB) ガンマ + (RGB) Input Black Level (RGB) 入力黒レベル + (RGB) Input White Level (RGB) 入力白レベル + (RGB) Output Black Level (RGB) 出力黒レベル + (RGB) Output White Level (RGB) 出力白レベル + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point 10 ビットのブラックポイント + 10 Bit White Point 10 ビットのホワイトポイント + 105mm Prime 105mm 単焦点 + 29.97 fps Drop-Frame 29.97 fps ドロップフレーム + 29.97 fps Non Drop-Frame 29.97 fps ノンドロップフレーム + 35mm Prime 35mm 単焦点 + 3D Roll 3D ロール + 3D Rotate 3D 回転 + 3D Spin 3D スピン + 3D Spinback 3D スピンバック + 4-Color Gradient 4 色グラデーション + 5.1 Media 5.1 メディア + 5.1 Mixdown Type 5.1 ミックスダウンの種類 + 50-300mm Zoom 50-300mm ズーム + 59.94 fps Drop-Frame 59.94 fps ドロップフレーム + 59.94 fps Non Drop-Frame 59.94 fps ノンドロップフレーム + ASC CDL ASC CDL + Accessible color contrast アクセシビリティに配慮したカラーコントラスト + Action-Safe Margin アクションセーフの余白 + Active Monitor アクティブなモニター + Add 加算 + Add tracks automatically when editing source clips onto the Timeline ソースクリップをタイムラインに配置するとき、トラックを自動的に追加する + Additional Scale 追加スケール + Additive Dissolve 加算ディゾルブ + Additive Dissolve (Legacy) 加算ディゾルブ (従来版) + Adjust 調整 + Advanced 詳細設定 + Aggressive 強め + Align and Transform 整列と変形 + All lines すべての行 + Allow Selection tool to choose Roll and Ripple trims without modifier key 選択ツールで修飾キーなしにロールトリムとリップルトリムを選べるようにする + Allow current tool to change trim type of previously selected edit point 前に選択した編集点のトリムの種類を現在のツールで変更できるようにする + Allow duplicate media during project import プロジェクトの読み込み時にメディアの重複を許可する + Alpha Adjust アルファ調整 + Alpha Blurriness アルファのぼかし + Alpha Channel アルファチャンネル + Alpha Glow アルファグロー + Always import embedded CEA-708 closed captions with wide aspect. 埋め込まれた CEA-708 クローズドキャプションを常にワイドアスペクトで読み込む + Always override audio tags オーディオタグを常に上書きする + Ambience 環境音 + Ambience Intensity 環境光の強さ + Ambience Light Color 環境光の色 + Amount of Noise ノイズの量 + Amount to Decolor 脱色の量 + Amount to Tint 色合いの適用量 + Amplifier アンプ + Amplify 増幅 + Amplitude 振幅 + Amplitude and Compression 振幅とコンプレッション + Analog アナログ + Analog Delay アナログディレイ + Analyze all imported media to search for visuals or audio 読み込んだすべてのメディアを解析して、ビジュアルやオーディオを検索できるようにする + Anamorphic アナモルフィック + Anchor アンカー + Anchor Point アンカーポイント + Angle 角度 + Animation アニメーション + Anti-aliasing Quality アンチエイリアスの品質 + Anti-flicker Filter ちらつき防止フィルター + Antialiasing for Best Quality 最高画質時のアンチエイリアス + Asymmetry 非対称 + At Startup 起動時 + At playback end, return to beginning when restarting playback 最後まで再生した後に再生を再開すると、先頭に戻る + Attempt to force hardware to document sample rate ハードウェアをシーケンスのサンプルレートで動作させるよう試みる + Audio Codec オーディオコーデック + Audio Duration オーディオの長さ + Audio Effects オーディオエフェクト + Audio Hardware オーディオハードウェア + Audio In Point オーディオのイン点 + Audio Info オーディオ情報 + Audio Out Point オーディオのアウト点 + Audio Samples オーディオサンプル + Audio Time Units オーディオ時間単位 + Audio Transition Default Duration オーディオトランジションの既定の長さ + Audio Transitions オーディオトランジション + Audio Usage オーディオの使用状況 + Auto 自動 + Auto Align 自動位置合わせ + Auto Reframe 自動リフレーム + Auto Save 自動保存 + Auto Save also saves the current project(s) 自動保存時に現在のプロジェクトも保存する + Auto-tag audio types in the timeline タイムラインでオーディオの種類を自動でタグ付けする + Auto-transcribe all imported clips 読み込んだすべてのクリップを自動で文字起こし + Auto-transcribe only clips in sequences シーケンス内のクリップのみ自動で文字起こし + Automatch Time オートマッチ時間 + Automatic Click Remover クリックノイズ自動除去 + Automatically Hide Dependent Clips 依存クリップを自動的に非表示にする + Automatically Save Every 自動保存の間隔 + Automatically delete cache files older than 一定期間を過ぎたキャッシュファイルを自動的に削除 + Automatically delete oldest cache files when cache exceeds キャッシュが上限を超えたら古いファイルから自動的に削除 + Automatically refresh growing files 書き込み中のファイルを自動的に更新する + Automatically resume playback for growing files in Source Monitor ソースモニターで書き込み中のファイルの再生を自動的に再開する + Automatically save projects プロジェクトを自動保存する + Automatically transcribe clips クリップを自動的に文字起こしする + Automation Keyframe Optimization オートメーションキーフレームの最適化 + Background Color 背景色 + Background Corner Radius 背景の角丸の半径 + Background Opacity 背景の不透明度 + Background Size 背景のサイズ + Background fill 背景の塗り + Balance バランス + Balanced Voice バランスの良い音声 + Band 1 バンド 1 + Band 2 バンド 2 + Band 3 バンド 3 + Band 4 バンド 4 + Band 5 バンド 5 + Band Pass バンドパス + Band Slide バンドスライド + Band Stop バンドストップ + Bandpass バンドパス + Barn Doors 観音開き + Basic 基本 + Basic 3D 基本 3D + Basic Correction 基本補正 + Bass 低音 + Behind 背面 + Bessel ベッセル + Bevel Alpha アルファベベル + Bevel join 面取りした角 + Binauralizer - Ambisonics バイノーラライザー - アンビソニックス + Bins ビン + Black 黒 + Black & White 白黒 + Black Input Level 入力黒レベル + Black and white 白黒 + Black and white with crushed blacks and strong contrast 黒をつぶし、コントラストを強めた白黒 + Bleach Bypass 銀残し + Bleach-bypass style: desaturated with hard contrast 銀残し風: 彩度を抑え、コントラストを強めた色調 + Blend ブレンド + Blend Mode 描画モード + Blend With Original 元の画像とのブレンド + Blending Mode 描画モード + Block Dissolve ブロックディゾルブ + Block Height ブロックの高さ + Block Motion ブロックモーション + Block Size ブロックサイズ + Block Width ブロックの幅 + Blocks ブロック数 + Blue Blurriness 青のぼかし + Blue Curve ブルーカーブ + Blue Offset 青のオフセット + Blue Power 青のパワー + Blue Slope 青のスロープ + Blue-Blue 青-青 + Blue-Const 青-定数 + Blue-Green 青-緑 + Blue-Red 青-赤 + Blur & Sharpen ぼかし・シャープ + Blur Amount ぼかし量 + Blur Angle ぼかしの角度 + Blur Dimensions ぼかしの方向 + Blur Dissolve ぼかしディゾルブ + Blur Layer ぼかしレイヤー + Blur Length ぼかしの長さ + Blurriness ぼかしの量 + Bokeh Blur ボケ風ぼかし + Boost ブースト + Border 境界 + Border Color 境界線の色 + Border Width 境界線の幅 + Borders 境界 + Bottom Center 下中央 + Bottom Left 左下 + Bottom Right 右下 + Bounce バウンス + Branching 分岐 + Bright 明るめ + Bright Air 明るい空気感 + Bright Studio 明るいスタジオ + Brightness & Contrast 明るさ・コントラスト + British Stack ブリティッシュスタック + Brush Hardness ブラシの硬さ + Brush Opacity ブラシの不透明度 + Brush Position ブラシの位置 + Brush Size ブラシサイズ + Brush Spacing (secs) ブラシの間隔 (秒) + Brush Strokes ブラシストローク + Bubbles 泡 + Bulge 膨らみ + Bulge Smoother 膨らみ (より滑らか) + Bump Height バンプの高さ + Burn Alpha バーンアルファ + Burn Chroma バーンクロマ + Burn Color バーンの色 + Butt cap 平らな端 + Butterworth バターワース + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use 再利用する解析結果のキャッシュ先 + Camera Angle カメラアングル + Camera Blur カメラぼかし + Camera Shake カメラの揺れ + Capitalisation 大文字化 + Cathedral 大聖堂 + Cell Pattern セルパターン + Center Channel Pan センターチャンネルのパン + Center Gradient 中央に配置 + Center Input センター入力 + Center Out 中央から外へ + Center Point (Latitude) 中心点 (緯度) + Center Point (Longitude) 中心点 (経度) + Center Split 中央分割 + Center of Sphere 球の中心 + Cents セント + Change to Color 指定色に変更 + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. 変更内容は、編集のたびに少し遅れてバックグラウンドでコピーされます。FilmCraft が予期せず終了した場合は、次回の起動時に復元できます。 + Channel チャンネル + Channel Blur チャンネルぼかし + Channel Mix チャンネルミックス + Channel Mixer チャンネルミキサー + Chaos カオス + Chebyshev チェビシェフ + Checker Wipe チェッカーワイプ + Checkerboard チェッカーボード + Chinese 中国語 + Choke チョーク + Chorus コーラス + Chorus/Flanger コーラス/フランジャー + Chroma クロマ + Chroma Leak クロマリーク + Chromatic Aberration (Blue) 色収差 (青) + Chromatic Aberration (Green) 色収差 (緑) + Chromatic Aberration (Red) 色収差 (赤) + Cineon Converter Cineon コンバーター + Circle 円形 + Clarity 明瞭度 + Clean Combo クリーンコンボ + Clean Up Noisy Dialogue ノイズの多いセリフをクリアに + Clear Muddiness こもりを解消 + Client クライアント + Clip クリップ + Clip Level クリッピングレベル + Clipping クリッピング + Clock Wipe クロックワイプ + Clock Wipe (Legacy) クロックワイプ (従来版) + Clockwise 時計回り + Clone クローン + Closed Captions クローズドキャプション + Color & Alpha カラーとアルファ + Color 1 カラー 1 + Color 2 カラー 2 + Color 3 カラー 3 + Color 4 カラー 4 + Color Balance カラーバランス + Color Burn 焼き込みカラー + Color Channel カラーチャンネル + Color Correction カラー補正 + Color Distortion 色のゆがみ + Color Dodge 覆い焼きカラー + Color Emboss カラーエンボス + Color Key カラーキー + Color Pass カラーパス + Color Replace 色の置換 + Color Space カラースペース + Color Theme カラーテーマ + Color To Leave 残す色 + Color Tolerance 色の許容値 + Color Wheels & Match カラーホイールとマッチ + Colorize カラー化 + Columns 列 + Comment コメント + Complexity 複雑さ + Composite In Back 背面に合成 + Composite In Front 前面に合成 + Composite Using 合成に使用 + Composite on Original 元の画像に合成 + Compound Blur 複合ぼかし + Compression Before Clipping クリッピング前の圧縮 + Constant Gain コンスタントゲイン + Constant Power コンスタントパワー + Conversion Type 変換の種類 + Convolution Reverb コンボリューションリバーブ + Cool Blue クールブルー + Cool, desaturated and punchy 寒色寄りで彩度を抑えた、メリハリのある色調 + Core Width コアの幅 + Corner コーナー + Corner Pin コーナーピン + Count 個数 + Counter-clockwise 反時計回り + Crash Recovery クラッシュからの復旧 + Crawl Left 左へクロール + Crawl Right 右へクロール + Create folder for imported projects 読み込んだプロジェクト用のフォルダーを作成する + Creative クリエイティブ + Crop クロップ + Crop Less <-> Smooth More クロップを減らす <-> 滑らかさを増す + Cross Displacement クロスディスプレイスメント + Cross Dissolve クロスディゾルブ + Cross Dissolve (Legacy) クロスディゾルブ (従来版) + Cross Zoom クロスズーム + Cross Zoom (Legacy) クロスズーム (従来版) + Crossfade クロスフェード + Crossovers クロスオーバー + Crystallize 結晶化 + Crystals 結晶 + Cube Spin キューブスピン + Curl Radius カールの半径 + Current Time 現在の時間 + Cursor カーソル + Curvature 曲率 + Curve カーブ + Curves カーブ + Custom カスタム + Custom Color カスタムカラー + Cutoff カットオフ + Cyan-teal shadows and warm highlights with a little more contrast シアン寄りのティールのシャドウと暖色のハイライトに、少し強めのコントラスト + Cycle (in Revolutions) 周期 (回転数) + Cycle Evolution 展開をループ + Damping ダンピング + Damping HF 高域ダンピング + Damping LF 低域ダンピング + Dark ダーク + Darken 比較 (暗) + Darker Color カラー比較 (暗) + Date Created 作成日 + Date Modified 変更日 + Day for night: darker, cooler and less saturated 疑似夜景: 暗めで寒色寄り、彩度を抑えた色調 + DeEsser ディエッサー + DeHummer ハムノイズ除去 + DeNoise ノイズ除去 + DeReverb リバーブ除去 + Decay 減衰 + Decay Time (RT60) 残響時間 (RT60) + Default Audio Tracks 既定のオーディオトラック + Default Emoji font 既定の絵文字フォント + Default Media Scaling 既定のメディアスケーリング + Default Output 既定の出力 + Default closed caption font 既定のクローズドキャプションフォント + Default language 既定の言語 + Default paragraph direction 既定の段落の方向 + Default subtitle and text font 既定の字幕・テキストフォント + Delay ディレイ + Delay 1 ディレイ 1 + Delay 2 ディレイ 2 + Delay 3 ディレイ 3 + Delay 4 ディレイ 4 + Delay and Echo ディレイとエコー + Delete files older than 次より古いファイルを削除 + Delete oldest files when the cache exceeds キャッシュが次のサイズを超えたら古いファイルから削除 + Delete… 削除… + Depth 深さ + Desaturate 彩度低減 + Description 説明 + Detail Amplitude ディテールの振幅 + Detail Level ディテールレベル + Detailed Analysis 詳細な分析 + Device Class デバイスクラス + Dialogue セリフ + Diamond ひし形 + Difference 差の絶対値 + Dimensions 方向 + Dimmed 暗め + Dip to Black 黒にディップ + Dip to Black (Legacy) 黒にディップ (従来版) + Dip to Color カラーにディップ + Dip to White 白にディップ + Dip to White (Legacy) 白にディップ (従来版) + Direction 方向 + Directional 平行光源 + Directional Blur 方向ぼかし + Directional Blur (Legacy) 方向ぼかし (従来版) + Disable video output when in the background バックグラウンド時はビデオ出力を無効にする + Disperse 分散 + Displacement ディスプレイスメント + Display Color Management ディスプレイのカラーマネジメント + Display Color Settings ディスプレイのカラー設定 + Display out of sync indicators for unlinked clips リンク解除したクリップの同期ずれインジケーターを表示 + Dissolve ディゾルブ + Dissolve Blocks ブロックをディゾルブ + Distance 距離 + Distance to Image 画像までの距離 + Distort ゆがみ + Distortion ディストーション + Distortion Complexity ゆがみの複雑さ + Distortion Rate ゆがみの速度 + Distortion Type ディストーションの種類 + Divide 除算 + Do not delete cache files automatically キャッシュファイルを自動的に削除しない + Don't label speakers 話者にラベルを付けない + Double-click ダブルクリック + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) 再生解像度を下げたときのドラフトデコード (H.264: 高速化、一部のフレームはフィルター処理を軽減) + Drive ドライブ + Drop Shadow ドロップシャドウ + Dry ドライ + Dry Out ドライ出力 + Dry Up a Roomy Voice 響きの多い音声をドライに + Duck Deep Under Dialogue セリフに合わせて深くダッキング + Duck Under Dialogue セリフに合わせてダッキング + Duck Under Voice and Effects 音声と効果音に合わせてダッキング + Ducking ダッキング + Dutch オランダ語 + Dynamic Link ダイナミックリンク + Dynamics Processing ダイナミクス処理 + Early Reflections 初期反射 + Earthquake 地震 + Ease In and Out イーズイン・アウト + Easing イージング + Echo エコー + Echo Glow エコーグロー + Echo Operator エコーの合成方法 + Echo Time (seconds) エコー時間 (秒) + Echoes エコー数 + Edge Color エッジの色 + Edge Feather エッジのぼかし + Edge Glow エッジグロー + Edge Sharpness エッジのシャープさ + Edge Softness エッジの柔らかさ + Edge Thickness エッジの太さ + Edge Thin エッジの細さ + Edge Threshold エッジのしきい値 + Edge Type エッジの種類 + Edge Width エッジの幅 + Elliptical 楕円 + Emboss エンボス + Enable HSL Secondary HSL セカンダリを有効にする + Enable Mercury Transmit Mercury Transmit を有効にする + Enable ProRes hardware accelerated encoding, if available 可能な場合は、ハードウェアアクセラレーションによる ProRes エンコードを有効にする + Enable clip and XMP metadata linking クリップと XMP メタデータのリンクを有効にする + Enable developer mode 開発者モードを有効にする + Enable hardware accelerated decoding (requires restart) ハードウェアアクセラレーションによるデコードを有効にする (再起動が必要) + Enable language auto-detection 言語の自動検出を有効にする + Enable proxies プロキシを有効にする + End Color 終了色 + End Point 終点 + End of Ramp ランプの終了点 + Error エラー + Evolution 展開 + Evolution Options 展開オプション + Exciter Amount エキサイター量 + Exciter Mode エキサイターモード + Exclusion 除外 + Exponential Fade 指数フェード + Extended Dynamic Range Monitoring 拡張ダイナミックレンジのモニタリング + FFT Filter FFT フィルター + Fade フェード + Fade Out フェードアウト + Faded Matte フェードマット + Falloff 減衰 + Far Away 遠くで + Fast 高速 + Faster Motion より速い動き + Feedback フィードバック + Feedback 1 フィードバック 1 + Feedback 2 フィードバック 2 + Feedback 3 フィードバック 3 + Feedback 4 フィードバック 4 + Feet + Frames 16mm フィート + フレーム 16mm + Feet + Frames 35mm フィート + フレーム 35mm + Female 女性 + File Size ファイルサイズ + Fill Background 背景を塗りつぶし + Fill Color 塗りの色 + Fill Left with Right 右チャンネルを左にコピー + Fill Point 塗りつぶしの基点 + Fill Right with Left 左チャンネルを右にコピー + Fill Selector 塗りつぶしの基準 + Film Dissolve フィルムディゾルブ + Film Dissolve (Legacy) フィルムディゾルブ (従来版) + Film Roll フィルムロール + Filter フィルター + Filter Frequency フィルター周波数 + Filter Resonance フィルターレゾナンス + Filter and EQ フィルターと EQ + Final Delay 最終ディレイ + Find Edges 輪郭検出 + Flanger フランジャー + Flare フレア + Flare Brightness フレアの明るさ + Flare Center フレアの中心 + Flare Color フレアの色 + Flash フラッシュ + Flicker ちらつき + Flickers ちらつき回数 + Flip Motion フリップモーション + Flip Over フリップオーバー + Float 浮動小数点 + Focus フォーカス + Focus Blur フォーカスぼかし + Focus Point フォーカスポイント + Focus Shape フォーカスの形状 + Focus Size フォーカスのサイズ + Fold Motion フォールドモーション + Foldback フォールドバック + For color management parameters, go to the Settings tab in the Lumetri Color panel. カラーマネジメントのパラメーターは、Lumetri カラーパネルの「設定」タブにあります。 + Format 形式 + Four Scopes (RGB, 8 bit) 4 つのスコープ (RGB、8 bit) + Four Scopes (YUV, float, no clamp) 4 つのスコープ (YUV、浮動小数点、クランプなし) + Fractal Influence フラクタルの影響 + Fractal Type フラクタルの種類 + Frame フレーム + Frame Color フレームの色 + Frame Count フレームカウント + Frame Layout フレームレイアウト + Frame Rate フレームレート + Frame Width フレームの幅 + Frame cache for monitors and thumbnails モニターとサムネイル用のフレームキャッシュ + Frames フレーム + Framing フレーミング + French フランス語 + Frequency 周波数 + From 変更元 + From East 右から + From North 上から + From South 下から + From West 左から + From the Left 左から + From the Right 右から + Front + LFE フロント + LFE + Front + Rear フロント + リア + Front + Rear + LFE フロント + リア + LFE + Front Only フロントのみ + Fuzz ファズ + GB GB + Gamma ガンマ + Gamma Correction ガンマ補正 + Gamut Warning 色域外警告 + Gamut Warning Color 色域外警告の色 + Gap ギャップ + Gap Color ギャップの色 + Gaussian Blur ガウスぼかし + Gaussian Blur (Legacy) ガウスぼかし (従来版) + General 一般 + Generate 生成 + Generate Timecode タイムコードを生成 + Generate waveforms and conform audio using multithreading マルチスレッドを使用して波形の生成とオーディオのコンフォームを行う + Generate waveforms automatically during import 読み込み時に波形を自動的に生成する + German ドイツ語 + Ghosts ゴースト数 + Glass ガラス + Glint きらめき + Glitch グリッチ + Glow グロー + Glow Brightness グローの明るさ + Glow Color グローの色 + Glow Only グローのみ + Glow Radius グローの半径 + Glow Saturation グローの彩度 + Glow Width グローの幅 + Golden Hour ゴールデンアワー + Good 良好 + Gradient グラデーション + Gradient End グラデーションの終点 + Gradient Layer グラデーションレイヤー + Gradient Placement グラデーションの配置 + Gradient Scatter グラデーションの拡散 + Gradient Shape グラデーションの形状 + Gradient Start グラデーションの始点 + Gradient Wipe グラデーションワイプ + Gradient Wipe (Legacy) グラデーションワイプ (従来版) + Grain Size グレインのサイズ + Graphic Equalizer (10 Bands) グラフィックイコライザー (10 バンド) + Graphic Equalizer (20 Bands) グラフィックイコライザー (20 バンド) + Graphic Equalizer (30 Bands) グラフィックイコライザー (30 バンド) + Graphics グラフィックス + Green Blurriness 緑のぼかし + Green Curve グリーンカーブ + Green Offset 緑のオフセット + Green Power 緑のパワー + Green Slope 緑のスロープ + Green-Blue 緑-青 + Green-Const 緑-定数 + Green-Green 緑-緑 + Green-Red 緑-赤 + Grid グリッド + Grow 拡大 + Growing Files 書き込み中のファイル + Grunge グランジ + Grunge & Distort グランジとゆがみ + GuitarSuite ギタースイート + HDR Graphics White (Nits) HDR グラフィックスのホワイトレベル (Nits) + HLS Vectorscope + Histogram HLS ベクトルスコープ + ヒストグラム + HSL Secondary HSL セカンダリ + Hard ハード + Hard Clip ハードクリップ + Hard Light ハードライト + Hard Limiter ハードリミッター + Hard Mix ハードミックス + Hardware decoding ハードウェアデコード + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. ハードウェアデコード: 「自動」では、対応しているストリームにはシステムのビデオデコーダー (macOS では VideoToolbox、Windows では Media Foundation、Linux では H.264・HEVC 用の VA-API) を使用し、それ以外のストリームや、ハードウェアで失敗した場合は FilmCraft 独自のデコーダーを使用します。すでに開いているメディアは、開き直すまで現在のデコーダーを使い続けます。 + Head Size 頭部サイズ + Heart ハート + Height 高さ + Hexagon 六角形 + Hide Back Face 裏面を非表示 + High 高 + High Cutoff 高域カットオフ + High Frequency Cut 高域カット + High Frequency Damping 高域ダンピング + High Pass ハイパス + High Shelf ハイシェルフ + High Shelf Gain ハイシェルフゲイン + High contrast with held highlights and deep shadows ハイライトを保ちつつシャドウを深く沈めた、高コントラストの色調 + Highlight ハイライト + Highlight Blue Balance ハイライトのブルーバランス + Highlight Brightness ハイライトの明るさ + Highlight Color ハイライトカラー + Highlight Green Balance ハイライトのグリーンバランス + Highlight Red Balance ハイライトのレッドバランス + Highlight Rolloff ハイライトのロールオフ + Highlight Threshold ハイライトのしきい値 + Highlight Tint ハイライトの色合い + Highlights Lightness ハイライトの明度 + Highpass ハイパス + Hindi ヒンディー語 + Hindi digits ヒンディー数字 + Horizontal 16:9 横長 16:9 + Horizontal Alignment 水平方向の整列 + Horizontal Blocks 水平ブロック数 + Horizontal Decentering 水平方向の偏心 + Horizontal Displacement 水平ディスプレイスメント + Horizontal Flip 左右反転 + Horizontal Margin 水平方向の余白 + Horizontal and Vertical 水平と垂直 + Hue Center 色相の中心 + Hue Tolerance 色相の許容値 + I/O Buffer Size I/O バッファーサイズ + Ignore Alpha アルファを無視 + Image Control 画像コントロール + Immersive Video イマーシブビデオ + Import numbered stills as image sequences 連番の静止画を画像シーケンスとして読み込む + Impulse インパルス + In a Room 部屋の中で + In the Media Cache メディアキャッシュ内 + In the project プロジェクト内 + In/Out Duration イン/アウト間の長さ + Include captions on import 読み込み時にキャプションを含める + Incoming 次のクリップ + Indeterminate Media Timebase 不定メディアのタイムベース + Initial Delay 初期ディレイ + Inner 内側 + Inner Radius 内側の半径 + Input Boost 入力ブースト + Inset インセット + Inside 内側 + Inside Color 内側の色 + Inter Inter + Interior Room Tone 屋内のルームトーン + Internal Black Point 内部ブラックポイント + Internal White Point 内部ホワイトポイント + Interpolation 補間 + Intersect 交差 + Inverse Falloff 減衰を反転 + Invert 反転 + Invert Alpha アルファを反転 + Invert Blur ぼかしを反転 + Invert Fill 塗りつぶしを反転 + Invert Gradient グラデーションを反転 + Invert Left 左の位相を反転 + Invert Right 右の位相を反転 + Iris アイリス + Iris Box 四角形アイリス + Iris Cross 十字アイリス + Iris Diamond ひし形アイリス + Iris Radius アイリスの半径 + Iris Rotation アイリスの回転 + Iris Round 円形アイリス + Iris Shape アイリスの形状 + Italian イタリア語 + Japanese 日本語 + JetBrains Mono JetBrains Mono + Join Through Edits スルー編集点を結合 + Kaleidoscope 万華鏡 + Keep a recovery copy of unsaved changes 未保存の変更の復旧用コピーを保持する + Key Color キーカラー + Keying キーイング + Korean 韓国語 + Label Defaults 既定のラベル + Label speakers 話者にラベルを付ける + Labels ラベル + Large Hall 大きなホール + Large Trim Offset 大幅トリムのオフセット + Large Volume Adjustment 大幅な音量調整 + Latch ラッチ + Layer Name レイヤー名 + Layers レイヤー + Leave Color 色を残す + Left - Left 左 - 左 + Left - Right 左 - 右 + Left channel to device output 左チャンネルを割り当てるデバイス出力 + Left to Right 左から右へ + Left to right 左から右 + Legacy 従来版 + Length 長さ + Lens Blur レンズぼかし + Lens Distortion レンズゆがみ + Lens Flare レンズフレア + Lens Type レンズの種類 + Level 1 レベル 1 + Level 2 レベル 2 + Level 3 レベル 3 + Level 4 レベル 4 + Levels レベル + Lifted blacks, softer contrast, muted colour 黒を浮かせ、コントラストを弱めた、くすんだ色調 + Light 1 光源 1 + Light 2 光源 2 + Light 3 光源 3 + Light 4 光源 4 + Light 5 光源 5 + Light Angle 光の角度 + Light Color 光の色 + Light Intensity 光の強さ + Light Leak 光漏れ + Light Leaks 光漏れ + Light Sweep ライトスイープ + Light Type 光源の種類 + Lighten 比較 (明) + Lighter Color カラー比較 (明) + Lighting Effects 照明エフェクト + Lightning 稲妻 + Lights & Blurs 光とぼかし + Lights & Glows 光とグロー + Line Thickness 線の太さ + Linear Burn 焼き込み (リニア) + Linear Dodge (Add) 覆い焼き (リニア) - 加算 + Linear Light リニアライト + Linear Ramp リニアランプ + Linear Wipe リニアワイプ + Linear Wipe (Legacy) リニアワイプ (従来版) + Linear keyframe thinning リニアキーフレームの間引き + Linear to Log リニアからログ + Liquid Distortion 液状ゆがみ + Local Projects ローカルプロジェクト + Location 場所 + Log Note ログメモ + Log to Linear ログからリニア + Log to Log ログからログ + Logo Cutout ロゴの切り抜き + Long Shadow ロングシャドウ + Look-Ahead Time 先読み時間 + Loudness ラウドネス + Loudness Maximizer ラウドネスマキシマイザー + Loudness Meter ラウドネスメーター + Louver ルーバー + Low 低 + Low Frequency Cut 低域カット + Low Pass ローパス + Low Shelf ローシェルフ + Low Shelf Gain ローシェルフゲイン + Lower Left 左下 + Lower Right 右下 + Lowpass ローパス + Luma 輝度 + Luma Curve 輝度カーブ + Luma Fade 輝度フェード + Luma Key 輝度キー + Luma Source 輝度のソース + Luma and Chroma 輝度とクロマ + Lumetri Presets Lumetri プリセット + Luminance 輝度 + Luminance Threshold 輝度のしきい値 + Luminosity 輝度 + MB MB + Magenta マゼンタ + Magnify 拡大鏡 + Magnify (Legacy) 拡大鏡 (従来版) + Maintain pitch while shuttling シャトル再生時にピッチを維持する + Major Radius 長半径 + Male 男性 + Mango マンゴー + Map Black To 黒に割り当てる色 + Map White To 白に割り当てる色 + Maps full-range levels into 16–235 (video range) with a luma curve 輝度カーブで、フルレンジのレベルを 16–235 (ビデオレンジ) に収めます + Mask Only マスクのみ + Master Amplitude マスター振幅 + Mastering マスタリング + Match frame sets in point マッチフレームでイン点を設定する + Matte マット + Matte Alpha マットのアルファ + Matte Cleanup マットのクリーンアップ + Matte Generation マットの生成 + Matte Luma マットの輝度 + Maximum 最大値 + Maximum Amplitude 最大振幅 + Maximum Block Size 最大ブロックサイズ + Maximum Blur 最大ぼかし + Maximum Project Versions プロジェクトの最大バージョン数 + Maximum Reduction 最大低減量 + Maximum Scale 最大スケール + Media メディア + Media Analysis メディア解析 + Media Analysis & Transcription メディア解析と文字起こし + Media Cache メディアキャッシュ + Media Cache Database メディアキャッシュデータベース + Media Cache Files メディアキャッシュファイル + Media Cache Management メディアキャッシュの管理 + Media Duration メディアの長さ + Media End メディア終了 + Media Time メディア時間 + Media Timecode メディアのタイムコード + Media Type メディアの種類 + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. メディアの解析結果と文字起こしは、メディアキャッシュフォルダーにキャッシュされます。このコンピューター上の別のプロジェクトにメディアを読み込んだ場合でも、再解析は必要ありません。 + Median 中央値 + Medium Room 中くらいの部屋 + Memory メモリー + Metadata & Timecode Burn-in メタデータとタイムコードの焼き込み + Method 方法 + Metrics Kerning メトリクスカーニング + Mid 中 + Mid Point 中間点 + Midtone Blue Balance 中間調のブルーバランス + Midtone Green Balance 中間調のグリーンバランス + Midtone Red Balance 中間調のレッドバランス + Midtones Lightness 中間調の明度 + Minimum 最小値 + Minimum time 最小時間 + Minimum time interval thinning 最小時間間隔での間引き + Minor Radius 短半径 + Mirror ミラー + Mirror Alternate 交互にミラー + Missing font replacement 見つからないフォントの代替 + Miter join とがった角 + Miter limit マイター制限 + Mixed Crystals 混合結晶 + Mode モード + Modern High Gain モダンハイゲイン + Modulation モジュレーション + Modulation Rate モジュレーションレート + Mono Media モノラルメディア + Monochrome モノクロ + Monochrome toned cyan-blue シアンブルーで調色したモノクロ + Monochrome toned warm brown 温かみのあるブラウンで調色したモノクロ + Monoscopic モノスコピック + Monospace only 等幅フォントのみ + More colour in the less saturated areas 彩度の低い部分の色を強調 + Morph Cut モーフカット + Mosaic モザイク + Mosaic (Legacy) モザイク (従来版) + Motion Blur モーションぼかし + Motion Camera モーションカメラ + Motion Preset モーションプリセット + Motion Tween モーショントゥイーン + Move 移動 + Movie ムービー + Movie (audio and video) ムービー (オーディオとビデオ) + Multiband Compressor マルチバンドコンプレッサー + Multichannel Mono Media マルチチャンネルのモノラルメディア + Multiply 乗算 + Multitap Delay マルチタップディレイ + Music 音楽 + Mute input during timeline recording タイムラインでの録音中は入力をミュートする + Name 名前 + Narrow 狭い + Neon Color ネオンの色 + Neon Wipe ネオンワイプ + Next to original media files 元のメディアファイルと同じ場所 + Night 夜 + No Motion 動きなし + No Scroll スクロールなし + No plugins are installed. Changes will take effect the next time you start FilmCraft. プラグインはインストールされていません。変更は次回 FilmCraft の起動時に反映されます。 + Noise ノイズ + Noise & Grain ノイズ・グレイン + Noise (Legacy) ノイズ (従来版) + Noise Level ノイズレベル + Noise Reduction/Restoration ノイズ除去/修復 + Noise Type ノイズの種類 + Non-Additive Dissolve 非加算ディゾルブ + Normal 通常 + Notch 1 ノッチ 1 + Notch 2 ノッチ 2 + Notch 3 ノッチ 3 + Notch 4 ノッチ 4 + Notch 5 ノッチ 5 + Notch 6 ノッチ 6 + Notch Filter ノッチフィルター + Notch Width ノッチ幅 + Number Of Echoes エコーの数 + Number of Harmonics 倍音の数 + Obsolete 旧式 + Octagon 八角形 + Offset オフセット + Offset (Turbulence) オフセット (乱流) + Offset From 開始オフセット + Offset To 終了オフセット + Omni 全方向光源 + On オン + On Original Image 元の画像上 + On Transparent 透明背景上 + Open Air 屋外 + Open Most Recent 前回のプロジェクトを開く + Open in new window 新しいウィンドウで開く + Open in place その場で開く + Open new tab 新しいタブで開く + Opens up the shadows シャドウを明るくします + Order オーダー + Orientation 向き + Outer 外側 + Outgoing 前のクリップ + Output 出力 + Output Mapping 出力マッピング + Outside 外側 + Outside Color 外側の色 + Overflow オーバーフロー + Overlay オーバーレイ + Overshoot オーバーシュート + Page Peel ページピール + Page Scroll ページスクロール + Page Turn ページめくり + Paint Bucket ペイントバケツ + Paint On Black 黒に描画 + Paint On Original Image 元の画像に描画 + Paint On Transparent 透明背景に描画 + Paint On White 白に描画 + Paint Style 描画スタイル + Paint Surface 描画面 + Pan (Y Axis) パン (Y 軸) + Panel Wipe パネルワイプ + Panels パネル数 + Panner - Ambisonics パンナー - アンビソニックス + Parade パレード + Parade (RGB) パレード (RGB) + Parametric Equalizer パラメトリックイコライザー + Passband Ripple 通過帯域リップル + Path パス + Path Points パスのポイント + Pause Media Encoder queue during playback 再生中は Media Encoder のキューを一時停止する + Peak Frequency ピーク周波数 + Peak Gain ピークゲイン + Pedestal ペデスタル + Pentagon 五角形 + Per line 行ごと + Percent Blur ぼかし率 + Perspective 遠近 + Phase Difference 位相差 + Phaser フェイザー + Phone Call 電話越しの声 + Phosphor 蛍光体 + Phosphor Color 蛍光体の色 + Photocopy コピー機風 + Photocopy Color コピー機風 (カラー) + Pillow ピロー + Pin All すべて固定 + Pin Horizontal 水平方向を固定 + Pin Light ピンライト + Pin Vertical 垂直方向を固定 + Pinning 固定 + Pitch Shifter ピッチシフター + Plate プレート + Plateau Wipe プラトーワイプ + Plateaus プラトー数 + Plates プレート + Play after rendering previews プレビューのレンダリング後に再生する + Play audio while scrubbing in Source and Program Monitors ソースモニターとプログラムモニターでのスクラブ中にオーディオを再生する + Playback 再生 + Playhead position determines trim monitor loop playback トリムモニターのループ再生で再生ヘッドの位置を基準にする + Plugins プラグイン + Podcast Voice ポッドキャスト向けの声 + Point 1 ポイント 1 + Point 1 Color ポイント 1 の色 + Point 1 Latitude ポイント 1 の緯度 + Point 1 Longitude ポイント 1 の経度 + Point 2 ポイント 2 + Point 2 Color ポイント 2 の色 + Point 2 Latitude ポイント 2 の緯度 + Point 2 Longitude ポイント 2 の経度 + Point 3 ポイント 3 + Point 3 Color ポイント 3 の色 + Point 3 Latitude ポイント 3 の緯度 + Point 3 Longitude ポイント 3 の経度 + Point 4 ポイント 4 + Point 5 ポイント 5 + Point 6 ポイント 6 + Point 7 ポイント 7 + Point 8 ポイント 8 + Points 頂点数 + Policy ポリシー + Pop Motion ポップモーション + Portuguese ポルトガル語 + Position & Rotation 位置・回転 + Position Amount 移動量 + Position, Scale & Rotation 位置・スケール・回転 + Position, Scale, Rotation 位置・スケール・回転 + Posterize ポスタライズ + Posterize Time 時間のポスタライズ + Pre-Delay プリディレイ + Prefix 接頭辞 + Presence Lift プレゼンスを強調 + Preserve Luminosity 輝度を保持 + Preserve Scale スケールを保持 + ProcAmp プロセスアンプ + Projecting cap 突き出た端 + Projects プロジェクト + Pull Motion プルモーション + Pulls highlights and whites down ハイライトとホワイトを抑えます + Push プッシュ + Push (Legacy) プッシュ (従来版) + Q Q + RAM reserved for other applications 他のアプリケーション用に確保する RAM + RGB RGB + RGB Split RGB 分割 + RGB-White RGB (白) + Radial 円形 + Radial Blur 放射状ぼかし + Radial Ramp 放射状ランプ + Radial Wipe 放射状ワイプ + Radial Wipe (Legacy) 放射状ワイプ (従来版) + Radio Announcer ラジオアナウンサー + Radio Voice ラジオ風の声 + Radius 半径 + Ramp ランプ + Ramp Shape ランプの形状 + Random ランダム + Random Seed ランダムシード + Range 範囲 + Ray 光線 + Ray Color 光線の色 + Ray Length 光線の長さ + Rays 光線数 + Rays Only 光線のみ + Read 読み取り + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness 赤のぼかし + Red Curve レッドカーブ + Red Offset 赤のオフセット + Red Power 赤のパワー + Red Slope 赤のスロープ + Red-Blue 赤-青 + Red-Const 赤-定数 + Red-Green 赤-緑 + Red-Red 赤-赤 + Reduce Axis 低減の対象 + Reduce Interlace Flicker インターレースのちらつきを軽減 + Reflected 反射形 + Reflection Angle 反射の角度 + Reflection Center 反射の中心 + Refraction 屈折 + Reframe Offset リフレームのオフセット + Reframe Zoom リフレームのズーム + Refresh growing Files Every 書き込み中のファイルの更新間隔 + Relaxed 控えめ + Release Time リリース時間 + Relief レリーフ + Remaining 残り時間 + Remove Fringe フリンジを除去 + Render Edit in Audition files to: Audition で編集するファイルのレンダリング先: + Render audio when rendering video ビデオのレンダリング時にオーディオもレンダリングする + Repair 修復 + Repeat Edge Pixels エッジピクセルを繰り返す + Replace Color 置換後の色 + Replace With Clip From Source Monitor ソースモニターのクリップで置換 + Replicate 複製 + Responsive Design - Position レスポンシブデザイン - 位置 + Responsive Design - Time レスポンシブデザイン - 時間 + Restore open sequences when opening projects プロジェクトを開くときに、開いていたシーケンスを復元する + Result 結果 + Retro レトロ + Reveal Nested Sequence ネストされたシーケンスを表示 + Reveal Original Image 元の画像を表示 + Reveal in Project プロジェクトで表示 + Reverb Amount リバーブ量 + Reverse 反転 + Right - Left 右 - 左 + Right - Right 右 - 右 + Right channel to device output 右チャンネルを割り当てるデバイス出力 + Right to left 右から左 + Ripple trim adds edits to keep both sides of trim in sync リップルトリムで編集点を追加し、トリムの両側の同期を保つ + Roll (Z Axis) ロール (Z 軸) + Room Size ルームサイズ + Rotation Amount 回転量 + Roughen 粗い + Roughen Color 粗い (カラー) + Roughen Edges エッジを粗く + Round cap 丸い端 + Round join 丸い角 + Rounded Crop 角丸クロップ + Rows 行 + Russian ロシア語 + Rusty さびた + Rusty Color さびた (カラー) + SFX 効果音 + Sample Rate サンプルレート + Saturation at 50 % 彩度 50 % + Save .cfa and .pek media cache files next to original media files when possible 可能な場合は、.cfa と .pek のメディアキャッシュファイルを元のメディアファイルと同じ場所に保存する + Scale (Degrees) スケール (度) + Scale Amount スケール量 + Scale From 開始スケール + Scale Height 高さのスケール + Scale Match スケール合わせ + Scale To 終了スケール + Scale Width 幅のスケール + Scale to frame size フレームサイズに合わせてスケール + Scan Lines 走査線 + Scene シーン + Scientific Filter サイエンティフィックフィルター + Scratch disk location for Captured Audio キャプチャしたオーディオのスクラッチディスクの場所 + Screen スクリーン + Seconds 秒 + Segments セグメント数 + Semi-tones 半音 + Sequence Name シーケンス名 + Sequence Timecode シーケンスのタイムコード + Set focus on the Timeline when performing Insert/Overwrite edits 挿入/上書き編集の実行時にタイムラインにフォーカスを移す + Set to frame size フレームサイズに設定 + Setting 設定 + Shadow Angle シャドウの角度 + Shadow Blue Balance シャドウのブルーバランス + Shadow Blur シャドウのぼかし + Shadow Color シャドウの色 + Shadow Distance シャドウの距離 + Shadow Green Balance シャドウのグリーンバランス + Shadow Only シャドウのみ + Shadow Opacity シャドウの不透明度 + Shadow Red Balance シャドウのレッドバランス + Shadow Size シャドウのサイズ + Shadow Tint シャドウの色合い + Shadows Lightness シャドウの明度 + Shake 揺れ + Shape Dissolve シェイプディゾルブ + Shape Flow シェイプフロー + Shape Size シェイプのサイズ + Shapes シェイプ + Shard Size 破片のサイズ + Sharp Colors シャープな色 + Sharpen Amount シャープの量 + Shift Center To 中心の移動先 + Shift clips that overlap trim point during ripple trimming リップルトリム時に、トリムポイントに重なるクリップをずらす + Shot ショット + Show "Project:" and "Bin:" in Project panel tab プロジェクトパネルのタブに「プロジェクト:」と「ビン:」を表示 + Show Clip Mismatch Warning dialog クリップの不一致警告ダイアログを表示 + Show Event Indicator イベントインジケーターを表示 + Show Focus Area フォーカス範囲を表示 + Show Home ホームを表示 + Show Mask Tracker Preview マスクトラッカーのプレビューを表示 + Show Only Noise ノイズのみ表示 + Show Open Dialog 「開く」ダイアログを表示 + Show Project Load Error Dialog プロジェクトの読み込みエラーダイアログを表示 + Show Rich Tool Tips 詳細なツールヒントを表示 + Show Tool Tips ツールヒントを表示 + Show Workspace Reset Warning dialog on double click ダブルクリック時にワークスペースのリセット警告ダイアログを表示 + Show system compatibility issues at startup 起動時にシステムの互換性の問題を表示 + Shrink 縮小 + Shutter Angle シャッター角度 + Similarity 類似度 + Simple Notch Filter 簡易ノッチフィルター + Simple Parametric EQ 簡易パラメトリック EQ + Simple Text シンプルテキスト + Single-band Compressor シングルバンドコンプレッサー + Sinusoidal 正弦波 + Skew 傾斜 + Skew Axis 傾斜の軸 + Slats 羽根の数 + Slice スライス + Slices スライス数 + Slide スライド + Slide (Legacy) スライド (従来版) + Slope スロープ + Slow 低速 + Slower Motion より遅い動き + Small Room 小さな部屋 + Smart Limit スマート制限 + Smart Tools スマートツール + Smart quotes スマート引用符 + Smooth スムーズ + Smooth Motion 滑らかな動き + Smooth Scroll スムーズスクロール + Smoothness 滑らかさ + Snap playhead in Timeline when Snap is enabled スナップが有効なときにタイムラインで再生ヘッドをスナップする + Soft ソフト + Soft Clamp ソフトクランプ + Soft Edges (Best Quality) ソフトエッジ (最高画質) + Soft Light ソフトライト + Soft Wipe ソフトワイプ + Soften Harshness きつさを和らげる + Softness 柔らかさ + Solarize ソラリゼーション + Solid Colors 単色 + Source Point ソースポイント + Source Text ソーステキスト + Spacer スペーサー + Spanish スペイン語 + Speaker Angle スピーカー角度 + Speaker Labeling 話者のラベル付け + Special 特殊 + Special Effects 特殊エフェクト + Speech model 音声認識モデル + Speed 速度 + Spherize 球面 + Spiky とげ状 + Spill スピル + Spill Suppression スピル抑制 + Spin スピン + Spin Direction 回転方向 + Spin Motion スピンモーション + Split 分割 + Split (Legacy) 分割 (従来版) + Spotlight スポットライト + Spread スプレッド + Spring Motion スプリングモーション + Square 正方形 + Square (Width Only) 正方形 (幅のみ) + Square 1:1 正方形 1:1 + Stabilization 手ぶれ補正 + Stabilize Only 手ぶれ補正のみ + Stabilize, Crop 手ぶれ補正、クロップ + Stabilize, Crop, Auto-scale 手ぶれ補正、クロップ、自動スケール + Stabilize, Synthesize Edges 手ぶれ補正、エッジを合成 + Stages ステージ数 + Star 星形 + Star Wipe 星形ワイプ + Start Angle 開始角度 + Start Color 開始色 + Start Point 始点 + Start at 0 0 から開始 + Start at 1 1 から開始 + Start of Ramp ランプの開始点 + Start with an Empty Project 空のプロジェクトで開始 + Starting Intensity 開始時の強さ + Static Plates 静止プレート + Step forward/back many 複数フレーム単位で前後にステップ + Stereo Expand ステレオ拡張 + Stereo Expander ステレオエキスパンダー + Stereo Imagery ステレオイメージ + Stereo Media ステレオメディア + Stereo Phasing ステレオ位相 + Stereoscopic - Over/Under ステレオスコピック - 上下 + Stereoscopic - Side by Side ステレオスコピック - 左右 + Still 静止画 + Still Image Default Duration 静止画の既定の長さ + Stone Church 石造りの教会 + Stopband Attenuation 阻止帯域減衰量 + Straight Color カラーのみ + Strength 強さ + Stretch 引き伸ばし + Stretch Gradient to Fit 全体に合わせて伸縮 + Stretch Map to Fit マップを伸縮して合わせる + Stretch Width or Height 幅または高さの伸縮 + Stretch Wipe 引き伸ばしワイプ + Stretch to Fit Frame フレームに合わせて伸縮 + Stripe ストライプ + Stripes ストライプ数 + Strobe Color ストロボの色 + Strobe Duration (secs) ストロボの長さ (秒) + Strobe Light ストロボライト + Strobe Period (secs) ストロボの周期 (秒) + Stroke 2 Color 線 2 の色 + Stroke 2 Type 線 2 の位置 + Stroke 2 Width 線 2 の幅 + Stroke Angle ストロークの角度 + Stroke Color 線の色 + Stroke Density ストロークの密度 + Stroke Length ストロークの長さ + Stroke Length (secs) ストロークの長さ (秒) + Stroke Only ストロークのみ + Stroke Randomness ストロークのランダムさ + Stroke Type 線の位置 + Stroke Width 線の幅 + Stroke line cap 線の端の形状 + Stroke line join 線の角の形状 + Studio Reverb スタジオリバーブ + Stylize スタイライズ + Subclip End サブクリップの終了 + Subclip Start サブクリップの開始 + Subspace Warp サブスペースワープ + Subtitle 字幕 + Subtract 減算 + Sum multichannel outputs to mono in Source Monitor ソースモニターでマルチチャンネル出力をモノラルにミックスダウンする + Super Narrow 極めて狭い + Surface Gloss 表面の光沢 + Surface Material 表面の材質 + Surround Reverb サラウンドリバーブ + Swap 左右入れ替え + Swap Channels チャンネルを入れ替え + Swivel スイベル + Synthetic 生成メディア + TV Power TV 電源 + Tape テープ + Tape Name テープ名 + Tape/Tube テープ/真空管 + Target Aspect 目標のアスペクト比 + Target Color 対象の色 + Target Field of View 目標の視野角 + Target Loudness 目標ラウドネス + Teal & Orange ティール&オレンジ + Telephone 電話風 + Teletext テレテキスト + Template Properties テンプレートのプロパティ + Text Animator テキストアニメーター + Text Color テキストの色 + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. メディアキャッシュには、未保存のプロジェクトのレンダリングプレビューなど、FilmCraft が再作成できるファイルが保存されています。これらはいつ削除しても問題ありません。FilmCraft が必要に応じて再構築します。 + Thickness 太さ + Tile Gradient タイル状に配置 + Tilt チルト + Tilt (X Axis) チルト (X 軸) + Time 時間 + Time and Pitch 時間とピッチ + Timecode Conversion タイムコード変換 + Timeline Mouse Scrolling タイムラインのマウススクロール + Timeline Playback Auto-Scrolling タイムライン再生時の自動スクロール + Tint Color 色合い + To 変更先 + Tolerance 許容値 + Tone トーン + Top Center 上中央 + Top Left 左上 + Top Right 右上 + Touch タッチ + Track Matte Key トラックマットキー + Transcription 文字起こし + Transcription preferences 文字起こしの設定 + Transform 変形 + Transformers 変形 + Transience トランジェント + Transition Completion トランジションの完了率 + Transition Softness トランジションの柔らかさ + Transparency 透明度 + Trash 汚し + Travel Motion トラベルモーション + Treble 高音 + Triangle 三角形 + Trim トリム + Tube 真空管 + Tube-modeled Compressor 真空管モデリングコンプレッサー + Tubular 管状 + Turbulent 乱流 + Turbulent Displace 乱流ディスプレイス + Turbulent Sharp 乱流 (シャープ) + Turbulent Smooth 乱流 (滑らか) + Turbulent Smoother 乱流 (より滑らか) + Tweed ツイード + Twirl 渦巻き + Twirl (Legacy) 渦巻き (従来版) + Twirl Center 渦巻きの中心 + Twirl Radius 渦巻きの半径 + Twist ねじり + Twist Smoother ねじり (より滑らか) + Typewriter タイプライター + Ultra Key ウルトラキー + Uniform Scale 縦横比を固定 + Uniform Spacing 等間隔 + Unsharp Mask アンシャープマスク + Update it at least every 更新間隔の上限 + Update timeline dynamically during ripple edits. リップル編集中にタイムラインを動的に更新する + Upper Frequency 上限周波数 + Upper Left 左上 + Upper Right 右上 + Use Color Noise カラーノイズを使用 + Use End Color 終了色を使用 + Use File ファイルに従う + Use Glow Color グローの色を使用 + Use Media Source メディアソースを使用 + Use Tint Color 色合いを使用 + Utility ユーティリティ + VHS Damage VHS ダメージ + VR Blur VR ぼかし + VR Chroma Leaks VR クロマリーク + VR Chromatic Aberrations VR 色収差 + VR Color Gradients VR カラーグラデーション + VR De-Noise VR ノイズ除去 + VR Digital Glitch VR デジタルグリッチ + VR Fractal Noise VR フラクタルノイズ + VR Glow VR グロー + VR Gradient Wipe VR グラデーションワイプ + VR Iris Wipe VR アイリスワイプ + VR Light Leaks VR ライトリーク + VR Light Rays VR 光線 + VR Mobius Zoom VR メビウスズーム + VR Plane to Sphere VR 平面から球面へ + VR Projection VR 投影 + VR Random Blocks VR ランダムブロック + VR Rotate Sphere VR 球面の回転 + VR Sharpen VR シャープ + VR Spherical Blur VR 球面ぼかし + Validate Content Credentials on import 読み込み時にコンテンツクレデンシャルを検証する + Vectorscope (YUV) ベクトルスコープ (YUV) + Vectorscope + Waveform YC ベクトルスコープ + YC 波形 + Vectorscope HLS ベクトルスコープ HLS + Vectorscope YUV ベクトルスコープ YUV + Venetian Blinds ベネチアンブラインド + Vertical 4:5 縦長 4:5 + Vertical 9:16 縦長 9:16 + Vertical Alignment 垂直方向の整列 + Vertical Blocks 垂直ブロック数 + Vertical Decentering 垂直方向の偏心 + Vertical Displacement 垂直ディスプレイスメント + Vertical Flip 上下反転 + Vertical Margin 垂直方向の余白 + Very Narrow とても狭い + Video 1 ビデオ 1 + Video 2 ビデオ 2 + Video 3 ビデオ 3 + Video 4 ビデオ 4 + Video 5 ビデオ 5 + Video 6 ビデオ 6 + Video 7 ビデオ 7 + Video 8 ビデオ 8 + Video Codec ビデオコーデック + Video Duration ビデオの長さ + Video Effects ビデオエフェクト + Video In Point ビデオのイン点 + Video Info ビデオ情報 + Video Limiter ビデオリミッター + Video Out Point ビデオのアウト点 + Video Transition Default Duration ビデオトランジションの既定の長さ + Video Transitions ビデオトランジション + Video Usage ビデオの使用状況 + Vignette ビネット + Vivid Light ビビッドライト + Vocal Booth ボーカルブース + Vocal Enhancer ボーカルエンハンサー + Voice 音声 + Voice in a Hall ホールに響く声 + Volumetric Rays ボリューメトリック光線 + Warm Film ウォームフィルム + Warm Room 温かみのある部屋 + Warm Voice 温かみのある声 + Warm print stock with gentle roll-off なだらかなロールオフを持つ、暖かみのあるプリントフィルム調 + Warm, soft late-afternoon light 夕方の暖かく柔らかな光 + Warning 警告 + Warp Stabilizer ワープスタビライザー + Wave ウェーブ + Wave Height 波の高さ + Wave Speed 波の速度 + Wave Warp 波状ワープ + Wave Width 波の幅 + Waveform 波形 + Waveform (Luma) 波形 (輝度) + Waveform (RGB) 波形 (RGB) + Wavelength 波長 + Wet ウェット + Wet Out ウェット出力 + When Opening a Project プロジェクトを開くとき + Whip ウィップ + Whip (Legacy) ウィップ (従来版) + Whisper base (balanced) Whisper base (バランス重視) + Whisper small (most accurate) Whisper small (最高精度) + Whisper tiny (fastest) Whisper tiny (最速) + White 白 + White Input Level 入力白レベル + White Is High 白を高く + Wide Outdoor 広い屋外 + Widener ワイドナー + Width 幅 + Wiggle ウィグル + Wipe ワイプ + Wipe Angle ワイプの角度 + Wonder Glow ワンダーグロー + Wrap Back 折り返し + Write 書き込み + Write XMP ID to files on import 読み込み時に XMP ID をファイルに書き込む + Write clip markers to XMP クリップマーカーを XMP に書き込む + Write-on 手書き + X Rotation X 軸回転 + Y Rotation Y 軸回転 + YC YC + YC no Chroma YC (クロマなし) + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves チャンネルカーブを交差させた、黄緑のハイライトと青く浮いたシャドウ + Z Position Z 位置 + Z Rotation Z 軸回転 + Zoom ズーム + Zoom Amount ズーム量 + Zoom Blur ズームぼかし + Zoom Strength ズームの強さ + days 日 + frames フレーム + milliseconds ミリ秒 + minute(s) 分 + second(s) 秒 + seconds 秒 + AAF… AAF… + Audio… オーディオ… + Avid Log Exchange… Avid Log Exchange… + Color… カラー… + EDL… EDL… + Edit Workspaces… ワークスペースを編集… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… 一般… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace このワークスペースへの変更を保存 + Save as New Workspace… 新規ワークスペースとして保存… + Create Search Bin 検索ビンを作成 + Edit Offline File オフラインファイルを編集 + Source Settings ソース設定 + Update Metadata メタデータを更新 + Automate To Sequence シーケンスに自動配置 + Scene Edit Detection シーン編集の検出 + Analyze 分析 + Normalize Mix Track ミックストラックをノーマライズ + Simplify Sequence シーケンスを単純化 + Simplify 単純化 + Transcribe Sequence シーケンスを文字起こし + Save as Template テンプレートとして保存 + Flash Cue Marker フラッシュキューマーカー + System Compatibility Report システム互換性レポート + no GPU adapter: the CPU compositor is used GPU アダプターがありません: CPU コンポジターを使用します + Avid Log Exchange Avid Log Exchange + FilmCraft Project FilmCraft プロジェクト + {name} Selection.fcproj {name} 選択項目.fcproj + {name} (Simplified) {name} (単純化済み) + Find in: 検索場所: + Find: 検索文字列: + Search: 検索対象: + Clip Names and Markers クリップ名とマーカー + Match: 一致条件: + All すべて + Any いずれか + Case Sensitive 大文字と小文字を区別 + Scratch Disks スクラッチディスク + Ingest Settings 取り込み設定 + Captured and Generated: キャプチャ・生成メディア: + Video Previews: ビデオプレビュー: + Audio Previews: オーディオプレビュー: + Project Auto Save: プロジェクトの自動保存: + Same as Project プロジェクトと同じ + Path: {path} パス: {path} + Transcode トランスコード + Copy and Create Proxies コピーしてプロキシを作成 + Action: 処理: + Destination: 出力先: + Next to the media メディアと同じ場所 + Video Rendering and Playback ビデオのレンダリングと再生 + Renderer: レンダラー: + Display Format: 表示形式: + Milliseconds ミリ秒 + Capture キャプチャ + Capture Format: キャプチャ形式: + Action and Title Safe Areas アクションセーフ・タイトルセーフエリア + Title Safe Area タイトルセーフエリア + Action Safe Area アクションセーフエリア + Total Duration 全体の長さ + Average Data Rate 平均データレート + Colour カラー + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} チャンネル + Media Name: メディア名: + Scene: シーン: + Shot/Take: ショット/テイク: + Log Note: ログメモ: + Codec: {codec} コーデック: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? 選択したクリップ ({n} 個) のメタデータを、メディアと同じ場所にある XMP ファイルに書き込みますか? + Existing XMP files written by other applications are left unchanged. 他のアプリケーションが書き込んだ既存の XMP ファイルは変更されません。 + To {name} 配置先: {name} + Ordering: 並び順: + Sort Order 並べ替え順 + Selection Order 選択順 + Placement: 配置: + Sequentially 順番に + At Unnumbered Markers 番号なしマーカーの位置に + Method: 方法: + Insert Edit 挿入編集 + Overwrite Edit 上書き編集 + Clip Overlap: クリップの重なり: + Frames per Still: 静止画あたりのフレーム数: + Transitions トランジション + Apply Default Audio Transition 既定のオーディオトランジションを適用 + Apply Default Video Transition 既定のビデオトランジションを適用 + Ignore Options 無視オプション + Ignore Audio オーディオを無視 + Ignore Video ビデオを無視 + Apply a cut at each detected cut point 検出したカット点ごとにカットを適用 + Create a subclip for each detected cut point 検出したカット点ごとにサブクリップを作成 + Generate clip markers at each detected cut point 検出したカット点ごとにクリップマーカーを生成 + Sensitivity: 感度: + Normalize Mix Track to: ミックストラックのノーマライズ値: + Disabled clips 無効なクリップ + Empty tracks 空のトラック + Gaps ギャップ + Video effects ビデオエフェクト + Audio effects オーディオエフェクト + Text (graphics and captions) テキスト (グラフィックスとキャプション) + Clips クリップ + Move clips to the lowest track possible クリップをできるだけ下のトラックへ移動 + Keep: 保持: + Video and Audio ビデオとオーディオ + Video Only ビデオのみ + Audio Only オーディオのみ + Language: 言語: + Audio on track {t} トラック {t} のオーディオ + Audio analysis: 分析するオーディオ: + Recognize when different speakers are talking 話者の切り替わりを認識 + Template Name: テンプレート名: + Comments: コメント: + (unavailable) (使用不可) + Decoders デコーダー + Containers コンテナー + Export formats 書き出し形式 + Match {n} of {total} {total} 件中 {n} 件目 + Saved template {path} テンプレートを保存しました: {path} + Scene Edit Detection: analysing in the background (see Progress) シーン編集の検出: バックグラウンドで分析中 (進行状況を参照) + Delete Search Bin 検索ビンを削除 + (removed from {list}) ({list} から削除) + {key} can't be used as a shortcut {key} はショートカットに使用できません + Keyboard Shortcuts キーボードショートカット + {active} (modified) {active} (変更あり) + Loaded “{name}” 「{name}」をロードしました + My Shortcuts マイショートカット + Deleted preset “{active}” プリセット「{active}」を削除しました + Export… 書き出し… + Exported to {path} {path} に書き出しました + Export: no file chosen (agents: shortcuts.export {path}) 書き出し: ファイルが選択されていません (エージェント: shortcuts.export {path}) + Imported “{name}” 「{name}」を読み込みました + Import: no file chosen (agents: shortcuts.import {path}) 読み込み: ファイルが選択されていません (エージェント: shortcuts.import {path}) + Keyboard: US (macOS) キーボード: US 配列 (macOS) + Keyboard: US キーボード: US 配列 + Save preset as: 保存するプリセット名: + Commands: コマンド: + {panel} Panel {panel}パネル + Application shortcuts work whatever panel has focus アプリケーションのショートカットは、どのパネルにフォーカスがあっても機能します + Panel shortcuts override application shortcuts while that panel has focus パネルのショートカットは、そのパネルにフォーカスがある間、アプリケーションのショートカットより優先されます + Modifier held / selected 押下中 / 選択中の修飾キー + Search commands or keys コマンドまたはキーを検索 + Selected: {command} 選択中: {command} + Command コマンド + Shortcut ショートカット + Type a shortcut… (Esc cancels) ショートカットを入力… (Esc でキャンセル) + Add another shortcut 別のショートカットを追加 + No matching commands 一致するコマンドがありません + Key: キー: + Modifiers 修飾キー + Nothing is assigned to this key このキーには何も割り当てられていません + Click a key on the keyboard to see its shortcuts キーボードのキーをクリックすると、割り当てられたショートカットを表示します + Assign {key} to {command} {key} を「{command}」に割り当て + Conflict: {key} is assigned to {commands} ({ctx}) 競合: {key} は「{commands}」に割り当てられています ({ctx}) + and と + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. コマンドの「ショートカット」セルをクリックしてキーを押すと割り当てられます。+ で別のショートカットを追加できます。 + Cleared {n} shortcut(s) of {command} 「{command}」のショートカットを {n} 個クリアしました + No export preset named `{name}` 「{name}」という名前の書き出しプリセットはありません + Open a sequence to export 書き出すシーケンスを開いてください + Destinations 出力先 + Media File メディアファイル + Queue ({n}) キュー ({n}) + Send exports here with Send to Queue, then start the queue. 「キューに送信」で書き出しをここに追加してから、キューを開始してください。 + Ready 準備完了 + Encoding {n}% エンコード中 {n}% + Start Queue キューを開始 + Stop 停止 + Clear Finished 終了した項目をクリア + Not supported by FilmCraft's encoders yet FilmCraft のエンコーダーはまだ対応していません + Sequence name シーケンス名 + Folder フォルダー + Preset プリセット + More presets… その他のプリセット… + Multiplexer マルチプレクサー + The sequence has no caption track. このシーケンスにはキャプショントラックがありません。 + Export Options 書き出しオプション + Burn Captions Into Video キャプションをビデオに焼き込み + Create Sidecar File サイドカーファイルを作成 + File Format ファイル形式 + SubRip Subtitle (.srt) SubRip 字幕 (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title タイトル + Creator 作成者 + Copyright 著作権 + Image 画像 + Frame Size フレームサイズ + Match Source ソースに合わせる + Field Order フィールドオーダー + Aspect アスペクト比 + Profile プロファイル + Bitrate Encoding ビットレート方式 + Target Bitrate ターゲットビットレート + Adaptive ({bpp} bits per pixel) アダプティブ (1 ピクセルあたり {bpp} ビット) + Maximum Bitrate 最大ビットレート + Key Frame Distance キーフレーム間隔 + Quality 画質 + Render at Maximum Depth 最大深度でレンダリングする + Use Maximum Render Quality 最高レンダリング画質を使用する + Export Audio オーディオを書き出す + Audio Format オーディオ形式 + Uncompressed (PCM) 非圧縮 (PCM) + Match Source ({seq_sr} Hz) ソースに合わせる ({seq_sr} Hz) + Channels チャンネル + Audio Bitrate オーディオビットレート + Sample Size サンプルサイズ + 16 bit 16 ビット + 24 bit 24 ビット + Size / Opacity サイズ / 不透明度 + Image Overlay 画像のオーバーレイ + PNG or JPEG file PNG または JPEG ファイル + Name Overlay 名前のオーバーレイ + Timecode Overlay タイムコードのオーバーレイ + Luma Min / Max 輝度の最小 / 最大 + Loudness Normalization ラウドネスのノーマライズ + True Peak Limit トゥルーピークの上限 + Audio only オーディオのみ + Scaling 拡大・縮小 + Scale To Fit 収まるように拡大・縮小 + Scale To Fill 埋まるように拡大・縮小 + Stretch To Fill 引き伸ばして埋める + Summary 概要 + Sequence, {w}x{h}, {fps} fps, {rate} Hz シーケンス、{w}x{h}、{fps} fps、{rate} Hz + Estimated file size: {size} 推定ファイルサイズ: {size} + Send to Queue キューに送信 + Exporting {path} {path} を書き出し中 + Added to the export queue 書き出しキューに追加しました + Preset Manager プリセットマネージャー + Search presets プリセットを検索 + Favorites only お気に入りのみ + Show in the Preset menu プリセットメニューに表示 + User ユーザー + Save current settings as: 現在の設定を次の名前で保存: + Export presets 書き出しプリセット + Import: no file chosen (agents: export.presets.import {path}) 読み込み: ファイルが選択されていません (エージェント: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) 書き出し: ファイルが選択されていません (エージェント: export.presets.export {path, names}) + Quick Export: open a sequence first クイック書き出し: 先にシーケンスを開いてください + File Name & Location ファイル名と場所 + Quick Export: {path} クイック書き出し: {path} + Graphics problem: switched to software compositing グラフィックスに問題が発生したため、ソフトウェア合成に切り替えました + Exporting 書き出し中 + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Opt、Cmd キーを併用すると、ほかのオプションも使えます。 + Click to select all clips to the right in all tracks. Shift-click for a single track. クリックすると、全トラックでクリック位置より右にあるクリップをすべて選択します。Shift+クリックでは 1 つのトラックだけが対象になります。 + Click to select all clips to the left in all tracks. Shift-click for a single track. クリックすると、全トラックでクリック位置より左にあるクリップをすべて選択します。Shift+クリックでは 1 つのトラックだけが対象になります。 + Drag an edit point to ripple trim; later clips move to keep the gap closed. 編集点をドラッグしてリップルトリムします。後続のクリップも移動するため、ギャップは生じません。 + Drag an edit point to roll it: the out of one clip and the in of the next move together. 編集点をドラッグしてロールトリムします。前のクリップのアウト点と次のクリップのイン点が一緒に動きます。 + Drag an edge to change the clip's speed so it fills the new duration. 端をドラッグすると、新しい長さに収まるようにクリップの速度を変更します。 + Drag the edge of a music clip to remix it to the new duration at musically matching beats. 音楽クリップの端をドラッグすると、曲のビートが合う位置で新しい長さにリミックスします。 + Click to split a clip. Shift-click to split all tracks. クリックでクリップを分割します。Shift+クリックではすべてのトラックを分割します。 + Drag a clip to slip its source in/out without moving it. クリップをドラッグすると、位置はそのままでソースのイン点とアウト点をずらします。 + Drag a clip to slide it between its neighbours. クリップをドラッグすると、前後のクリップの間でスライドします。 + Drag to scroll the timeline. ドラッグしてタイムラインをスクロールします。 + Click to zoom in; Opt-click to zoom out. クリックでズームイン、Opt+クリックでズームアウトします。 + FilmCraft hit an error FilmCraft でエラーが発生しました + Details: {path} 詳細: {path} + Opened {url} {url} を開きました + (empty) (空) + Step Back 1 Frame 1 フレーム戻る + Step Forward 1 Frame 1 フレーム進む + Match Playhead 再生ヘッドに合わせる + Gang to Program プログラムと連動 + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit + AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) アナモルフィック 2:1 (2.0) + Animated GIF アニメーション GIF + Animated GIF 640×360 アニメーション GIF 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Application アプリケーション + Apply Guide Template ガイドテンプレートを適用 + Auto Save Now 今すぐ自動保存 + Auto-Match Loudness ラウドネスを自動マッチ + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) Avid 方式の OP-Atom: DNxHR HQ のビデオファイルと、チャンネルごとに 1 つの 24-bit 48 kHz PCM ファイル (_A1、_A2…) + BMP BMP + BMP Sequence BMP 連番画像 + Baseline ベースライン + Begin Text Editing for a Graphic Layer グラフィックレイヤーのテキスト編集を開始 + Begins With 前方一致 + Blur In ぼかしイン + Blur Out ぼかしアウト + Brightens inside a feathered circular mask 境界をぼかした円形マスクの内側を明るくします + Broadcast 放送 + CBR CBR + Cancel Dynamic Trim ダイナミックトリムをキャンセル + Cancel Queued Export キュー内の書き出しをキャンセル + Center Left 左中央 + Center Right 右中央 + Center align text テキストを中央揃え + Change Mask マスクを変更 + Clear All Events すべてのイベントをクリア + Clear Edit Point Selection 編集点の選択を解除 + Clear Finished Exports 終了した書き出しをクリア + Clear Poster Frame ポスターフレームをクリア + Close Bin ビンを閉じる + Close Sequence シーケンスを閉じる + Contains 含む + Cut to Camera 1 カメラ 1 にカット + Cut to Camera 2 カメラ 2 にカット + Cut to Camera 3 カメラ 3 にカット + Cut to Camera 4 カメラ 4 にカット + Cut to Camera 5 カメラ 5 にカット + Cut to Camera 6 カメラ 6 にカット + Cut to Camera 7 カメラ 7 にカット + Cut to Camera 8 カメラ 8 にカット + Cut to Camera 9 カメラ 9 にカット + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask 境界をぼかした楕円マスクの外側を暗くします + Decrease Audio Tracks Height オーディオトラックの高さを縮小 + Decrease Clip Volume クリップの音量を下げる + Decrease Clip Volume Many クリップの音量を大幅に下げる + Decrease Font Size by Five Units フォントサイズを 5 単位下げる + Decrease Font Size by One Unit フォントサイズを 1 単位下げる + Decrease Leading by Five Units 行送りを 5 単位減らす + Decrease Leading by One Unit 行送りを 1 単位減らす + Decrease Video Tracks Height ビデオトラックの高さを縮小 + Delete Captions キャプションを削除 + Delete Graphic Layer グラフィックレイヤーを削除 + Delete Guide Template ガイドテンプレートを削除 + Delete Media Cache Files メディアキャッシュファイルを削除 + Discard Unsaved Changes 未保存の変更を破棄 + Does Not Contain 含まない + Download Speech Model 音声認識モデルをダウンロード + Dynamic Trim Stop ダイナミックトリムを停止 + Edit Caption Text キャプションのテキストを編集 + Ellipse Tool 楕円ツール + Ends With 後方一致 + Entire Source ソース全体 + EssentialSound エッセンシャルサウンド + Export Frame フレームを書き出し + Extend Next Edit To Playhead 次の編集点を再生ヘッドまで延長 + Extend Previous Edit To Playhead 前の編集点を再生ヘッドまで延長 + Extend Selected Edit to Playhead 選択した編集点を再生ヘッドまで延長 + Extend Selection Down 選択範囲を下に拡張 + Extend Selection Left 選択範囲を左に拡張 + Extend Selection Right 選択範囲を右に拡張 + Extend Selection Up 選択範囲を上に拡張 + Fade In フェードイン + Gaussian Blur from 0 to 60 over the last second 最後の 1 秒でガウスぼかしを 0 から 60 に変化 + Gaussian Blur from 60 to 0 over the first second 最初の 1 秒でガウスぼかしを 60 から 0 に変化 + Generate Ducking Keyframes ダッキングキーフレームを生成 + Go to Next Edit Point 次の編集点へ移動 + Go to Next Edit Point on Any Track いずれかのトラックの次の編集点へ移動 + Go to Previous Edit Point 前の編集点へ移動 + Go to Previous Edit Point on Any Track いずれかのトラックの前の編集点へ移動 + Go to Selected Clip End 選択したクリップの終了位置へ移動 + Go to Selected Clip Start 選択したクリップの開始位置へ移動 + Go to Sequence End シーケンスの終了位置へ移動 + Go to Sequence Start シーケンスの開始位置へ移動 + H.264 H.264 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps 動画共有サイト向けの H.264 1920×1080、VBR 1 パス、16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080、VBR 1 パス、ターゲット 20 Mbps、AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080、VBR 2 パス、18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps 動画共有サイト向けの H.264 3840×2160、VBR 1 パス、45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160、VBR 1 パス、ターゲット 60 Mbps、AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160、VBR 2 パス、50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps SNS ショート動画向けの H.264 9:16 縦型、フレームを埋めるようにスケール、12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel シーケンスと同じフレームサイズ・フレームレートの H.264、1 ピクセルあたり 0.05 ビット + H.264 at the sequence's frame size and rate; 0.1 bits per pixel シーケンスと同じフレームサイズ・フレームレートの H.264、1 ピクセルあたり 0.1 ビット + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) シーケンスと同じフレームサイズ・フレームレートの H.264、ビットレートはフレームサイズに応じて変化 (1 ピクセルあたり 0.2 ビット) + HD Anamorphic 1080 (1.333) HD アナモルフィック 1080 (1.333) + Hand Tool 手のひらツール + High Quality 1080p HD 高画質 1080p HD + High Quality 2160p 4K 高画質 2160p 4K + Image Sequence 連番画像 + Increase Audio Tracks Height オーディオトラックの高さを拡大 + Increase Clip Volume クリップの音量を上げる + Increase Clip Volume Many クリップの音量を大幅に上げる + Increase Font Size by Five Units フォントサイズを 5 単位上げる + Increase Font Size by One Unit フォントサイズを 1 単位上げる + Increase Leading by Five Units 行送りを 5 単位増やす + Increase Leading by One Unit 行送りを 1 単位増やす + Increase Video Tracks Height ビデオトラックの高さを拡大 + Large blocks resolving to the picture over the first second 最初の 1 秒で大きなブロックから元の画像に変化 + Left align text テキストを左揃え + Looping GIF, 640×360 at 15 fps ループする GIF、640×360、15 fps + Lower First 下位フィールド優先 + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a、DNxHR HQ (8-bit 4:2:2)、24-bit 48 kHz PCM、開始タイムコード付き + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a、H.264 High ロング GOP (1 ピクセルあたり 0.3 ビット)、24-bit 48 kHz PCM + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a、ProRes 422 HQ、24-bit 48 kHz PCM、開始タイムコード付き + Main メイン + Mark In (Hover Scrub) イン点をマーク (ホバースクラブ) + Mark Out (Hover Scrub) アウト点をマーク (ホバースクラブ) + Masks マスク + Match Source – Adaptive High Bitrate ソースに合わせる – アダプティブ高ビットレート + Match Source – Adaptive Low Bitrate ソースに合わせる – アダプティブ低ビットレート + Match Source – Adaptive Medium Bitrate ソースに合わせる – アダプティブ中ビットレート + Matches 一致 + Maximize or Restore Active Frame アクティブなパネルを最大化 / 元に戻す + Maximize or Restore Frame Under Cursor カーソル下のパネルを最大化 / 元に戻す + Media Browser Settings メディアブラウザーの設定 + MediaBrowser メディアブラウザー + MediaCache メディアキャッシュ + Merge Captions キャプションを結合 + Mixer ミキサー + Mosaic In モザイクイン + Move All Audio Targets Down すべてのオーディオターゲットを下へ移動 + Move All Audio Targets Up すべてのオーディオターゲットを上へ移動 + Move All Video Targets Down すべてのビデオターゲットを下へ移動 + Move All Video Targets Up すべてのビデオターゲットを上へ移動 + Move Selection Down 選択を下に移動 + Move Selection End 選択を末尾に移動 + Move Selection Home 選択を先頭に移動 + Move Selection Left 選択を左に移動 + Move Selection Page Down 選択を 1 ページ下に移動 + Move Selection Page Up 選択を 1 ページ上に移動 + Move Selection Right 選択を右に移動 + Move Selection Up 選択を上に移動 + Multi-Camera Record On/Off Toggle マルチカメラ記録のオン/オフを切り替え + Multicam マルチカメラ + Navigate to End of Segment セグメントの末尾へ移動 + Navigate to Next Line 次の行へ移動 + Navigate to Next Word 次の単語へ移動 + Navigate to Previous Line 前の行へ移動 + Navigate to Previous Word 前の単語へ移動 + Navigate to Start of Segment セグメントの先頭へ移動 + Navigation ナビゲーション + Next Multi-Camera Page 次のマルチカメラページ + Nudge Clip Selection Down 選択したクリップを下へ微調整 + Nudge Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へ微調整 + Nudge Clip Selection Left One Frame 選択したクリップを 1 フレーム左へ微調整 + Nudge Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へ微調整 + Nudge Clip Selection Right One Frame 選択したクリップを 1 フレーム右へ微調整 + Nudge Clip Selection Up 選択したクリップを上へ微調整 + Nudge Selected Object down by five 選択したオブジェクトを 5 単位下へ微調整 + Nudge Selected Object down by one 選択したオブジェクトを 1 単位下へ微調整 + Nudge Selected Object to left by five 選択したオブジェクトを 5 単位左へ微調整 + Nudge Selected Object to left by one 選択したオブジェクトを 1 単位左へ微調整 + Nudge Selected Object to right by five 選択したオブジェクトを 5 単位右へ微調整 + Nudge Selected Object to right by one 選択したオブジェクトを 1 単位右へ微調整 + Nudge Selected Object up by five 選択したオブジェクトを 5 単位上へ微調整 + Nudge Selected Object up by one 選択したオブジェクトを 1 単位上へ微調整 + Nudge Volume +1dB 音量を +1dB 微調整 + Nudge Volume +3dB 音量を +3dB 微調整 + Nudge Volume -1dB 音量を -1dB 微調整 + Nudge Volume -3dB 音量を -3dB 微調整 + Numbered BMP stills (24-bit) 連番 BMP 静止画 (24-bit) + Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 + Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) + Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 + Opacity 100 → 0 % over the last second 最後の 1 秒で不透明度を 100 → 0 % に変化 + Open Bin ビンを開く + Open Search 検索を開く + PNG PNG + PNG Sequence PNG 連番画像 + Panel パネル + Pen Tool ペンツール + PiP 35% Upper Right PiP 35% 右上 + Picture-in-picture: 35 % scale in the upper right ピクチャーインピクチャー: スケール 35 % で右上に配置 + Play Around 前後を再生 + Play In to Out with Preroll/Postroll プリロール/ポストロール付きでインからアウトまで再生 + Play from Playhead to Out Point 再生ヘッドからアウト点まで再生 + Play/Stop 再生/停止 + Previous Multi-Camera Page 前のマルチカメラページ + Progressive プログレッシブ + ProjectPanel プロジェクトパネル + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、APV 422-10 (10-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、APV 422-12 (12-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR HQ (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR LB (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime、DNxHR SQ (8-bit 4:2:2)、24-bit 48 kHz PCM + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime、ProRes 422 HQ、24-bit 48 kHz PCM + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime、ProRes 422 LT、24-bit 48 kHz PCM + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime、ProRes 422 Proxy、24-bit 48 kHz PCM + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime、ProRes 422、24-bit 48 kHz PCM + Quit FilmCraft FilmCraft を終了 + Rate Stretch Tool レートストレッチツール + Razor Tool カミソリツール + Rectangle Tool 長方形ツール + Remix Tool リミックスツール + Right align text テキストを右揃え + Ripple Edit Tool リップル編集ツール + Ripple Trim Next Edit to Playhead 次の編集点を再生ヘッドまでリップルトリム + Ripple Trim Previous Edit to Playhead 前の編集点を再生ヘッドまでリップルトリム + Rolling Edit Tool ロール編集ツール + Select Camera 1 カメラ 1 を選択 + Select Camera 2 カメラ 2 を選択 + Select Camera 3 カメラ 3 を選択 + Select Camera 4 カメラ 4 を選択 + Select Camera 5 カメラ 5 を選択 + Select Camera 6 カメラ 6 を選択 + Select Camera 7 カメラ 7 を選択 + Select Camera 8 カメラ 8 を選択 + Select Camera 9 カメラ 9 を選択 + Select Clip at Playhead 再生ヘッドの位置のクリップを選択 + Select Find Box 検索ボックスを選択 + Select Next Clip 次のクリップを選択 + Select Next Panel 次のパネルを選択 + Select Previous Clip 前のクリップを選択 + Select Previous Panel 前のパネルを選択 + Select to Next Line 次の行まで選択 + Select to Next Word 次の単語まで選択 + Select to Previous Line 前の行まで選択 + Select to Previous Word 前の単語まで選択 + Select to Segment End セグメントの末尾まで選択 + Select to Segment Start セグメントの先頭まで選択 + Selection Tool 選択ツール + Set Comparison Reference 比較の基準を設定 + Set Creative Look クリエイティブのルックを設定 + Set Input LUT 入力 LUT を設定 + Set Project Panel View プロジェクトパネルの表示を設定 + Show Next Screen 次の画面を表示 + Show Previous Screen 前の画面を表示 + Show Program Transcript プログラムの文字起こしを表示 + Shuttle Left 左へシャトル + Shuttle Right 右へシャトル + Shuttle Slow Left 低速で左へシャトル + Shuttle Slow Right 低速で右へシャトル + Shuttle Stop シャトルを停止 + Slide Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へスライド + Slide Clip Selection Left One Frame 選択したクリップを 1 フレーム左へスライド + Slide Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へスライド + Slide Clip Selection Right One Frame 選択したクリップを 1 フレーム右へスライド + Slide Tool スライドツール + Slip Clip Selection Left Five Frames 選択したクリップを 5 フレーム左へスリップ + Slip Clip Selection Left One Frame 選択したクリップを 1 フレーム左へスリップ + Slip Clip Selection Right Five Frames 選択したクリップを 5 フレーム右へスリップ + Slip Clip Selection Right One Frame 選択したクリップを 1 フレーム右へスリップ + Slip Tool スリップツール + Social Vertical 1080×1920 SNS 縦型 1080×1920 + Soft Vignette ソフトビネット + Source In/Out ソースのイン/アウト + Split Caption キャプションを分割 + Square Pixels (1.0) 正方形ピクセル (1.0) + Stack Clips クリップをスタック + Start Export Queue 書き出しキューを開始 + Start Voice-over Recording ナレーションの録音を開始 + Step Back Five Frames 5 フレーム戻る + Step Back One Frame 1 フレーム戻る + Step Forward Five Frames 5 フレーム進む + Step Forward One Frame 1 フレーム進む + Stop Export Queue 書き出しキューを停止 + Stop Multi-Camera Recording マルチカメラの記録を停止 + Stop Voice-over Recording ナレーションの録音を停止 + Sync Voice-over Capture ナレーションのキャプチャを同期 + TIFF TIFF + TIFF Sequence TIFF 連番画像 + TextPanel テキストパネル + Thumbnail Size Next 次のサムネイルサイズ + Thumbnail Size Previous 前のサムネイルサイズ + Toggle All Audio Targets すべてのオーディオターゲットを切り替え + Toggle All Source Audio すべてのソースオーディオを切り替え + Toggle All Source Video すべてのソースビデオを切り替え + Toggle All Video Targets すべてのビデオターゲットを切り替え + Toggle Audio During Scrubbing スクラブ中のオーディオを切り替え + Toggle Full Screen 全画面表示を切り替え + Toggle Mute for All Targeted Audio Tracks ターゲット設定されたすべてのオーディオトラックのミュートを切り替え + Toggle Solo for All Targeted Audio Tracks ターゲット設定されたすべてのオーディオトラックのソロを切り替え + Toggle Source/Program Monitor Focus ソース/プログラムモニターのフォーカスを切り替え + Toggle Target Audio 1 オーディオ 1 のターゲットを切り替え + Toggle Target Audio 2 オーディオ 2 のターゲットを切り替え + Toggle Target Audio 3 オーディオ 3 のターゲットを切り替え + Toggle Target Audio 4 オーディオ 4 のターゲットを切り替え + Toggle Target Audio 5 オーディオ 5 のターゲットを切り替え + Toggle Target Audio 6 オーディオ 6 のターゲットを切り替え + Toggle Target Audio 7 オーディオ 7 のターゲットを切り替え + Toggle Target Audio 8 オーディオ 8 のターゲットを切り替え + Toggle Target Video 1 ビデオ 1 のターゲットを切り替え + Toggle Target Video 2 ビデオ 2 のターゲットを切り替え + Toggle Target Video 3 ビデオ 3 のターゲットを切り替え + Toggle Target Video 4 ビデオ 4 のターゲットを切り替え + Toggle Target Video 5 ビデオ 5 のターゲットを切り替え + Toggle Target Video 6 ビデオ 6 のターゲットを切り替え + Toggle Target Video 7 ビデオ 7 のターゲットを切り替え + Toggle Target Video 8 ビデオ 8 のターゲットを切り替え + Toggle Track Output for All Targeted Video Tracks ターゲット設定されたすべてのビデオトラックの出力を切り替え + Toggle Trim Type トリムの種類を切り替え + Toggle View 表示を切り替え + Track Select Backward Tool 後方トラック選択ツール + Track Select Forward Tool 前方トラック選択ツール + Track Selected Mask 選択したマスクをトラッキング + Trim Next Edit to Playhead 次の編集点を再生ヘッドまでトリム + Trim Previous Edit to Playhead 前の編集点を再生ヘッドまでトリム + Trimming トリム + Type Tool 文字ツール + Unstack Clips クリップのスタックを解除 + Upper First 上位フィールド優先 + VBR, 1 pass VBR、1 パス + VBR, 2 pass VBR、2 パス + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record ナレーションを録音 + Voiceover ナレーション + WAV, stereo 48 kHz 16-bit PCM WAV、ステレオ 48 kHz 16-bit PCM + WAV, stereo 48 kHz 24-bit PCM WAV、ステレオ 48 kHz 24-bit PCM + Warm Tint 暖かい色合い + Warm highlights, brown shadows 暖色のハイライト、茶色のシャドウ + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16-bit + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24-bit + Web & Social Web・SNS + Work Area ワークエリア + Write Automation オートメーションを書き込み + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% プログラムモニターを 100% にズーム + Zoom Program Monitor to Fit プログラムモニターを画面に合わせてズーム + Zoom Source Monitor to 100% ソースモニターを 100% にズーム + Zoom Source Monitor to Fit ソースモニターを画面に合わせてズーム + Zoom Tool ズームツール + with: 置換後: + Interface Language 表示言語 + System Language システムの言語 + Cinematic シネマティック + Film Emulation フィルム再現 + Technical テクニカル + Teal Shadows, Warm Skin ティールのシャドウと暖かな肌色 + Night Exterior 夜の屋外 + Golden Dusk 黄金色の夕暮れ + Steel Blue スチールブルー + Hard Sun 強い日差し + Faded Print 色あせたプリント + Silver Retention 銀残し + Cross Processed クロスプロセス + Warm Negative 暖色ネガ + Neutral Mono ニュートラルモノクロ + Hard Mono 硬調モノクロ + Sepia Tone セピア調 + Cold Mono 寒色モノクロ + Lift Shadows シャドウを持ち上げ + Protect Highlights ハイライトを保護 + Half Saturation 彩度半減 + Boost Vibrance 自然な彩度を強化 + Legal Range Squeeze リーガルレンジに圧縮 + A boxed label with a round pointer; move the label and the pointer follows. 丸いポインター付きのボックス型ラベルです。ラベルを動かすと、ポインターも追従します。 + A rounded tag that fits its text, with a status dot. テキストに合わせて大きさが変わる、ステータスドット付きの角丸タグです。 + A title on a rounded card with a soft shadow. ソフトなシャドウ付きの角丸カードに載せたタイトルです。 + Accent Color アクセントカラー + Bar Color バーの色 + Box Color ボックスの色 + Box Opacity ボックスの不透明度 + Callout – Pointer コールアウト – ポインター + Callout – Tag コールアウト – タグ + Callouts コールアウト + Credits クレジット + Dot Color ドットの色 + Heading 見出し + Large centred title, accent rule and subtitle. 中央に大きなタイトルを置き、アクセントの罫線とサブタイトルを添えたデザインです。 + Lower Third – Rule ロワーサード – 罫線 + Lower Third – Slab ロワーサード – 帯 + Lower Thirds ロワーサード + Name Color 名前の色 + Name Size 名前のサイズ + Name and role on a coloured slab that widens with the name. 名前の長さに合わせて幅が広がる色付きの帯に、名前と肩書きを表示します。 + Name over a thin rule that always matches the name's width. 名前の下に、常に名前と同じ幅の細い罫線を引きます。 + Pointer Color ポインターの色 + Role 肩書き + Rule Color 罫線の色 + Show Dot ドットを表示 + Show Role 肩書きを表示 + Slab Color 帯の色 + Tag Color タグの色 + Title Color タイトルの色 + Title Size タイトルのサイズ + Title – Boxed タイトル – ボックス + Title – Centered タイトル – 中央揃え + Titles タイトル + Select an item to preview it プレビューする項目を選択してください + End Credits エンドクレジット + End Credits – Roll エンドクレジット – ロール + Ticker – Crawl ティッカー – クロール + Centred credits that roll up from below the frame and leave at the top. 中央揃えのクレジットが画面の下から現れて上へ流れ、上端から抜けていきます。 + A news-style line that crawls in from the right and leaves at the left. ニュース風のテキスト行が右から流れ込み、左へ抜けていきます。 + out アウト + in イン + The language changed but could not be saved: {e} 表示言語は変更されましたが、保存できませんでした: {e} + no Japanese font is installed on this system; the interface stays in English このシステムには日本語フォントがインストールされていないため、表示は英語のままです + Show 表示 + Sort 並べ替え + Reverse the order 順序を反転 + Grab bag 一覧 + Table 表 + No contributor data was built into this copy. このビルドにはコントリビューターのデータが含まれていません。 + No model credits were built into this copy. このビルドにはモデルのクレジットが含まれていません。 + Company 会社 + Model モデル + Version バージョン + Commits コミット + % of all commits 全コミットに占める割合 (%) + Lines +/− 行数 +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Clip Speed / Duration クリップの速度 / 長さ + Speed: 速度: + Reverse Speed 逆再生する + Ripple Edit, Shifting Trailing Clips 後続のクリップをずらしてリップル編集する + About バージョン情報 + Contributors コントリビューター + Models モデル + Main (8-bit) メイン (8 ビット) + Main 10 (10-bit, HDR) メイン 10 (10 ビット、HDR) + Main (8-bit; Main 10 for HDR sequences) メイン (8 ビット、HDR シーケンスではメイン 10) + Encoder エンコーダー + Hardware (H.265 has no software encoder) ハードウェア (H.265 にはソフトウェアエンコーダーがありません) + Text Properties テキストのプロパティ + Text Box Height テキストボックスの高さ + Paragraph Text 段落テキスト + Point Text ポイントテキスト + Text Layer Type テキストレイヤーの種類 + Text Styling テキストのスタイル + New Color Matte 新規カラーマット + Color Matte Color カラーマットの色 + Color: 色: + · {time} left · 残り {time} + Discard take テイクを破棄 + Voice-over take discarded ナレーションのテイクを破棄しました + Binary assets added 追加したバイナリアセット + Binary assets removed 削除したバイナリアセット + Deselect All Project Items すべてのプロジェクト項目の選択を解除 + Display name 表示名 + First commit 最初のコミット + H.265 (HEVC) H.265 (HEVC) + Last commit 最後のコミット + Line delta 行数の増減 + Lines added 追加した行数 + Lines deleted 削除した行数 + Merged PRs マージされた PR + Name (A–Z) 名前 (A–Z) + Real name 本名 + Select All Project Items すべてのプロジェクト項目を選択 + Username ユーザー名 + Vertical Type Tool 縦書き文字ツール + Go to previous keyframe 前のキーフレームへ移動 + Go to next keyframe 次のキーフレームへ移動 + Add keyframe キーフレームを追加 + Remove keyframe キーフレームを削除 + Turn on animation and add keyframe アニメーションをオンにしてキーフレームを追加 + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。 + Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。 + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除 + Include Alpha Channel アルファチャンネルを含める + Sequence Settings シーケンス設定 + Color Management カラーマネジメント + VR Properties VR プロパティ + Editing Mode: 編集モード: + Timebase: タイムベース: + Frame Size: フレームサイズ: + Pixel Aspect Ratio: ピクセルアスペクト比: + Fields: フィールド: + Channel Format: チャンネル形式: + Number of Channels: チャンネル数: + Preview File Format: プレビューファイル形式: + Working Color Space: 作業用カラースペース: + Projection: 投影法: + Layout: レイアウト: + Captured View: 撮影視野: + Video Previews ビデオプレビュー + No Fields (Progressive Scan) フィールドなし (プログレッシブスキャン) + Custom ({ratio}) カスタム ({ratio}) + {rate} frames/second {rate} フレーム/秒 + {rate} fps Drop-Frame Timecode {rate} fps ドロップフレームタイムコード + {rate} fps Non Drop-Frame Timecode {rate} fps ノンドロップフレームタイムコード + {rate} fps Timecode {rate} fps タイムコード + horizontal 水平 + vertical 垂直 + Scale motion effects proportionally when changing frame size フレームサイズの変更時にモーションエフェクトを比例してスケールする + Maximum Bit Depth 最大ビット深度 + Maximum Render Quality 最高レンダリング画質 + Composite in Linear Color リニアカラーで合成 + Auto Tone Map Media (HDR and log into SDR) メディアの自動トーンマッピング (HDR と log を SDR に変換) + Configure… 設定… + Not supported in FilmCraft yet FilmCraft ではまだサポートされていません + FilmCraft always composites in linear light FilmCraft は常にリニアライトで合成します + Loop marked range マークした範囲をループ + Play Source ソースを再生 + Source Play/Stop ソースの再生/停止 + Stop Source ソースを停止 + New Sequence 新規シーケンス + Tracks トラック + tracks トラック + Export Markers as CSV… マーカーを CSV として書き出し… + Fill Type 塗りの種類 + Gradient Angle グラデーションの角度 + Linear Gradient 線形グラデーション + Solid べた塗り + Text to Speech テキスト読み上げ + Edit Narration ナレーションを編集 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs new file mode 100644 index 000000000..3f4b845e3 --- /dev/null +++ b/crates/ui-egui/src/i18n/mod.rs @@ -0,0 +1,808 @@ +//! Interface translations. Strings in code stay English and are the lookup keys; one catalog per +//! language (`i18n/.tsv`, format in the header of `es.tsv`) maps them to display text at draw +//! time. Untranslated strings are shown in English, so coverage can grow incrementally. Command ids, +//! automation ids, preference values, the control channel, the CLI and MCP stay English (an +//! automation element's label is the text shown, so it follows the interface language). +//! +//! # Looking strings up +//! - `tl!` with a string literal: that UI string in the language the UI is drawn in (see +//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; the tests check +//! that each `tl!` literal has a Spanish translation. +//! - [`t`]: the same for a string that is not a literal (a name from a registry or a list). +//! - [`tr_ctx`]: when one English word needs different translations (the Edit menu, the Edit button). +//! - [`fmt`]: fill `{name}` placeholders after translating a template; translators may reorder them. +//! +//! Japanese text uses the Japanese craft-fonts when FilmCraft was built with them (`CRAFT_FONTS_DIR`; +//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise +//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to +//! Japanese is refused with a message. + +mod catalog; + +use std::cell::Cell; +use std::sync::{Arc, OnceLock}; + +use catalog::Catalog; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, + Es, + /// Persisted as `pt-br` (the blanket `rename_all` would produce `ptbr`). + #[serde(rename = "pt-br")] + PtBr, + Uk, +} + +static JAPANESE: OnceLock = OnceLock::new(); +static SPANISH: OnceLock = OnceLock::new(); +static PORTUGUESE: OnceLock = OnceLock::new(); +static UKRAINIAN: OnceLock = OnceLock::new(); + +impl Language { + pub const ALL: [Self; 5] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + Self::Es => "Español", + Self::PtBr => "Português (Brasil)", + Self::Uk => "Українська", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + "es" => Some(Self::Es), + "pt-br" => Some(Self::PtBr), + "uk" => Some(Self::Uk), + _ => None, + } + } + + /// Interface Language ▸ System Language (#218): the first of the user's preferred languages + /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the + /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. + pub fn from_locales(tags: &[String]) -> Self { + const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)]; + tags.iter() + .find_map(|tag| { + let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); + PRIMARY.iter().find(|(code, _)| primary.eq_ignore_ascii_case(code)).map(|(_, l)| *l) + }) + .unwrap_or_default() + } + + /// Stable code: the `general.interfaceLanguage` preference value (`Language::parse` reads it back). + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + Self::Es => "es", + Self::PtBr => "pt-br", + Self::Uk => "uk", + } + } + + /// The language's catalog (none for English, the source language). + fn catalog(self) -> Option<&'static Catalog> { + match self { + Self::En => None, + Self::Ja => Some(JAPANESE.get_or_init(|| Catalog::parse(include_str!("ja.tsv")))), + Self::Es => Some(SPANISH.get_or_init(|| Catalog::parse(include_str!("es.tsv")))), + Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), + Self::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))), + } + } + + /// Translate an English UI string; unknown strings come back unchanged. + pub fn tr(self, text: &str) -> &str { + self.catalog().and_then(|c| c.plain(text)).unwrap_or(text) + } + + /// Does this language's catalog translate `text`? (English never does: it is the source.) + pub fn has(self, text: &str) -> bool { + self.catalog().and_then(|c| c.plain(text)).is_some() + } +} + +thread_local! { + /// The language the UI is drawn in. Per thread: the UI is drawn on one thread, and tests that + /// switch language don't leak it into tests running on other threads. + static CURRENT: Cell = const { Cell::new(Language::En) }; +} + +/// Set the language for drawing. `FilmcraftApp::frame` calls this at the start of every frame from +/// the UI state, so widgets translate without every call site carrying the language around. +pub fn set_current(lang: Language) { + CURRENT.with(|c| c.set(lang)); +} + +/// The language the UI is drawn in. +pub fn current() -> Language { + CURRENT.with(Cell::get) +} + +/// Translate an English UI string into the current language (the `tl!` macro, for literals). +pub fn t(text: &str) -> &str { + current().tr(text) +} + +/// Match a lowercased search query against both the source and the displayed label. +pub fn matches_query(text: &str, query: &str) -> bool { + text.to_lowercase().contains(query) || t(text).to_lowercase().contains(query) +} + +/// Like [`t`], for an English string that needs a disambiguating `context` (a catalog row whose +/// first column is that context); falls back to the context-free translation. +pub fn tr_ctx<'a>(context: &str, text: &'a str) -> &'a str { + let lang = current(); + lang.catalog().and_then(|c| c.contextual(context, text)).unwrap_or_else(|| lang.tr(text)) +} + +/// Fill `{name}` placeholders. Unknown placeholders are left as written. +pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some((prefix, after)) = rest.split_once('{') { + out.push_str(prefix); + let Some((name, tail)) = after.split_once('}') else { + out.push('{'); + out.push_str(after); + return out; + }; + if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { + out.push_str(value); + } else { + out.push('{'); + out.push_str(name); + out.push('}'); + } + rest = tail; + } + out.push_str(rest); + out +} + +/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana). +const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; + +/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces +/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if +/// none of these is present. +const PREFERRED_JAPANESE: &[&str] = &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Hiragino Kaku Gothic Pro", + "Yu Gothic UI", + "Yu Gothic", + "Meiryo UI", + "Meiryo", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans JP", + "Source Han Sans", + "IPAexGothic", + "IPAGothic", + "TakaoGothic", + "VL Gothic", +]; + +const JAPANESE_FONT: &str = "system-japanese"; + +/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up +/// once per process: the system font folders are scanned on first use (name tables only), then the +/// chosen face's file is read. `None` on the web and on systems without a Japanese font. +pub fn system_japanese_font() -> Option> { + static FONT: OnceLock>> = OnceLock::new(); + FONT.get_or_init(|| { + filmcraft_text::fonts::scan_system(); + let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); + let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); + // within a family, the face closest to regular weight + let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); + let preferred = + PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); + let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; + // kept for the life of the process (one font, read once) so installing it again after a + // theme change shares the bytes instead of copying the whole file + let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); + Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + }) + .clone() +} + +/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a +/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds +/// these faces to every font family, so nothing else needs installing (and no system scan runs). +pub fn craft_japanese_font() -> bool { + filmcraft_text::fonts::craft_japanese().next().is_some() + && filmcraft_text::fonts::all_faces() + .iter() + .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) +} + +/// Japanese for the interface: true when built with the craft-fonts (already installed by +/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next +/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again +/// after `theme::install`, which replaces the font definitions. +pub fn install_japanese_font(ctx: &egui::Context) -> bool { + if craft_japanese_font() { + return true; + } + let Some(font) = system_japanese_font() else { return false }; + let families = crate::theme::font_families() + .into_iter() + .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) + .collect(); + // queued for the next pass (works before the first frame); a no-op when already installed + ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalogs_are_well_formed() { + for (code, text) in + [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv"))] + { + let (entries, errors) = catalog::parse_entries(text); + assert!(errors.is_empty(), "{code}: {errors:?}"); + for (i, (ctx, en, tr)) in entries.iter().enumerate() { + assert!(entries.iter().take(i).all(|(c, e, _)| (c, e) != (ctx, en)), "{code}: duplicate entry {ctx:?} {en:?}"); + let (mut a, mut b) = (catalog::placeholders(en), catalog::placeholders(tr)); + a.sort_unstable(); + b.sort_unstable(); + assert_eq!(a, b, "{code}: placeholders differ in {en:?} → {tr:?}"); + assert_eq!(en.ends_with('…'), tr.ends_with('…'), "{code}: ellipsis differs in {en:?} → {tr:?}"); + } + } + assert_eq!(Language::Es.tr("File"), "Archivo"); + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::En.tr("File"), "File"); + assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("es"), Some(Language::Es)); + assert_eq!(Language::parse("xx"), None); + assert_eq!(Language::PtBr.tr("File"), "Arquivo"); + assert_eq!(Language::PtBr.tr("meu video.mp4"), "meu video.mp4"); + assert_eq!(Language::PtBr.name(), "Português (Brasil)"); + assert_eq!(Language::Uk.name(), "Українська"); + assert_eq!(Language::Uk.tr("File"), "Файл"); + assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4"); + assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label"); + for l in Language::ALL { + assert_eq!(Language::parse(l.code()), Some(l)); + let json = serde_json::to_string(&l).unwrap(); + assert_eq!(json, format!("\"{}\"", l.code())); + assert_eq!(serde_json::from_str::(&json).unwrap(), l); + } + assert_eq!(fmt("{b} y {a}", &[("a", "1"), ("b", "2")]), "2 y 1"); + assert_eq!(fmt("{a} {zz}", &[("a", "1")]), "1 {zz}"); + } + + #[test] + fn the_current_language_is_per_thread() { + set_current(Language::Es); + assert_eq!((current(), t("File")), (Language::Es, "Archivo")); + // another thread (another test) still draws in English + assert_eq!(std::thread::spawn(|| t("File").to_string()).join().ok().as_deref(), Some("File")); + set_current(Language::En); + assert_eq!(t("File"), "File"); + } + + #[test] + fn template_values_are_not_interpreted_as_placeholders() { + assert_eq!(fmt("{name}: {n}", &[("name", "Vídeo {n}.mp4"), ("n", "3")]), "Vídeo {n}.mp4: 3"); + assert_eq!(fmt("{n} {name} {n}", &[("name", "{n}"), ("n", "日本語")]), "日本語 {n} 日本語"); + assert_eq!(fmt("{missing} y {unfinished", &[]), "{missing} y {unfinished"); + } + + #[test] + fn searches_match_spanish_labels_and_english_names() { + set_current(Language::Es); + assert!(matches_query("Gaussian Blur", "desenfoque")); + assert!(matches_query("Gaussian Blur", "gaussian")); + assert!(matches_query("Selection Tool", &"SELECCIÓN".to_lowercase())); + assert!(!matches_query("Gaussian Blur", "selección")); + set_current(Language::En); + } + + #[test] + fn spanish_translates_every_menu_label() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && !Language::Es.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + missing.sort(); + assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); + } + + /// The Brazilian Portuguese catalog covers the menus; every entry must still be a menu label + /// (a renamed command would otherwise leave a dead entry and an untranslated menu item). + #[test] + fn portuguese_entries_are_menu_labels() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let items = crate::menus::menu_items(&app); + let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); + let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + assert!(!entries.is_empty()); + for (_, en, _) in entries { + assert!(known(&en), "not a menu label: {en}"); + } + assert_eq!(Language::PtBr.tr("File"), "Arquivo"); + } + + /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. + #[test] + fn spanish_translates_every_tl_literal() { + let mut missing = Vec::new(); + for (path, text) in sources() { + for lit in tl_literals(&text) { + if !Language::Es.has(&lit) && !missing.contains(&lit) { + missing.push(lit); + } + } + assert!(!text.contains("tl!(r") && !text.contains("tlf!(r"), "{path}: use a plain string literal in tl! and tlf!"); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}"); + } + + /// The crate's sources (`src/**/*.rs`), each cut at its test module. + fn sources() -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut dirs = vec![std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))]; + while let Some(d) = dirs.pop() { + for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = entry.path(); + if p.is_dir() { + dirs.push(p); + } else if p.extension().is_some_and(|e| e == "rs") { + // a Windows checkout with core.autocrlf has CRLF, which the `\n` below would not match + let text = std::fs::read_to_string(&p).unwrap_or_default().replace("\r\n", "\n"); + let cut = text.find("#[cfg(test)]\nmod tests").unwrap_or(text.len()); + out.push((p.display().to_string(), text[..cut].to_string())); + } + } + } + out + } + + /// The (unescaped) string literals passed to `tl!` and `tlf!` in a source file. + fn tl_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + loop { + let next = ["tl!(\"", "tlf!(\""].into_iter().filter_map(|p| Some((rest.find(p)?, p.len()))).min(); + let Some((at, len)) = next else { break }; + rest = &rest[at + len..]; + let mut lit = String::new(); + let mut chars = rest.char_indices(); + let mut end = rest.len(); + while let Some((i, c)) = chars.next() { + match c { + '"' => { + end = i + 1; + break; + } + '\\' => match chars.next().map(|(_, e)| e) { + Some('n') => lit.push('\n'), + Some('t') => lit.push('\t'), + Some('u') => { + // \u{201c} + let hex: String = chars.by_ref().map(|(_, h)| h).take_while(|h| *h != '}').filter(|h| *h != '{').collect(); + lit.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(e) => lit.push(e), + None => {} + }, + c => lit.push(c), + } + } + rest = &rest[end..]; + out.push(lit); + } + out + } + + /// Does `s` hold words to translate? Numbers with units ("1.25 kHz", "24 fps", "8 Bit", "100 IRE") + /// and the language names (always shown in their own language) don't. + fn translatable(s: &str) -> bool { + const UNITS: [&str; 14] = ["Hz", "kHz", "dB", "oct", "fps", "IRE", "mm", "px", "ms", "s", "x", "Bit", "HLG", "PQ"]; + !Language::ALL.iter().any(|l| l.name() == s) && s.split(|c: char| !c.is_alphabetic()).any(|w| !w.is_empty() && !UNITS.contains(&w)) + } + + fn push(out: &mut Vec, s: &str) { + if translatable(s) && !out.iter().any(|o| o == s) { + out.push(s.to_string()); + } + } + + fn settings_rows(list: &[filmcraft_engine::settings::Row], out: &mut Vec) { + use filmcraft_engine::settings::{Kind, Row}; + for r in list { + match r { + Row::Field(f) => { + push(out, f.label); + match f.kind { + Kind::Int { unit, .. } | Kind::Float { unit, .. } => push(out, unit), + Kind::Choice(opts) => opts.iter().for_each(|(_, l)| push(out, l)), + _ => {} + } + } + Row::Group(title, inner) => { + push(out, title); + settings_rows(inner, out); + } + Row::Note(text) => push(out, text), + Row::Button { label, .. } => push(out, label), + Row::Custom(_) => {} + } + } + } + + /// Names the interface shows through `i18n::t` rather than as `tl!` literals: registries and + /// schemas from the engine and the other crates, and the UI's own enums. + fn dynamic_names() -> Vec { + let mut out = Vec::new(); + for c in filmcraft_engine::settings::categories() { + push(&mut out, c.title); + settings_rows(c.rows, &mut out); + } + filmcraft_engine::settings::DURATION_UNITS.iter().for_each(|(_, l)| push(&mut out, l)); + for top in filmcraft_project::vtransition::EFFECT_TOP_FOLDERS { + push(&mut out, top); + } + for d in filmcraft_project::effect_defs() { + push(&mut out, d.name); + d.category.iter().for_each(|c| push(&mut out, c)); + for p in &d.params { + push(&mut out, p.label); + p.group.iter().for_each(|g| push(&mut out, g)); + if let filmcraft_project::ParamKind::Choice(opts) = p.kind { + opts.iter().for_each(|o| push(&mut out, o)); + } + } + } + crate::panels::timeline::CLIP_MENU.iter().flat_map(|g| g.iter()).for_each(|(l, _)| push(&mut out, l)); + crate::panels::timeline::EDIT_POINT_TYPES.iter().for_each(|(l, ..)| push(&mut out, l)); + crate::panels::project::NEW_ITEMS.iter().for_each(|(l, _)| push(&mut out, l)); + // section headers keyed by their English name (collapsed state), translated when drawn + let sections = [ + // Lumetri Color + "Basic Correction", + "Creative", + "Curves", + "Color Wheels & Match", + "HSL Secondary", + "Vignette", + // Properties + "Transform", + "Crop", + // Essential Graphics + "Layers", + "Template Properties", + "Responsive Design - Time", + "Responsive Design - Position", + "Align and Transform", + "Text", + "Shape", + "Appearance", + ]; + sections.iter().for_each(|s| push(&mut out, s)); + for p in filmcraft_render::lumetri_presets::presets() { + push(&mut out, p.name); + push(&mut out, p.folder); + push(&mut out, p.description); + } + crate::dock::PanelKind::ALL.iter().for_each(|p| push(&mut out, p.title())); + crate::dock::WORKSPACES.iter().for_each(|w| push(&mut out, w)); + filmcraft_project::Label::ALL.iter().for_each(|l| push(&mut out, l.name())); + crate::panels::panel_state::TcMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + crate::panels::panel_state::TcSource::ALL.iter().for_each(|s| push(&mut out, s.label())); + crate::panels::panel_state::SCOPE_PRESETS.iter().for_each(|p| push(&mut out, p.0)); + filmcraft_time::TimeDisplay::ALL.iter().for_each(|d| push(&mut out, d.label())); + use filmcraft_engine::panels::Level; + [Level::Info, Level::Warning, Level::Error].iter().for_each(|l| push(&mut out, l.label())); + use filmcraft_scopes as sc; + sc::ScopeKind::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::WaveformType::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::ParadeType::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::ColorSpace::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::Scale::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::Brightness::ALL.iter().for_each(|k| push(&mut out, k.label())); + [sc::Targets::Percent75, sc::Targets::Percent100].iter().for_each(|k| push(&mut out, k.label())); + crate::state::PlaybackRes::ALL.iter().for_each(|r| push(&mut out, r.label())); + crate::state::Tool::ALL.iter().for_each(|t| push(&mut out, t.label())); + crate::credits::NameMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + crate::credits::SortKey::ALL.iter().for_each(|k| push(&mut out, k.label().0)); + filmcraft_project::TimeInterpolation::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::CaptionFormat::ALL.iter().for_each(|f| push(&mut out, f.label())); + filmcraft_project::MaskMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + use filmcraft_project::essential as es; + for kind in es::AudioType::ALL { + push(&mut out, kind.label()); + kind.sections().iter().for_each(|s| push(&mut out, s.label())); + } + es::EQ_PRESETS.iter().for_each(|p| push(&mut out, p.name)); + es::REVERB_PRESETS.iter().for_each(|p| push(&mut out, p.name)); + filmcraft_engine::essential_sound::presets(&filmcraft_engine::Session::default()).iter().for_each(|p| push(&mut out, &p.name)); + filmcraft_project::TrackMethod::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::AutomationMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::graphic_design::RollMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::InputMap::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_engine::panels::LOG_FIELDS.iter().for_each(|f| push(&mut out, f)); + filmcraft_engine::project_panel::BUILTIN_COLUMNS.iter().for_each(|c| push(&mut out, c)); + filmcraft_project::find::COLUMNS.iter().for_each(|c| push(&mut out, c)); + filmcraft_project::FindOp::ALL.iter().for_each(|o| push(&mut out, o.label())); + filmcraft_engine::media_browser::ALL_COLUMNS.iter().for_each(|c| push(&mut out, c)); + // export: presets, settings choices, ranges and pixel aspects + use filmcraft_engine::export as ex; + for p in ex::builtin_presets() { + push(&mut out, &p.name); + push(&mut out, &p.category); + push(&mut out, &p.description); + } + ex::Format::ALL.iter().for_each(|f| push(&mut out, f.label())); + ex::MxfVideoCodec::ALL.iter().for_each(|c| push(&mut out, c.label())); + ex::Placement::ALL.iter().for_each(|p| push(&mut out, p.label())); + [ex::FieldOrder::Progressive, ex::FieldOrder::UpperFirst, ex::FieldOrder::LowerFirst].iter().for_each(|f| push(&mut out, f.label())); + [ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass].iter().for_each(|m| push(&mut out, m.label())); + [ex::H264Profile::Baseline, ex::H264Profile::Main, ex::H264Profile::High].iter().for_each(|p| push(&mut out, p.label())); + crate::panels::export_mode::RANGES.iter().for_each(|(_, l)| push(&mut out, l)); + crate::panels::export_mode::PARS.iter().for_each(|(l, _)| push(&mut out, l)); + for template in filmcraft_project::gtemplate::builtin_templates() { + push(&mut out, &template.name); + push(&mut out, &template.category); + push(&mut out, &template.description); + template.controls.iter().for_each(|c| push(&mut out, &c.name)); + } + // effect presets (Effects panel ▸ Presets) + for p in filmcraft_engine::presets::builtin_presets() { + push(&mut out, &p.name); + push(&mut out, &p.description); + } + // every command (Keyboard Shortcuts dialog, History panel) and the shortcut contexts + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + for c in app.session.shortcuts.commands() { + push(&mut out, &c.label); + push(&mut out, &c.category); + } + push(&mut out, filmcraft_engine::shortcuts::APPLICATION); + filmcraft_engine::shortcuts::PANELS.iter().for_each(|p| push(&mut out, p)); + // metadata rows, item types and history entries of the demo project + let mut s = filmcraft_engine::Session::default(); + let _ = s.execute("file.openDemoProject", serde_json::json!({})); + for it in s.project.items.values() { + push(&mut out, it.type_label()); + for f in filmcraft_engine::panels::metadata_fields(&s.project, it.id) { + push(&mut out, f.section); + push(&mut out, &f.name); + } + } + out + } + + #[test] + fn spanish_translates_dynamic_names() { + let mut missing: Vec = dynamic_names().into_iter().filter(|n| !Language::Es.has(n)).collect(); + missing.sort(); + assert!(missing.is_empty(), "untranslated names: {missing:#?}"); + } + + #[test] + fn tl_literals_are_found_and_unescaped() { + let src = "a(tl!(\"Save\")); b(tl!(\"Say \\\"hi\\\"\\n\")); tl!(x); tlf!(\"{n} file(s)\", n);"; + assert_eq!(tl_literals(src), ["Save", "Say \"hi\"\n", "{n} file(s)"]); + } + + #[test] + fn language_commands_switch_and_persist_without_a_document() { + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Ja); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Es); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Es); + assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.spanish" && it.checked == Some(true))); + crate::menus::invoke(&mut app, &ctx, "app.language.portuguese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::PtBr); + assert_eq!(app.session.prefs.general.interface_language, "pt-br"); + assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.portuguese" && it.checked == Some(true))); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.ukrainian", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("uk")); + assert_eq!(app.ui.language, Language::Uk); + for item in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(item.checked, Some(item.id == "app.language.ukrainian"), "{}", item.id); + } + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"uk\""), "{saved}"); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Uk); + assert_eq!(app.session.prefs.general.interface_language, "uk"); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Uk); + crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::En); + set_current(Language::En); + } + + /// #218: System Language picks the first of the user's preferred languages the interface has. + #[test] + fn system_language_follows_the_preferred_locales() { + let l = |tags: &[&str]| Language::from_locales(&tags.iter().map(|t| t.to_string()).collect::>()); + assert_eq!(l(&["es_ES.UTF-8"]), Language::Es); + assert_eq!(l(&["es-419"]), Language::Es); + assert_eq!(l(&["ES"]), Language::Es); + assert_eq!(l(&["ja-JP"]), Language::Ja); + assert_eq!(l(&["pt-BR"]), Language::PtBr); + for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua"] { + assert_eq!(l(&[tag]), Language::Uk); + } + assert_eq!(l(&["pt_PT.UTF-8@euro"]), Language::PtBr); + assert_eq!(l(&["fr-FR", "de", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["en-GB", "es"]), Language::En); + for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"]] { + assert_eq!(l(none), Language::En, "{none:?}"); + } + assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); + } + + /// #218: the `system` preference (the default) asks the host for the system's languages; an + /// explicit choice does not, and without a host hook System Language is English. + #[test] + fn system_language_preference_uses_the_host_languages() { + let ctx = egui::Context::default(); + let session = filmcraft_engine::Session::default(); + assert_eq!(session.prefs.general.interface_language, "system"); + let mut app = crate::FilmcraftApp::new(session); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::En, "no host hook"); + + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = asked.clone(); + app.hooks.system_languages = Some(Box::new(move || { + count.set(count.get() + 1); + vec!["fr-FR".into(), "es-ES".into()] + })); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::Es); + assert_eq!(current(), Language::Es); + crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!((app.ui.language, app.session.prefs.general.interface_language.as_str()), (Language::En, "en")); + let asked_before = asked.get(); + app.session.execute("prefs.set", serde_json::json!({"key": "general.interfaceLanguage", "value": "ja"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(asked.get(), asked_before, "an explicit language does not ask the system"); + app.session.execute("prefs.set", serde_json::json!({"key": "general.interfaceLanguage", "value": "system"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::Es); + assert_eq!(asked.get(), asked_before + 1); + set_current(Language::En); + } + + #[test] + fn ukrainian_entries_cover_the_original_menu_catalog() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let items = crate::menus::menu_items(&app); + let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + for (_, en, _) in entries { + assert!(known(&en) || en == "Settings", "not a menu label: {en}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + assert!(Language::Uk.has(&en), "missing Ukrainian menu label: {en}"); + } + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); + } + } + + #[test] + fn ukrainian_renders_with_bundled_fonts() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + ctx.fonts_mut(|fonts| { + // Include the entire alphabet, even letters not yet used by a translated menu label. + let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя"; + let chars: std::collections::BTreeSet<_> = alphabet + .chars() + .chain(Language::Uk.name().chars()) + .chain(entries.iter().flat_map(|(_, _, uk)| uk.chars())) + .filter(|ch| !ch.is_ascii()) + .collect(); + for family in crate::theme::font_families() { + let font = egui::FontId::new(13.0, family); + // has_glyph compares font faces, giving false negatives when Inter also supplies + // the replacement character (the named medium/semibold stacks). Compare the + // actual glyph texture regions instead; the noncharacter U+10FFFF is missing. + let text: String = chars.iter().copied().chain(['\u{10ffff}']).collect(); + let galley = fonts.layout_no_wrap(text, font.clone(), egui::Color32::WHITE); + let glyphs = &galley.rows[0].glyphs; + assert_eq!(glyphs.len(), chars.len() + 1); + let replacement = glyphs.last().unwrap().uv_rect; + for glyph in glyphs.iter().take(chars.len()) { + assert_ne!(glyph.uv_rect, replacement, "missing {} in {font:?}", glyph.chr); + } + } + }); + } + + #[test] + fn japanese_needs_an_installed_font() { + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); + let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})); + let craft = craft_japanese_font(); + if !craft && system_japanese_font().is_none() { + // no craft-fonts and no system font: refused, and the interface stays English + assert!(r.is_err(), "{r:?}"); + assert_eq!(app.ui.language, Language::En); + return; + } + assert!(r.is_ok(), "{r:?}"); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + // every theme family falls back to the craft-fonts (when built with them; no system + // font is added then) or to the system Japanese font + for family in crate::theme::font_families() { + let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + if craft { + assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); + assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); + } else { + assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}"); + } + } + // and the glyphs resolve. (Only families whose replacement-box face is another font: + // egui's `has_glyph` reports false for any character served by the face it also uses + // for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the + // Japanese font itself.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + let font = egui::FontId::new(13.0, family); + for ch in JAPANESE_SAMPLE.chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); + } + } + }); + } +} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv new file mode 100644 index 000000000..8a8c7e5c9 --- /dev/null +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -0,0 +1,354 @@ +# Brazilian Portuguese (pt-br) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Brazilian Portuguese computing vocabulary. No proprietary localisation resource, string table or +# product UI was consulted or copied. This catalog covers the menus; anything else is shown in +# English. + + About FilmCraft Sobre o FilmCraft + Add Audio Submix Track Adicionar faixa de submixagem de áudio + Add Caption at Playhead Adicionar legenda no cursor de reprodução + Add Chapter Marker… Adicionar marcador de capítulo… + Add Edit Adicionar edição + Add Edit to All Tracks Adicionar edição em todas as faixas + Add Flash Cue Marker… Adicionar marcador de referência Flash… + Add Frame Hold Adicionar quadro congelado + Add Guide… Adicionar guia… + Add Marker Adicionar marcador + Add New Caption Track… Adicionar nova faixa de legendas… + Add Range Marker Adicionar marcador de intervalo + Add Range Marker to In and Out Adicionar marcador de intervalo da entrada à saída + Add Tracks… Adicionar faixas… + Adjustment Layer… Camada de ajuste… + Align to Selection Alinhar à seleção + Align to Video Frame Alinhar ao quadro de vídeo + Align to Video Frame as Group Alinhar ao quadro de vídeo como grupo + All Panels Todos os painéis + Alpha Alfa + Appearance Aparência + Appearance… Aparência… + Apply Audio Transition Aplicar transição de áudio + Apply Default Transitions to Selection Aplicar transições padrão à seleção + Apply Video Transition Aplicar transição de vídeo + Arrange Organizar + ArtCraft Website Site do ArtCraft + Assembly Montagem + Attach Proxies… Anexar proxies… + Audio Channels… Canais de áudio… + Audio Clip Mixer Misturador de clipes de áudio + Audio Gain… Ganho de áudio… + Audio Hardware… Hardware de áudio… + Audio In Entrada de áudio + Audio Meters Medidores de áudio + Audio Options Opções de áudio + Audio Out Saída de áudio + Audio Track Mixer Misturador de faixas de áudio + Audio Waveform Forma de onda de áudio + Auto Save… Salvamento automático… + Automate to Sequence… Automatizar para a sequência… + Bars and Tone… Barras e tom… + Bin Pasta + Bin From Selection Pasta a partir da seleção + Black Video… Vídeo preto… + Blue Azul + Bottom Inferior + Breakout to Mono Separar em mono + Bring Forward Trazer para a frente + Bring to Front Trazer ao topo + Brown Marrom + Captions Legendas + Captions and Graphics Legendas e gráficos + Captions… Legendas… + Caribbean Caribe + Center Horizontally Centralizar horizontalmente + Center Vertically Centralizar verticalmente + Cerulean Cerúleo + Clear Limpar + Clear Guides Limpar guias + Clear In Limpar entrada + Clear In and Out Limpar entrada e saída + Clear Markers Limpar marcadores + Clear Out Limpar saída + Clear Selected Marker Limpar marcador selecionado + Close Fechar + Close All Other Projects Fechar todos os outros projetos + Close All Projects Fechar todos os projetos + Close Gap Fechar lacuna + Close Project Fechar projeto + Color Management… Gerenciamento de cores… + Color Matte… Matte de cor… + Comparison View Visualização de comparação + Composite Video Vídeo composto + Consolidate Duplicates Consolidar duplicatas + Copy Copiar + Copy Paste Includes Sequence Markers Copiar e colar inclui marcadores da sequência + Create Captions from Transcript… Criar legendas a partir da transcrição… + Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera… + Create Proxies… Criar proxies… + Cut Recortar + Dark Escuro + Darkest Mais escuro + Delete Render Files Excluir arquivos de renderização + Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída + Delete Tracks… Excluir faixas… + Delete Transcript Excluir transcrição + Demo Footage Clipes de demonstração + Demo Project Projeto de demonstração + Deselect All Desmarcar tudo + Detach Proxies Desanexar proxies + Display Mode Modo de exibição + Distribute Distribuir + Distribute Horizontally Distribuir horizontalmente + Distribute Space Horizontally Distribuir espaço horizontalmente + Distribute Space Vertically Distribuir espaço verticalmente + Distribute Vertically Distribuir verticalmente + Duplicate Duplicar + Dynamic Audio Waveforms Formas de onda de áudio dinâmicas + Edit Editar + Edit Offline… Editar offline… + Edit Original Editar original + Edit Subclip… Editar subclipe… + Editing Edição + Effect Controls Controles de efeito + Effects Efeitos + Ellipse Elipse + Enable Ativar + Enable Remix Ativar remixagem + Essential Graphics Gráficos essenciais + Essential Sound Som essencial + Events Eventos + Export Exportar + Export As Motion Graphics Template… Exportar como modelo de gráficos de movimento… + Extract Extrair + Extract Audio Extrair áudio + Field Options… Opções de campo… + File Arquivo + File… Arquivo… + Fill frame Preencher quadro + FilmCraft Help… Ajuda do FilmCraft… + FilmCraft on GitHub FilmCraft no GitHub + FilmCraft on getartcraft.com FilmCraft no getartcraft.com + Find Next Localizar próximo + Find… Localizar… + Fit Ajustar + Fit to frame Ajustar ao quadro + Flatten Achatar + Forest Floresta + Frame Blending Emenda de quadros + Frame Hold Options… Opções de quadro congelado… + Frame Sampling Amostragem de quadros + From Bin Da pasta + From Source Monitor Do monitor de origem + From Source Monitor, Match Frame Do monitor de origem, quadro correspondente + From file… Do arquivo… + Full Total + Generate Audio Waveform Gerar forma de onda de áudio + Get Media File Properties for Obter propriedades do arquivo de mídia de + Go to Gap Ir para a lacuna + Go to In Ir para a entrada + Go to Next Caption Segment Ir para o próximo segmento de legenda + Go to Next Marker Ir para o próximo marcador + Go to Out Ir para a saída + Go to Previous Caption Segment Ir para o segmento de legenda anterior + Go to Previous Marker Ir para o marcador anterior + Go to Split Ir para a divisão + Graphics and Titles Gráficos e títulos + Graphics… Gráficos… + Green Verde + Group Agrupar + Guide Templates Modelos de guias + Help Ajuda + Hide All Caption Tracks Ocultar todas as faixas de legendas + High Quality Playback Reprodução em alta qualidade + History Histórico + Import From Importar de + Import Image Sequence… Importar sequência de imagens… + Import from Media Browser Importar do navegador de mídia + Import… Importar… + Info Informações + Ingest Settings… Configurações de ingestão… + Insert Inserir + Insert Frame Hold Segment Inserir segmento de quadro congelado + Install Motion Graphics Template… Instalar modelo de gráficos de movimento… + Interpret Footage… Interpretar clipes… + Join the ArtCraft Discord… Entre no Discord do ArtCraft… + Keyboard Shortcuts… Atalhos de teclado… + Label Rótulo + Labels… Rótulos… + Language Idioma + Lavender Lavanda + Learning Aprendizado + Left Esquerda + Libraries Bibliotecas + Lift Levantar + Light Claro + Link Vincular + Link Media… Vincular mídia… + Linked Selection Seleção vinculada + Lock Guides Bloquear guias + Lumetri Color Cor Lumetri + Lumetri Scopes Escopos Lumetri + Magnification Ampliação + Make Offline… Tornar offline… + Make Subclip… Criar subclipe… + Make Subsequence Criar subsequência + Manage Guides… Gerenciar guias… + Mark Clip Marcar clipe + Mark In Marcar entrada + Mark Out Marcar saída + Mark Selection Marcar seleção + Mark Split Marcar divisão + Markers Marcadores + Match Frame Quadro correspondente + Media Analysis & Transcription… Análise e transcrição de mídia… + Media Browser Navegador de mídia + Media Cache… Cache de mídia… + Media… Mídia… + Memory… Memória… + Merge Clips… Mesclar clipes… + Metadata Metadados + Modify Modificar + Motion Graphics Template… Modelo de gráficos de movimento… + Multi-Camera Multicâmera + Multi-Camera View Visualização multicâmera + Nest… Aninhar… + New Novo + New Layer Nova camada + New Project Panel Novo painel de projeto + Next in Sequence Próximo na sequência + Next in Track Próximo na faixa + Normalize Mix Track… Normalizar faixa de mixagem… + Offline File… Arquivo offline… + Open Project… Abrir projeto… + Optical Flow Fluxo óptico + Overwrite Sobrescrever + Paste Colar + Paste Attributes… Colar atributos… + Paste Insert Inserir colando + Paused Resolution Resolução pausada + Playback Resolution Resolução de reprodução + Playback… Reprodução… + Plugins… Plug-ins… + Polygon Polígono + Preferences Preferências + Previous in Sequence Anterior na sequência + Previous in Track Anterior na faixa + Program Programa + Progress Progresso + Project Projeto + Project Manager… Gerenciador de projetos… + Project Settings Configurações do projeto + Project… Projeto… + Properties Propriedades + Purple Roxo + Reconnect Full Resolution Media… Reconectar mídia em resolução total… + Recover Unsaved Changes… Recuperar alterações não salvas… + Rectangle Retângulo + Red Vermelho + Redo Refazer + Reference Monitor Monitor de referência + Remix Remixagem + Remix Properties… Propriedades da remixagem… + Remove Attributes… Remover atributos… + Remove Filler Words Remover muletas + Remove Pauses Remover pausas + Remove Unused Remover não usados + Rename… Renomear… + Render Audio Renderizar áudio + Render Effects In to Out Renderizar efeitos da entrada à saída + Render In to Out Renderizar da entrada à saída + Render Selection Renderizar seleção + Replace Fonts in Projects… Substituir fontes em projetos… + Replace With Clip Substituir por clipe + Report an Issue… Relatar um problema… + Reset All Parameters Redefinir todos os parâmetros + Reset Duration Redefinir duração + Reset to Saved Layout Redefinir para o layout salvo + Restore Captions from Source Clip Restaurar legendas do clipe de origem + Reveal Log Files… Mostrar arquivos de log… + Reverse Match Frame Quadro correspondente inverso + Revert Reverter + Revert Remix Reverter remixagem + Review Revisão + Right Direita + Ripple Delete Excluir e fechar lacuna + Ripple Sequence Markers Deslocar marcadores da sequência + Rose Rosa + Safe Margins Margens de segurança + Save Salvar + Save All Salvar tudo + Save As… Salvar como… + Save Guides as Template… Salvar guias como modelo… + Save a Copy… Salvar uma cópia… + Save as Template… Salvar como modelo… + Scale to Frame Size Escalar para o tamanho do quadro + Scene Edit Detection… Detecção de edição de cena… + Scratch Disks… Discos de trabalho… + Search Bin Pasta de pesquisa + Select Selecionar + Select All Selecionar tudo + Select All Matching Selecionar todos correspondentes + Select Label Group Selecionar grupo de rótulos + Select Next Graphic Selecionar próximo gráfico + Select Next Layer Selecionar próxima camada + Select Previous Graphic Selecionar gráfico anterior + Select Previous Layer Selecionar camada anterior + Selection Follows Playhead A seleção segue o cursor de reprodução + Selection as FilmCraft Project… Seleção como projeto do FilmCraft… + Selection… Seleção… + Send Backward Enviar para trás + Send to Back Enviar para o fundo + Sequence Sequência + Sequence From Clip Sequência a partir do clipe + Sequence Settings… Configurações da sequência… + Sequence… Sequência… + Show Active Caption Tracks Only Mostrar apenas faixas de legendas ativas + Show All Caption Tracks Mostrar todas as faixas de legendas + Show All Marker Colors Mostrar todas as cores de marcador + Show Guides Mostrar guias + Show Rulers Mostrar réguas + Show Through Edits Mostrar edições contíguas + Simplify Sequence… Simplificar sequência… + Snap in Program Monitor Encaixar no monitor do programa + Snap in Timeline Encaixar na linha do tempo + Source Origem + Source Settings… Configurações de origem… + Speed/Duration… Velocidade/duração… + Synchronize… Sincronizar… + System Compatibility Report… Relatório de compatibilidade do sistema… + Tan Canela + Teal Verde-azulado + Text Texto + Time Interpolation Interpolação de tempo + Timecode Timecode + Timecode… Timecode… + Timeline Linha do tempo + Timeline… Linha do tempo… + Toggle Proxies Alternar proxies + Tools Ferramentas + Top Superior + Transcribe Sequence… Transcrever sequência… + Transcribe… Transcrever… + Transcript Transcrição + Transparent Video… Vídeo transparente… + Trim Edit Edição de aparagem + Trim… Aparar… + Undo Desfazer + Ungroup Desagrupar + Universal Counting Leader… Cabeçote de contagem universal… + Update Metadata… Atualizar metadados… + Upgrade Caption to Graphic Converter legenda em gráfico + Upgrade to Source Graphic Converter em gráfico de origem + Vertical Text Texto vertical + Video In Entrada de vídeo + Video Options Opções de vídeo + Video Out Saída de vídeo + Video and Audio Waveform Split Vídeo e forma de onda de áudio separados + View Exibir + Violet Violeta + Window Janela + Workspaces Áreas de trabalho + Yellow Amarelo + Zoom In Ampliar + Zoom Out Reduzir + Zoom to Sequence Zoom para a sequência diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv new file mode 100644 index 000000000..2e4fd5272 --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -0,0 +1,356 @@ +# Ukrainian (uk) catalog. Columns separated by TAB: context source translation +# Empty context is a plain UI string. Escapes: \n, \t, \\; preserve placeholders and trailing …. +# Original clean-room translations from the meaning of FilmCraft's English menu labels, +# using Ukrainian video-editing vocabulary. No proprietary localization resources were used. +# Core menu coverage; other interface strings fall back to English. + + About FilmCraft Про FilmCraft + Add Audio Submix Track Додати доріжку підміксу аудіо + Add Caption at Playhead Додати субтитр у позиції курсора відтворення + Add Chapter Marker… Додати маркер розділу… + Add Edit Додати розріз + Add Edit to All Tracks Додати розріз на всіх доріжках + Add Flash Cue Marker… Додати маркер сигналу Flash… + Add Frame Hold Додати стоп-кадр + Add Guide… Додати напрямну… + Add Marker Додати маркер + Add New Caption Track… Додати нову доріжку субтитрів… + Add Range Marker Додати маркер діапазону + Add Range Marker to In and Out Додати маркер діапазону між початковою та кінцевою позначками + Add Tracks… Додати доріжки… + Adjustment Layer… Коригувальний шар… + Align to Selection Вирівняти за виділенням + Align to Video Frame Вирівняти за відеокадром + Align to Video Frame as Group Вирівняти за відеокадром як групу + All Panels Усі панелі + Alpha Альфа + Appearance Вигляд + Appearance… Вигляд… + Apply Audio Transition Застосувати аудіоперехід + Apply Default Transitions to Selection Застосувати типові переходи до виділення + Apply Video Transition Застосувати відеоперехід + Arrange Упорядкувати + ArtCraft Website Вебсайт ArtCraft + Assembly Компонування + Attach Proxies… Приєднати проксі… + Audio Аудіо + Audio Channels… Аудіоканали… + Audio Clip Mixer Мікшер аудіокліпів + Audio Gain… Підсилення аудіо… + Audio Hardware… Аудіообладнання… + Audio In Початок аудіо + Audio Meters Індикатори рівня аудіо + Audio Options Параметри аудіо + Audio Out Кінець аудіо + Audio Track Mixer Мікшер аудіодоріжок + Audio Waveform Осцилограма аудіо + Auto Save… Автозбереження… + Automate to Sequence… Автоматично розмістити в секвенції… + Bars and Tone… Кольорові смуги й тон… + Bin Контейнер + Bin From Selection Контейнер із виділення + Black Video… Чорне відео… + Blue Синій + Bottom Низ + Breakout to Mono Розділити на моноканали + Bring Forward Перемістити вперед + Bring to Front На передній план + Brown Коричневий + Captions Субтитри + Captions and Graphics Субтитри й графіка + Captions… Субтитри… + Caribbean Карибський + Center Horizontally По центру горизонтально + Center Vertically По центру вертикально + Cerulean Лазурний + Clear Видалити + Clear Guides Видалити напрямні + Clear In Прибрати початкову позначку + Clear In and Out Прибрати початкову та кінцеву позначки + Clear Markers Видалити маркери + Clear Out Прибрати кінцеву позначку + Clear Selected Marker Видалити вибраний маркер + Clip Кліп + Close Закрити + Close All Other Projects Закрити всі інші проєкти + Close All Projects Закрити всі проєкти + Close Gap Прибрати проміжок + Close Project Закрити проєкт + Color Management… Керування кольором… + Color Matte… Кольорова підкладка… + Comparison View Режим порівняння + Composite Video Композитне відео + Consolidate Duplicates Об’єднати дублікати + Copy Копіювати + Copy Paste Includes Sequence Markers Копіювати й вставляти разом із маркерами секвенції + Create Captions from Transcript… Створити субтитри з транскрипції… + Create Multi-Camera Source Sequence… Створити вихідну мультикамерну секвенцію… + Create Proxies… Створити проксі… + Cut Вирізати + Dark Темний + Darkest Найтемніший + Delete Render Files Видалити файли рендерингу + Delete Render Files In to Out Видалити файли рендерингу між початковою та кінцевою позначками + Delete Tracks… Видалити доріжки… + Delete Transcript Видалити транскрипцію + Demo Footage Демонстраційні матеріали + Demo Project Демонстраційний проєкт + Deselect All Зняти все виділення + Detach Proxies Від’єднати проксі + Display Mode Режим відображення + Distribute Розподілити + Distribute Horizontally Розподілити горизонтально + Distribute Space Horizontally Рівні проміжки по горизонталі + Distribute Space Vertically Рівні проміжки по вертикалі + Distribute Vertically Розподілити вертикально + Duplicate Дублювати + Dynamic Audio Waveforms Динамічні осцилограми аудіо + Edit Редагування + Edit Offline… Редагувати автономно… + Edit Original Редагувати оригінал + Edit Subclip… Редагувати підкліп… + Editing Монтаж + Effect Controls Керування ефектами + Effects Ефекти + Ellipse Еліпс + Enable Увімкнути + Enable Remix Увімкнути ремікс + Essential Graphics Основна графіка + Essential Sound Основний звук + Events Події + Export Експорт + Export As Motion Graphics Template… Експортувати як шаблон анімаційної графіки… + Extract Вилучити зі зсувом + Extract Audio Видобути аудіо + Field Options… Параметри полів… + File Файл + File… Файл… + Fill frame Заповнити кадр + FilmCraft Help… Довідка FilmCraft… + FilmCraft on GitHub FilmCraft на GitHub + FilmCraft on getartcraft.com FilmCraft на getartcraft.com + Find Next Знайти далі + Find… Знайти… + Fit Умістити + Fit to frame Умістити в кадр + Flatten Звести + Forest Лісовий + Frame Blending Змішування кадрів + Frame Hold Options… Параметри стоп-кадру… + Frame Sampling Вибірка кадрів + From Bin Із контейнера + From Source Monitor Із монітора джерела + From Source Monitor, Match Frame Із монітора джерела, відповідний кадр + From file… Із файлу… + Full Повна + Generate Audio Waveform Створити осцилограму аудіо + Get Media File Properties for Показати властивості медіафайлу для + Go to Gap Перейти до проміжку + Go to In Перейти до початкової позначки + Go to Next Caption Segment Перейти до наступного сегмента субтитрів + Go to Next Marker Перейти до наступного маркера + Go to Out Перейти до кінцевої позначки + Go to Previous Caption Segment Перейти до попереднього сегмента субтитрів + Go to Previous Marker Перейти до попереднього маркера + Go to Split Перейти до розділеної позначки + Graphics and Titles Графіка й титри + Graphics… Графіка… + Green Зелений + Group Згрупувати + Guide Templates Шаблони напрямних + Help Довідка + Hide All Caption Tracks Приховати всі доріжки субтитрів + High Quality Playback Відтворення у високій якості + History Історія + Import From Імпортувати з + Import Image Sequence… Імпортувати послідовність зображень… + Import from Media Browser Імпортувати з медіабраузера + Import… Імпортувати… + Info Інформація + Ingest Settings… Налаштування завантаження медіа… + Insert Вставити + Insert Frame Hold Segment Вставити сегмент стоп-кадру + Install Motion Graphics Template… Установити шаблон анімаційної графіки… + Interpret Footage… Інтерпретувати матеріал… + Join the ArtCraft Discord… Приєднатися до Discord ArtCraft… + Keyboard Shortcuts… Клавіатурні скорочення… + Label Мітка + Labels… Мітки… + Language Мова + Lavender Лавандовий + Learning Навчання + Left Ліворуч + Libraries Бібліотеки + Lift Вилучити без зсуву + Light Світлий + Link Зв’язати + Link Media… Прив’язати медіа… + Linked Selection Пов’язане виділення + Lock Guides Заблокувати напрямні + Lumetri Color Колір Lumetri + Lumetri Scopes Відеоскопи Lumetri + Magnification Масштаб + Make Offline… Зробити автономним… + Make Subclip… Створити підкліп… + Make Subsequence Створити підсеквенцію + Manage Guides… Керувати напрямними… + Mark Clip Позначити кліп + Mark In Установити початкову позначку + Mark Out Установити кінцеву позначку + Mark Selection Позначити виділення + Mark Split Установити розділену позначку + Markers Маркери + Match Frame Знайти відповідний кадр + Media Analysis & Transcription… Аналіз медіа й транскрибування… + Media Browser Медіабраузер + Media Cache… Медіакеш… + Media… Медіа… + Memory… Пам’ять… + Merge Clips… Об’єднати кліпи… + Metadata Метадані + Modify Змінити + Motion Graphics Template… Шаблон анімаційної графіки… + Multi-Camera Мультикамера + Multi-Camera View Мультикамерний режим + Nest… Вкласти в секвенцію… + New Створити + New Layer Новий шар + New Project Panel Нова панель проєкту + Next in Sequence Наступний у секвенції + Next in Track Наступний на доріжці + Normalize Mix Track… Нормалізувати доріжку міксу… + Offline File… Автономний файл… + Open Project… Відкрити проєкт… + Optical Flow Оптичний потік + Overwrite Перезаписати + Paste Вставити + Paste Attributes… Вставити атрибути… + Paste Insert Вставити зі зсувом + Paused Resolution Роздільність під час паузи + Playback Resolution Роздільність відтворення + Playback… Відтворення… + Plugins… Плагіни… + Polygon Багатокутник + Preferences Налаштування + Previous in Sequence Попередній у секвенції + Previous in Track Попередній на доріжці + Program Програма + Progress Перебіг + Project Проєкт + Project Manager… Менеджер проєкту… + Project Settings Налаштування проєкту + Project… Проєкт… + Properties Властивості + Purple Пурпуровий + Reconnect Full Resolution Media… Повторно під’єднати медіа повної роздільності… + Recover Unsaved Changes… Відновити незбережені зміни… + Rectangle Прямокутник + Red Червоний + Redo Повторити + Reference Monitor Еталонний монітор + Remix Ремікс + Remix Properties… Властивості реміксу… + Remove Attributes… Видалити атрибути… + Remove Filler Words Видалити слова-паразити + Remove Pauses Видалити паузи + Remove Unused Видалити невикористане + Rename… Перейменувати… + Render Audio Рендерити аудіо + Render Effects In to Out Рендерити ефекти між початковою та кінцевою позначками + Render In to Out Рендерити між початковою та кінцевою позначками + Render Selection Рендерити виділення + Replace Fonts in Projects… Замінити шрифти в проєктах… + Replace With Clip Замінити кліпом + Report an Issue… Повідомити про проблему… + Reset All Parameters Скинути всі параметри + Reset Duration Скинути тривалість + Reset to Saved Layout Відновити збережене компонування + Restore Captions from Source Clip Відновити субтитри з вихідного кліпу + Reveal Log Files… Показати файли журналу… + Reverse Match Frame Знайти відповідний кадр у секвенції + Revert Повернути до збереженого + Revert Remix Скасувати ремікс + Review Перегляд + Right Праворуч + Ripple Delete Видалити зі зсувом + Ripple Sequence Markers Зсувати маркери секвенції + Rose Рожевий + Safe Margins Безпечні поля + Save Зберегти + Save All Зберегти все + Save As… Зберегти як… + Save Guides as Template… Зберегти напрямні як шаблон… + Save a Copy… Зберегти копію… + Save as Template… Зберегти як шаблон… + Scale to Frame Size Масштабувати до розміру кадру + Scene Edit Detection… Виявлення монтажних склейок… + Scratch Disks… Робочі диски… + Search Bin Пошуковий контейнер + Select Вибрати + Select All Виділити все + Select All Matching Виділити всі відповідні + Select Label Group Виділити групу міток + Select Next Graphic Вибрати наступний графічний елемент + Select Next Layer Вибрати наступний шар + Select Previous Graphic Вибрати попередній графічний елемент + Select Previous Layer Вибрати попередній шар + Selection Follows Playhead Виділення слідує за курсором відтворення + Selection as FilmCraft Project… Виділення як проєкт FilmCraft… + Selection… Виділення… + Send Backward Перемістити назад + Send to Back На задній план + Sequence Секвенція + Sequence From Clip Секвенція з кліпу + Sequence Settings… Налаштування секвенції… + Sequence… Секвенція… + Settings Налаштування + Show Active Caption Tracks Only Показати лише активні доріжки субтитрів + Show All Caption Tracks Показати всі доріжки субтитрів + Show All Marker Colors Показати всі кольори маркерів + Show Guides Показати напрямні + Show Rulers Показати лінійки + Show Through Edits Показати розрізи без зміни кадру + Simplify Sequence… Спростити секвенцію… + Snap in Program Monitor Прив’язка в моніторі програми + Snap in Timeline Прив’язка на часовій шкалі + Source Джерело + Source Settings… Налаштування джерела… + Speed/Duration… Швидкість/тривалість… + Synchronize… Синхронізувати… + System Compatibility Report… Звіт про сумісність системи… + Tan Світло-коричневий + Teal Бірюзовий + Text Текст + Time Interpolation Часова інтерполяція + Timecode Таймкод + Timecode… Таймкод… + Timeline Часова шкала + Timeline… Часова шкала… + Toggle Proxies Перемкнути проксі + Tools Інструменти + Top Верх + Transcribe Sequence… Транскрибувати секвенцію… + Transcribe… Транскрибувати… + Transcript Транскрипція + Transparent Video… Прозоре відео… + Trim Edit Підрізати склейку + Trim… Підрізання… + Undo Скасувати + Ungroup Розгрупувати + Universal Counting Leader… Універсальний зворотний відлік… + Update Metadata… Оновити метадані… + Upgrade Caption to Graphic Перетворити субтитр на графіку + Upgrade to Source Graphic Перетворити на вихідну графіку + Vertical Text Вертикальний текст + Video In Початок відео + Video Options Параметри відео + Video Out Кінець відео + Video and Audio Waveform Split Розділений вигляд відео й осцилограми аудіо + View Вигляд + Violet Фіолетовий + Window Вікно + Workspaces Робочі середовища + Yellow Жовтий + Zoom In Збільшити + Zoom Out Зменшити + Zoom to Sequence Умістити секвенцію diff --git a/crates/ui-egui/src/i18n/uk.tsv.attribution b/crates/ui-egui/src/i18n/uk.tsv.attribution new file mode 100644 index 000000000..87d76b83d --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv.attribution @@ -0,0 +1,8 @@ +asset: uk.tsv +title: Ukrainian interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-09 by dmatviichuk +notes: Original English-to-Ukrainian menu translations migrated to the TSV catalog format. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index aafc8741a..d302d2f4c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -114,6 +114,8 @@ pub enum Icon { Network, /// Media Browser: recent directories (a clock face). Clock, + /// Colour parameters: pick a colour from the Program monitor (a pipette). + Eyedropper, } pub struct Pen16<'a> { @@ -331,15 +333,55 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.fill(&[(3.5, 3.5), (10.0, 8.0), (3.5, 12.5)]); } GoToIn => { - pen.line(&[(5.0, 3.0), (3.0, 3.0), (3.0, 13.0), (5.0, 13.0)]); + pen.line(&[(5.0, 3.0), (4.0, 3.0), (3.5, 3.5), (3.5, 6.0), (3.0, 7.0), (2.0, 8.0), (3.0, 9.0), (3.5, 10.0), (3.5, 12.5), (4.0, 13.0), (5.0, 13.0)]); pen.fill(&[(13.0, 3.5), (6.0, 8.0), (13.0, 12.5)]); } GoToOut => { - pen.line(&[(11.0, 3.0), (13.0, 3.0), (13.0, 13.0), (11.0, 13.0)]); + pen.line(&[ + (11.0, 3.0), + (12.0, 3.0), + (12.5, 3.5), + (12.5, 6.0), + (13.0, 7.0), + (14.0, 8.0), + (13.0, 9.0), + (12.5, 10.0), + (12.5, 12.5), + (12.0, 13.0), + (11.0, 13.0), + ]); pen.fill(&[(3.0, 3.5), (10.0, 8.0), (3.0, 12.5)]); } - MarkIn => pen.line(&[(10.5, 2.5), (5.5, 2.5), (5.5, 13.5), (10.5, 13.5)]), - MarkOut => pen.line(&[(5.5, 2.5), (10.5, 2.5), (10.5, 13.5), (5.5, 13.5)]), + MarkIn => pen.line(&[ + (10.5, 2.5), + (8.5, 2.5), + (7.5, 3.0), + (7.0, 4.0), + (7.0, 6.0), + (6.5, 7.0), + (5.0, 8.0), + (6.5, 9.0), + (7.0, 10.0), + (7.0, 12.0), + (7.5, 13.0), + (8.5, 13.5), + (10.5, 13.5), + ]), + MarkOut => pen.line(&[ + (5.5, 2.5), + (7.5, 2.5), + (8.5, 3.0), + (9.0, 4.0), + (9.0, 6.0), + (9.5, 7.0), + (11.0, 8.0), + (9.5, 9.0), + (9.0, 10.0), + (9.0, 12.0), + (8.5, 13.0), + (7.5, 13.5), + (5.5, 13.5), + ]), Marker => pen.fill(&[(4.0, 2.5), (12.0, 2.5), (12.0, 9.5), (8.0, 13.5), (4.0, 9.5)]), Insert => { pen.rect(2.0, 5.0, 14.0, 13.0); @@ -685,6 +727,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.circle(8.0, 8.0, 6.0); pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]); } + Eyedropper => { + // a pipette: bulb at the top right, glass tube running to a tip at the bottom left + pen.closed(&[(10.0, 4.5), (11.5, 3.0), (13.0, 3.0), (13.0, 4.5), (11.5, 6.0)]); + pen.line(&[(8.2, 6.2), (10.8, 8.8)]); + pen.line(&[(9.5, 7.5), (3.5, 12.5)]); + pen.line(&[(3.5, 12.5), (2.5, 13.5)]); + } } } @@ -813,6 +862,7 @@ mod tests { Drive, Network, Clock, + Eyedropper, ]; /// Paint `icon` at `ppp` device pixels per point into a rect of `size` points whose corner is diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b50617604..ab9fa35a2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,8 +6,32 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +/// A literal UI string in the language the interface is drawn in (`i18n::t`). Only literals: the +/// i18n tests read every `tl!` literal in the crate and require a Spanish translation for it. +macro_rules! tl { + ($s:literal) => { + $crate::i18n::t($s) + }; +} + +/// A translated template with `{name}` placeholders filled in, for text that `format!` would build. +/// After the template literal come the values: `n = count`, or just `gain` for a variable of the +/// same name. Values are formatted with `Display`; the translation may reorder placeholders. +macro_rules! tlf { + (@v $k:ident = $v:expr) => { + $v + }; + (@v $k:ident) => { + $k + }; + ($s:literal $(, $k:ident $(= $v:expr)?)* $(,)?) => { + $crate::i18n::fmt($crate::i18n::t($s), &[$((stringify!($k), ToString::to_string(&tlf!(@v $k $(= $v)?)).as_str())),*]) + }; +} + pub mod automation; pub mod brand; +mod cjk; pub mod control; pub mod crash; pub mod credits; @@ -22,6 +46,8 @@ pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; +pub mod scrub; +pub mod source_playback; pub mod state; pub mod theme; pub mod widgets; @@ -85,10 +111,15 @@ pub struct HostHooks { pub pick_save_as: Option Option>>, /// The active keyboard shortcuts changed: update native menu key equivalents. pub shortcuts_changed: Option>, + /// The user's preferred languages (locale tags, most preferred first) for Interface Language ▸ + /// System Language. Without it, System Language is English. + pub system_languages: Option Vec>>, /// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path. pub pick_open_file: Option Option>>, /// Folder picker (Link Media search, proxy and Project Manager destinations). pub pick_folder: Option Option>>, + /// Folder picker starting at the current destination (Export Frame). + pub pick_folder_at: Option Option>>, /// Pick one file for a command that relinks to it (Link Media ▸ Locate…, Attach Proxies, /// Reconnect Full Resolution) instead of importing it, as [`Self::pick_files`] does. Native /// hosts return the path. A host whose picker is asynchronous (the web) returns `None` and @@ -130,6 +161,8 @@ pub enum Dialog { DeleteTracks, /// Sequence ▸ Add Tracks…. AddTracks, + /// Sequence ▸ Sequence Settings…. + SequenceSettings, } #[derive(Default)] @@ -172,13 +205,28 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0; /// timeline zoom animation finished) before capturing what is there. const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; +/// Export entries that run from the menus through the save panel: command, filter label, +/// extension. +pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ + ("markers.exportCsv", "Marker report (CSV)", "csv"), + ("file.exportEdl", "EDL", "edl"), + ("file.exportFcp7Xml", "Final Cut Pro XML", "xml"), + ("file.exportFcpxml", "FCPXML", "fcpxml"), + ("file.exportOtio", "OpenTimelineIO", "otio"), + ("file.exportAle", "Avid Log Exchange", "ale"), + ("file.exportSelectionProject", "FilmCraft Project", "fcproj"), +]; + pub struct FilmcraftApp { pub session: Session, pub ui: UiState, pub tokens: Tokens, pub frames: Arc, pub playback: Playback, + pub source_playback: source_playback::SourcePlayback, pub audio: Option>, + /// Audio during scrubbing (#211). + pub scrub: scrub::ScrubAudio, pub hooks: HostHooks, pub dialog: Option, pub file_dialogs: panels::file_dialogs::FileDialogState, @@ -200,6 +248,11 @@ pub struct FilmcraftApp { /// A paused monitor drew a stand-in (nearest cached) picture last frame: its exact frame is /// still decoding. pub(crate) monitor_inexact: bool, + /// Per monitor: the frames it asked for on its latest paused passes, oldest first (stand-ins + /// while the newest renders, see `panels::monitor`). + pub(crate) monitor_asked: HashMap>, + /// The frame whose picture the Program monitor drew last: the frame due, or its stand-in. + pub(crate) program_shown: Option, /// Consecutive frames the timeline zoom / scroll has been at rest. `ui.elements` answers from /// the frame before the last one, so its timeline rects are final from 2 on. pub(crate) timeline_still: u32, @@ -259,13 +312,7 @@ pub struct GpuState { /// Largest texture side a plan needs on the GPU (output and every layer). fn plan_side(plan: &filmcraft_render::plan::FramePlan) -> usize { - use filmcraft_render::plan::FramePlan; - match plan { - FramePlan::Layers { width, height, layers } => { - layers.iter().map(|l| l.frame.width.max(l.frame.height) as usize).fold((*width).max(*height), usize::max) - } - FramePlan::Image(img) => img.w.max(img.h), - } + plan.max_side() } /// Whether the GPU compositor can run on this adapter: it renders and blends Rgba16Float @@ -322,7 +369,7 @@ impl FilmcraftApp { { g.render_state.renderer.write().free_texture(&id); } - self.ui.status = "Graphics problem: switched to software compositing".into(); + self.ui.status = tl!("Graphics problem: switched to software compositing").into(); } /// Composite a plan on the GPU and return the egui texture showing it. @@ -374,13 +421,16 @@ impl FilmcraftApp { let frames = Arc::new(FrameServer::new(session.media.clone(), session.services.clone(), session.previews.clone(), FrameServer::default_workers())); let workspaces = session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| dock::WorkspacePrefs::load(&d.join(dock::WORKSPACES_FILE))).unwrap_or_default(); + let language = i18n::Language::parse(&session.prefs.general.interface_language).unwrap_or_default(); Self { session, - ui: UiState::default(), + ui: UiState { language, ..UiState::default() }, tokens: Tokens::for_kind(ThemeKind::Dark), frames, playback: Playback { speed: 1.0, ..Default::default() }, + source_playback: Default::default(), audio: None, + scrub: Default::default(), hooks: HostHooks::default(), // Unsaved changes left by a session that died are offered first thing. dialog: recovery.then_some(Dialog::Recovery), @@ -396,6 +446,8 @@ impl FilmcraftApp { pending_screenshots: Vec::new(), queued_screenshots: Vec::new(), monitor_inexact: false, + monitor_asked: HashMap::new(), + program_shown: None, timeline_still: 0, input_waiters: Vec::new(), next_token: 1, @@ -476,6 +528,26 @@ impl FilmcraftApp { if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) { self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme)); } + if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) { + let language = match i18n::Language::parse(&p.general.interface_language) { + Some(l) => l, + None if p.general.interface_language == "system" => { + i18n::Language::from_locales(&self.hooks.system_languages.as_ref().map(|f| f()).unwrap_or_default()) + } + None => i18n::Language::default(), + }; + if language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) { + self.ui.language = i18n::Language::En; + self.ui.status = tl!("no Japanese font is installed on this system; the interface stays in English").into(); + } else { + self.ui.language = language; + } + i18n::set_current(self.ui.language); + let items = menus::menu_items(self); + if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { + hook(&items); + } + } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) { @@ -672,6 +744,7 @@ impl FilmcraftApp { } pub fn play(&mut self, speed: f64) { + self.stop_source(); if self.session.active_sequence().is_none() { return; } @@ -829,7 +902,7 @@ impl FilmcraftApp { audio.stop(); } log::warn!("audio output lost its playback clock; playing without sound"); - self.ui.status = "Audio output failed: playing without sound (check Settings ▸ Audio Hardware)".into(); + self.ui.status = tl!("Audio output failed: playing without sound (check Settings ▸ Audio Hardware)").into(); } if let Some((f, sr)) = reading { if f != self.playback.audio_seen.0 { @@ -845,7 +918,7 @@ impl FilmcraftApp { a.stop(); } log::warn!("audio output stalled (no samples consumed for {AUDIO_STALL_S} s); playing without sound"); - self.ui.status = "Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware)".into(); + self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware)").into(); } } let elapsed = match reading { @@ -925,6 +998,17 @@ impl FilmcraftApp { self.textures.get(name).map(|(_, t)| (t.id(), t.size_vec2())) } + /// The frame a named texture shows. + pub fn texture_key(&self, name: &str) -> Option { + self.textures.get(name).map(|(k, _)| *k) + } + + /// The frame whose picture the Program monitor drew last: the frame due at the playhead, or + /// the stand-in shown while that one renders (`perf.stats`, tests). + pub fn program_picture(&self) -> Option { + self.program_shown + } + /// Get a thumbnail texture for an item at a media time (requested at low priority). /// Cache revision of a project item's own frames: media changes only when its file does /// (relink, Make Offline, proxies on/off), so its frames survive unrelated edits; other items @@ -971,6 +1055,11 @@ impl FilmcraftApp { // ---------------------------------------------------------------- files + /// Destination of imports started from the currently shown Project panel view. + pub fn import_bin(&self) -> filmcraft_project::BinId { + panels::project::view_of(self, panels::project::shown_inst(self)).bin + } + pub fn file_dialog(&mut self, id: &str, params: &Value) -> Result { match id { "file.import" => { @@ -985,7 +1074,8 @@ impl FilmcraftApp { if paths.is_empty() { return Ok(Value::Null); } - let r = self.session.execute("file.import", json!({"paths": paths})).map_err(|e| e.to_string()); + let bin = params.get("bin").cloned().unwrap_or_else(|| json!(self.import_bin().0)); + let r = self.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); if let Ok(v) = &r && let Some(errs) = v.get("errors").and_then(Value::as_array) && !errs.is_empty() @@ -1040,10 +1130,40 @@ impl FilmcraftApp { p["path"] = json!(path); self.session.execute("captions.export", p).map_err(|e| e.to_string()) } - _ => Err(format!("no dialog for {id}")), + _ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) { + Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params), + None => Err(format!("no dialog for {id}")), + }, } } + /// File ▸ Export ▸ EDL…, Final Cut Pro XML… etc. from the menus (#382): ask where to save, then + /// run the command with that `path` and the rest of `params`. + fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result { + filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?; + let Some(pick) = self.hooks.pick_save_as.as_mut() else { + return Err("no save dialog available: run the command with a `path`".into()); + }; + // Timelines are named after the sequence; a selection must not suggest the open project's file. + let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()); + let stem = match id { + "file.exportAle" => self.session.project.name.clone(), + "file.exportSelectionProject" => format!("{} selection", self.session.project.name), + _ => sequence.unwrap_or_else(|| self.session.project.name.clone()), + }; + let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect(); + let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem }; + let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) }; + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("path".into(), json!(path)); + let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string()); + self.ui.status = match &r { + Ok(_) => tlf!("Exported {path}", path), + Err(e) => e.clone(), + }; + r + } + /// Import dropped files. fn handle_drops(&mut self, ctx: &egui::Context) { let dropped = ctx.input(|i| i.raw.dropped_files.clone()); @@ -1055,7 +1175,7 @@ impl FilmcraftApp { } } if !paths.is_empty() { - let _ = self.session.execute("file.import", json!({"paths": paths})); + let _ = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0})); } } @@ -1100,7 +1220,9 @@ impl FilmcraftApp { } // Mark In/Out in the Source monitor when it has focus. let params = if self.ui.focused == PanelKind::Source - && (matches!(id.as_str(), "markers.markIn" | "markers.markOut") || id.starts_with("markers.markSplit") || id.starts_with("markers.goToSplit")) + && (matches!(id.as_str(), "markers.markIn" | "markers.markOut" | "markers.clearInOut") + || id.starts_with("markers.markSplit") + || id.starts_with("markers.goToSplit")) { json!({"target": "source"}) } else { @@ -1223,6 +1345,7 @@ impl FilmcraftApp { // ---------------------------------------------------------------- frame fn frame(&mut self, ui: &mut egui::Ui) { + i18n::set_current(self.ui.language); if std::mem::take(&mut self.panic_next_frame) { crash::injected_fault("injected UI fault"); } @@ -1265,6 +1388,8 @@ impl FilmcraftApp { } self.handle_shortcuts(&ctx); self.advance_playback(&ctx); + self.advance_source_playback(&ctx); + self.scrub_audio(&ctx); // Text to Speech: a selected narration clip loads into the panel panels::tts::follow_selection(self); if self.ui.tts.pending.is_some() || self.ui.tts.download_job.is_some() { @@ -1273,10 +1398,12 @@ impl FilmcraftApp { let t = self.tokens; let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.app_bg); - let header_h = 38.0; + let header_h = if self.ui.show_header { 38.0 } else { 0.0 }; let header = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), header_h)); - header::show(self, ui, header); - let status_h = 20.0; + if self.ui.show_header { + header::show(self, ui, header); + } + let status_h = if self.ui.show_status_bar { 20.0 } else { 0.0 }; let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 1.0, header.max.y + 1.0), egui::pos2(full.max.x - 1.0, full.max.y - status_h - 2.0)); match self.ui.mode { state::Mode::Edit => self.dock_area(ui, body), @@ -1284,9 +1411,14 @@ impl FilmcraftApp { state::Mode::Export => panels::export_mode::show(self, ui, body), } panels::dialogs::show(self, &ctx); + if !self.ui.show_status_bar { + // no bar to draw the job in, but a finished preview render still plays + self.watch_jobs(ui.ctx()); + return; + } // Status / hint bar let sb = egui::Rect::from_min_max(egui::pos2(full.min.x, full.max.y - status_h), full.max); - ui.painter().rect_filled(sb, 0.0, egui::Color32::from_rgb(0x1c, 0x1c, 0x1c)); + ui.painter().rect_filled(sb, 0.0, t.header_bg); let now = ui.input(|i| i.time); if self.ui.status != self.status_seen.0 { self.status_seen = (self.ui.status.clone(), now); @@ -1306,9 +1438,9 @@ impl FilmcraftApp { self.job_status(ui, sb, &t); } - /// Right side of the status bar: the running job (export / render previews) with a progress - /// bar and a cancel button; plays the rendered range when a preview render completes. - fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) { + /// The running job (export / render previews), if any; plays the rendered range when a preview + /// render completes. Runs every frame, with or without the status bar. + fn watch_jobs(&mut self, ctx: &egui::Context) -> Option { use std::sync::atomic::Ordering; let running = self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned(); // Play after rendering previews. @@ -1323,12 +1455,20 @@ impl FilmcraftApp { self.play(1.0); } } - let Some(job) = running else { return }; + let job = running?; if job.label.starts_with("Rendering ") && !job.label.contains("audio") && self.watched_render.is_none_or(|w| w.0 != job.id) { let from = self.session.active_sequence().and_then(|q| q.mark_in).unwrap_or(Tick::ZERO); self.watched_render = Some((job.id, from)); } - ui.ctx().request_repaint_after(std::time::Duration::from_millis(150)); + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + Some(job) + } + + /// Right side of the status bar: the running job (export / render previews) with a progress + /// bar and a cancel button. + fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) { + use std::sync::atomic::Ordering; + let Some(job) = self.watch_jobs(ui.ctx()) else { return }; let f = job.progress.fraction().clamp(0.0, 1.0); let left = job.progress.eta().map(panels::left_text).unwrap_or_default(); let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0)); @@ -1336,7 +1476,7 @@ impl FilmcraftApp { let p = ui.painter(); p.rect_filled(bar, 3.0, t.separator); p.rect_filled(egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * f, bar.height())), 3.0, t.accent); - let verb = if job.label.starts_with("Rendering") { job.label.clone() } else { "Exporting".to_string() }; + let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() }; p.text( egui::pos2(bar.min.x - 8.0, sb.center().y), egui::Align2::RIGHT_CENTER, @@ -1351,7 +1491,7 @@ impl FilmcraftApp { p.line_segment([cancel.center() + egui::vec2(-k, k), cancel.center() + egui::vec2(k, -k)], egui::Stroke::new(1.4, c)); self.auto.add("status.job.cancel", cancel, &format!("Cancel {}", job.label)); self.auto.add("status.job.progress", bar, &format!("{:.0}%{left}", f * 100.0)); - if resp.on_hover_text("Cancel").clicked() { + if resp.on_hover_text(tl!("Cancel")).clicked() { job.progress.cancel.store(true, Ordering::Relaxed); self.watched_render = None; } @@ -1359,19 +1499,24 @@ impl FilmcraftApp { /// Contextual hint for the status bar (Premiere shows tool/gesture hints here). fn hint_text(&self) -> String { + let mac = cfg!(target_os = "macos"); match self.ui.tool { - state::Tool::Selection => "Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.", - state::Tool::TrackSelectForward => "Click to select all clips to the right in all tracks. Shift-click for a single track.", - state::Tool::TrackSelectBackward => "Click to select all clips to the left in all tracks. Shift-click for a single track.", - state::Tool::Ripple => "Drag an edit point to ripple trim; later clips move to keep the gap closed.", - state::Tool::Rolling => "Drag an edit point to roll it: the out of one clip and the in of the next move together.", - state::Tool::RateStretch => "Drag an edge to change the clip's speed so it fills the new duration.", - state::Tool::Remix => "Drag the edge of a music clip to remix it to the new duration at musically matching beats.", - state::Tool::Razor => "Click to split a clip. Shift-click to split all tracks.", - state::Tool::Slip => "Drag a clip to slip its source in/out without moving it.", - state::Tool::Slide => "Drag a clip to slide it between its neighbours.", - state::Tool::Hand => "Drag to scroll the timeline.", - state::Tool::Zoom => "Click to zoom in; Opt-click to zoom out.", + state::Tool::Selection if mac => { + tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.") + } + state::Tool::Selection => tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options."), + state::Tool::TrackSelectForward => tl!("Click to select all clips to the right in all tracks. Shift-click for a single track."), + state::Tool::TrackSelectBackward => tl!("Click to select all clips to the left in all tracks. Shift-click for a single track."), + state::Tool::Ripple => tl!("Drag an edit point to ripple trim; later clips move to keep the gap closed."), + state::Tool::Rolling => tl!("Drag an edit point to roll it: the out of one clip and the in of the next move together."), + state::Tool::RateStretch => tl!("Drag an edge to change the clip's speed so it fills the new duration."), + state::Tool::Remix => tl!("Drag the edge of a music clip to remix it to the new duration at musically matching beats."), + state::Tool::Razor => tl!("Click to split a clip. Shift-click to split all tracks."), + state::Tool::Slip => tl!("Drag a clip to slip its source in/out without moving it."), + state::Tool::Slide => tl!("Drag a clip to slide it between its neighbours."), + state::Tool::Hand => tl!("Drag to scroll the timeline."), + state::Tool::Zoom if mac => tl!("Click to zoom in; Opt-click to zoom out."), + state::Tool::Zoom => tl!("Click to zoom in; Alt-click to zoom out."), _ => "", } .to_string() @@ -1457,26 +1602,31 @@ impl FilmcraftApp { fn error_window(&mut self, ctx: &egui::Context) { let Some(msg) = self.ui_error.clone() else { return }; let mut close = false; - egui::Window::new("FilmCraft hit an error").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_max_width(460.0); - ui.label("Something went wrong while drawing the window. Your project is still open; save it to be safe."); - ui.add_space(6.0); - ui.label(egui::RichText::new(&msg).monospace().small()); - if let Some(p) = crash::log_path() { - ui.label(egui::RichText::new(format!("Details: {}", p.display())).small()); - } - ui.add_space(8.0); - ui.horizontal(|ui| { - let s = ui.button("Save Project"); - self.auto.add("error.save", s.rect, "Save Project"); - if s.clicked() { - let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({})); + egui::Window::new(tl!("FilmCraft hit an error")) + .id(egui::Id::new("FilmCraft hit an error")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(460.0); + ui.label(tl!("Something went wrong while drawing the window. Your project is still open; save it to be safe.")); + ui.add_space(6.0); + ui.label(egui::RichText::new(&msg).monospace().small()); + if let Some(p) = crash::log_path() { + ui.label(egui::RichText::new(tlf!("Details: {path}", path = p.display())).small()); } - let d = ui.button("Continue"); - self.auto.add("error.dismiss", d.rect, "Continue"); - close |= d.clicked(); + ui.add_space(8.0); + ui.horizontal(|ui| { + let s = ui.button(tl!("Save Project")); + self.auto.add("error.save", s.rect, "Save Project"); + if s.clicked() { + let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({})); + } + let d = ui.button(tl!("Continue")); + self.auto.add("error.dismiss", d.rect, "Continue"); + close |= d.clicked(); + }); }); - }); if close { self.ui_error = None; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a02ecbf9b..5cac050e4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), + uic!("app.language.ukrainian", "Українська", ["Edit", "Language"], None), + uic!("source.playback.toggle", "Source Play/Stop", [], None), + uic!("source.playback.play", "Play Source", [], None), + uic!("source.playback.stop", "Stop Source", [], None), uic!("playback.toggle", "Play/Stop", [], Some("Space")), uic!("playback.forward", "Shuttle Right", [], Some("L")), uic!("playback.stop", "Shuttle Stop", [], Some("K")), @@ -87,8 +91,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("multicam.editCamerasDialog", "Edit Cameras…", [], None), uic!("voiceover.recordToggle", "Voice-over Record", [], None), uic!("voiceover.settingsDialog", "Voice-Over Record Settings…", [], None), + // ids follow `ThemeKind`, labels follow Settings ▸ Appearance ▸ Color Theme (`darkest`, `dark`, `light`) uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None), - uic!("view.theme.medium", "Medium", ["View", "Appearance"], None), + uic!("view.theme.medium", "Dark", ["View", "Appearance"], None), uic!("view.theme.light", "Light", ["View", "Appearance"], None), uic!("window.workspace.editing", "Editing", ["Window", "Workspaces"], Some("Alt+Shift+1")), uic!("window.workspace.assembly", "Assembly", ["Window", "Workspaces"], Some("Alt+Shift+2")), @@ -150,19 +155,41 @@ pub fn panel_command_id(p: PanelKind) -> String { format!("window.panel.{}", p.id()) } +/// Explicit monitor parameters override keyboard focus. +pub fn targets_source(app: &FilmcraftApp, params: &Value) -> bool { + match params.get("monitor").and_then(Value::as_str) { + Some("source") => true, + Some(_) => false, + None => app.ui.focused == PanelKind::Source, + } +} + /// Execute a UI or engine command by id. -pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { - if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese") { +pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result { + if filmcraft_engine::source_monitor::source_command(id) && targets_source(app, ¶ms) && params.get("target").is_none() { + let object = params.as_object_mut().ok_or("command parameters must be an object")?; + object.insert("target".into(), json!("source")); + } + if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.ukrainian") { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into()); } - app.ui.language = match id { + let language = match id { "app.language.japanese" => crate::i18n::Language::Ja, "app.language.spanish" => crate::i18n::Language::Es, "app.language.portuguese" => crate::i18n::Language::PtBr, + "app.language.ukrainian" => crate::i18n::Language::Uk, _ => crate::i18n::Language::En, }; + // The preference is updated in memory before it is written, so a failed write (read-only + // or full disk) still switches the interface; it only can't be remembered for next time. + let saved = app.session.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": language.code()})); + app.ui.language = language; + crate::i18n::set_current(language); + if let Err(e) = saved { + app.ui.status = tlf!("The language changed but could not be saved: {e}", e); + } let items = menu_items(app); if let Some(hook) = app.hooks.shortcuts_changed.as_mut() { hook(&items); @@ -170,6 +197,15 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val ctx.request_repaint(); return Ok(json!(app.ui.language)); } + if id == "file.exportFrame" { + if params.get("target").is_none() { + let source = targets_source(app, ¶ms); + params.as_object_mut().ok_or("command parameters must be an object")?.insert("target".into(), json!(if source { "source" } else { "program" })); + } + if params.get("path").is_none() { + return crate::panels::frame_export::open(app, ctx, ¶ms); + } + } if id == "perf.stats" { // the engine's counters plus playback, frame workers and UI timings return Ok(crate::perf::stats(app)); @@ -190,7 +226,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val if let Some(r) = crate::panels::keyboard::route(app, ctx, id, ¶ms) { return r; } - if let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) { + if !targets_source(app, ¶ms) + && let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) + { return r; } if let Some(r) = crate::panels::multicam::route(app, id, ¶ms) { @@ -233,14 +271,56 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val return r; } match id { + "playback.slowForward" | "playback.slowReverse" if targets_source(app, ¶ms) => { + return Err("Source playback currently supports normal forward speed".into()); + } "playback.slowForward" | "playback.slowReverse" => { app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 }); return Ok(json!({"speed": app.playback.speed})); } + "source.playback.toggle" => { + app.toggle_source_play()?; + return Ok(json!({"playing": app.source_playback.clock.playing})); + } + "source.playback.play" => { + app.play_source()?; + return Ok(json!({"playing": app.source_playback.clock.playing})); + } + "source.playback.stop" => { + app.stop_source(); + return Ok(Value::Null); + } + "playback.toggle" + if params.get("monitor").and_then(Value::as_str) == Some("source") || (params.get("monitor").is_none() && app.ui.focused == PanelKind::Source) => + { + app.toggle_source_play()?; + return Ok(json!({"playing": app.source_playback.clock.playing})); + } "playback.toggle" => { app.toggle_play(1.0); return Ok(json!({"playing": app.playback.playing})); } + "playback.forward" if targets_source(app, ¶ms) => { + app.play_source()?; + return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0})); + } + "playback.reverse" if targets_source(app, ¶ms) => { + return Err("Source playback currently supports normal forward speed; use Play/Space or frame stepping".into()); + } + "playhead.stepBack" | "playhead.stepForward" | "playhead.stepBack5" | "playhead.stepForward5" if targets_source(app, ¶ms) => { + app.stop_source(); + let item = app.session.state.source_item.ok_or("Open a Source clip first")?; + let view = filmcraft_engine::clip_ops::source_view(&app.session, item).ok_or("Source clip is unavailable")?; + let count = if id.ends_with('5') { i64::from(app.session.prefs.playback.step_many_frames) } else { 1 }; + let direction = if id.contains("Back") { -1 } else { 1 }; + let time = if view.rate.frame_duration().0 == 0 { + app.session.state.source_playhead + } else { + let frame = view.rate.frame_at(app.session.state.source_playhead).saturating_add(count.saturating_mul(direction)); + view.rate.tick_of(frame) + }; + return app.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string()); + } "playback.forward" => { let s = if app.playback.playing && app.playback.speed > 0.0 { (app.playback.speed * 2.0).min(8.0) } else { 1.0 }; app.play(s); @@ -252,9 +332,21 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val return Ok(json!({"speed": app.playback.speed})); } "playback.stop" => { - app.stop(); + if targets_source(app, ¶ms) { + app.stop_source(); + } else { + app.stop(); + } return Ok(Value::Null); } + "playback.inToOut" if targets_source(app, ¶ms) => { + app.play_source_range(false, false)?; + return Ok(Value::Null); + } + "playback.loop" if targets_source(app, ¶ms) => { + app.source_playback.clock.looping = !app.source_playback.clock.looping; + return Ok(json!({"loop": app.source_playback.clock.looping})); + } "playback.inToOut" => { let seq = app.session.active_sequence(); let out = seq.and_then(|q| q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration())); @@ -298,7 +390,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val id if crate::links::url_for(id).is_some() => { let url = crate::links::url_for(id).unwrap_or_default(); crate::links::open(ctx, url); - app.ui.status = format!("Opened {url}"); + app.ui.status = tlf!("Opened {url}", url); return Ok(json!({"url": url})); } "help.shortcuts" | "app.keyboardShortcuts" => { @@ -310,6 +402,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; return crate::panels::interchange_export::open(app, ctx, id); } + // File ▸ Export ▸ Media… opens the Export mode, like ⌘M (#382); with a path it exports directly. + "file.exportMedia" if params.get("path").is_none() => { + filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; + app.ui.mode = Mode::Export; + return Ok(json!({"mode": "export"})); + } // Audio Gain from the menu or G opens the dialog; with params it applies directly. "clip.audioGain" if params.as_object().is_none_or(|m| m.is_empty()) => { filmcraft_engine::find_command("clip.audioGain").map_or(Ok(()), |c| (c.enabled)(&app.session))?; @@ -351,6 +449,19 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val app.dialog = Some(crate::Dialog::DeleteTracks); return Ok(json!({"dialog": "deleteTracks"})); } + // Sequence Settings… from the menu opens the dialog; with params the engine command applies + // them directly. + "sequence.settings" if params.as_object().is_none_or(|m| m.is_empty()) => { + filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; + crate::panels::sequence_settings::open(app); + return Ok(json!({"dialog": "sequenceSettings"})); + } + // File ▸ New ▸ Sequence… (Cmd+N) opens New Sequence; with params (a clip, an agent) it + // makes the sequence directly + "file.newSequence" if params.as_object().is_none_or(|m| m.is_empty()) => { + crate::panels::sequence_settings::open_new(app); + return Ok(json!({"dialog": "newSequence"})); + } "sequence.colorSettings" if params.as_object().is_none_or(|m| m.is_empty()) => { filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?; crate::panels::color_dialogs::open_sequence(app); @@ -388,6 +499,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val || (id == "file.save" && params.get("path").is_none() && app.session.path.is_none()) || (matches!(id, "captions.import" | "captions.export") && params.get("path").is_none()) || (id == "graphics.newFromFile" && params.get("path").is_none()) + || (crate::EXPORT_SAVE_DIALOGS.iter().any(|(c, ..)| *c == id) && params.get("path").is_none()) { return app.file_dialog(id, ¶ms); } @@ -438,6 +550,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.japanese" => it.checked = Some(app.ui.language == crate::i18n::Language::Ja), "app.language.spanish" => it.checked = Some(app.ui.language == crate::i18n::Language::Es), "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), + "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk), _ => {} } if it.id.starts_with("view.") { @@ -502,16 +615,22 @@ pub fn shortcut_text(s: &str) -> String { Chord::parse(s).map(|c| c.display(Platform::current())).unwrap_or_else(|_| s.to_string()) } -/// Parse "Cmd+Shift+K" into modifiers + key. +/// Parse "Cmd+Shift+K" into the modifiers a chord requires + its key. Off macOS the Control key +/// is the primary modifier, so `Ctrl` and `Cmd` are one key and both mean `command` (the engine's +/// `Chord::effective` says the same): a physical Ctrl press carries `command` there, and egui's +/// matching asks only for what the pattern names, so a `Ctrl+…` chord matches a `Cmd+…` binding +/// and the other way round (#245). On a Mac they stay two keys. pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; let parts: Vec<&str> = if s == "+" { vec!["+"] } else { s.split('+').collect() }; + let mac = cfg!(target_os = "macos"); for p in parts { match p { "Cmd" => m.command = true, "Shift" => m.shift = true, "Alt" => m.alt = true, + "Ctrl" if !mac => m.command = true, "Ctrl" => m.ctrl = true, k => { key = match k { @@ -584,13 +703,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); - ui.menu_button(app.ui.language.tr(top), |ui| { + let r = ui.menu_button(app.ui.language.tr(top), |ui| { ui.set_min_width(260.0); if mine.is_empty() { - ui.add_enabled(false, egui::Button::new("(empty)")); + ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } - menu_level(ui, &mine, 1, &mut clicked); + menu_level(ui, &mine, 1, &mut clicked, &mut app.auto); }); + app.auto.add(&format!("menu.{top}"), r.response.rect, top); } }); if let Some(id) = clicked { @@ -600,7 +720,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { /// One menu level: items whose path ends here, and a submenu (at its first item's position) for /// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). -fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { +fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, auto: &mut crate::automation::Registry) { let mut subs: Vec<&str> = Vec::new(); for it in items { if let Some(sub) = it.path.get(depth).map(String::as_str) { @@ -612,16 +732,16 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); - menu_level(ui, &inner, depth + 1, clicked); + menu_level(ui, &inner, depth + 1, clicked, auto); }); - } else if menu_entry(ui, it) { + } else if menu_entry(ui, it, auto) { *clicked = Some(it.id.clone()); ui.close(); } } } -fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { +fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool { // checkable items leave room for a checkmark drawn at the left let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() }; let mut b = egui::Button::new(label); @@ -629,6 +749,7 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { b = b.shortcut_text(shortcut_text(s)); } let r = ui.add_enabled(it.enabled, b); + auto.add(&format!("menu.{}", it.id), r.rect, &it.label); if it.checked == Some(true) { let c = r.rect.left_center() + egui::vec2(10.0, 0.0); let col = ui.visuals().text_color(); @@ -638,3 +759,33 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { } r.clicked() } + +#[cfg(test)] +mod parse_shortcut_tests { + use super::parse_shortcut; + + /// Off macOS `Ctrl` and `Cmd` are the same key, so either spelling parses to the modifiers a + /// physical Ctrl press carries and matches a binding written the other way (#245). + #[test] + fn ctrl_and_cmd_are_one_key_off_macos() { + let (ctrl, k) = parse_shortcut("Ctrl+Z").unwrap(); + let (cmd, k2) = parse_shortcut("Cmd+Z").unwrap(); + assert_eq!((k, k2), (egui::Key::Z, egui::Key::Z)); + if cfg!(target_os = "macos") { + assert_eq!((ctrl.ctrl, ctrl.command), (true, false)); + assert_eq!((cmd.ctrl, cmd.command), (false, true)); + assert!(!ctrl.matches_logically(cmd) && !cmd.matches_logically(ctrl), "two different keys on a Mac"); + } else { + assert_eq!((ctrl.ctrl, ctrl.command), (false, true)); + assert_eq!(ctrl, cmd, "one key off a Mac"); + // what egui-winit reports for the physical key, and a bare `command` as tests send it + let physical = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + assert!(physical.matches_logically(cmd) && physical.matches_logically(ctrl)); + assert!(egui::Modifiers::COMMAND.matches_logically(ctrl)); + } + assert!(!parse_shortcut("Z").unwrap().0.matches_logically(cmd), "a bare key never stands in for the chord"); + let (m, k) = parse_shortcut("Cmd+Shift+K").unwrap(); + assert!(m.command && m.shift && !m.alt && k == egui::Key::K); + assert_eq!(parse_shortcut("+").map(|(_, k)| k), Some(egui::Key::Plus)); + } +} diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index b5a7c705b..bccdd0ad7 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -172,8 +172,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { close.push(target); continue; } - let name = inst.def().map(|d| d.name).unwrap_or("Effect"); - let kind = if matches!(target, FxTarget::Clip { .. }) { "Clip Fx Editor" } else { "Track Fx Editor" }; + let name = inst.def().map_or(tl!("Effect"), |d| crate::i18n::t(d.name)); + let kind = if matches!(target, FxTarget::Clip { .. }) { tl!("Clip Fx Editor") } else { tl!("Track Fx Editor") }; let title = format!("{kind} - {name}: {place}"); let k = key(&target); let drafts_id = egui::Id::new(("fx-editor-drafts", &k)); @@ -191,7 +191,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.separator(); ui.horizontal(|ui| { - let r = ui.button("Close"); + let r = ui.button(tl!("Close")); ed.auto(format!("fxEditor.{}.close", ed.fx), r.rect, "Close"); if r.clicked() { closed = true; @@ -253,10 +253,10 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) - let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag()); let p = ui.painter_at(r); let t = &ed.t; - p.rect_filled(r, 2.0, Color32::from_gray(0x18)); + p.rect_filled(r, 2.0, ed.t.plot_bg); for f in [50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0] { let x = x_of(f, r); - p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_gray(0x2c))); + p.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, ed.t.plot_grid)); let label = if f >= 1000.0 { format!("{}k", f / 1000.0) } else { format!("{f}") }; p.text(pos2(x + 2.0, r.max.y - 2.0), Align2::LEFT_BOTTOM, label, Tokens::ui(9.0), t.text_faint); } @@ -264,7 +264,7 @@ fn response_plot(ui: &mut egui::Ui, ed: &mut Ed, size: egui::Vec2, range: f64) - let mut d = -range; while d <= range + 1e-9 { let y = y_of(d, r, range); - let col = if d.abs() < 1e-9 { Color32::from_gray(0x44) } else { Color32::from_gray(0x2c) }; + let col = if d.abs() < 1e-9 { ed.t.plot_axis } else { ed.t.plot_grid }; p.line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, col)); p.text(pos2(r.min.x + 2.0, y - 1.0), Align2::LEFT_BOTTOM, format!("{d:+.0}"), Tokens::ui(9.0), t.text_faint); d += step; @@ -310,7 +310,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { let id = egui::Id::new(("peq-node", ed.target.clone(), pre)); let resp = ui.interact(nr, id, Sense::click_and_drag()); let on = ed.on(&on_id); - let col = if on { Color32::from_rgb(0xe8, 0xe8, 0xe8) } else { Color32::from_gray(0x70) }; + let col = if on { ed.t.eq_node } else { ed.t.eq_node_off }; ui.painter().circle_stroke(c, 7.0, Stroke::new(1.5, col)); ui.painter().text(c, Align2::CENTER_CENTER, label, Tokens::ui(8.5), col); ed.auto(format!("fxEditor.{}.node.{pre}", ed.fx), nr, label); @@ -349,7 +349,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { ed.toggle(ui, &format!("{pre}_on"), label); } }); - ed.slider(ui, "master_gain", "Master Gain", false); + ed.slider(ui, "master_gain", tl!("Master Gain"), false); } // --------------------------------------------------------------------------- graphic EQ @@ -374,14 +374,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { let pid = format!("b{}", i + 1); let x = area.min.x + colw * (i as f32 + 0.5); let track = Rect::from_center_size(pos2(x, area.min.y + 70.0), vec2(6.0, 130.0)); - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x30)); - ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, Color32::from_gray(0x60))); + ui.painter().rect_filled(track, 2.0, ed.t.eq_track); + ui.painter().line_segment([pos2(x - 5.0, track.center().y), pos2(x + 5.0, track.center().y)], Stroke::new(1.0, ed.t.eq_tick)); let v = ed.v(&pid); let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5; let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0)); let id = egui::Id::new(("geq-band", ed.target.clone(), i)); let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag()); - ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { Color32::from_gray(0xf0) } else { Color32::from_gray(0xc0) }); + ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob }); ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim); let short = label.replace(" Hz", "").replace(" kHz", "k"); ui.painter().text(pos2(x, area.max.y - 8.0), Align2::CENTER_CENTER, short, Tokens::ui(8.5), ed.t.text_faint); @@ -401,8 +401,8 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { } } ui.horizontal(|ui| { - ed.slider(ui, "gain", "Master Gain", false); - let r = ui.button("Reset"); + ed.slider(ui, "gain", tl!("Master Gain"), false); + let r = ui.button(tl!("Reset")); ed.auto(format!("fxEditor.{}.reset", ed.fx), r.rect, "Reset"); if r.clicked() { for i in 0..n { @@ -420,14 +420,14 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: String) { let (r, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); let p = ui.painter_at(r); - p.rect_filled(r, 2.0, Color32::from_gray(0x18)); + p.rect_filled(r, 2.0, ed.t.plot_bg); let lo = -60.0; let map = |x: f64, y: f64| pos2(r.min.x + ((x - lo) / -lo) as f32 * r.width(), r.max.y - (((y - lo) / -lo).clamp(-0.05, 1.05)) as f32 * r.height()); for d in [-48.0, -36.0, -24.0, -12.0] { - p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, Color32::from_gray(0x2c))); - p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, Color32::from_gray(0x2c))); + p.line_segment([map(d, lo), map(d, 0.0)], Stroke::new(1.0, ed.t.plot_grid)); + p.line_segment([map(lo, d), map(0.0, d)], Stroke::new(1.0, ed.t.plot_grid)); } - p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, Color32::from_gray(0x44))); + p.line_segment([map(lo, lo), map(0.0, 0.0)], Stroke::new(1.0, ed.t.plot_axis)); if let Some(dsp) = filmcraft_render::audio_fx::configured(&ed.preview(), ed.mt, 48000) { let pts: Vec = (0..=120) .filter_map(|i| { @@ -437,8 +437,8 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S .collect(); p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_rgb(0xff, 0xb3, 0x47)))); } - p.text(r.left_top() + vec2(3.0, 2.0), Align2::LEFT_TOP, "out", Tokens::ui(8.5), ed.t.text_faint); - p.text(r.right_bottom() - vec2(3.0, 2.0), Align2::RIGHT_BOTTOM, "in", Tokens::ui(8.5), ed.t.text_faint); + p.text(r.left_top() + vec2(3.0, 2.0), Align2::LEFT_TOP, tl!("out"), Tokens::ui(8.5), ed.t.text_faint); + p.text(r.right_bottom() - vec2(3.0, 2.0), Align2::RIGHT_BOTTOM, tl!("in"), Tokens::ui(8.5), ed.t.text_faint); ed.auto(auto, r, "Transfer curve"); } @@ -447,7 +447,7 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { // Spectrum strip with draggable crossover handles. let (r, _) = ui.allocate_exact_size(vec2(640.0, 70.0), Sense::hover()); - ui.painter().rect_filled(r, 2.0, Color32::from_gray(0x18)); + ui.painter().rect_filled(r, 2.0, ed.t.plot_bg); let cols = [Color32::from_rgb(0x3d, 0x6e, 0xb4), Color32::from_rgb(0x3d, 0xa0, 0x6e), Color32::from_rgb(0xb4, 0x9a, 0x3d), Color32::from_rgb(0xb4, 0x4f, 0x3d)]; let xo = [ed.v("xo1"), ed.v("xo2"), ed.v("xo3")]; @@ -455,13 +455,14 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for b in 0..4 { let br = Rect::from_min_max(pos2(edges[b], r.min.y), pos2(edges[b + 1], r.max.y)); ui.painter().rect_filled(br.shrink2(vec2(0.0, 8.0)), 0.0, cols[b].gamma_multiply(0.35)); - ui.painter().text(br.center(), Align2::CENTER_CENTER, format!("Band {}", b + 1), Tokens::ui(10.0), ed.t.text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Band {n}", n = b + 1), Tokens::ui(10.0), ed.t.text); } for (k, pid) in ["xo1", "xo2", "xo3"].iter().enumerate() { let x = edges[k + 1]; let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height())); let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag()); - ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, Color32::WHITE)); + ui.painter() + .line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle)); ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim); ed.auto(format!("fxEditor.{}.{pid}", ed.fx), hr, "Crossover"); if resp.dragged() @@ -480,29 +481,29 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for b in 1..=4 { ui.vertical(|ui| { ui.set_width(150.0); - ui.label(egui::RichText::new(format!("Band {b}")).strong()); + ui.label(egui::RichText::new(tlf!("Band {n}", n = b)).strong()); transfer_plot(ui, ed, 110.0, b - 1, format!("fxEditor.{}.curve.{b}", ed.fx)); ui.horizontal(|ui| { - ed.toggle(ui, &format!("b{b}_solo"), "Solo"); - ed.toggle(ui, &format!("b{b}_bypass"), "Bypass"); + ed.toggle(ui, &format!("b{b}_solo"), tl!("Solo")); + ed.toggle(ui, &format!("b{b}_bypass"), tl!("Bypass")); }); - ed.slider(ui, &format!("b{b}_threshold"), "Thr", false); - ed.slider(ui, &format!("b{b}_ratio"), "Ratio", false); - ed.slider(ui, &format!("b{b}_attack"), "Att", true); - ed.slider(ui, &format!("b{b}_release"), "Rel", true); - ed.slider(ui, &format!("b{b}_gain"), "Gain", false); + ed.slider(ui, &format!("b{b}_threshold"), tl!("Thr"), false); + ed.slider(ui, &format!("b{b}_ratio"), tl!("Ratio"), false); + ed.slider(ui, &format!("b{b}_attack"), tl!("Att"), true); + ed.slider(ui, &format!("b{b}_release"), tl!("Rel"), true); + ed.slider(ui, &format!("b{b}_gain"), tl!("Gain"), false); }); } }); ui.separator(); ui.horizontal(|ui| { - ed.slider(ui, "output", "Output Gain", false); - ed.toggle(ui, "lim_on", "Limiter"); - ed.toggle(ui, "link", "Link Channels"); + ed.slider(ui, "output", tl!("Output Gain"), false); + ed.toggle(ui, "lim_on", tl!("Limiter")); + ed.toggle(ui, "link", tl!("Link Channels")); }); ui.horizontal(|ui| { - ed.slider(ui, "lim_threshold", "Limiter Threshold", false); - ed.slider(ui, "lim_release", "Limiter Release", true); + ed.slider(ui, "lim_threshold", tl!("Limiter Threshold"), false); + ed.slider(ui, "lim_release", tl!("Limiter Release"), true); }); } @@ -513,34 +514,34 @@ fn dynamics(ui: &mut egui::Ui, ed: &mut Ed) { transfer_plot(ui, ed, 220.0, 0, format!("fxEditor.{}.curve", ed.fx)); ui.vertical(|ui| { ui.set_width(330.0); - ed.toggle(ui, "gate_on", "Auto Gate"); - ed.slider(ui, "gate_threshold", "Threshold", false); - ed.slider(ui, "gate_attack", "Attack", true); - ed.slider(ui, "gate_release", "Release", true); - ed.slider(ui, "gate_hold", "Hold", false); + ed.toggle(ui, "gate_on", tl!("Auto Gate")); + ed.slider(ui, "gate_threshold", tl!("Threshold"), false); + ed.slider(ui, "gate_attack", tl!("Attack"), true); + ed.slider(ui, "gate_release", tl!("Release"), true); + ed.slider(ui, "gate_hold", tl!("Hold"), false); ui.separator(); - ed.toggle(ui, "comp_on", "Compressor"); - ed.slider(ui, "comp_threshold", "Threshold", false); - ed.slider(ui, "comp_ratio", "Ratio", false); - ed.slider(ui, "comp_attack", "Attack", true); - ed.slider(ui, "comp_release", "Release", true); + ed.toggle(ui, "comp_on", tl!("Compressor")); + ed.slider(ui, "comp_threshold", tl!("Threshold"), false); + ed.slider(ui, "comp_ratio", tl!("Ratio"), false); + ed.slider(ui, "comp_attack", tl!("Attack"), true); + ed.slider(ui, "comp_release", tl!("Release"), true); ui.horizontal(|ui| { - ed.toggle(ui, "comp_auto", "Auto Makeup"); + ed.toggle(ui, "comp_auto", tl!("Auto Makeup")); }); - ed.slider(ui, "comp_makeup", "Makeup", false); + ed.slider(ui, "comp_makeup", tl!("Makeup"), false); }); ui.vertical(|ui| { ui.set_width(330.0); - ed.toggle(ui, "exp_on", "Expander"); - ed.slider(ui, "exp_threshold", "Threshold", false); - ed.slider(ui, "exp_ratio", "Ratio", false); + ed.toggle(ui, "exp_on", tl!("Expander")); + ed.slider(ui, "exp_threshold", tl!("Threshold"), false); + ed.slider(ui, "exp_ratio", tl!("Ratio"), false); ui.separator(); - ed.toggle(ui, "lim_on", "Limiter"); - ed.slider(ui, "lim_threshold", "Threshold", false); - ed.slider(ui, "lim_release", "Release", true); - ed.toggle(ui, "soft_clip", "Soft Clip"); + ed.toggle(ui, "lim_on", tl!("Limiter")); + ed.slider(ui, "lim_threshold", tl!("Threshold"), false); + ed.slider(ui, "lim_release", tl!("Release"), true); + ed.toggle(ui, "soft_clip", tl!("Soft Clip")); ui.separator(); - ed.slider(ui, "output", "Output Gain", false); + ed.slider(ui, "output", tl!("Output Gain"), false); }); }); } diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index a205e1ce6..104961e35 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -44,27 +44,40 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp /// Dialog title and automation prefix of a command's dialog. fn meta(command: &str) -> Option<(&'static str, &'static str)> { Some(match command { - "edit.pasteAttributes" => ("Paste Attributes", "pasteAttributes"), - "edit.removeAttributes" => ("Remove Attributes", "removeAttributes"), - "file.newOfflineFile" => ("New Offline File", "offlineFile"), - "clip.nest" => ("Nested Sequence Name", "nest"), - "clip.makeSubclip" => ("Make Subclip", "makeSubclip"), - "clip.editSubclip" => ("Edit Subclip", "editSubclip"), - "clip.audioChannels" => ("Modify Clip: Audio Channels", "audioChannels"), - "clip.modifyTimecode" => ("Modify Clip: Timecode", "timecode"), - "clip.frameHoldOptions" => ("Frame Hold Options", "frameHold"), - "clip.fieldOptions" => ("Field Options", "fieldOptions"), - "clip.speedDuration" => ("Clip Speed / Duration", "speedDuration"), - "file.closeProject" => ("Save Project", "closeProject"), + "edit.pasteAttributes" => (tl!("Paste Attributes"), "pasteAttributes"), + "edit.removeAttributes" => (tl!("Remove Attributes"), "removeAttributes"), + "file.newOfflineFile" => (tl!("New Offline File"), "offlineFile"), + "clip.nest" => (tl!("Nested Sequence Name"), "nest"), + "clip.makeSubclip" => (tl!("Make Subclip"), "makeSubclip"), + "clip.editSubclip" => (tl!("Edit Subclip"), "editSubclip"), + "clip.audioChannels" => (tl!("Modify Clip: Audio Channels"), "audioChannels"), + "clip.modifyTimecode" => (tl!("Modify Clip: Timecode"), "timecode"), + "clip.frameHoldOptions" => (tl!("Frame Hold Options"), "frameHold"), + "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"), + "clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"), + "file.closeProject" => (tl!("Save Project"), "closeProject"), _ => return None, }) } -const INTRINSIC_VIDEO: [(&str, &str); 3] = [("motion", "Motion"), ("opacity", "Opacity"), ("timeRemapping", "Time Remapping")]; -const INTRINSIC_AUDIO: [(&str, &str); 3] = [("volume", "Volume"), ("channelVolume", "Channel Volume"), ("panner", "Panner")]; -const FORMATS: [(&str, &str); 4] = [("mono", "Mono"), ("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive")]; -const HOLD_ON: [(&str, &str); 5] = - [("in", "In Point"), ("out", "Out Point"), ("playhead", "Playhead"), ("sourceTimecode", "Source Timecode"), ("sequenceTime", "Sequence Time")]; +fn intrinsic_video() -> [(&'static str, &'static str); 3] { + [("motion", tl!("Motion")), ("opacity", tl!("Opacity")), ("timeRemapping", tl!("Time Remapping"))] +} +fn intrinsic_audio() -> [(&'static str, &'static str); 3] { + [("volume", tl!("Volume")), ("channelVolume", tl!("Channel Volume")), ("panner", tl!("Panner"))] +} +fn formats() -> [(&'static str, &'static str); 4] { + [("mono", tl!("Mono")), ("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive"))] +} +fn hold_on() -> [(&'static str, &'static str); 5] { + [ + ("in", tl!("In Point")), + ("out", tl!("Out Point")), + ("playhead", tl!("Playhead")), + ("sourceTimecode", tl!("Source Timecode")), + ("sequenceTime", tl!("Sequence Time")), + ] +} /// Menu / shortcut entry points without params open a dialog. Returns None for other ids. pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option> { @@ -155,7 +168,7 @@ fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) { let item = s.state.project_selection.iter().find_map(|i| s.project.item(*i).and_then(|it| it.as_media()).filter(|m| m.info.has_audio())); match item { Some(m) => { - 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 map = m.interpret.audio_channels.clone().unwrap_or_else(|| AudioChannelMap::for_format(AudioChannels::Stereo, n)); (json!({"format": format_name(map.format), "clips": map.clips}), json!({"channels": n, "items": true})) } @@ -278,30 +291,32 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut action: Option<&'static str> = None; let project_name = app.session.project.name.clone(); let can_save = app.session.path.is_some(); - egui::Window::new(title).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + // the title is translated; the window keeps one id whatever the interface language + let id = egui::Id::new(("clip-dialog", pre)); + egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { "edit.pasteAttributes" | "edit.removeAttributes" => { let (v, a) = (d.info["video"].as_bool().unwrap_or(true), d.info["audio"].as_bool().unwrap_or(true)); - ui.label(RichText::new("Video Attributes").strong()); - for (k, l) in INTRINSIC_VIDEO { + ui.label(RichText::new(tl!("Video Attributes")).strong()); + for (k, l) in intrinsic_video() { check(ui, &mut elems, pre, p, k, l, v); } - ui.label(RichText::new("Audio Attributes").strong()); - for (k, l) in INTRINSIC_AUDIO { + ui.label(RichText::new(tl!("Audio Attributes")).strong()); + for (k, l) in intrinsic_audio() { check(ui, &mut elems, pre, p, k, l, a); } let effects: Vec<(String, String)> = d.info["effects"] .as_array() .map(|x| x.iter().filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string()))).collect()) .unwrap_or_default(); - ui.label(RichText::new("Effects").strong()); + ui.label(RichText::new(tl!("Effects")).strong()); if effects.is_empty() { - ui.label(RichText::new("No effects").weak()); + ui.label(RichText::new(tl!("No effects")).weak()); } for (id, name) in &effects { let mut on = p["effects"].as_array().is_some_and(|x| x.iter().any(|e| e.as_str() == Some(id))); - let r = ui.checkbox(&mut on, name); + let r = ui.checkbox(&mut on, crate::i18n::t(name)); push(&mut elems, format!("{pre}.effect.{id}"), &r, name.as_str()); if r.changed() { let mut list: Vec = p["effects"].as_array().cloned().unwrap_or_default(); @@ -314,33 +329,33 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } if d.command == "edit.pasteAttributes" { ui.separator(); - check(ui, &mut elems, pre, p, "scaleTimes", "Scale Attribute Times", true); + check(ui, &mut elems, pre, p, "scaleTimes", tl!("Scale Attribute Times"), true); } } "file.newOfflineFile" => { - text(ui, &mut elems, pre, p, "fileName", "File Name:", 220.0); - text(ui, &mut elems, pre, p, "name", "Clip Name:", 220.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0); - text(ui, &mut elems, pre, p, "description", "Description:", 220.0); + text(ui, &mut elems, pre, p, "fileName", tl!("File Name:"), 220.0); + text(ui, &mut elems, pre, p, "name", tl!("Clip Name:"), 220.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 220.0); + text(ui, &mut elems, pre, p, "description", tl!("Description:"), 220.0); ui.horizontal(|ui| { - ui.label("Contains:"); - check(ui, &mut elems, pre, p, "video", "Video", true); - check(ui, &mut elems, pre, p, "audio", "Audio", true); + ui.label(tl!("Contains:")); + check(ui, &mut elems, pre, p, "video", tl!("Video"), true); + check(ui, &mut elems, pre, p, "audio", tl!("Audio"), true); }); let has_v = p["video"].as_bool().unwrap_or(true); ui.add_enabled_ui(has_v, |ui| { - number(ui, &mut elems, pre, p, "width", "Width:", 16.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "height", "Height:", 16.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "fps", "Frame Rate:", 1.0..=240.0, " fps"); + number(ui, &mut elems, pre, p, "width", tl!("Width:"), 16.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "height", tl!("Height:"), 16.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "fps", tl!("Frame Rate:"), 1.0..=240.0, " fps"); }); - text(ui, &mut elems, pre, p, "timecode", "Media Start:", 120.0); - number(ui, &mut elems, pre, p, "seconds", "Duration:", 0.05..=86_400.0, " s"); + text(ui, &mut elems, pre, p, "timecode", tl!("Media Start:"), 120.0); + number(ui, &mut elems, pre, p, "seconds", tl!("Duration:"), 0.05..=86_400.0, " s"); } "clip.nest" => { // the name is ready to type over, and Enter accepts it let mut v = p["name"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let id = egui::Id::new("nest-name"); let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)); push(&mut elems, format!("{pre}.name"), &r, "Name:"); @@ -360,23 +375,23 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { "clip.makeSubclip" | "clip.editSubclip" => { let fps = d.info["fps"].as_f64().unwrap_or(24.0); if d.command == "clip.makeSubclip" { - text(ui, &mut elems, pre, p, "name", "Name:", 240.0); + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 240.0); } else { - ui.label(format!("Subclip: {}", d.info["name"].as_str().unwrap_or_default())); + ui.label(tlf!("Subclip: {name}", name = d.info["name"].as_str().unwrap_or_default())); } - frames(ui, &mut elems, pre, p, "startFrame", "Start:", fps); - frames(ui, &mut elems, pre, p, "endFrame", "End:", fps); - check(ui, &mut elems, pre, p, "restrictTrims", "Restrict Trims To Subclip Boundaries", true); + frames(ui, &mut elems, pre, p, "startFrame", tl!("Start:"), fps); + frames(ui, &mut elems, pre, p, "endFrame", tl!("End:"), fps); + check(ui, &mut elems, pre, p, "restrictTrims", tl!("Restrict Trims To Subclip Boundaries"), true); if d.command == "clip.editSubclip" { - check(ui, &mut elems, pre, p, "convertToMaster", "Convert to Master Clip", true); + check(ui, &mut elems, pre, p, "convertToMaster", tl!("Convert to Master Clip"), true); } } "clip.audioChannels" => { let n = d.info["channels"].as_u64().unwrap_or(2).max(1) as u16; if d.info["items"].as_bool().unwrap_or(false) { ui.horizontal(|ui| { - ui.label("Clip Channel Format:"); - for (k, l) in FORMATS { + ui.label(tl!("Clip Channel Format:")); + for (k, l) in formats() { let r = ui.radio(p["format"].as_str() == Some(k), l); push(&mut elems, format!("{pre}.format.{k}"), &r, l); if r.clicked() { @@ -387,7 +402,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { }); let mut clips: Vec> = serde_json::from_value(p["clips"].clone()).unwrap_or_default(); ui.horizontal(|ui| { - ui.label("Number of Audio Clips:"); + ui.label(tl!("Number of Audio Clips:")); let mut count = clips.len() as u32; let r = ui.add(egui::DragValue::new(&mut count).range(1..=n as u32)); push(&mut elems, format!("{pre}.count"), &r, "Number of Audio Clips"); @@ -400,15 +415,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { clips.truncate(count); } }); - ui.label(RichText::new("Media Source Channels").strong()); + ui.label(RichText::new(tl!("Media Source Channels")).strong()); egui::Grid::new("audio-channel-matrix").striped(true).show(ui, |ui| { ui.label(""); for c in 0..n { - ui.label(format!("Ch. {}", c + 1)); + ui.label(tlf!("Ch. {n}", n = c + 1)); } ui.end_row(); for (ci, chans) in clips.iter_mut().enumerate() { - ui.label(format!("Clip {}", ci + 1)); + ui.label(tlf!("Clip {n}", n = ci + 1)); for c in 0..n { let mut on = chans.contains(&c); let r = ui.checkbox(&mut on, ""); @@ -427,12 +442,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { }); p["clips"] = json!(clips); } else { - ui.label("Source channels the selected audio clips play:"); + ui.label(tl!("Source channels the selected audio clips play:")); let mut chans: Vec = serde_json::from_value(p["channels"].clone()).unwrap_or_default(); ui.horizontal(|ui| { for c in 0..n.max(2) { let mut on = chans.contains(&c); - let r = ui.checkbox(&mut on, format!("Ch. {}", c + 1)); + let r = ui.checkbox(&mut on, tlf!("Ch. {n}", n = c + 1)); push(&mut elems, format!("{pre}.clip.0.ch.{c}"), &r, format!("Ch. {}", c + 1)); if r.changed() { if on { @@ -448,15 +463,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } "clip.modifyTimecode" => { - text(ui, &mut elems, pre, p, "timecode", "Set Timecode:", 120.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 200.0); - check(ui, &mut elems, pre, p, "reset", "Use the file's timecode", true); + text(ui, &mut elems, pre, p, "timecode", tl!("Set Timecode:"), 120.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 200.0); + check(ui, &mut elems, pre, p, "reset", tl!("Use the file's timecode"), true); } "clip.frameHoldOptions" => { - check(ui, &mut elems, pre, p, "enabled", "Hold On", true); + check(ui, &mut elems, pre, p, "enabled", tl!("Hold On"), true); let on = p["enabled"].as_bool().unwrap_or(true); ui.add_enabled_ui(on, |ui| { - for (k, l) in HOLD_ON { + for (k, l) in hold_on() { let r = ui.radio(p["holdOn"].as_str() == Some(k), l); push(&mut elems, format!("{pre}.holdOn.{k}"), &r, l); if r.clicked() { @@ -464,15 +479,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } if matches!(p["holdOn"].as_str(), Some("sourceTimecode" | "sequenceTime")) { - text(ui, &mut elems, pre, p, "timecode", "Timecode:", 120.0); + text(ui, &mut elems, pre, p, "timecode", tl!("Timecode:"), 120.0); } - check(ui, &mut elems, pre, p, "holdFilters", "Hold Filters", true); + check(ui, &mut elems, pre, p, "holdFilters", tl!("Hold Filters"), true); }); } "clip.fieldOptions" => { - check(ui, &mut elems, pre, p, "reverseFieldDominance", "Reverse Field Dominance", true); - ui.label(RichText::new("Processing Options").strong()); - for (k, l) in [("none", "None"), ("alwaysDeinterlace", "Always Deinterlace"), ("flickerRemoval", "Flicker Removal")] { + check(ui, &mut elems, pre, p, "reverseFieldDominance", tl!("Reverse Field Dominance"), true); + ui.label(RichText::new(tl!("Processing Options")).strong()); + for (k, l) in [("none", tl!("None")), ("alwaysDeinterlace", tl!("Always Deinterlace")), ("flickerRemoval", tl!("Flicker Removal"))] { let r = ui.radio(p["processing"].as_str() == Some(k), l); push(&mut elems, format!("{pre}.processing.{k}"), &r, l); if r.clicked() { @@ -481,7 +496,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } "clip.speedDuration" => { - number(ui, &mut elems, pre, p, "speed", "Speed:", 0.01..=100_000.0, " %"); + number(ui, &mut elems, pre, p, "speed", tl!("Speed:"), 0.01..=100_000.0, " %"); // Duration follows the speed: the media shown stays the same let (dur, was, fps) = (d.info["duration"].as_i64().unwrap_or(0), d.info["speed"].as_f64().unwrap_or(1.0), d.info["fps"].as_f64().unwrap_or(24.0)); @@ -490,15 +505,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let rate = filmcraft_time::FrameRate::from_f64(if fps > 0.0 { fps } else { 24.0 }); let ticks = filmcraft_time::Tick((dur as f64 * was / now).round() as i64); ui.horizontal(|ui| { - ui.label("Duration:"); + ui.label(tl!("Duration:")); ui.label(RichText::new(filmcraft_time::format_timecode_frames(rate.frame_at(ticks), rate, false)).monospace()); }); } - check(ui, &mut elems, pre, p, "reverse", "Reverse Speed", true); - check(ui, &mut elems, pre, p, "ripple", "Ripple Edit, Shifting Trailing Clips", true); - ui.label(RichText::new("Time Interpolation").strong()); + check(ui, &mut elems, pre, p, "reverse", tl!("Reverse Speed"), true); + check(ui, &mut elems, pre, p, "ripple", tl!("Ripple Edit, Shifting Trailing Clips"), true); + ui.label(RichText::new(tl!("Time Interpolation")).strong()); for m in filmcraft_project::TimeInterpolation::ALL { - let r = ui.radio(p["interpolation"].as_str() == Some(m.name()), m.label()); + let r = ui.radio(p["interpolation"].as_str() == Some(m.name()), crate::i18n::t(m.label())); push(&mut elems, format!("{pre}.interpolation.{}", m.name()), &r, m.label()); if r.clicked() { p["interpolation"] = json!(m.name()); @@ -506,20 +521,20 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } "file.closeProject" => { - ui.label(format!("Save changes to “{project_name}” before closing?")); + ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name)); ui.add_space(8.0); ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); if r.clicked() { action = Some("cancel"); } - let r = ui.button("Don't Save"); + let r = ui.button(tl!("Don't Save")); push(&mut elems, format!("{pre}.dontSave"), &r, "Don't Save"); if r.clicked() { action = Some("dontSave"); } - let r = ui.add_enabled(can_save, egui::Button::new("Save")); + let r = ui.add_enabled(can_save, egui::Button::new(tl!("Save"))); push(&mut elems, format!("{pre}.save"), &r, "Save"); if r.clicked() { action = Some("save"); @@ -534,12 +549,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.add_space(6.0); ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); if r.clicked() { action = Some("cancel"); } - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); push(&mut elems, format!("{pre}.ok"), &r, "OK"); if r.clicked() { action = Some("ok"); @@ -593,10 +608,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { fn finish(d: &ClipDialogDraft) -> Value { let mut p = d.params.clone(); match d.command.as_str() { - "file.newOfflineFile" => { - if p["name"].as_str().is_some_and(str::is_empty) { - p.as_object_mut().map(|m| m.remove("name")); - } + "file.newOfflineFile" if p["name"].as_str().is_some_and(str::is_empty) => { + p.as_object_mut().map(|m| m.remove("name")); } "clip.modifyTimecode" if p["reset"].as_bool() == Some(true) => { p.as_object_mut().map(|m| m.remove("timecode")); diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs index 671653a45..9ee1c809f 100644 --- a/crates/ui-egui/src/panels/color_dialogs.rs +++ b/crates/ui-egui/src/panels/color_dialogs.rs @@ -48,9 +48,9 @@ fn buttons(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> (bool, bool) { let mut out = (false, false); ui.add_space(6.0); ui.horizontal(|ui| { - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); app.auto.add("colorDialog.cancel", c.rect, "Cancel"); - let o = ui.button("OK"); + let o = ui.button(tl!("OK")); app.auto.add("colorDialog.ok", o.rect, "OK"); out = (o.clicked(), c.clicked()); }); @@ -70,47 +70,62 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .and_then(|v| v["detectedLabel"].as_str().map(str::to_string)) .unwrap_or_else(|| "—".into()); let n = items.len(); - egui::Window::new("Interpret Footage").open(&mut open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| { - ui.label(egui::RichText::new(format!("Color Management · {n} item{}", if n == 1 { "" } else { "s" })).strong()); - ui.label(format!("Media colour space (from file metadata): {detected}")); - ui.add_space(4.0); - ui.label("Override media colour space:"); - if radio(app, ui, "colorDialog.space.auto", color_space == "auto", "Use file metadata") { - *color_space = "auto".into(); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for cs in ColorSpace::ALL { - if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) { - *color_space = cs.id().into(); - } + egui::Window::new(tl!("Interpret Footage")) + .id(egui::Id::new("Interpret Footage")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(380.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new(if n == 1 { tlf!("Color Management · {n} item", n) } else { tlf!("Color Management · {n} items", n) }).strong(), + ); + ui.label(tlf!("Media colour space (from file metadata): {detected}", detected)); + ui.add_space(4.0); + ui.label(tl!("Override media colour space:")); + if radio(app, ui, "colorDialog.space.auto", color_space == "auto", tl!("Use file metadata")) { + *color_space = "auto".into(); } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for cs in ColorSpace::ALL { + if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) { + *color_space = cs.id().into(); + } + } + }); + let (ok, cancel) = buttons(app, ui); + if ok { + apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space}))); + } + close = ok || cancel; }); - let (ok, cancel) = buttons(app, ui); - if ok { - apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space}))); - } - close = ok || cancel; - }); } ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => { - egui::Window::new("Sequence Color Management").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| { - ui.label(egui::RichText::new("Working Color Space").strong()); - for w in WorkingSpace::ALL { - if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) { - *working_space = w.id().into(); + egui::Window::new(tl!("Sequence Color Management")) + .id(egui::Id::new("Sequence Color Management")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(340.0) + .show(ctx, |ui| { + ui.label(egui::RichText::new(tl!("Working Color Space")).strong()); + for w in WorkingSpace::ALL { + if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) { + *working_space = w.id().into(); + } } - } - ui.add_space(4.0); - let r = ui.checkbox(wide_gamut, "Wide gamut color (composite in BT.2020)"); - app.auto.add("colorDialog.wideGamut", r.rect, "Wide gamut"); - let r = ui.checkbox(auto_tone_map, "Auto tone map media (HDR and log into SDR)"); - app.auto.add("colorDialog.autoToneMap", r.rect, "Auto tone map"); - let (ok, cancel) = buttons(app, ui); - if ok { - apply = Some(("sequence.colorSettings", json!({"workingSpace": working_space, "wideGamut": *wide_gamut, "autoToneMap": *auto_tone_map}))); - } - close = ok || cancel; - }); + ui.add_space(4.0); + let r = ui.checkbox(wide_gamut, tl!("Wide gamut color (composite in BT.2020)")); + app.auto.add("colorDialog.wideGamut", r.rect, "Wide gamut"); + let r = ui.checkbox(auto_tone_map, tl!("Auto tone map media (HDR and log into SDR)")); + app.auto.add("colorDialog.autoToneMap", r.rect, "Auto tone map"); + let (ok, cancel) = buttons(app, ui); + if ok { + apply = + Some(("sequence.colorSettings", json!({"workingSpace": working_space, "wideGamut": *wide_gamut, "autoToneMap": *auto_tone_map}))); + } + close = ok || cancel; + }); } } app.ui.color_dialog = if close || !open { None } else { Some(d) }; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 46136e79c..e82e2c3cf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -19,6 +19,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { crate::panels::voiceover::show(app, ctx); crate::panels::remix::show(app, ctx); crate::panels::interchange_export::show(app, ctx); + crate::panels::frame_export::show(app, ctx); crate::panels::project_dialogs::show(app, ctx); crate::panels::media_browser::dialogs(app, ctx); let Some(d) = app.dialog else { return }; @@ -31,7 +32,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; match d { Dialog::About => { - egui::Window::new("About FilmCraft") + egui::Window::new(tl!("About FilmCraft")) + .id(egui::Id::new("About FilmCraft")) .open(&mut open) .collapsible(false) .resizable(false) @@ -65,6 +67,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } return; } + Dialog::SequenceSettings => { + if !crate::panels::sequence_settings::show(app, ctx) && app.dialog == Some(d) { + app.dialog = None; + } + return; + } Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {} } if !open { @@ -93,44 +101,49 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - egui::Window::new("Audio Gain").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.add_space(6.0); - egui::Grid::new("audio-gain").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - let rows: [(&str, &str, &mut f64); 4] = [ - ("set", "Set Gain to:", &mut draft.set_db), - ("adjust", "Adjust Gain by:", &mut draft.adjust_db), - ("normalizeMax", "Normalize Max Peak to:", &mut draft.max_peak_db), - ("normalizeAll", "Normalize All Peaks to:", &mut draft.all_peaks_db), - ]; - for (id, label, v) in rows { - let r = ui.radio(draft.mode == id, label); - elems.push((format!("audioGain.{id}"), r.rect, label.to_string())); - if r.clicked() { - draft.mode = id.to_string(); + egui::Window::new(tl!("Audio Gain")) + .id(egui::Id::new("Audio Gain")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.add_space(6.0); + egui::Grid::new("audio-gain").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + let rows: [(&str, &str, &mut f64); 4] = [ + ("set", tl!("Set Gain to:"), &mut draft.set_db), + ("adjust", tl!("Adjust Gain by:"), &mut draft.adjust_db), + ("normalizeMax", tl!("Normalize Max Peak to:"), &mut draft.max_peak_db), + ("normalizeAll", tl!("Normalize All Peaks to:"), &mut draft.all_peaks_db), + ]; + for (id, label, v) in rows { + let r = ui.radio(draft.mode == id, label); + elems.push((format!("audioGain.{id}"), r.rect, label.to_string())); + if r.clicked() { + draft.mode = id.to_string(); + } + let f = ui.add_enabled(draft.mode == id, egui::DragValue::new(v).speed(0.1).range(-96.0..=96.0).suffix(" dB")); + elems.push((format!("audioGain.{id}.value"), f.rect, format!("{v:.1} dB"))); + ui.end_row(); } - let f = ui.add_enabled(draft.mode == id, egui::DragValue::new(v).speed(0.1).range(-96.0..=96.0).suffix(" dB")); - elems.push((format!("audioGain.{id}.value"), f.rect, format!("{v:.1} dB"))); - ui.end_row(); - } + }); + ui.add_space(8.0); + let pk_text = tlf!("Peak Amplitude: {peak}", peak = peak.map(|p| format!("{p:.1} dB")).unwrap_or_else(|| "—".into())); + let pk = ui.label(&pk_text); + elems.push(("audioGain.peak".into(), pk.rect, pk_text)); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("audioGain.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; + } + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("audioGain.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); }); - ui.add_space(8.0); - let pk_text = format!("Peak Amplitude: {}", peak.map(|p| format!("{p:.1} dB")).unwrap_or_else(|| "—".into())); - let pk = ui.label(&pk_text); - elems.push(("audioGain.peak".into(), pk.rect, pk_text)); - ui.add_space(8.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("audioGain.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("audioGain.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -172,88 +185,94 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - const AUDIO_TYPES: [(&str, &str); 4] = [("standard", "Standard"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")]; - const SUBMIX_TYPES: [(&str, &str); 4] = [("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")]; - egui::Window::new("Add Tracks").collapsible(false).resizable(false).default_width(320.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - // everything spans the width the window has: no empty band beside the fields - ui.set_width(ui.available_width()); - // the label column is as wide as its widest label; the lists take the rest of the row, - // up to the group's right edge (as in Premiere's dialog) - let font = egui::TextStyle::Body.resolve(ui.style()); - let label_w = ["Amount", "Placement", "Track type"] - .iter() - .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x) - .fold(0.0, f32::max); - let row_h = ui.spacing().interact_size.y; - let label = |ui: &mut egui::Ui, text: &str| { - ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)); - }; - let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [ - ("video", "Add video tracks", &mut d.video, &mut d.video_after, &vnames, None), - ("audio", "Add audio tracks", &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &AUDIO_TYPES))), - ("submix", "Add audio submix tracks", &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &SUBMIX_TYPES))), - ]; - for (kind, title, amount, after, tracks, track_type) in groups { - ui.add_space(4.0); - ui.label(egui::RichText::new(title).strong()); - ui.group(|ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - label(ui, "Amount"); - let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1)); - elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string())); - }); - ui.horizontal(|ui| { - label(ui, "Placement"); - *after = (*after).min(tracks.len()); - let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) { - Some(name) => format!("After {name}"), - None => "Before First Track".to_string(), - }; - let shown = place(*after); - let list_w = ui.available_width(); - // nowhere to choose from while the sequence has no track of the kind - ui.add_enabled_ui(!tracks.is_empty(), |ui| { - let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { - for n in 0..=tracks.len() { - let label = place(n); - let o = ui.selectable_value(after, n, &label); - elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label)); - } - }); - elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone())); - }); - }); - if let Some((chosen, types)) = track_type { + let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; + let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; + egui::Window::new(tl!("Add Tracks")) + .id(egui::Id::new("Add Tracks")) + .collapsible(false) + .resizable(false) + .default_width(320.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + // everything spans the width the window has: no empty band beside the fields + ui.set_width(ui.available_width()); + // the label column is as wide as its widest label; the lists take the rest of the row, + // up to the group's right edge (as in Premiere's dialog) + let font = egui::TextStyle::Body.resolve(ui.style()); + let label_w = [tl!("Amount"), tl!("Placement"), tl!("Track type")] + .iter() + .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x) + .fold(0.0, f32::max); + let row_h = ui.spacing().interact_size.y; + let label = |ui: &mut egui::Ui, text: &str| { + ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)); + }; + let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [ + ("video", tl!("Add video tracks"), &mut d.video, &mut d.video_after, &vnames, None), + ("audio", tl!("Add audio tracks"), &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &audio_types))), + ("submix", tl!("Add audio submix tracks"), &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &submix_types))), + ]; + for (kind, title, amount, after, tracks, track_type) in groups { + ui.add_space(4.0); + ui.label(egui::RichText::new(title).strong()); + ui.group(|ui| { + ui.set_width(ui.available_width()); ui.horizontal(|ui| { - label(ui, "Track type"); - let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string(); - let list_w = ui.available_width(); - let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { - for (id, label) in types { - let o = ui.selectable_value(chosen, id.to_string(), *label); - elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string())); - } - }); - elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown)); + label(ui, tl!("Amount")); + let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1)); + elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string())); }); + ui.horizontal(|ui| { + label(ui, tl!("Placement")); + *after = (*after).min(tracks.len()); + let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) { + Some(name) => tlf!("After {name}", name), + None => tl!("Before First Track").to_string(), + }; + let shown = place(*after); + let list_w = ui.available_width(); + // nowhere to choose from while the sequence has no track of the kind + ui.add_enabled_ui(!tracks.is_empty(), |ui| { + let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for n in 0..=tracks.len() { + let label = place(n); + let o = ui.selectable_value(after, n, &label); + elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label)); + } + }); + elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone())); + }); + }); + if let Some((chosen, types)) = track_type { + ui.horizontal(|ui| { + label(ui, tl!("Track type")); + let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string(); + let list_w = ui.available_width(); + let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for (id, label) in types { + let o = ui.selectable_value(chosen, id.to_string(), *label); + elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string())); + } + }); + elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown)); + }); + } + }); + } + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("addTracks.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + let c = ui.button(tl!("Cancel")); + elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; } }); - } - ui.add_space(10.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("addTracks.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - let c = ui.button("Cancel"); - elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -297,48 +316,55 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - egui::Window::new("Delete Tracks").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_min_width(300.0); - for (kind, title, on, target, list) in [ - ("video", "Video Tracks", &mut draft.video, &mut draft.video_target, &vnames), - ("audio", "Audio Tracks", &mut draft.audio, &mut draft.audio_target, &anames), - ] { - ui.add_space(4.0); - ui.label(egui::RichText::new(title).strong()); - let label = format!("Delete {title}"); - let c = ui.checkbox(on, &label); - elems.push((format!("deleteTracks.{kind}"), c.rect, label)); - ui.horizontal(|ui| { - ui.add_space(22.0); - let shown = if target == "empty" { "All Empty Tracks".to_string() } else { target.clone() }; - ui.add_enabled_ui(*on, |ui| { - let r = egui::ComboBox::from_id_salt(("delete-tracks", kind)).selected_text(&shown).width(170.0).show_ui(ui, |ui| { - let e = ui.selectable_value(target, "empty".to_string(), "All Empty Tracks"); - elems.push((format!("deleteTracks.{kind}.option.empty"), e.rect, "All Empty Tracks".into())); - for n in list { - let r = ui.selectable_value(target, n.clone(), n); - elems.push((format!("deleteTracks.{kind}.option.{n}"), r.rect, n.clone())); - } + egui::Window::new(tl!("Delete Tracks")) + .id(egui::Id::new("Delete Tracks")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(300.0); + for (kind, title, delete, on, target, list) in [ + ("video", tl!("Video Tracks"), tl!("Delete Video Tracks"), &mut draft.video, &mut draft.video_target, &vnames), + ("audio", tl!("Audio Tracks"), tl!("Delete Audio Tracks"), &mut draft.audio, &mut draft.audio_target, &anames), + ] { + ui.add_space(4.0); + ui.label(egui::RichText::new(title).strong()); + let label = delete.to_string(); + let c = ui.checkbox(on, &label); + elems.push((format!("deleteTracks.{kind}"), c.rect, label)); + ui.horizontal(|ui| { + ui.add_space(22.0); + let shown = if target == "empty" { tl!("All Empty Tracks").to_string() } else { target.clone() }; + ui.add_enabled_ui(*on, |ui| { + let r = egui::ComboBox::from_id_salt(("delete-tracks", kind)).selected_text(&shown).width(170.0).show_ui(ui, |ui| { + let e = ui.selectable_value(target, "empty".to_string(), tl!("All Empty Tracks")); + elems.push((format!("deleteTracks.{kind}.option.empty"), e.rect, "All Empty Tracks".into())); + for n in list { + let r = ui.selectable_value(target, n.clone(), n); + elems.push((format!("deleteTracks.{kind}.option.{n}"), r.rect, n.clone())); + } + }); + elems.push((format!("deleteTracks.{kind}.target"), r.response.rect, shown)); }); - elems.push((format!("deleteTracks.{kind}.target"), r.response.rect, shown)); }); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("deleteTracks.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; + } + let o = ui.add_enabled( + draft.video || draft.audio, + egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent), + ); + elems.push(("deleteTracks.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } }); - } - ui.add_space(10.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("deleteTracks.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } - let o = - ui.add_enabled(draft.video || draft.audio, egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("deleteTracks.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -372,7 +398,7 @@ fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let tab_id = egui::Id::new("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { - for (i, (id, label)) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")].into_iter().enumerate() { + for (i, (id, label)) in [("about", tl!("About")), ("contributors", tl!("Contributors")), ("models", tl!("Models"))].into_iter().enumerate() { let i = i as u8; let r = ui.selectable_label(tab == i, label); app.auto.add(&format!("about.tab.{id}"), r.rect, label); @@ -412,8 +438,8 @@ fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) { crate::brand::paint_wordmark(ui, egui::pos2(r.min.x, r.center().y), 20.0, app.ui.dark); ui.add_space(6.0); ui.heading("FilmCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.label("A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family."); + ui.label(tlf!("Version {version}", version = env!("CARGO_PKG_VERSION"))); + ui.label(tl!("A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family.")); ui.add_space(10.0); let mut link = |ui: &mut egui::Ui, id: &str, icon: Icon, label: &str, url: &str, primary: bool| { let size = egui::vec2(ui.available_width(), if primary { 36.0 } else { 28.0 }); @@ -442,12 +468,12 @@ fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) { links::open(ui.ctx(), url); } }; - link(ui, "discord", Icon::Chat, "Join the ArtCraft Discord", links::DISCORD, true); + link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true); ui.add_space(6.0); link(ui, "website", Icon::Globe, "getartcraft.com", links::WEBSITE, false); - link(ui, "appPage", Icon::Globe, "FilmCraft on getartcraft.com", links::APP_PAGE, false); - link(ui, "github", Icon::Code, "Source code on GitHub", links::GITHUB, false); - link(ui, "reportIssue", Icon::Code, "Report an issue", links::ISSUES, false); + link(ui, "appPage", Icon::Globe, tl!("FilmCraft on getartcraft.com"), links::APP_PAGE, false); + link(ui, "github", Icon::Code, tl!("Source code on GitHub"), links::GITHUB, false); + link(ui, "reportIssue", Icon::Code, tl!("Report an issue"), links::ISSUES, false); ui.add_space(10.0); - ui.label(egui::RichText::new("MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1).").small().color(t.text_dim)); + ui.label(egui::RichText::new(tl!("MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1).")).small().color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 9931aa724..b4142ca45 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -3,7 +3,7 @@ //! keyframe lane on the right. Also hosts the Lumetri Color panel body (same editor, grouped). use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; -use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem}; +use filmcraft_project::{ClipId, EffectInstance, ParamKind, ParamValue, TrackItem, TrackKind}; use filmcraft_time::Tick; use serde_json::{Value, json}; @@ -12,37 +12,65 @@ use crate::icons::{self, Icon}; use crate::theme::Tokens; const ROW_H: f32 = 22.0; +/// Keyframe lane header: the time ruler (beside the Source / Sequence pills) and the clip's bar. +const RULER_H: f32 = 24.0; +const CLIP_BAR_H: f32 = 18.0; +/// Distance between the keyframe navigator's buttons (◀ ◆ ▶). +const NAV_STEP: f32 = 16.0; +/// Properties panel: the keyframe diamonds' column, from a row's right edge (the arrows sit one +/// [`NAV_STEP`] to either side of it, and a section's reset button above it). +const PROPS_NAV_X: f32 = 26.0; +/// Properties panel: where a row's value fields end, left of the navigator. +const PROPS_VALUE_R: f32 = PROPS_NAV_X + NAV_STEP + 12.0; -fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, filmcraft_project::TrackKind)> { - let seq = app.session.active_sequence()?; - let mut best = None; - for c in &app.session.state.selection { - if let Some((tid, it)) = seq.find_item(*c) { - let kind = seq.track(tid)?.kind; - if kind == filmcraft_project::TrackKind::Video { - return Some((*c, it.clone(), kind)); - } - best.get_or_insert((*c, it.clone(), kind)); - } +/// Timeline time at which the clip shows media time `m` (where a keyframe sits in the sequence). +fn timeline_time_of(it: &TrackItem, m: Tick) -> Tick { + it.start + Tick(((m - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64) +} + +fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { + selected_clips(app).into_iter().next() +} + +/// The selected clip (video first) and, for a video clip, the selected audio linked to it. +fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { + let Some(seq) = app.session.active_sequence() else { return Vec::new() }; + let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app + .session + .state + .selection + .iter() + .filter_map(|c| { + let (tid, it) = seq.find_item(*c)?; + Some((*c, it, seq.track(tid)?.kind)) + }) + .collect(); + let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() }; + let mut out = vec![(clip, it.clone(), kind)]; + if kind == TrackKind::Video + && let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link) + { + out.push((ac, ai.clone(), ak)); } - best + out } pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let Some((clip, it, kind)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "(no clip selected)"); + let clips = selected_clips(app); + let Some((clip, it, _)) = clips.first().cloned() else { + crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0); let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0)); // Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active) let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| { - ui.painter().rect_filled(r, 4.0, if active { Color32::from_rgb(0x3a, 0x3a, 0x3a) } else { t.panel_bg }); + ui.painter().rect_filled(r, 4.0, if active { t.pill_active_bg } else { t.panel_bg }); if !active { ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); } @@ -50,24 +78,31 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cp.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), if active { t.text } else { t.text_dim }); }; let half = (head.width() - 6.0) / 2.0; - pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &format!("Source · {}", it.name), false); + pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &tlf!("Source · {name}", name = it.name), false); pill(ui, Rect::from_min_size(head.min + vec2(half + 6.0, 0.0), vec2(half, 24.0)), &format!("{} · {}", seq_name(app), it.name), true); - // keyframe lane header: mini ruler over the clip's duration + // keyframe lane header, as Premiere's: a time ruler over the clip's stretch of the sequence, + // which the playhead's handle rides on, and the clip's bar under it let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 26.0)); ui.painter().rect_filled(lane, 0.0, t.tl_bg); let ph = app.session.playhead(); let dur = it.duration.0.max(1) as f64; let lx = |tk: Tick| -> f32 { lane.min.x + (((tk - it.start).0 as f64 / dur) as f32).clamp(0.0, 1.0) * lane.width() }; - ui.painter().rect_filled(Rect::from_min_max(pos2(lane.min.x, lane.min.y + 2.0), pos2(lane.max.x, lane.min.y + 16.0)), 2.0, Color32::from_rgb(58, 58, 70)); - ui.painter().text(pos2(lane.min.x + 4.0, lane.min.y + 9.0), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); - let scrub = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + 18.0)); + let rate = seq.settings.frame_rate; + let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); + paint_ruler(ui.painter(), ruler, &it, rate, seq.settings.drop_frame, &t); + let bar = Rect::from_min_max(pos2(lane.min.x, ruler.max.y + 4.0), pos2(lane.max.x, ruler.max.y + 4.0 + CLIP_BAR_H)); + ui.painter().rect_filled(bar.shrink2(vec2(0.0, 2.0)), 2.0, t.clip_bar_bg); + ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); + // click or drag anywhere on the ruler (or the bar) to move the playhead + let scrub = Rect::from_min_max(ruler.min, bar.max); let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); app.auto.add("effectControls.lane", lane, "keyframe lane"); + app.auto.add("effectControls.ruler", scrub, "time ruler"); if (sresp.dragged() || sresp.clicked()) && let Some(pos) = sresp.interact_pointer_pos() { let f = ((pos.x - lane.min.x) / lane.width()).clamp(0.0, 1.0) as f64; - let tk = it.start + Tick((f * it.duration.0 as f64) as i64); + let tk = rate.snap_nearest(it.start + Tick((f * it.duration.0 as f64) as i64)); app.stop(); app.session.set_playhead(tk); } @@ -75,97 +110,106 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Vec<(String, Value)> = Vec::new(); let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body")); bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y))); - let mt_now = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); - let heading = if kind == filmcraft_project::TrackKind::Video { "Video" } else { "Audio" }; - bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); - bui.add_space(18.0); - // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. - let mut order: Vec = (0..it.effects.len()).collect(); - order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic)); let scroll_out = egui::ScrollArea::vertical().id_salt("ec-scroll").auto_shrink([false, false]).show(&mut bui, |bui| { - for idx in order { - let e = &it.effects[idx]; - let Some(def) = e.def() else { continue }; - let key = format!("{}:{}", clip.0, idx); - let open = !app.ui.collapsed_fx.contains(&key); - let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); - if resp.hovered() { - bui.painter().rect_filled(r, 0.0, t.hover); - } - icons::paint( - bui.painter(), - Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), - if open { Icon::ChevronDown } else { Icon::ChevronRight }, - t.text_dim, - ); - // fx enable toggle - let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); - let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); - bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); - if !e.enabled { - bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); - } - if fxresp.clicked() { - actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); - } - bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, def.name, Tokens::ui(12.0), t.text); - // reset button - let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text("Reset Effect"); - icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); - if rresp.clicked() { - actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); - } - app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); - if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { - if open { - app.ui.collapsed_fx.push(key.clone()); - } else { - app.ui.collapsed_fx.retain(|k| *k != key); + for (clip, it, kind) in &clips { + let clip = *clip; + let (hr, _) = bui.allocate_exact_size(vec2(body.width(), 18.0), Sense::hover()); + let heading = if *kind == TrackKind::Video { tl!("Video") } else { tl!("Audio") }; + bui.painter().text(pos2(hr.min.x + 8.0, hr.center().y), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); + let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. + let mut order: Vec = (0..it.effects.len()).collect(); + order.sort_by_key(|i| !it.effects[*i].def().is_some_and(|d| d.intrinsic)); + for idx in order { + let e = &it.effects[idx]; + let Some(def) = e.def() else { continue }; + let key = format!("{}:{}", clip.0, idx); + let open = !app.ui.collapsed_fx.contains(&key); + let (r, resp) = bui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::click()); + if resp.hovered() { + bui.painter().rect_filled(r, 0.0, t.hover); } - } - let mut save_preset = false; - let fx_id = e.effect.clone(); - resp.context_menu(|ui| { - let sp = ui.button("Save Preset…"); - app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…"); - if sp.clicked() { - save_preset = true; - ui.close(); + icons::paint( + bui.painter(), + Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_dim, + ); + // fx enable toggle + let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); + let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); + bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); + if !e.enabled { + bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); } - if !def.intrinsic && ui.button("Clear").clicked() { - actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); - ui.close(); + if fxresp.clicked() { + actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); } - }); - if save_preset { - crate::panels::presets::open_save(app, clip.0, vec![idx], def.name); - } - if !open { - continue; - } - if crate::panels::audio_fx_editor::has_editor(&e.effect) { - custom_setup_row(app, bui, body, clip, idx, &e.effect); - } - for pd in &def.params { - param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, &it); - if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) - && let Some(param) = e.params.get(pd.id) - && param.is_animated() - && matches!(param.value, ParamValue::Float(_)) - { - graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, &it, &mut actions); + bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text); + // reset button + let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); + let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); + icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); + if rresp.clicked() { + actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); + } + app.auto.add(&format!("effectControls.effect.{}", e.effect), r, def.name); + if resp.clicked() && !fxresp.clicked() && !rresp.clicked() { + if open { + app.ui.collapsed_fx.push(key.clone()); + } else { + app.ui.collapsed_fx.retain(|k| *k != key); + } + } + let mut save_preset = false; + let fx_id = e.effect.clone(); + resp.context_menu(|ui| { + let sp = ui.button(tl!("Save Preset…")); + app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…"); + if sp.clicked() { + save_preset = true; + ui.close(); + } + if !def.intrinsic && ui.button(tl!("Clear")).clicked() { + actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); + ui.close(); + } + }); + if save_preset { + crate::panels::presets::open_save(app, clip.0, vec![idx], def.name); + } + if !open { + continue; + } + if crate::panels::audio_fx_editor::has_editor(&e.effect) { + custom_setup_row(app, bui, body, clip, idx, &e.effect); + } + for pd in &def.params { + param_row(app, bui, body, clip, idx, e, None, pd, mt_now, &mut actions, &lane, &lx, it); + if app.ui.expanded_fx.contains(&graph_key(clip, idx, pd.id)) + && let Some(param) = e.params.get(pd.id) + && param.is_animated() + && matches!(param.value, ParamValue::Float(_)) + { + graph_rows(app, bui, body, clip, idx, None, pd, param, &lane, it, &mut actions); + } + } + if crate::panels::masks::maskable(e) { + crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, it); } - } - if crate::panels::masks::maskable(e) { - crate::panels::masks::effect_rows(app, bui, body, clip, idx, e, mt_now, &mut actions, &lane, &lx, &it); } } }); let _ = scroll_out; - // playhead in lane - let px = lx(ph); - ui.painter().line_segment([pos2(px, lane.min.y), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead)); + // playhead: a handle on the ruler and a line down the lane, while it is on the clip + if ph >= it.start && ph <= it.end() { + let px = lx(ph); + let (top, tip) = (ruler.max.y - 13.0, ruler.max.y); + let head = vec![pos2(px - 5.0, top), pos2(px + 5.0, top), pos2(px + 5.0, tip - 5.0), pos2(px, tip), pos2(px - 5.0, tip - 5.0)]; + ui.painter().add(egui::Shape::convex_polygon(head, t.playhead, Stroke::NONE)); + ui.painter().line_segment([pos2(px, tip), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead)); + app.auto.add("effectControls.playhead", Rect::from_min_max(pos2(px - 5.0, top), pos2(px + 5.0, tip)), "playhead"); + } // footer timecode let tc = filmcraft_time::format_time(ph, seq.settings.frame_rate, seq.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); ui.painter().text(pos2(rect.min.x + 10.0, rect.max.y - 13.0), Align2::LEFT_CENTER, tc, Tokens::mono(13.0), t.timecode); @@ -176,12 +220,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, effect: &str) { let t = app.tokens; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); - ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, "Custom Setup", Tokens::ui(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(br.center(), Align2::CENTER_CENTER, "Edit…", Tokens::ui(11.5), t.text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text); app.auto.add(&format!("effectControls.effect.{effect}.edit"), br, "Edit…"); if resp.clicked() { crate::panels::audio_fx_editor::open(app, crate::panels::audio_fx_editor::FxTarget::Clip { clip: clip.0, index: idx }); @@ -192,6 +236,137 @@ fn seq_name(app: &FilmcraftApp) -> String { app.session.state.active_sequence.and_then(|s| app.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default() } +/// The keyframe lane's time ruler: ticks and sequence timecode across the clip's stretch of the +/// timeline (the lane shows exactly the clip, so the ruler starts at the clip's start). +fn paint_ruler(p: &egui::Painter, ruler: Rect, it: &TrackItem, rate: filmcraft_time::FrameRate, drop_frame: bool, t: &Tokens) { + let p = p.with_clip_rect(ruler); + let dur = it.duration.0.max(1) as f64; + let frame_px = ruler.width() as f64 * rate.frame_duration().0.max(1) as f64 / dur; + let base = rate.timecode_base(); + let steps = [ + 1, + 2, + 5, + 10, + base / 2, + base, + base * 2, + base * 5, + base * 10, + base * 15, + base * 30, + base * 60, + base * 120, + base * 300, + base * 600, + base * 1800, + base * 3600, + ]; + // labels far enough apart to read; the small ticks divide the labelled ones evenly + let label_step = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 80.0); + let minor = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 8.0 && label_step.is_none_or(|l| l % s == 0)); + let (f0, f1) = (rate.frame_at(it.start), rate.frame_at(it.end())); + let base_y = ruler.max.y - 1.0; + for (step, h, labelled) in [(minor, 3.0, false), (label_step, 7.0, true)] { + let Some(step) = step else { continue }; + let mut f = f0.div_euclid(step).saturating_mul(step); + // a step is at least 8 px wide, so this covers any ruler; the cap is for damaged numbers + for _ in 0..4096 { + if f > f1 { + break; + } + let x = ruler.min.x + ((rate.tick_of(f) - it.start).0 as f64 / dur) as f32 * ruler.width(); + p.line_segment([pos2(x, base_y - h), pos2(x, base_y)], Stroke::new(1.0, t.tl_ruler_tick)); + if labelled { + let label = filmcraft_time::format_time(rate.tick_of(f), rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); + p.text(pos2(x, ruler.min.y + 7.0), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); + } + f = f.saturating_add(step); + } + } +} + +/// Premiere's keyframe navigator, centred on `at`: a diamond that adds a keyframe at the playhead +/// or removes the one there (filled while the playhead is on a keyframe) and, once the parameter +/// is animated, arrows to the previous and the next keyframe. `param` is `None` while the effect +/// is not on the clip yet. Automation ids: `.addKeyframe`, `.prevKeyframe`, `.nextKeyframe`. +/// Returns whether the diamond was clicked; the arrows queue their own `playhead.set`. +#[allow(clippy::too_many_arguments)] +fn keyframe_nav( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + at: Pos2, + param: Option<&filmcraft_project::Param>, + mt: Tick, + it: &TrackItem, + id: egui::Id, + auto: &str, + tip: &str, + actions: &mut Vec<(String, Value)>, +) -> bool { + let t = app.tokens; + if let Some(param) = param.filter(|p| p.is_animated()) { + for (d, target, name, tip) in + [(-1.0, param.prev_keyframe(mt), "prevKeyframe", "Go to previous keyframe"), (1.0, param.next_keyframe(mt), "nextKeyframe", "Go to next keyframe")] + { + let r = Rect::from_center_size(at + vec2(d * NAV_STEP, 0.0), vec2(12.0, 14.0)); + let resp = ui.interact(r, id.with(name), Sense::click()).on_hover_text(crate::i18n::t(tip)); + let c = r.center(); + let pts = vec![c + vec2(-3.0 * d, -4.0), c + vec2(-3.0 * d, 4.0), c + vec2(3.0 * d, 0.0)]; + ui.painter().add(egui::Shape::convex_polygon(pts, if target.is_some() { t.text } else { t.text_faint }, Stroke::NONE)); + app.auto.add(&format!("{auto}.{name}"), r, tip); + if resp.clicked() + && let Some(k) = target + { + actions.push(("playhead.set".into(), json!({"time": timeline_time_of(it, k).0}))); + } + } + } + let r = Rect::from_center_size(at, vec2(13.0, 13.0)); + let resp = ui.interact(r.expand(1.5), id.with("key"), Sense::click()).on_hover_text(crate::i18n::t(tip)); + if param.is_some_and(|p| p.keyframes.iter().any(|k| k.time == mt)) { + icons::paint(ui.painter(), r, Icon::Keyframe, t.hot_text); + } else { + let s = 4.0; + let outline = vec![at + vec2(0.0, -s), at + vec2(s, 0.0), at + vec2(0.0, s), at + vec2(-s, 0.0)]; + ui.painter().add(egui::Shape::closed_line(outline, Stroke::new(1.2, if resp.hovered() { t.text } else { t.text_dim }))); + } + app.auto.add(&format!("{auto}.addKeyframe"), r, tip); + resp.clicked() +} + +/// A Properties row's keyframe navigator for `effect`.`param`, with Premiere's tooltips. The +/// diamond is `effects.addKeyframe`: it adds a keyframe at the playhead (turning animation on) or +/// removes the one there, so the value falls back to what the remaining keyframes give. An effect +/// the clip does not have yet (Crop) is applied first. +#[allow(clippy::too_many_arguments)] +fn properties_nav( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + row: Rect, + clip: ClipId, + it: &TrackItem, + mt: Tick, + effect: &str, + param: &str, + actions: &mut Vec<(String, Value)>, +) { + let prm = it.effect(effect).and_then(|e| e.param(param)); + let tip = match prm { + Some(p) if p.keyframes.iter().any(|k| k.time == mt) => "Remove keyframe", + Some(p) if p.is_animated() => "Add keyframe", + _ => "Turn on animation and add keyframe", + }; + let at = pos2(row.max.x - PROPS_NAV_X, row.center().y); + let id = egui::Id::new(("props-nav", effect, param, clip.0)); + if keyframe_nav(app, ui, at, prm, mt, it, id, &format!("properties.{effect}.{param}"), tip, actions) { + if it.effect(effect).is_none() { + actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": effect}))); + } + actions.push(("effects.addKeyframe".into(), json!({"clip": clip.0, "effect": effect, "param": param}))); + } +} + #[allow(clippy::too_many_arguments)] pub(crate) fn param_row( app: &mut FilmcraftApp, @@ -234,7 +409,7 @@ pub(crate) fn param_row( let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); - let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text("Show graphs"); + let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs"); if tresp.clicked() { @@ -247,7 +422,7 @@ pub(crate) fn param_row( } if pd.animatable { let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0)); - let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text("Toggle animation"); + let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim }); if resp.clicked() { actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id})))); @@ -255,7 +430,7 @@ pub(crate) fn param_row( app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation"); } x += 14.0; - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, pd.label, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(pd.label), Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(150.0); let value = param.value_at(mt); let mut vui = ui.new_child( @@ -294,9 +469,9 @@ pub(crate) fn param_row( } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).copied().unwrap_or("")).width(130.0).show_ui(&mut vui, |ui| { + egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { - if ui.selectable_value(&mut sel, i, *o).changed() { + if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); } } @@ -307,6 +482,14 @@ pub(crate) fn param_row( if egui::color_picker::color_edit_button_rgba(&mut vui, &mut rgba, egui::color_picker::Alpha::Opaque).changed() { set = Some(json!([rgba.r(), rgba.g(), rgba.b(), rgba.a()])); } + // Eyedropper: arm it, then click a pixel in the Program monitor (Esc cancels) + let target = crate::state::Eyedropper { clip: clip.0, effect: idx, param: pd.id.to_string(), mask }; + let armed = app.ui.eyedropper.as_ref() == Some(&target); + let drop = icons::button(&mut vui, Icon::Eyedropper, 18.0, armed, &t, tl!("Eyedropper (pick a color from the Program monitor)")); + app.auto.add(&format!("effectControls.{}.{}.eyedropper", e.effect, pkey), drop.rect, "Eyedropper"); + if drop.clicked() { + app.ui.eyedropper = if armed { None } else { Some(target) }; + } } (ParamKind::Path, _) => { crate::panels::masks::path_value(app, &mut vui, clip, idx, mask, actions); @@ -337,41 +520,11 @@ pub(crate) fn param_row( // keyframe navigator ◀ ◆ ▶ (when animated) let eff_json = json!(idx); if pd.animatable && param.is_animated() { - let nx = r.max.x - 58.0; - let cy = r.center().y; - let at_key = param.keyframes.iter().any(|k| k.time == mt); - let prev = param.prev_keyframe(mt); - let next = param.next_keyframe(mt); - let to_tl = |k: Tick| it.start + Tick(((k - it.source_in).0 as f64 / it.speed.abs().max(1e-6)) as i64); - let pr = Rect::from_center_size(pos2(nx, cy), vec2(12.0, 14.0)); - let kr = Rect::from_center_size(pos2(nx + 16.0, cy), vec2(12.0, 12.0)); - let nr = Rect::from_center_size(pos2(nx + 32.0, cy), vec2(12.0, 14.0)); - let arrow = |p: &egui::Painter, r: Rect, left: bool, on: bool| { - let c = r.center(); - let pts = if left { - vec![c + vec2(3.0, -4.0), c + vec2(3.0, 4.0), c + vec2(-3.0, 0.0)] - } else { - vec![c + vec2(-3.0, -4.0), c + vec2(-3.0, 4.0), c + vec2(3.0, 0.0)] - }; - p.add(egui::Shape::convex_polygon(pts, if on { t.text } else { t.text_faint }, Stroke::NONE)); - }; - arrow(ui.painter(), pr, true, prev.is_some()); - arrow(ui.painter(), nr, false, next.is_some()); - icons::paint(ui.painter(), kr, Icon::Keyframe, if at_key { t.hot_text } else { t.text_dim }); - if ui.interact(pr, egui::Id::new(("kprev", clip.0, idx, pkey)), Sense::click()).clicked() - && let Some(k) = prev - { - actions.push(("playhead.set".into(), json!({"time": to_tl(k).0}))); - } - if ui.interact(nr, egui::Id::new(("knext", clip.0, idx, pkey)), Sense::click()).clicked() - && let Some(k) = next - { - actions.push(("playhead.set".into(), json!({"time": to_tl(k).0}))); - } - if ui.interact(kr, egui::Id::new(("kadd", clip.0, idx, pkey)), Sense::click()).on_hover_text("Add/Remove Keyframe").clicked() { + let at = pos2(r.max.x - 42.0, r.center().y); + let auto = format!("effectControls.{}.{}", e.effect, pkey); + if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::new(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) { actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } - app.auto.add(&format!("effectControls.{}.{}.addKeyframe", e.effect, pkey), kr, "Add/Remove Keyframe"); } // keyframes in the lane: draggable diamonds; right-click for interpolation if param.is_animated() { @@ -422,15 +575,15 @@ pub(crate) fn param_row( actions.push(("playhead.set".into(), json!({"time": tl.0}))); } resp.context_menu(|ui| { - ui.label(egui::RichText::new("Temporal Interpolation").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(t.text_dim)); for (label, key) in [ - ("Linear", "linear"), - ("Bezier", "bezier"), - ("Auto Bezier", "autoBezier"), - ("Continuous Bezier", "continuousBezier"), - ("Hold", "hold"), - ("Ease In", "easeIn"), - ("Ease Out", "easeOut"), + (tl!("Linear"), "linear"), + (tl!("Bezier"), "bezier"), + (tl!("Auto Bezier"), "autoBezier"), + (tl!("Continuous Bezier"), "continuousBezier"), + (tl!("Hold"), "hold"), + (tl!("Ease In"), "easeIn"), + (tl!("Ease Out"), "easeOut"), ] { if ui.button(label).clicked() { actions.push(( @@ -441,7 +594,7 @@ pub(crate) fn param_row( } } ui.separator(); - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { actions .push(("effects.deleteKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0})))); ui.close(); @@ -455,14 +608,14 @@ pub(crate) fn param_row( pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it, _)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to grade"); + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to grade")); return; }; let idx = it.effects.iter().position(|e| e.effect == "lumetri"); let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(6.0)).id_salt("lumetri")); - bui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + bui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); let Some(idx) = idx else { - if bui.button("Add Lumetri Color to clip").clicked() { + if bui.button(tl!("Add Lumetri Color to clip")).clicked() { let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})); } return; @@ -484,7 +637,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for g in groups { let key = format!("lumetri:{g}"); let open = !app.ui.collapsed_fx.contains(&key); - let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), g, open, &t, true); + let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true); app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g); if now_open != open { if now_open { @@ -500,7 +653,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let v = e.params.get(pd.id).map(|p| p.value_at(mt)).unwrap_or(pd.default.clone()); ui.horizontal(|ui| { ui.add_space(18.0); - ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(pd.label).size(12.0))); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(crate::i18n::t(pd.label)).size(12.0))); if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) { let mut val = *x; let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false)); @@ -530,8 +683,9 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { /// Properties panel (Premiere 26): a compact inspector for the selected clip. pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let Some((clip, it, kind)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to see its properties"); + let clips = selected_clips(app); + let Some((clip, it, kind)) = clips.first().cloned() else { + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties")); return; }; let ph = app.session.playhead(); @@ -561,9 +715,9 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); if let Some((idx, c)) = reset { - let rr = Rect::from_center_size(pos2(r.max.x - 10.0, r.center().y), vec2(14.0, 14.0)); + let rr = Rect::from_center_size(pos2(r.max.x - PROPS_NAV_X, r.center().y), vec2(14.0, 14.0)); icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim); if ui.interact(rr, egui::Id::new(("props-reset", name, c)), Sense::click()).clicked() { actions.push(("effects.reset".into(), json!({"clip": c, "index": idx}))); @@ -578,34 +732,20 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } open }; - let diamond = |ui: &mut egui::Ui, r: Rect, animated: bool, id: egui::Id| -> bool { - let dr = Rect::from_center_size(pos2(r.max.x - 10.0, r.center().y), vec2(10.0, 10.0)); - let resp = ui.interact(dr.expand(3.0), id, Sense::click()); - if animated { - icons::paint(ui.painter(), dr, Icon::Keyframe, t.hot_text); - } else { - let c = dr.center(); - let s = 4.5; - ui.painter() - .add(egui::Shape::closed_line(vec![c + vec2(0.0, -s), c + vec2(s, 0.0), c + vec2(0.0, s), c + vec2(-s, 0.0)], Stroke::new(1.2, t.text_dim))); - } - resp.clicked() - }; let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id); let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); - let anim = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).is_some_and(|p| p.is_animated()); - if kind == filmcraft_project::TrackKind::Video { + if kind == TrackKind::Video { if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) { for (label, p, unit, speed, range) in [ - ("Position", "position", "", 1.0, (-100_000.0, 100_000.0)), - ("Anchor point", "anchor", "", 1.0, (-100_000.0, 100_000.0)), - ("Scale", "scale", " %", 0.5, (0.0, 10000.0)), - ("Rotation", "rotation", " °", 0.5, (-36000.0, 36000.0)), + (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Anchor point"), "anchor", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Scale"), "scale", " %", 0.5, (0.0, 10000.0)), + (tl!("Rotation"), "rotation", " °", 0.5, (-36000.0, 36000.0)), ] { let r = row(&mut bui, label); let mut vui = bui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); match val("motion", p) { @@ -629,14 +769,12 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } _ => {} } - if diamond(&mut bui, r, anim("motion", p), egui::Id::new(("pd", p, clip.0))) { - actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "motion", "param": p}))); - } + properties_nav(app, &mut bui, r, clip, &it, mt, "motion", p, &mut actions); } - let r = row(&mut bui, "Opacity"); + let r = row(&mut bui, tl!("Opacity")); let mut vui = bui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { @@ -645,17 +783,15 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); } } - if diamond(&mut bui, r, anim("opacity", "opacity"), egui::Id::new(("pd-op", clip.0))) { - actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity"}))); - } + properties_nav(app, &mut bui, r, clip, &it, mt, "opacity", "opacity", &mut actions); } let crop = eff_idx("crop"); if section(&mut bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) { - for (label, p) in [("Left", "left"), ("Top", "top"), ("Right", "right"), ("Bottom", "bottom")] { + for (label, p) in [(tl!("Left"), "left"), (tl!("Top"), "top"), (tl!("Right"), "right"), (tl!("Bottom"), "bottom")] { let r = row(&mut bui, label); let mut vui = bui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); @@ -666,14 +802,20 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "crop", "param": p, "value": nv}))); } - diamond(&mut bui, r, anim("crop", p), egui::Id::new(("pd-crop", p, clip.0))); + properties_nav(app, &mut bui, r, clip, &it, mt, "crop", p, &mut actions); } } - } else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) { - let r = row(&mut bui, "Level"); + } + if let Some((clip, it, _)) = clips.iter().find(|c| c.2 == TrackKind::Audio) + && section(&mut bui, app, "Audio", it.effects.iter().position(|e| e.effect == "volume").map(|i| (i, clip.0)), &mut actions) + { + let clip = *clip; + let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt)); + let r = row(&mut bui, tl!("Level")); let mut vui = bui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); @@ -681,13 +823,11 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } - if diamond(&mut bui, r, anim("volume", "level"), egui::Id::new(("pd-vol", clip.0))) { - actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "volume", "param": "level"}))); - } - let r = row(&mut bui, "Pan"); + properties_nav(app, &mut bui, r, clip, it, mt, "volume", "level", &mut actions); + let r = row(&mut bui, tl!("Pan")); let mut vui = bui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) + .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - PROPS_VALUE_R, r.max.y - 4.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); @@ -701,7 +841,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click()); bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg }); bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - bui.painter().text(br.center(), Align2::CENTER_CENTER, format!("Speed {:.0}%", it.speed * 100.0), Tokens::ui(12.0), t.text); + bui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Speed {n}%", n = format!("{:.0}", it.speed * 100.0)), Tokens::ui(12.0), t.text); run(app, ui.ctx(), actions); } @@ -783,16 +923,16 @@ pub(crate) fn graph_rows( let y_of = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height(); let v_of = |y: f32| lo + ((area.max.y - y) / area.height()) as f64 * (hi - lo); let p = ui.painter(); - p.rect_filled(area, 0.0, Color32::from_rgb(0x19, 0x19, 0x19)); + p.rect_filled(area, 0.0, t.keyframe_plot_bg); for g in 1..4 { let y = area.min.y + area.height() * g as f32 / 4.0; - p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_rgb(0x2a, 0x2a, 0x2a))); + p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, t.keyframe_plot_grid)); } // range labels in the property column let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 }; p.text(pos2(vr.max.x - 30.0, area.min.y + 6.0), Align2::RIGHT_CENTER, format!("{hi:.dec$}"), Tokens::ui(10.0), t.text_dim); p.text(pos2(vr.max.x - 30.0, area.max.y - 6.0), Align2::RIGHT_CENTER, format!("{lo:.dec$}"), Tokens::ui(10.0), t.text_dim); - p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, "Value", Tokens::ui(11.0), t.text_dim); + p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, tl!("Value"), Tokens::ui(11.0), t.text_dim); let line: Vec = vals.iter().enumerate().map(|(i, v)| pos2(x_of(i as f32 / n as f32), y_of(*v))).collect(); p.add(egui::Shape::line(line, Stroke::new(1.5, t.accent))); // velocity (units per second, derivative of the sampled value) @@ -800,12 +940,12 @@ pub(crate) fn graph_rows( let vel: Vec = (0..n).map(|i| (vals[i + 1] - vals[i]) / secs.max(1e-9)).collect(); let vmax = vel.iter().fold(1e-6f64, |a, v| a.max(v.abs())) * 1.15; let varea = Rect::from_min_max(pos2(lane.min.x, velr.min.y + 2.0), pos2(lane.max.x, velr.max.y - 4.0)); - p.rect_filled(varea, 0.0, Color32::from_rgb(0x19, 0x19, 0x19)); + p.rect_filled(varea, 0.0, t.keyframe_plot_bg); let vy = |v: f64| varea.center().y - (v / vmax) as f32 * varea.height() / 2.0; - p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, Color32::from_rgb(0x33, 0x33, 0x33))); + p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, t.keyframe_plot_axis)); let vline: Vec = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect(); p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40)))); - p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, "Velocity", Tokens::ui(11.0), t.text_dim); + p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim); p.text(pos2(velr.max.x - 30.0, varea.min.y + 6.0), Align2::RIGHT_CENTER, format!("{vmax:.1}/s"), Tokens::ui(10.0), t.text_dim); let p = p.clone(); // keyframes + handles @@ -835,8 +975,8 @@ pub(crate) fn graph_rows( let hdx: f32 = ui.data(|d| d.get_temp(hid)).unwrap_or(0.0); let hx = c.x + side * (infl * seg + hdx * side).clamp(seg * 0.01, seg); let hp = pos2(hx, c.y); - p.line_segment([c, hp], Stroke::new(1.0, Color32::from_rgb(0x90, 0x90, 0x90))); - p.circle_filled(hp, 3.5, Color32::from_rgb(0xd0, 0xd0, 0xd0)); + p.line_segment([c, hp], Stroke::new(1.0, t.plot_handle_dim)); + p.circle_filled(hp, 3.5, t.keyframe_handle); let hr = ui.interact(Rect::from_center_size(hp, vec2(10.0, 10.0)), hid.with("h"), Sense::drag()); if hr.dragged() { let nx = hdx + hr.drag_delta().x; @@ -849,7 +989,7 @@ pub(crate) fn graph_rows( actions.push(("effects.setKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id, "mediaTime": k.time.0, key: ni})))); } } - p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { Color32::from_rgb(0xe0, 0xe0, 0xe0) }); + p.circle_filled(c, 4.5, if resp.dragged() { t.hot_text } else { t.plot_handle }); if resp.dragged() { let ny = dy + resp.drag_delta().y; ui.data_mut(|d| d.insert_temp(id, ny)); @@ -868,10 +1008,10 @@ pub(crate) fn graph_rows( fn param_text(project: &filmcraft_project::Project, id: &str, value: &ParamValue) -> String { match value { ParamValue::Text(s) if id.ends_with("_lut") => filmcraft_render::luts::label(Some(project), s), - ParamValue::Text(s) if s.is_empty() => "None".into(), + ParamValue::Text(s) if s.is_empty() => tl!("None").into(), ParamValue::Text(s) => s.clone(), - ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => "Default".into(), - ParamValue::Curve(pts) => format!("Custom ({} points)", pts.len()), + ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => tl!("Default").into(), + ParamValue::Curve(pts) => tlf!("Custom ({n} points)", n = pts.len()), other => format!("{other:?}"), } } @@ -938,3 +1078,97 @@ mod drag_undo_tests { assert_eq!(opacity(&app), start, "and then the first"); } } + +#[cfg(test)] +mod lane_tests { + use super::*; + + /// A context with the app's fonts (the panels use the semibold family). + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(Default::default())); + ctx + } + + /// A project file is only checked for overflow, so a damaged one can carry a clip with no + /// (or a negative) duration. Selecting it must still draw both panels, ruler, playhead and + /// keyframe navigators included. + #[test] + fn a_clip_without_a_duration_still_draws() { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id; + s.execute("timeline.select", json!({"clips": [clip.0]})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "motion", "param": "scale"})).unwrap(); + let seq = s.state.active_sequence.unwrap(); + let mut app = crate::FilmcraftApp::new(s); + let ctx = context(); + for duration in [0, -5 * filmcraft_time::TICKS_PER_SECOND] { + let project = std::sync::Arc::make_mut(&mut app.session.project); + project.sequence_mut(seq).unwrap().find_item_mut(clip).unwrap().1.duration = Tick(duration); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(700.0, 500.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui, rect); + properties_panel(&mut app, ui, rect); + }); + out.textures_delta.clear(); + } + } + + /// The ruler takes its numbers from the project (clip times, the sequence's frame rate) and + /// the panel's size. Anything a project that opens can carry (times within `Tick::MIN..=MAX`, + /// a rate within `SequenceSettings::validate`, or an unset one) must not make it panic or + /// tick forever. + #[test] + fn ruler_survives_damaged_numbers() { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut it = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + let ctx = context(); + let t = Tokens::for_kind(Default::default()); + let big = i64::from(u32::MAX); + let rates = [(24000, 1001), (30, 1), (1000, 1), (1, 1), (1, big), (big, big), (0, 0), (-25, 1)]; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + for width in [300.0, 0.0, -40.0, 0.001, 1.0e9, f32::NAN, f32::INFINITY] { + for duration in [1, 0, -7, filmcraft_time::TICKS_PER_SECOND, Tick::MAX.0 / 2] { + for start in [0, Tick::MIN.0, Tick::MAX.0 / 2] { + for (num, den) in rates { + it.start = Tick(start); + it.duration = Tick(duration); + let ruler = Rect::from_min_size(pos2(10.0, 10.0), vec2(width, RULER_H)); + paint_ruler(ui.painter(), ruler, &it, filmcraft_time::FrameRate { num, den }, false, &t); + } + } + } + } + }); + out.textures_delta.clear(); + } + + /// A five-second lane 300 px wide is labelled every two seconds, in sequence timecode. + #[test] + fn ruler_labels_sequence_timecode() { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut it = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + let rate = filmcraft_time::FrameRate { num: 24, den: 1 }; + it.start = rate.tick_of(24 * 60); + it.duration = rate.tick_of(24 * 5); + let ctx = context(); + let t = Tokens::for_kind(Default::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint_ruler(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, RULER_H)), &it, rate, false, &t); + }); + out.textures_delta.clear(); + fn texts(s: egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Vec(v) => v.into_iter().for_each(|s| texts(s, out)), + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + _ => {} + } + } + let mut labels = Vec::new(); + out.shapes.into_iter().for_each(|c| texts(c.shape, &mut labels)); + assert_eq!(labels, ["00:01:00:00", "00:01:02:00", "00:01:04:00"]); + } +} diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index 75b25e34f..76f4f8c28 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -20,7 +20,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sr = Rect::from_min_size(rect.min + vec2(8.0, 6.0), vec2(rect.width() - 16.0, 22.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr)); let mut q = app.ui.effects_search.clone(); - crate::widgets::search_field(&mut child, &mut q, "Search effects", sr.width(), &t); + crate::widgets::search_field(&mut child, &mut q, tl!("Search effects"), sr.width(), &t); app.ui.effects_search = q.clone(); let body = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); // wide panel: the tree on the left, the Lumetri Presets thumbnail grid on the right @@ -31,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("fx-body")); bui.set_clip_rect(body); - let filter = q.to_ascii_lowercase(); + let filter = q.to_lowercase(); let defs = filmcraft_project::effect_defs(); // folder tree: top-level categories in Premiere's order let tops = filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; @@ -41,7 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| { for &top in tops { if top == "Presets" { - let any = filter.is_empty() || app.session.presets.all().iter().any(|p| p.name.to_ascii_lowercase().contains(&filter)); + let any = app.session.presets.all().iter().any(|p| crate::i18n::matches_query(&p.name, &filter)); if any && folder_row(app, ui, top, 0, &filter) { preset_action = crate::panels::presets::folder_rows(app, ui, &filter).or(preset_action.take()); } @@ -53,11 +53,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } continue; } - let mut items: Vec<_> = defs - .iter() - .filter(|d| d.category.first() == Some(&top)) - .filter(|d| filter.is_empty() || d.name.to_ascii_lowercase().contains(&filter)) - .collect(); + let mut items: Vec<_> = defs.iter().filter(|d| d.category.first() == Some(&top)).filter(|d| crate::i18n::matches_query(d.name, &filter)).collect(); // Premiere lists effect folders and effects alphabetically (obsolete ones last) if top == "Video Effects" || top == "Legacy" { items.sort_by_key(|d| (d.category.get(1) == Some(&"Obsolete"), d.category.get(1).copied().unwrap_or(""), d.name.to_ascii_lowercase())); @@ -99,13 +95,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { ui.painter().text(icon_r.center(), Align2::CENTER_CENTER, "fx", Tokens::ui(8.0), t.text_dim); } - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, d.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(d.name), Tokens::ui(12.0), t.text); // badges: accelerated / 32-bit / YUV let mut bx = r.max.x - 8.0; for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] { if on { let br = Rect::from_min_size(pos2(bx - 22.0, r.min.y + 3.0), vec2(20.0, 14.0)); - ui.painter().rect_filled(br, 2.0, Color32::from_rgb(48, 48, 48)); + ui.painter().rect_filled(br, 2.0, t.field_border); ui.painter().text(br.center(), Align2::CENTER_CENTER, label, Tokens::ui(8.5), t.text_dim); bx -= 24.0; } @@ -158,7 +154,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize ); icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 18.0, r.center().y), vec2(13.0, 13.0)), Icon::Folder, t.text_dim); let name = key.rsplit('/').next().unwrap_or(key); - ui.painter().text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.folder.{key}"), r, name); if resp.clicked() { if open { @@ -175,7 +171,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize fn apply_menu(app: &mut FilmcraftApp, resp: &egui::Response) -> bool { let mut chosen = false; resp.context_menu(|ui| { - let b = ui.button("Apply to Selected Clips"); + let b = ui.button(tl!("Apply to Selected Clips")); app.auto.add("effects.presetMenu.apply", b.rect, "Apply to Selected Clips"); if b.clicked() { chosen = true; @@ -210,7 +206,7 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti use filmcraft_render::lumetri_presets as lp; let t = app.tokens; let top = "Lumetri Presets"; - let presets: Vec = lp::presets().into_iter().filter(|p| filter.is_empty() || p.name.to_ascii_lowercase().contains(filter)).collect(); + let presets: Vec = lp::presets().into_iter().filter(|p| crate::i18n::matches_query(p.name, filter)).collect(); if !filter.is_empty() && presets.is_empty() { return None; } @@ -243,9 +239,9 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti let tex = preset_texture(app, ui.ctx(), p, 64); let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0)); ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, p.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name); - let resp = resp.on_hover_text(p.description); + let resp = resp.on_hover_text(crate::i18n::t(p.description)); if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } @@ -277,10 +273,10 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(p.description); + let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let label = ui.painter().layout(p.name.to_string(), Tokens::ui(11.0), t.text, cw - 6.0); + let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text); app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name); if apply_menu(app, &resp) { diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 2a64b5725..958594db3 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -61,11 +61,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ty = rect.min.y + 16.0; let mut new_tab = None; for name in ["Browse", "Edit"] { - let g = painter.layout_no_wrap(name.to_string(), Tokens::ui(12.5), t.text); + let shown = if name == "Browse" { tl!("Browse") } else { tl!("Edit") }; + let g = painter.layout_no_wrap(shown.to_string(), Tokens::ui(12.5), t.text); let r = Rect::from_min_size(pos2(x, ty - 9.0), vec2(g.size().x, 20.0)); let resp = ui.interact(r, egui::Id::new(("es-tab", name)), Sense::click()); let col = if name == tab || resp.hovered() { t.text } else { t.text_dim }; - painter.text(pos2(x, ty), Align2::LEFT_CENTER, name, Tokens::ui(12.5), col); + painter.text(pos2(x, ty), Align2::LEFT_CENTER, shown, Tokens::ui(12.5), col); if name == tab { painter.line_segment([pos2(x, ty + 10.0), pos2(x + g.size().x, ty + 10.0)], Stroke::new(1.5, t.text)); } @@ -111,19 +112,19 @@ fn browse(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, presets: &[es::Preset], i egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut child, |ui| { ui.set_max_width(ui.available_width() - 10.0); if items.is_empty() { - ui.label(egui::RichText::new("Select audio clips, then click a preset to apply it.").color(t.text_faint).size(11.5)); + ui.label(egui::RichText::new(tl!("Select audio clips, then click a preset to apply it.")).color(t.text_faint).size(11.5)); } for kind in AudioType::ALL { ui.add_space(8.0); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); type_icon(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(16.0, 16.0)), kind, t.text); - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::semibold(12.5), t.text); + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::semibold(12.5), t.text); for p in presets.iter().filter(|p| p.kind == kind) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 2.0, t.hover); } - let name = if p.builtin { p.name.clone() } else { format!("{} (user)", p.name) }; + let name = if p.builtin { crate::i18n::t(&p.name).to_string() } else { tlf!("{name} (user)", name = p.name) }; ui.painter().text( pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, @@ -145,12 +146,12 @@ fn browse(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, presets: &[es::Preset], i fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], presets: &[es::Preset], collapsed: Vec, expanded: Vec) { let t = cx.t; if items.is_empty() { - crate::dock::placeholder(ui, rect, &t, "Select audio clips to edit them here"); + crate::dock::placeholder(ui, rect, &t, tl!("Select audio clips to edit them here")); return; } let mut kinds: Vec> = items.iter().map(|i| i.essential.as_ref().map(|e| e.kind)).collect(); kinds.dedup(); - let title = if items.len() > 1 { "Multiple Clips Selected".to_string() } else { items[0].name.clone() }; + let title = if items.len() > 1 { tl!("Multiple Clips Selected").to_string() } else { items[0].name.clone() }; let body = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - FOOTER_H)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min + vec2(PAD, 2.0), body.max - vec2(PAD - 2.0, 2.0))).id_salt("es-edit")); @@ -164,12 +165,12 @@ fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], preset _ => { if kinds.len() > 1 { ui.label( - egui::RichText::new("The selected clips have different audio types. Assign one type to edit them together.") + egui::RichText::new(tl!("The selected clips have different audio types. Assign one type to edit them together.")) .color(t.text_dim) .size(11.5), ); } else { - ui.label(egui::RichText::new("Assign an audio type to the selection:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Assign an audio type to the selection:")).color(t.text_dim).size(12.0)); } ui.add_space(6.0); for kind in AudioType::ALL { @@ -178,7 +179,7 @@ fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], preset p.rect_filled(r.shrink(2.0), 4.0, if resp.hovered() { t.hover } else { t.field_bg }); p.rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); type_icon(p, Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(22.0, 22.0)), kind, t.text); - p.text(pos2(r.min.x + 50.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::semibold(13.0), t.text); + p.text(pos2(r.min.x + 50.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::semibold(13.0), t.text); cx.auto.add(&format!("essentialSound.type.{}", kind.label()), r, kind.label()); if resp.clicked() { cx.run("essentialSound.setType", json!({"type": kind.id()})); @@ -198,16 +199,16 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra // type badge + Clear Audio Type let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); type_icon(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(18.0, 18.0)), kind, t.text); - ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::ui(12.5), t.text); + ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::ui(12.5), t.text); let br = Rect::from_min_max(pos2(r.max.x - 122.0, r.min.y + 3.0), pos2(r.max.x, r.max.y - 3.0)); - if button(ui, cx, br, "Clear Audio Type", "essentialSound.clearType") { + if button(ui, cx, br, tl!("Clear Audio Type"), "essentialSound.clearType") { cx.run("essentialSound.clearType", json!({})); } ui.add_space(6.0); // Preset: [▾] save delete - ui.label(egui::RichText::new("Preset:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim).size(12.0)); let mine: Vec<&es::Preset> = presets.iter().filter(|p| p.kind == kind).collect(); - let current = if st.preset.is_empty() { "(Custom)".to_string() } else { st.preset.clone() }; + let current = if st.preset.is_empty() { tl!("(Custom)").to_string() } else { crate::i18n::t(&st.preset).to_string() }; let mut picked = None; let draft_id = egui::Id::new("es-preset-draft"); let mut draft: Option = ui.data(|d| d.get_temp(draft_id)); @@ -241,9 +242,9 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra if let Some(mut d) = draft.take() { let mut done = false; ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text("Preset name").desired_width(ui.available_width() - 70.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)); cx.auto.add("essentialSound.preset.name", r.rect, "Preset name"); - let ok = ui.button("Save"); + let ok = ui.button(tl!("Save")); cx.auto.add("essentialSound.preset.ok", ok.rect, "Save"); if (ok.clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))) && !d.trim().is_empty() { cx.run("essentialSound.savePreset", json!({"name": d.trim()})); @@ -275,46 +276,52 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra match sec { Section::Loudness => loudness(ui, cx, st, items), Section::Repair => { - slot(ui, cx, "Reduce Noise", "repair.noise", st.repair.noise, on); - slot(ui, cx, "Reduce Rumble", "repair.rumble", st.repair.rumble, on); - slot(ui, cx, "DeHum", "repair.dehum", st.repair.dehum, on); + slot(ui, cx, tl!("Reduce Noise"), "repair.noise", st.repair.noise, on); + slot(ui, cx, tl!("Reduce Rumble"), "repair.rumble", st.repair.rumble, on); + slot(ui, cx, tl!("DeHum"), "repair.dehum", st.repair.dehum, on); choice_row(ui, cx, "repair.humHz", &["50 Hz", "60 Hz"], usize::from(st.repair.hum_hz == 60), on && st.repair.dehum.on, |i| { json!(if i == 1 { 60 } else { 50 }) }); - slot(ui, cx, "DeEss", "repair.deess", st.repair.deess, on); - slot(ui, cx, "Reduce Reverb", "repair.reverb", st.repair.reverb, on); + slot(ui, cx, tl!("DeEss"), "repair.deess", st.repair.deess, on); + slot(ui, cx, tl!("Reduce Reverb"), "repair.reverb", st.repair.reverb, on); } Section::Clarity => { - slot(ui, cx, "Dynamics", "clarity.dynamics", st.clarity.dynamics, on); - switch_row(ui, cx, "EQ", "clarity.eq.on", st.clarity.eq.on, on); + slot(ui, cx, tl!("Dynamics"), "clarity.dynamics", st.clarity.dynamics, on); + switch_row(ui, cx, tl!("EQ"), "clarity.eq.on", st.clarity.eq.on, on); let names: Vec<&str> = es::EQ_PRESETS.iter().map(|p| p.name).collect(); combo(ui, cx, "clarity.eqPreset", &names, &st.clarity.eq_preset, on && st.clarity.eq.on); - slider(ui, cx, "Amount", "clarity.eq.amount", st.clarity.eq.amount, (0.0, 10.0), 1, "", on && st.clarity.eq.on); - switch_row(ui, cx, "Enhance Speech", "clarity.enhance.on", st.clarity.enhance.on, on); - choice_row(ui, cx, "clarity.enhanceTone", &["Low Tone", "High Tone"], st.clarity.enhance_tone as usize, on && st.clarity.enhance.on, |i| { - json!(i) - }); - slider(ui, cx, "Mix", "clarity.enhance.amount", st.clarity.enhance.amount, (0.0, 10.0), 1, "", on && st.clarity.enhance.on); + slider(ui, cx, tl!("Amount"), "clarity.eq.amount", st.clarity.eq.amount, (0.0, 10.0), 1, "", on && st.clarity.eq.on); + switch_row(ui, cx, tl!("Enhance Speech"), "clarity.enhance.on", st.clarity.enhance.on, on); + choice_row( + ui, + cx, + "clarity.enhanceTone", + &[tl!("Low Tone"), tl!("High Tone")], + st.clarity.enhance_tone as usize, + on && st.clarity.enhance.on, + |i| json!(i), + ); + slider(ui, cx, tl!("Mix"), "clarity.enhance.amount", st.clarity.enhance.amount, (0.0, 10.0), 1, "", on && st.clarity.enhance.on); } Section::Creative => { - switch_row(ui, cx, "Reverb", "creative.reverb.on", st.creative.reverb.on, on); - ui.label(egui::RichText::new("Preset:").color(t.text_dim).size(12.0)); + switch_row(ui, cx, tl!("Reverb"), "creative.reverb.on", st.creative.reverb.on, on); + ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim).size(12.0)); let names: Vec<&str> = es::REVERB_PRESETS.iter().map(|p| p.name).collect(); combo(ui, cx, "creative.reverbPreset", &names, &st.creative.reverb_preset, on && st.creative.reverb.on); - slider(ui, cx, "Amount", "creative.reverb.amount", st.creative.reverb.amount, (0.0, 10.0), 1, "", on && st.creative.reverb.on); + slider(ui, cx, tl!("Amount"), "creative.reverb.amount", st.creative.reverb.amount, (0.0, 10.0), 1, "", on && st.creative.reverb.on); if kind == AudioType::Ambience { - slot(ui, cx, "Stereo Width", "creative.width", st.creative.width, on); + slot(ui, cx, tl!("Stereo Width"), "creative.width", st.creative.width, on); } } Section::Ducking => ducking(ui, cx, st, on), Section::Duration => { ui.label( - egui::RichText::new("Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead.") + egui::RichText::new(tl!("Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead.")) .color(t.text_faint) .size(11.5), ); } - Section::Pan => slider(ui, cx, "Pan", "pan.value", st.pan.value, (-100.0, 100.0), 1, "", on), + Section::Pan => slider(ui, cx, tl!("Pan"), "pan.value", st.pan.value, (-100.0, 100.0), 1, "", on), } ui.add_space(8.0); } @@ -327,22 +334,22 @@ fn loudness(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Track let t = cx.t; let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); let br = Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(104.0, 24.0)); - if button(ui, cx, br, "Auto-Match", "essentialSound.autoMatch") { + if button(ui, cx, br, tl!("Auto-Match"), "essentialSound.autoMatch") { cx.run("essentialSound.autoMatch", json!({})); } let text = match (st.loudness.measured_lufs, st.loudness.target_lufs) { (Some(m), Some(tg)) => format!("{m:.1} → {tg:.1} LUFS ({:+.1} dB)", st.loudness.gain_db), - _ => "Not matched yet".to_string(), + _ => tl!("Not matched yet").to_string(), }; ui.painter().text(pos2(br.max.x + 10.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); if items.len() > 1 { - ui.label(egui::RichText::new(format!("Matches each of the {} clips on its own.", items.len())).color(t.text_faint).size(11.0)); + ui.label(egui::RichText::new(tlf!("Matches each of the {n} clips on its own.", n = items.len())).color(t.text_faint).size(11.0)); } } fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { let t = cx.t; - ui.label(egui::RichText::new("Duck against:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Duck against:")).color(t.text_dim).size(12.0)); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); let mut x = r.min.x; for kind in AudioType::ALL { @@ -353,7 +360,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { br, 4.0, if sel { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() { t.hover } else { @@ -380,25 +387,25 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { br, 4.0, if sel { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }, ); - ui.painter().text(br.center(), Align2::CENTER_CENTER, "Untagged", Tokens::ui(11.5), if sel { t.text } else { t.text_faint }); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Untagged"), Tokens::ui(11.5), if sel { t.text } else { t.text_faint }); cx.auto.add("essentialSound.ducking.against.Untagged", br, "Untagged"); if resp.clicked() && on { cx.set("ducking.againstUntyped", json!(!sel), true); } - slider(ui, cx, "Sensitivity", "ducking.sensitivity", st.ducking.sensitivity, (0.0, 10.0), 1, "", on); - slider(ui, cx, "Reduce By", "ducking.reduceDb", st.ducking.reduce_db, (-40.0, 0.0), 1, " dB", on); - slider(ui, cx, "Fades", "ducking.fadeS", st.ducking.fade_s * 1000.0, (0.0, 5000.0), 0, " ms", on); + slider(ui, cx, tl!("Sensitivity"), "ducking.sensitivity", st.ducking.sensitivity, (0.0, 10.0), 1, "", on); + slider(ui, cx, tl!("Reduce By"), "ducking.reduceDb", st.ducking.reduce_db, (-40.0, 0.0), 1, " dB", on); + slider(ui, cx, tl!("Fades"), "ducking.fadeS", st.ducking.fade_s * 1000.0, (0.0, 5000.0), 0, " ms", on); ui.add_space(4.0); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); let br = Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(150.0, 24.0)); - if button(ui, cx, br, "Generate Keyframes", "essentialSound.generateDucking") && on { + if button(ui, cx, br, tl!("Generate Keyframes"), "essentialSound.generateDucking") && on { cx.run("essentialSound.generateDucking", json!({})); } } @@ -413,7 +420,7 @@ fn footer(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, st: Option<&EssentialSoun if switch(ui, cx, Rect::from_min_size(row.min + vec2(0.0, 4.0), vec2(28.0, 14.0)), st.volume.on, "essentialSound.volume.on", "Clip Volume") { cx.set("volume.on", json!(!st.volume.on), true); } - ui.painter().text(pos2(row.min.x + 38.0, row.center().y), Align2::LEFT_CENTER, "Clip Volume", Tokens::ui(12.5), t.text); + ui.painter().text(pos2(row.min.x + 38.0, row.center().y), Align2::LEFT_CENTER, tl!("Clip Volume"), Tokens::ui(12.5), t.text); let vr = Rect::from_min_max(pos2(row.max.x - 70.0, row.min.y), row.max); ui.painter().text(pos2(vr.max.x - 18.0, vr.center().y), Align2::RIGHT_CENTER, format!("{:.1}", st.volume.level_db), Tokens::ui(12.0), t.hot_text); ui.painter().text(pos2(vr.max.x, vr.center().y), Align2::RIGHT_CENTER, "dB", Tokens::ui(12.0), t.text_dim); @@ -423,7 +430,7 @@ fn footer(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, st: Option<&EssentialSoun if switch(ui, cx, mr, st.mute, "essentialSound.mute", "Mute") { cx.set("mute", json!(!st.mute), true); } - ui.painter().text(pos2(mr.max.x + 10.0, mr.center().y), Align2::LEFT_CENTER, "Mute", Tokens::ui(12.5), t.text); + ui.painter().text(pos2(mr.max.x + 10.0, mr.center().y), Align2::LEFT_CENTER, tl!("Mute"), Tokens::ui(12.5), t.text); } // ------------------------------------------------------------------------------------- widgets @@ -439,7 +446,7 @@ fn section_header(ui: &mut egui::Ui, cx: &mut Ctx, sec: Section, open: bool, ena if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text, ); - ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, sec.label(), Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(sec.label()), Tokens::semibold(13.0), t.text); cx.auto.add(&format!("essentialSound.section.{}", sec.label()), r, sec.label()); if let (Some(on), Some(k)) = (enabled, sec.key()) { let sw = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(30.0, 15.0)); @@ -460,11 +467,11 @@ fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: & let p = ui.painter(); let rad = r.height() / 2.0; if on { - p.rect_filled(r, rad, Color32::from_gray(0xd4)); - p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, Color32::from_gray(0x1d)); + p.rect_filled(r, rad, cx.t.switch_fill); + p.circle_filled(pos2(r.max.x - rad, r.center().y), rad - 3.0, cx.t.switch_knob); } else { - p.rect_stroke(r, rad, Stroke::new(1.5, Color32::from_gray(if resp.hovered() { 0xb0 } else { 0x8a })), StrokeKind::Inside); - p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, Color32::from_gray(0xd4))); + p.rect_stroke(r, rad, Stroke::new(1.5, if resp.hovered() { cx.t.control_handle_dim } else { cx.t.switch_border }), StrokeKind::Inside); + p.circle_stroke(pos2(r.min.x + rad, r.center().y), rad - 3.0, Stroke::new(1.5, cx.t.switch_fill)); } cx.auto.add(id, r, label); resp.clicked() @@ -514,7 +521,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag()); - let line = if active { Color32::from_gray(0x8a) } else { Color32::from_gray(0x4a) }; + let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track }; ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line)); ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line)); ui.painter().circle_filled(pos2(kx, y), 6.0, t.panel_bg); @@ -526,9 +533,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, if resp.dragged() { t.hot_text } else if active { - Color32::from_gray(0xd4) + cx.t.switch_fill } else { - Color32::from_gray(0x6a) + cx.t.slider_disabled_knob }, ), ); @@ -553,9 +560,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, fn combo(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, names: &[&str], current: &str, active: bool) { let mut picked = None; ui.add_enabled_ui(active, |ui| { - let cb = egui::ComboBox::from_id_salt(("es-combo", key)).selected_text(current).width(ui.available_width() - 4.0).show_ui(ui, |ui| { + let cb = egui::ComboBox::from_id_salt(("es-combo", key)).selected_text(crate::i18n::t(current)).width(ui.available_width() - 4.0).show_ui(ui, |ui| { for n in names { - if ui.selectable_label(*n == current, *n).clicked() { + if ui.selectable_label(*n == current, crate::i18n::t(n)).clicked() { picked = Some(n.to_string()); } } @@ -580,7 +587,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us br, 4.0, if on { - Color32::from_gray(0x4b) + cx.t.pressed } else if resp.hovered() && active { t.hover } else { @@ -598,8 +605,8 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool { let t = cx.t; let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click()); - ui.painter().rect_filled(r, 4.0, if resp.hovered() { Color32::from_gray(0x2a) } else { t.field_bg }); - ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_gray(0x4b)), StrokeKind::Inside); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg }); + ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside); ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text); cx.auto.add(id, r, text); resp.clicked() diff --git a/crates/ui-egui/src/panels/events.rs b/crates/ui-egui/src/panels/events.rs index d9649a0a4..85174e260 100644 --- a/crates/ui-egui/src/panels/events.rs +++ b/crates/ui-egui/src/panels/events.rs @@ -42,10 +42,10 @@ fn ago(ms: u64) -> String { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(ms); let s = now.saturating_sub(ms) / 1000; match s { - 0..=4 => "just now".into(), - 5..=59 => format!("{s} s ago"), - 60..=3599 => format!("{} min ago", s / 60), - _ => format!("{} h ago", s / 3600), + 0..=4 => tl!("just now").into(), + 5..=59 => tlf!("{n} s ago", n = s), + 60..=3599 => tlf!("{n} min ago", n = s / 60), + _ => tlf!("{n} h ago", n = s / 3600), } } @@ -63,9 +63,9 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // toolbar: [All ▾] counts … [Clear All] ui.horizontal(|ui| { let label = |l: Level| match l { - Level::Info => "All Events", - Level::Warning => "Warnings and Errors", - Level::Error => "Errors Only", + Level::Info => tl!("All Events"), + Level::Warning => tl!("Warnings and Errors"), + Level::Error => tl!("Errors Only"), }; let r = egui::ComboBox::from_id_salt("events-filter").selected_text(label(min)).width(150.0).show_ui(ui, |ui| { for l in [Level::Info, Level::Warning, Level::Error] { @@ -84,7 +84,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(RichText::new(log.count(l).to_string()).color(t.text_dim)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.button("Clear All"); + let r = ui.button(tl!("Clear All")); elems.push(("events.clearAll".into(), r.rect, "Clear All".into())); clear = r.clicked(); }); @@ -94,7 +94,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let list_h = (ui.available_height() - details_h).max(20.0); egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| { if entries.is_empty() { - ui.label(RichText::new("No events.").color(t.text_faint)); + ui.label(RichText::new(tl!("No events.")).color(t.text_faint)); } for (n, e) in entries.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); @@ -121,7 +121,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(sel) = st.selected.and_then(|id| entries.iter().find(|e| e.id == id)) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), details_h - 4.0), Sense::hover()); ui.painter().rect_filled(r, 3.0, t.field_bg); - let text = format!("{} · {} · {}\n{}", sel.level.label(), sel.source, ago(sel.time_ms), sel.message); + let text = format!("{} · {} · {}\n{}", crate::i18n::t(sel.level.label()), sel.source, ago(sel.time_ms), sel.message); let g = ui.painter().layout(text, Tokens::ui(11.5), t.text, r.width() - 12.0); ui.painter().with_clip_rect(r).galley(r.min + vec2(6.0, 4.0), g, t.text); elems.push(("events.details".into(), r, sel.message.clone())); @@ -153,7 +153,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut elems: Vec<(String, Rect, String)> = Vec::new(); let mut cancel = None; let mut finished = show_finished; - let r = ui.checkbox(&mut finished, "Show finished jobs"); + let r = ui.checkbox(&mut finished, tl!("Show finished jobs")); elems.push(("progress.showFinished".into(), r.rect, "Show finished jobs".into())); ui.add_space(4.0); let active = jobs.iter().filter(|j| j["finished"] != json!(true)).count(); @@ -166,16 +166,16 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } shown += 1; let id = j["id"].as_u64().unwrap_or(0); - let label = j["label"].as_str().unwrap_or("Job"); + let label = crate::i18n::t(j["label"].as_str().unwrap_or("Job")); let frac = j["progress"].as_f64().unwrap_or(0.0).clamp(0.0, 1.0) as f32; let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); ui.painter().rect_filled(r.shrink(1.0), 3.0, t.row_alt); ui.painter().text(r.min + vec2(8.0, 6.0), Align2::LEFT_TOP, label, Tokens::semibold(12.0), t.text); let err = j["result"]["error"].as_str(); let status = match (done, err) { - (true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => "Cancelled".to_string(), - (true, Some(e)) => format!("Failed: {e}"), - (true, None) => "Done".to_string(), + (true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => tl!("Cancelled").to_string(), + (true, Some(e)) => tlf!("Failed: {e}", e), + (true, None) => tl!("Done").to_string(), (false, _) => { let s = j["status"].as_str().unwrap_or(""); let n = format!("{} / {}", j["done"].as_u64().unwrap_or(0), j["total"].as_u64().unwrap_or(0)); @@ -204,13 +204,13 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s); ui.painter().line_segment([cr.center() + vec2(5.0, -5.0), cr.center() + vec2(-5.0, 5.0)], s); elems.push((format!("progress.cancel.{id}"), cr, format!("Cancel {label}"))); - if resp.on_hover_text("Cancel").clicked() { + if resp.on_hover_text(tl!("Cancel")).clicked() { cancel = Some(id); } } } if shown == 0 { - ui.label(RichText::new(if active == 0 { "No background jobs." } else { "" }).color(t.text_faint)); + ui.label(RichText::new(if active == 0 { tl!("No background jobs.") } else { "" }).color(t.text_faint)); } }); for (id, r, l) in elems { diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 23d3d7e97..4838ce5ff 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -120,7 +120,7 @@ pub fn apply_preset(app: &mut FilmcraftApp, name: &str) -> bool { true } None => { - app.ui.status = format!("No export preset named `{name}`"); + app.ui.status = tlf!("No export preset named `{name}`", name); false } } @@ -129,7 +129,7 @@ pub fn apply_preset(app: &mut FilmcraftApp, name: &str) -> bool { /// Full output path of the current settings. pub fn output_path(ex: &ExportUi) -> String { let name = if ex.file_name.trim().is_empty() { "Export" } else { ex.file_name.trim() }; - let dir = expand_home(ex.location.trim()); + let dir = filmcraft_engine::export_tools::expand_home(ex.location.trim()); let file = format!("{name}.{}", ex.settings.extension()); if dir.is_empty() { file } else { std::path::Path::new(&dir).join(file).to_string_lossy().to_string() } } @@ -165,7 +165,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(r, t.radius, t.panel_bg); } let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, mid, &t, "Open a sequence to export"); + crate::dock::placeholder(ui, mid, &t, tl!("Open a sequence to export")); queue_column(app, ui, left); return; }; @@ -195,21 +195,21 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let mut reg = Reg::default(); let p = ui.painter(); - p.text(rect.min + vec2(14.0, 20.0), Align2::LEFT_CENTER, "Destinations", Tokens::semibold(13.0), t.text); + p.text(rect.min + vec2(14.0, 20.0), Align2::LEFT_CENTER, tl!("Destinations"), Tokens::semibold(13.0), t.text); let mf = Rect::from_min_size(rect.min + vec2(8.0, 38.0), vec2(rect.width() - 16.0, 26.0)); p.rect_filled(mf, 4.0, t.row_selected); - p.text(pos2(mf.min.x + 10.0, mf.center().y), Align2::LEFT_CENTER, "Media File", Tokens::ui(12.5), t.text); + p.text(pos2(mf.min.x + 10.0, mf.center().y), Align2::LEFT_CENTER, tl!("Media File"), Tokens::ui(12.5), t.text); reg.add("export.destination.mediaFile", mf, "Media File"); let qtop = mf.max.y + 18.0; let n = app.session.export_queue.items.len(); - p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, format!("Queue ({n})"), Tokens::semibold(13.0), t.text); + p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, tlf!("Queue ({n})", n), Tokens::semibold(13.0), t.text); let body = Rect::from_min_max(pos2(rect.min.x + 8.0, qtop + 14.0), pos2(rect.max.x - 8.0, rect.max.y - 40.0)); let items: Vec = app.session.execute("export.queue.list", json!({})).ok().and_then(|v| v["items"].as_array().cloned()).unwrap_or_default(); let mut action: Option<(&'static str, Value)> = None; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("export-queue")); egui::ScrollArea::vertical().id_salt("export-queue-scroll").auto_shrink([false, false]).show(&mut child, |ui| { if items.is_empty() { - ui.label(egui::RichText::new("Send exports here with Send to Queue, then start the queue.").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("Send exports here with Send to Queue, then start the queue.")).color(t.text_dim).size(11.5)); } for (i, it) in items.iter().enumerate() { let id = it["id"].as_u64().unwrap_or(0); @@ -222,18 +222,20 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(format!("{} → {file}", it["sequenceName"].as_str().unwrap_or(""))).size(12.0).color(t.text)); let preset = it["preset"].as_str().filter(|s| !s.is_empty()).unwrap_or(CUSTOM); let (label, col) = match status { - "ready" => ("Ready".to_string(), t.text_dim), - "encoding" => (format!("Encoding {:.0}%{}", it["progress"].as_f64().unwrap_or(0.0) * 100.0, super::eta_suffix(it)), t.accent), - "done" => ("Done".to_string(), t.render_green), - "failed" => (format!("Failed: {}", it["error"].as_str().unwrap_or("")), t.danger), - _ => ("Cancelled".to_string(), t.text_faint), + "ready" => (tl!("Ready").to_string(), t.text_dim), + "encoding" => { + (tlf!("Encoding {n}%", n = format!("{:.0}", it["progress"].as_f64().unwrap_or(0.0) * 100.0)) + &super::eta_suffix(it), t.accent) + } + "done" => (tl!("Done").to_string(), t.render_green), + "failed" => (tlf!("Failed: {e}", e = it["error"].as_str().unwrap_or("")), t.danger), + _ => (tl!("Cancelled").to_string(), t.text_faint), }; if status == "encoding" { // the progress and the time left on a line of their own: the column is narrow, and a wrapped "· 15 s left" reads badly - ui.label(egui::RichText::new(preset).size(11.0).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::t(preset)).size(11.0).color(t.text_dim)); ui.label(egui::RichText::new(label).size(11.0).color(col)); } else { - ui.label(egui::RichText::new(format!("{preset} · {label}")).size(11.0).color(col)); + ui.label(egui::RichText::new(format!("{} · {label}", crate::i18n::t(preset))).size(11.0).color(col)); } if status == "encoding" { ui.add(egui::ProgressBar::new(it["progress"].as_f64().unwrap_or(0.0) as f32).desired_height(6.0)); @@ -265,17 +267,18 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let has_ready = items.iter().any(|i| i["status"] == "ready"); let foot = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.max.y - 34.0), pos2(rect.max.x - 8.0, rect.max.y - 6.0)); let mut f = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::left_to_right(egui::Align::Center))); - let r = f.add_enabled(!running && has_ready, egui::Button::new("Start Queue")); + let r = f.add_enabled(!running && has_ready, egui::Button::new(tl!("Start Queue"))); reg.add("export.queue.start", r.rect, "Start Queue"); if r.clicked() { action = Some(("export.queue.start", json!({}))); } - let r = f.add_enabled(running, egui::Button::new("Stop")); + let r = f.add_enabled(running, egui::Button::new(tl!("Stop"))); reg.add("export.queue.stop", r.rect, "Stop"); if r.clicked() { action = Some(("export.queue.stop", json!({}))); } - let r = f.add_enabled(items.iter().any(|i| matches!(i["status"].as_str(), Some("done" | "failed" | "cancelled"))), egui::Button::new("Clear Finished")); + let r = + f.add_enabled(items.iter().any(|i| matches!(i["status"].as_str(), Some("done" | "failed" | "cancelled"))), egui::Button::new(tl!("Clear Finished"))); reg.add("export.queue.clear", r.rect, "Clear Finished"); if r.clicked() { action = Some(("export.queue.clear", json!({}))); @@ -306,10 +309,10 @@ fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui /// A combo box over `options` (label, enabled); returns the chosen index. fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option { let mut chosen = None; - let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(width).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(selected)).width(width).show_ui(ui, |ui| { for (i, (label, enabled)) in options.iter().enumerate() { - let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, label)); - let resp = if *enabled { resp } else { resp.on_disabled_hover_text("Not supported by FilmCraft's encoders yet") }; + let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, crate::i18n::t(label))); + let resp = if *enabled { resp } else { resp.on_disabled_hover_text(tl!("Not supported by FilmCraft's encoders yet")) }; reg.add(format!("{id}.option.{i}"), resp.rect, label.clone()); if resp.clicked() { chosen = Some(i); @@ -369,7 +372,7 @@ const RATES: [(&str, FrameRate); 11] = [ ("60", FrameRate::FPS_60), ]; -const PARS: [(&str, Option<(u32, u32)>); 6] = [ +pub(crate) const PARS: [(&str, Option<(u32, u32)>); 6] = [ ("Square Pixels (1.0)", None), ("D1/DV NTSC (0.9091)", Some((10, 11))), ("D1/DV PAL (1.0940)", Some((59, 54))), @@ -389,6 +392,8 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .map(|q| (q.settings.width, q.settings.height, q.settings.frame_rate, q.settings.sample_rate)) .unwrap_or((1920, 1080, FrameRate::FPS_24, 48_000)); let has_captions = app.session.active_sequence().is_some_and(|q| !q.caption_tracks.is_empty()); + // an HDR sequence exports HDR (H.265 Main 10 where the hardware encoder has it) unless SDR is asked for + let seq_hdr = app.session.active_sequence().is_some_and(|q| q.settings.color.working.is_hdr()); let all_presets = app.session.export_presets.all(); let favs: Vec = all_presets.iter().filter(|p| app.session.export_presets.is_favorite(&p.name)).map(|p| p.name.clone()).collect(); let mut pick_folder = false; @@ -396,53 +401,54 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0))).id_salt("export-settings")); let ex = &mut app.ui.export; egui::ScrollArea::vertical().id_salt("export-settings-scroll").auto_shrink([false, false]).show(&mut child, |ui| { - ui.label(egui::RichText::new("Media File").strong().size(14.0)); + ui.label(egui::RichText::new(tl!("Media File")).strong().size(14.0)); ui.add_space(6.0); - row(ui, &t, "File Name", |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, "Sequence name")); - row(ui, &t, "Location", |ui| { + row(ui, &t, tl!("File Name"), |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, tl!("Sequence name"))); + row(ui, &t, tl!("Location"), |ui| { let r = ui.small_button("…"); reg.add("export.location.browse", r.rect, "Choose folder"); pick_folder = r.clicked(); - text(ui, &mut reg, "export.location", &mut ex.location, "Folder"); + text(ui, &mut reg, "export.location", &mut ex.location, tl!("Folder")); }); // Preset: favourites, then every preset by category, then More presets… - row(ui, &t, "Preset", |ui| { - let r = egui::ComboBox::from_id_salt("export.preset").selected_text(&ex.preset).width(ui.available_width() - 4.0).show_ui(ui, |ui| { - if !favs.is_empty() { - ui.label(egui::RichText::new("Favorites").color(t.text_dim).size(11.0)); - for f in &favs { - if ui.selectable_label(*f == ex.preset, f).clicked() { - chosen_preset = Some(f.clone()); + row(ui, &t, tl!("Preset"), |ui| { + let r = + egui::ComboBox::from_id_salt("export.preset").selected_text(crate::i18n::t(&ex.preset)).width(ui.available_width() - 4.0).show_ui(ui, |ui| { + if !favs.is_empty() { + ui.label(egui::RichText::new(tl!("Favorites")).color(t.text_dim).size(11.0)); + for f in &favs { + if ui.selectable_label(*f == ex.preset, crate::i18n::t(f)).clicked() { + chosen_preset = Some(f.clone()); + } + } + ui.separator(); + } + let mut cat = String::new(); + for p in &all_presets { + if p.category != cat { + cat = p.category.clone(); + ui.label(egui::RichText::new(crate::i18n::t(&cat)).color(t.text_dim).size(11.0)); + } + if ui.selectable_label(p.name == ex.preset, crate::i18n::t(&p.name)).clicked() { + chosen_preset = Some(p.name.clone()); } } ui.separator(); - } - let mut cat = String::new(); - for p in &all_presets { - if p.category != cat { - cat = p.category.clone(); - ui.label(egui::RichText::new(&cat).color(t.text_dim).size(11.0)); + if ui.button(tl!("More presets…")).clicked() { + open_manager = true; } - if ui.selectable_label(p.name == ex.preset, &p.name).clicked() { - chosen_preset = Some(p.name.clone()); - } - } - ui.separator(); - if ui.button("More presets…").clicked() { - open_manager = true; - } - }); + }); reg.add("export.preset", r.response.rect, ex.preset.clone()); }); ui.horizontal(|ui| { ui.add_space(LABEL_W + 8.0); - let r = ui.link("More presets…"); + let r = ui.link(tl!("More presets…")); reg.add("export.preset.more", r.rect, "More presets…"); if r.clicked() { open_manager = true; } }); - row(ui, &t, "Format", |ui| { + row(ui, &t, tl!("Format"), |ui| { let labels: Vec<(String, bool)> = Format::ALL.iter().map(|f| (f.label().to_string(), filmcraft_engine::export::available(*f))).collect(); if let Some(i) = combo(ui, &mut reg, "export.format", ex.settings.format.label(), &labels, ui.available_width() - 4.0) { let f = Format::ALL[i]; @@ -462,8 +468,8 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); ui.add_space(8.0); let s = &mut ex.settings; - if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) { - video_section(ui, &mut reg, s, &t, seq_w, seq_h); + if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", tl!("Video"), &t) { + video_section(ui, &mut reg, s, &t, seq_w, seq_h, seq_hdr); } if (s.has_audio() || !s.has_video() @@ -472,57 +478,59 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { || s.format.is_mxf()) && !s.is_image_sequence() && s.format != Format::Gif - && section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t) + && section(ui, &mut reg, &mut ex.open_sections, "audio", tl!("Audio"), &t) { audio_section(ui, &mut reg, s, &t, seq_sr); } - if s.format.is_h26x() && section(ui, &mut reg, &mut ex.open_sections, "multiplexer", "Multiplexer", &t) { - row(ui, &t, "Multiplexer", |ui| { + if s.format.is_h26x() && section(ui, &mut reg, &mut ex.open_sections, "multiplexer", tl!("Multiplexer"), &t) { + row(ui, &t, tl!("Multiplexer"), |ui| { let cur = if s.multiplexer == Multiplexer::Mp4 { "MP4" } else { "QuickTime" }; if let Some(i) = combo(ui, &mut reg, "export.multiplexer", cur, &opts(&["MP4", "QuickTime"]), 160.0) { s.multiplexer = if i == 0 { Multiplexer::Mp4 } else { Multiplexer::Mov }; } }); } - if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "captions", "Captions", &t) { + if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "captions", tl!("Captions"), &t) { if !has_captions { - ui.label(egui::RichText::new("The sequence has no caption track.").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("The sequence has no caption track.")).color(t.text_dim).size(11.5)); } - row(ui, &t, "Export Options", |ui| { + row(ui, &t, tl!("Export Options"), |ui| { + let modes = [tl!("None"), tl!("Burn Captions Into Video"), tl!("Create Sidecar File")]; let cur = if s.burn_captions { - "Burn Captions Into Video" + modes[1] } else if s.caption_sidecar.is_some() { - "Create Sidecar File" + modes[2] } else { - "None" + modes[0] }; - if let Some(i) = combo(ui, &mut reg, "export.captions.mode", cur, &opts(&["None", "Burn Captions Into Video", "Create Sidecar File"]), 220.0) { + if let Some(i) = combo(ui, &mut reg, "export.captions.mode", cur, &opts(&modes), 220.0) { s.burn_captions = i == 1; s.caption_sidecar = (i == 2).then(|| s.caption_sidecar.clone().unwrap_or_else(|| "srt".into())); } }); if let Some(kind) = s.caption_sidecar.clone() { - row(ui, &t, "File Format", |ui| { - let cur = if kind == "vtt" { "WebVTT (.vtt)" } else { "SubRip Subtitle (.srt)" }; - if let Some(i) = combo(ui, &mut reg, "export.captions.format", cur, &opts(&["SubRip Subtitle (.srt)", "WebVTT (.vtt)"]), 220.0) { + row(ui, &t, tl!("File Format"), |ui| { + let formats = [tl!("SubRip Subtitle (.srt)"), tl!("WebVTT (.vtt)")]; + let cur = if kind == "vtt" { formats[1] } else { formats[0] }; + if let Some(i) = combo(ui, &mut reg, "export.captions.format", cur, &opts(&formats), 220.0) { s.caption_sidecar = Some(if i == 0 { "srt" } else { "vtt" }.into()); } }); } } - if section(ui, &mut reg, &mut ex.open_sections, "effects", "Effects", &t) { + if section(ui, &mut reg, &mut ex.open_sections, "effects", tl!("Effects"), &t) { pick_overlay = effects_section(ui, &mut reg, s, &t); } if matches!(s.format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) - && section(ui, &mut reg, &mut ex.open_sections, "metadata", "Metadata", &t) + && section(ui, &mut reg, &mut ex.open_sections, "metadata", tl!("Metadata"), &t) { let m = &mut s.metadata; for (key, label, v) in [ - ("title", "Title", &mut m.title), - ("creator", "Creator", &mut m.creator), - ("copyright", "Copyright", &mut m.copyright), - ("description", "Description", &mut m.description), - ("comment", "Comment", &mut m.comment), + ("title", tl!("Title"), &mut m.title), + ("creator", tl!("Creator"), &mut m.creator), + ("copyright", tl!("Copyright"), &mut m.copyright), + ("description", tl!("Description"), &mut m.description), + ("comment", tl!("Comment"), &mut m.comment), ] { row(ui, &t, label, |ui| text(ui, &mut reg, &format!("export.metadata.{key}"), v, "")); } @@ -538,18 +546,18 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if pick_folder && let Some(d) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { app.ui.export.location = d; } - if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Image", &["png", "jpg", "jpeg"])) { + if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Image"), &["png", "jpg", "jpeg"])) { app.ui.export.settings.effects.image_overlay.path = f; app.ui.export.settings.effects.image_overlay.enabled = true; } reg.flush(app); } -fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32) { +fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32, seq_hdr: bool) { // frame size let mut match_size = s.frame_size.is_none(); - row(ui, t, "Frame Size", |ui| { - if check(ui, reg, "export.video.matchSize", &mut match_size, "Match Source") { + row(ui, t, tl!("Frame Size"), |ui| { + if check(ui, reg, "export.video.matchSize", &mut match_size, tl!("Match Source")) { s.frame_size = if match_size { None } else { Some((seq_w, seq_h)) }; } }); @@ -564,8 +572,8 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } // frame rate let mut match_rate = s.frame_rate.is_none(); - row(ui, t, "Frame Rate", |ui| { - if check(ui, reg, "export.video.matchRate", &mut match_rate, "Match Source") { + row(ui, t, tl!("Frame Rate"), |ui| { + if check(ui, reg, "export.video.matchRate", &mut match_rate, tl!("Match Source")) { s.frame_rate = if match_rate { None } else { Some(FrameRate::FPS_29_97) }; } if let Some(r) = s.frame_rate { @@ -576,54 +584,64 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } }); - row(ui, t, "Field Order", |ui| { + row(ui, t, tl!("Field Order"), |ui| { let o = [FieldOrder::Progressive, FieldOrder::UpperFirst, FieldOrder::LowerFirst]; let labels: Vec<(String, bool)> = o.iter().map(|f| (f.label().to_string(), *f == FieldOrder::Progressive)).collect(); if let Some(i) = combo(ui, reg, "export.video.fieldOrder", s.field_order.label(), &labels, 160.0) { s.field_order = o[i]; } }); - row(ui, t, "Aspect", |ui| { - let cur = PARS.iter().find(|(_, p)| *p == s.pixel_aspect).map(|(l, _)| l.to_string()).unwrap_or_else(|| "Custom".into()); + row(ui, t, tl!("Aspect"), |ui| { + let cur = PARS.iter().find(|(_, p)| *p == s.pixel_aspect).map(|(l, _)| l.to_string()).unwrap_or_else(|| tl!("Custom").into()); let labels: Vec<(String, bool)> = PARS.iter().map(|(l, _)| (l.to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.aspect", &cur, &labels, 220.0) { s.pixel_aspect = PARS[i].1; } }); if s.format.is_mxf() { - row(ui, t, "Video Codec", |ui| { + row(ui, t, tl!("Video Codec"), |ui| { let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) { s.mxf_video_codec = MxfVideoCodec::ALL[i]; } }); } + if matches!(s.format, Format::PngSequence | Format::TiffSequence) { + row(ui, t, tl!("Alpha"), |ui| { + check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); + }); + } match s.video_format() { f @ (Format::H264 | Format::Hevc) => { let hevc = f == Format::Hevc; if hevc { - // the hardware encoder writes one profile: Main, 8-bit 4:2:0, level chosen by the encoder - row(ui, t, "Profile", |ui| ui.label("Main (8-bit)")); - row(ui, t, "Encoder", |ui| ui.label("Hardware (H.265 has no software encoder)")); + // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder + let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) { + (true, true) => tl!("Main 10 (10-bit, HDR)"), + (true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"), + (false, _) => tl!("Main (8-bit)"), + }; + row(ui, t, tl!("Profile"), |ui| ui.label(label)); + row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)"))); } else { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High]; let labels: Vec<(String, bool)> = o.iter().map(|p| (p.label().to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.profile", s.h264_profile.label(), &labels, 120.0) { s.h264_profile = o[i]; } }); - row(ui, t, "Level", |ui| { + row(ui, t, tl!("Level"), |ui| { let levels: [Option; 13] = [None, Some(30), Some(31), Some(32), Some(40), Some(41), Some(42), Some(50), Some(51), Some(52), Some(60), Some(61), Some(62)]; - let lab = |l: &Option| l.map(|l| format!("{}.{}", l / 10, l % 10)).unwrap_or_else(|| "Auto".into()); + let lab = |l: &Option| l.map(|l| format!("{}.{}", l / 10, l % 10)).unwrap_or_else(|| tl!("Auto").into()); let labels: Vec<(String, bool)> = levels.iter().map(|l| (lab(l), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.level", &lab(&s.h264_level), &labels, 120.0) { s.h264_level = levels[i]; } }); } - row(ui, t, "Bitrate Encoding", |ui| { + row(ui, t, tl!("Bitrate Encoding"), |ui| { // a hardware encoder has one pass: H.265 offers no two-pass mode let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass]; let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), !(hevc && *m == BitrateMode::Vbr2Pass))).collect(); @@ -632,23 +650,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); if let Some(bpp) = s.adaptive_bitrate { - row(ui, t, "Target Bitrate", |ui| { - ui.label(egui::RichText::new(format!("Adaptive ({bpp} bits per pixel)")).size(12.0)); - let r = ui.small_button("Set"); + row(ui, t, tl!("Target Bitrate"), |ui| { + ui.label(egui::RichText::new(tlf!("Adaptive ({bpp} bits per pixel)", bpp)).size(12.0)); + let r = ui.small_button(tl!("Set")); reg.add("export.video.fixedBitrate", r.rect, "Set a fixed bitrate"); if r.clicked() { s.adaptive_bitrate = None; } }); } else { - row(ui, t, "Target Bitrate", |ui| { + row(ui, t, tl!("Target Bitrate"), |ui| { let mut mbps = s.bitrate_kbps as f64 / 1000.0; if drag(ui, reg, "export.video.target", &mut mbps, 0.1..=800.0, 0.1, " Mbps", 1) { s.bitrate_kbps = (mbps * 1000.0).round() as u32; } }); if s.bitrate_mode != BitrateMode::Cbr { - row(ui, t, "Maximum Bitrate", |ui| { + row(ui, t, tl!("Maximum Bitrate"), |ui| { let mut mbps = s.max_bitrate_kbps.unwrap_or(s.bitrate_kbps * 3 / 2) as f64 / 1000.0; if drag(ui, reg, "export.video.max", &mut mbps, 0.1..=1000.0, 0.1, " Mbps", 1) { s.max_bitrate_kbps = Some((mbps * 1000.0).round() as u32); @@ -656,7 +674,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } } - row(ui, t, "Key Frame Distance", |ui| { + row(ui, t, tl!("Key Frame Distance"), |ui| { let mut on = s.keyframe_distance.is_some(); if check(ui, reg, "export.video.keyframeOn", &mut on, "") { s.keyframe_distance = on.then_some(48); @@ -681,7 +699,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } Format::ProRes => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["proxy", "lt", "standard", "hq"]; let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"]; let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3); @@ -691,7 +709,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::DnxHr => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["lb", "sq", "hq", "hqx"]; let labels = ["DNxHR LB", "DNxHR SQ", "DNxHR HQ", "DNxHR HQX (10-bit)"]; let cur = keys.iter().position(|k| *k == s.dnx_profile).unwrap_or(2); @@ -701,7 +719,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::Apv => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["422-10", "422-12", "444-10", "444-12"]; let labels = ["APV 422-10", "APV 422-12", "APV 444-10", "APV 444-12"]; let cur = keys.iter().position(|k| *k == s.apv_profile).unwrap_or(0); @@ -711,7 +729,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::Mjpeg => { - row(ui, t, "Quality", |ui| { + row(ui, t, tl!("Quality"), |ui| { let mut q = s.quality as f64; drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0); s.quality = q as u8; @@ -719,51 +737,53 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } _ => {} } - row(ui, t, "", |ui| check(ui, reg, "export.video.maxDepth", &mut s.render_at_max_depth, "Render at Maximum Depth")); - row(ui, t, "", |ui| check(ui, reg, "export.video.maxQuality", &mut s.max_render_quality, "Use Maximum Render Quality")); + row(ui, t, "", |ui| check(ui, reg, "export.video.maxDepth", &mut s.render_at_max_depth, tl!("Render at Maximum Depth"))); + row(ui, t, "", |ui| check(ui, reg, "export.video.maxQuality", &mut s.max_render_quality, tl!("Use Maximum Render Quality"))); } fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_sr: u32) { let audio_only = !s.has_video(); if !audio_only { - row(ui, t, "", |ui| check(ui, reg, "export.audio.include", &mut s.include_audio, "Export Audio")); + row(ui, t, "", |ui| check(ui, reg, "export.audio.include", &mut s.include_audio, tl!("Export Audio"))); if !s.include_audio { return; } } - row(ui, t, "Audio Format", |ui| { + row(ui, t, tl!("Audio Format"), |ui| { let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", - _ => "Uncompressed (PCM)", + _ => tl!("Uncompressed (PCM)"), }; - let labels = vec![("AAC".to_string(), !audio_only), ("Uncompressed (PCM)".to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; + let labels = + vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm }; } }); - row(ui, t, "Sample Rate", |ui| { + row(ui, t, tl!("Sample Rate"), |ui| { let rates: [Option; 5] = [None, Some(32_000), Some(44_100), Some(48_000), Some(96_000)]; - let lab = |r: &Option| r.map(|r| format!("{r} Hz")).unwrap_or_else(|| format!("Match Source ({seq_sr} Hz)")); + let lab = |r: &Option| r.map(|r| format!("{r} Hz")).unwrap_or_else(|| tlf!("Match Source ({seq_sr} Hz)", seq_sr)); let labels: Vec<(String, bool)> = rates.iter().map(|r| (lab(r), true)).collect(); if let Some(i) = combo(ui, reg, "export.audio.sampleRate", &lab(&s.audio.sample_rate), &labels, 180.0) { s.audio.sample_rate = rates[i]; } }); - row(ui, t, "Channels", |ui| { + row(ui, t, tl!("Channels"), |ui| { + let names = [tl!("Mono"), tl!("Stereo"), "5.1"]; let cur = match s.audio.channels { - 1 => "Mono", - 6 => "5.1", - _ => "Stereo", + 1 => names[0], + 6 => names[2], + _ => names[1], }; - if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&["Mono", "Stereo", "5.1"]), 120.0) { + if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&names), 120.0) { s.audio.channels = [1, 2, 6][i.min(2)]; } }); if s.audio_codec() == AudioCodec::Aac { - row(ui, t, "Audio Bitrate", |ui| { + row(ui, t, tl!("Audio Bitrate"), |ui| { let rates = [128u32, 160, 192, 256, 320]; let labels: Vec<(String, bool)> = rates.iter().map(|r| (format!("{r} kbps"), true)).collect(); if let Some(i) = combo(ui, reg, "export.audio.bitrate", &format!("{} kbps", s.audio.bitrate_kbps), &labels, 120.0) { @@ -771,9 +791,10 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } else { - row(ui, t, "Sample Size", |ui| { - let cur = if s.audio.bits >= 24 { "24 bit" } else { "16 bit" }; - if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&["16 bit", "24 bit"]), 120.0) { + row(ui, t, tl!("Sample Size"), |ui| { + let sizes = [tl!("16 bit"), tl!("24 bit")]; + let cur = if s.audio.bits >= 24 { sizes[1] } else { sizes[0] }; + if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&sizes), 120.0) { s.audio.bits = if i == 0 { 16 } else { 24 }; } }); @@ -789,8 +810,8 @@ fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement fn text_overlay(ui: &mut egui::Ui, reg: &mut Reg, key: &str, o: &mut TextOverlay, t: &Tokens, text_label: &str) { row(ui, t, text_label, |ui| text(ui, reg, &format!("export.effects.{key}.text"), &mut o.text, "")); - row(ui, t, "Position", |ui| placement_combo(ui, reg, &format!("export.effects.{key}.position"), &mut o.placement)); - row(ui, t, "Size / Opacity", |ui| { + row(ui, t, tl!("Position"), |ui| placement_combo(ui, reg, &format!("export.effects.{key}.position"), &mut o.placement)); + row(ui, t, tl!("Size / Opacity"), |ui| { let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64); drag(ui, reg, &format!("export.effects.{key}.size"), &mut sz, 1.0..=50.0, 0.2, " %", 1); drag(ui, reg, &format!("export.effects.{key}.opacity"), &mut op, 0.0..=100.0, 1.0, " %", 0); @@ -805,34 +826,34 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: let video = s.has_video(); let fx = &mut s.effects; if video { - check(ui, reg, "export.effects.imageOverlay", &mut fx.image_overlay.enabled, "Image Overlay"); + check(ui, reg, "export.effects.imageOverlay", &mut fx.image_overlay.enabled, tl!("Image Overlay")); if fx.image_overlay.enabled { let o = &mut fx.image_overlay; - row(ui, t, "Image", |ui| { - let r = ui.small_button("Browse…"); + row(ui, t, tl!("Image"), |ui| { + let r = ui.small_button(tl!("Browse…")); reg.add("export.effects.imageOverlay.browse", r.rect, "Browse…"); browse = r.clicked(); - text(ui, reg, "export.effects.imageOverlay.path", &mut o.path, "PNG or JPEG file"); + text(ui, reg, "export.effects.imageOverlay.path", &mut o.path, tl!("PNG or JPEG file")); }); - row(ui, t, "Position", |ui| placement_combo(ui, reg, "export.effects.imageOverlay.position", &mut o.placement)); - row(ui, t, "Size / Opacity", |ui| { + row(ui, t, tl!("Position"), |ui| placement_combo(ui, reg, "export.effects.imageOverlay.position", &mut o.placement)); + row(ui, t, tl!("Size / Opacity"), |ui| { let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64); drag(ui, reg, "export.effects.imageOverlay.size", &mut sz, 1.0..=100.0, 0.5, " %", 1); drag(ui, reg, "export.effects.imageOverlay.opacity", &mut op, 0.0..=100.0, 1.0, " %", 0); (o.size_percent, o.opacity) = (sz as f32, op as f32); }); } - check(ui, reg, "export.effects.nameOverlay", &mut fx.name_overlay.enabled, "Name Overlay"); + check(ui, reg, "export.effects.nameOverlay", &mut fx.name_overlay.enabled, tl!("Name Overlay")); if fx.name_overlay.enabled { - text_overlay(ui, reg, "nameOverlay", &mut fx.name_overlay, t, "Name"); + text_overlay(ui, reg, "nameOverlay", &mut fx.name_overlay, t, tl!("Name")); } - check(ui, reg, "export.effects.timecodeOverlay", &mut fx.timecode_overlay.enabled, "Timecode Overlay"); + check(ui, reg, "export.effects.timecodeOverlay", &mut fx.timecode_overlay.enabled, tl!("Timecode Overlay")); if fx.timecode_overlay.enabled { - text_overlay(ui, reg, "timecodeOverlay", &mut fx.timecode_overlay, t, "Prefix"); + text_overlay(ui, reg, "timecodeOverlay", &mut fx.timecode_overlay, t, tl!("Prefix")); } - check(ui, reg, "export.effects.videoLimiter", &mut fx.video_limiter.enabled, "Video Limiter"); + check(ui, reg, "export.effects.videoLimiter", &mut fx.video_limiter.enabled, tl!("Video Limiter")); if fx.video_limiter.enabled { - row(ui, t, "Luma Min / Max", |ui| { + row(ui, t, tl!("Luma Min / Max"), |ui| { let (mut lo, mut hi) = (fx.video_limiter.min_percent as f64, fx.video_limiter.max_percent as f64); drag(ui, reg, "export.effects.videoLimiter.min", &mut lo, 0.0..=50.0, 0.5, " %", 1); drag(ui, reg, "export.effects.videoLimiter.max", &mut hi, 50.0..=100.0, 0.5, " %", 1); @@ -841,12 +862,12 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: } } if audio { - check(ui, reg, "export.effects.loudness", &mut fx.loudness.enabled, "Loudness Normalization"); + check(ui, reg, "export.effects.loudness", &mut fx.loudness.enabled, tl!("Loudness Normalization")); if fx.loudness.enabled { - row(ui, t, "Target Loudness", |ui| { + row(ui, t, tl!("Target Loudness"), |ui| { drag(ui, reg, "export.effects.loudness.target", &mut fx.loudness.target_lufs, -40.0..=-5.0, 0.1, " LUFS", 1); }); - row(ui, t, "True Peak Limit", |ui| { + row(ui, t, tl!("True Peak Limit"), |ui| { drag(ui, reg, "export.effects.loudness.truePeak", &mut fx.loudness.true_peak_dbtp, -9.0..=0.0, 0.1, " dBTP", 1); }); } @@ -858,7 +879,7 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: // right: preview, range, summary, buttons // --------------------------------------------------------------------------------------------- -const RANGES: [(&str, &str); 4] = [("entire", "Entire Source"), ("inOut", "Source In/Out"), ("workArea", "Work Area"), ("custom", "Custom")]; +pub(crate) const RANGES: [(&str, &str); 4] = [("entire", "Entire Source"), ("inOut", "Source In/Out"), ("workArea", "Work Area"), ("custom", "Custom")]; fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: filmcraft_engine::project::ItemId) { let t = app.tokens; @@ -889,7 +910,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: ui.painter().with_clip_rect(pic).image(tex, inner, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } else { - ui.painter().text(pic.center(), Align2::CENTER_CENTER, "Audio only", Tokens::ui(13.0), t.text_dim); + ui.painter().text(pic.center(), Align2::CENTER_CENTER, tl!("Audio only"), Tokens::ui(13.0), t.text_dim); } reg.add("export.preview", pic, "Preview"); // range + scaling @@ -901,7 +922,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: let ex = &mut app.ui.export; let has_work_area = q.work_area.is_some(); child.horizontal(|ui| { - ui.label(egui::RichText::new("Range").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Range")).color(t.text_dim)); let cur = RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or("Entire Source"); let labels: Vec<(String, bool)> = RANGES.iter().map(|(k, l)| (l.to_string(), *k != "workArea" || has_work_area)).collect(); if let Some(i) = combo(ui, &mut reg, "export.range", cur, &labels, 140.0) { @@ -914,9 +935,10 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: }); if ex.settings.has_video() { child.horizontal(|ui| { - ui.label(egui::RichText::new("Scaling").color(t.text_dim)); - let o = [(Scaling::ScaleToFit, "Scale To Fit"), (Scaling::ScaleToFill, "Scale To Fill"), (Scaling::StretchToFill, "Stretch To Fill")]; - let cur = o.iter().find(|(x, _)| *x == ex.settings.scaling).map(|x| x.1).unwrap_or("Scale To Fit"); + ui.label(egui::RichText::new(tl!("Scaling")).color(t.text_dim)); + let o = + [(Scaling::ScaleToFit, tl!("Scale To Fit")), (Scaling::ScaleToFill, tl!("Scale To Fill")), (Scaling::StretchToFill, tl!("Stretch To Fill"))]; + let cur = o.iter().find(|(x, _)| *x == ex.settings.scaling).map_or(o[0].1, |x| x.1); let labels: Vec<(String, bool)> = o.iter().map(|(_, l)| (l.to_string(), true)).collect(); if let Some(i) = combo(ui, &mut reg, "export.scaling", cur, &labels, 140.0) { ex.settings.scaling = o[i].0; @@ -933,10 +955,10 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: .inner_margin(egui::Margin::same(10)) .show(&mut child, |ui| { ui.set_width(ui.available_width()); - ui.label(egui::RichText::new("Summary").strong()); + ui.label(egui::RichText::new(tl!("Summary")).strong()); egui::Grid::new("export-summary-grid").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| { let line = |ui: &mut egui::Ui, k: &str, v: &str| { - ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(k)).color(t.text_dim).size(11.5)); ui.add(egui::Label::new(egui::RichText::new(v).size(11.5)).wrap_mode(egui::TextWrapMode::Wrap).halign(egui::Align::Min)); ui.end_row(); }; @@ -944,35 +966,49 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: line(ui, "", &sum.format); line(ui, "", &sum.video); line(ui, "", &sum.audio); - line(ui, "Source", &format!("Sequence, {}x{}, {} fps, {} Hz", q.settings.width, q.settings.height, rate.label(), q.settings.sample_rate)); + line( + ui, + "Source", + &tlf!( + "Sequence, {w}x{h}, {fps} fps, {rate} Hz", + w = q.settings.width, + h = q.settings.height, + fps = rate.label(), + rate = q.settings.sample_rate + ), + ); line( ui, "Range", - &format!("{} ({:.2} s)", RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or(""), (range.1 - range.0).seconds()), + &format!( + "{} ({:.2} s)", + crate::i18n::t(RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or("")), + (range.1 - range.0).seconds() + ), ); }); }) .response; reg.add("export.summary", resp.rect, sum.video.clone()); child.add_space(4.0); - let est = child.label(egui::RichText::new(format!("Estimated file size: {}", format_bytes(sum.estimated_bytes))).color(t.text_dim)); + let est = child.label(egui::RichText::new(tlf!("Estimated file size: {size}", size = format_bytes(sum.estimated_bytes))).color(t.text_dim)); reg.add("export.estimate", est.rect, sum.estimated_size.clone()); // buttons let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0)); let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0)); let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click()); ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); - ui.painter().text(queue.center(), Align2::CENTER_CENTER, "Send to Queue", Tokens::semibold(12.5), t.text); + ui.painter().text(queue.center(), Align2::CENTER_CENTER, tl!("Send to Queue"), Tokens::semibold(12.5), t.text); reg.add("export.sendToQueue", queue, "Send to Queue"); let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click()); ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(go.center(), Align2::CENTER_CENTER, "Export", Tokens::semibold(13.0), Color32::WHITE); + ui.painter().text(go.center(), Align2::CENTER_CENTER, tl!("Export"), Tokens::semibold(13.0), Color32::WHITE); reg.add("export.button", go, "Export"); reg.flush(app); if resp.clicked() { let r = app.session.execute("file.exportMedia", export_params(app)); match r { - Ok(v) => app.ui.status = format!("Exporting {}", v["path"].as_str().unwrap_or("")), + Ok(v) => app.ui.status = tlf!("Exporting {path}", path = v["path"].as_str().unwrap_or("")), Err(e) => app.ui.status = e.to_string(), } } @@ -983,7 +1019,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: p["preset"] = json!(app.ui.export.preset); } match app.session.execute("export.queue.add", p) { - Ok(_) => app.ui.status = "Added to the export queue".into(), + Ok(_) => app.ui.status = tl!("Added to the export queue").into(), Err(e) => app.ui.status = e.to_string(), } } @@ -1019,8 +1055,9 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut pick: Option<&'static str> = None; let all = app.session.export_presets.all(); let favorite = |n: &str| app.session.export_presets.is_favorite(n); - let q = m.query.to_ascii_lowercase(); - egui::Window::new("Preset Manager") + let q = m.query.to_lowercase(); + egui::Window::new(tl!("Preset Manager")) + .id(egui::Id::new("Preset Manager")) .id(egui::Id::new("preset-manager")) .open(&mut open) .collapsible(false) @@ -1030,35 +1067,32 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { ui.horizontal(|ui| { - let r = crate::widgets::search_field(ui, &mut m.query, "Search presets", 300.0, &t); + let r = crate::widgets::search_field(ui, &mut m.query, tl!("Search presets"), 300.0, &t); reg.add("presetManager.search", r.rect, "Search"); - check(ui, &mut reg, "presetManager.favoritesOnly", &mut m.favorites_only, "Favorites only"); + check(ui, &mut reg, "presetManager.favoritesOnly", &mut m.favorites_only, tl!("Favorites only")); }); ui.separator(); egui::ScrollArea::vertical().max_height(340.0).auto_shrink([false, false]).show(ui, |ui| { let mut cat = String::new(); for p in all.iter().filter(|p| { - (q.is_empty() - || p.name.to_ascii_lowercase().contains(&q) - || p.description.to_ascii_lowercase().contains(&q) - || p.category.to_ascii_lowercase().contains(&q)) + (crate::i18n::matches_query(&p.name, &q) || crate::i18n::matches_query(&p.description, &q) || crate::i18n::matches_query(&p.category, &q)) && (!m.favorites_only || favorite(&p.name)) }) { if p.category != cat { cat = p.category.clone(); ui.add_space(4.0); - ui.label(egui::RichText::new(&cat).strong().color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(&cat)).strong().color(t.text_dim).size(11.5)); } ui.horizontal(|ui| { let fav = favorite(&p.name); let (sr, sresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); paint_star(ui.painter(), sr.shrink(2.0), fav, if fav { Color32::from_rgb(0xf5, 0xc2, 0x42) } else { t.text_faint }); reg.add(format!("presetManager.favorite.{}", p.name), sr, if fav { "Remove from favorites" } else { "Add to favorites" }); - if sresp.on_hover_text("Show in the Preset menu").clicked() { + if sresp.on_hover_text(tl!("Show in the Preset menu")).clicked() { cmd = Some(("export.presets.favorite", json!({"name": p.name}))); } let sel = preset_key(&m.selected) == preset_key(&p.name); - let r = list_row(ui, &t, sel, &p.name, 300.0); + let r = list_row(ui, &t, sel, crate::i18n::t(&p.name), 300.0); reg.add(format!("presetManager.item.{}", p.name), r.rect, p.name.clone()); if r.clicked() { m.selected = p.name.clone(); @@ -1066,11 +1100,11 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { if r.double_clicked() { apply = Some(p.name.clone()); } - let r = r.on_hover_text(&p.description); + let r = r.on_hover_text(crate::i18n::t(&p.description)); let _ = r; ui.label(egui::RichText::new(p.settings.format.label()).color(t.text_dim).size(11.0)); if !p.builtin { - ui.label(egui::RichText::new("User").color(t.accent).size(10.5)); + ui.label(egui::RichText::new(tl!("User")).color(t.accent).size(10.5)); } }); } @@ -1078,13 +1112,13 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); let sel = all.iter().find(|p| preset_key(&p.name) == preset_key(&m.selected)).cloned(); if let Some(p) = &sel { - ui.label(egui::RichText::new(&p.description).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(&p.description)).color(t.text_dim).size(11.5)); } ui.horizontal(|ui| { - ui.label("Save current settings as:"); - let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text("Preset name").desired_width(200.0)); + ui.label(tl!("Save current settings as:")); + let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)); reg.add("presetManager.saveName", r.rect, "Preset name"); - let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new("Save")); + let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new(tl!("Save"))); reg.add("presetManager.save", r.rect, "Save"); if r.clicked() { cmd = Some(("export.presets.save", json!({"name": m.save_name.trim(), "settings": settings_json(&app.ui.export.settings)}))); @@ -1092,28 +1126,28 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.horizontal(|ui| { - let r = ui.button("Import…"); + let r = ui.button(tl!("Import…")); reg.add("presetManager.import", r.rect, "Import…"); if r.clicked() { pick = Some("import"); } - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Export…")); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Export…"))); reg.add("presetManager.export", r.rect, "Export…"); if r.clicked() { pick = Some("export"); } - let r = ui.add_enabled(sel.as_ref().is_some_and(|p| !p.builtin), egui::Button::new("Delete")); + let r = ui.add_enabled(sel.as_ref().is_some_and(|p| !p.builtin), egui::Button::new(tl!("Delete"))); reg.add("presetManager.delete", r.rect, "Delete"); if r.clicked() { cmd = Some(("export.presets.delete", json!({"name": m.selected}))); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add_enabled(sel.is_some(), egui::Button::new("OK")); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("OK"))); reg.add("presetManager.ok", r.rect, "OK"); if r.clicked() { apply = Some(m.selected.clone()); } - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); reg.add("presetManager.cancel", r.rect, "Cancel"); close |= r.clicked(); }); @@ -1126,21 +1160,21 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { app.ui.status = e.to_string(); } match pick { - Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f("Export presets", &["json"])) { + Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Export presets"), &["json"])) { Some(path) => { if let Err(e) = app.session.execute("export.presets.import", json!({"path": path})) { app.ui.status = e.to_string(); } } - None => app.ui.status = "Import: no file chosen (agents: export.presets.import {path})".into(), + None => app.ui.status = tl!("Import: no file chosen (agents: export.presets.import {path})").into(), }, - Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f("Export presets", &["json"], &format!("{}.json", m.selected))) { + Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Export presets"), &["json"], &format!("{}.json", m.selected))) { Some(path) => { if let Err(e) = app.session.execute("export.presets.export", json!({"path": path, "names": [m.selected]})) { app.ui.status = e.to_string(); } } - None => app.ui.status = "Export: no file chosen (agents: export.presets.export {path, names})".into(), + None => app.ui.status = tl!("Export: no file chosen (agents: export.presets.export {path, names})").into(), }, None => {} } @@ -1196,7 +1230,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P let t = app.tokens; let Some(seq) = app.session.state.active_sequence else { app.ui.export.quick_open = false; - app.ui.status = "Quick Export: open a sequence first".into(); + app.ui.status = tl!("Quick Export: open a sequence first").into(); return; }; if app.ui.export.quick_preset.is_empty() { @@ -1226,21 +1260,21 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Quick Export").strong().size(14.0)); + ui.label(egui::RichText::new(tl!("Quick Export")).strong().size(14.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.small_button("Close"); + let r = ui.small_button(tl!("Close")); reg.add("quickExport.close", r.rect, "Close"); close = r.clicked(); }); }); - ui.label(egui::RichText::new("File Name & Location").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("File Name & Location")).color(t.text_dim).size(11.5)); let r = ui.add(egui::TextEdit::singleline(&mut q.quick_path).desired_width(f32::INFINITY)); reg.add("quickExport.path", r.rect, "File Name & Location"); ui.add_space(4.0); - ui.label(egui::RichText::new("Preset").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim).size(11.5)); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for name in &presets { - let r = list_row(ui, &t, preset_key(name) == preset_key(&q.quick_preset), name, 320.0); + let r = list_row(ui, &t, preset_key(name) == preset_key(&q.quick_preset), crate::i18n::t(name), 320.0); reg.add(format!("quickExport.preset.{name}"), r.rect, name.clone()); if r.clicked() { q.quick_preset = name.clone(); @@ -1248,10 +1282,11 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P } }); ui.add_space(4.0); - ui.label(egui::RichText::new(format!("Estimated file size: {}", est.as_deref().unwrap_or("–"))).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tlf!("Estimated file size: {size}", size = est.as_deref().unwrap_or("–"))).color(t.text_dim).size(11.5)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui - .add(egui::Button::new(egui::RichText::new("Export").color(Color32::WHITE)).fill(t.accent).corner_radius(12.0).min_size(vec2(90.0, 26.0))); + let r = ui.add( + egui::Button::new(egui::RichText::new(tl!("Export")).color(Color32::WHITE)).fill(t.accent).corner_radius(12.0).min_size(vec2(90.0, 26.0)), + ); reg.add("quickExport.go", r.rect, "Export"); go = r.clicked(); }); @@ -1264,7 +1299,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P if go { let p = json!({"preset": app.ui.export.quick_preset, "path": app.ui.export.quick_path}); match app.session.execute("export.quick", p) { - Ok(v) => app.ui.status = format!("Quick Export: {}", v["path"].as_str().unwrap_or("")), + Ok(v) => app.ui.status = tlf!("Quick Export: {path}", path = v["path"].as_str().unwrap_or("")), Err(e) => app.ui.status = e.to_string(), } close = true; @@ -1288,10 +1323,3 @@ fn list_row(ui: &mut egui::Ui, t: &Tokens, selected: bool, text: &str, width: f3 ui.painter().text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.5), if selected { Color32::WHITE } else { t.text }); resp } - -fn expand_home(p: &str) -> String { - match (p.strip_prefix("~/"), std::env::var("HOME")) { - (Some(rest), Ok(h)) => format!("{h}/{rest}"), - _ => p.to_string(), - } -} diff --git a/crates/ui-egui/src/panels/file_dialogs.rs b/crates/ui-egui/src/panels/file_dialogs.rs index b9009b5b7..6266b59fa 100644 --- a/crates/ui-egui/src/panels/file_dialogs.rs +++ b/crates/ui-egui/src/panels/file_dialogs.rs @@ -45,31 +45,31 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(520.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { - ui.label(RichText::new("Recover Unsaved Changes").size(18.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Recover Unsaved Changes")).size(18.0).strong().color(t.text)); ui.add_space(6.0); ui.separator(); ui.add_space(6.0); let c = &items[choice]; - let name = c["projectName"].as_str().unwrap_or("Untitled"); + let name = c["projectName"].as_str().unwrap_or(tl!("Untitled")); let when = c["savedAt"].as_str().unwrap_or(""); let why = if c["cleanExit"].as_bool() == Some(true) { - format!("FilmCraft was closed while “{name}” had unsaved changes.") + tlf!("FilmCraft was closed while “{name}” had unsaved changes.", name) } else { - format!("FilmCraft quit unexpectedly while “{name}” had unsaved changes.") + tlf!("FilmCraft quit unexpectedly while “{name}” had unsaved changes.", name) }; ui.label(RichText::new(why).size(13.5).color(t.text)); ui.add_space(4.0); - ui.label(RichText::new(format!("Recover unsaved changes from {when}?")).size(13.5).color(t.text)); + ui.label(RichText::new(tlf!("Recover unsaved changes from {when}?", when)).size(13.5).color(t.text)); if let Some(p) = c["projectPath"].as_str() { ui.label(RichText::new(p).size(11.5).color(t.text_dim)); } else { - ui.label(RichText::new("The project had not been saved yet.").size(11.5).color(t.text_dim)); + ui.label(RichText::new(tl!("The project had not been saved yet.")).size(11.5).color(t.text_dim)); } if items.len() > 1 { ui.add_space(8.0); - ui.label(RichText::new(format!("{} sessions have unsaved changes:", items.len())).size(12.0).color(t.text_dim)); + ui.label(RichText::new(tlf!("{n} sessions have unsaved changes:", n = items.len())).size(12.0).color(t.text_dim)); for (i, it) in items.iter().enumerate() { - let label = format!("{} — {}", it["projectName"].as_str().unwrap_or("Untitled"), it["savedAt"].as_str().unwrap_or("")); + let label = format!("{} — {}", it["projectName"].as_str().unwrap_or(tl!("Untitled")), it["savedAt"].as_str().unwrap_or("")); let r = ui.radio(choice == i, RichText::new(&label).size(12.5)); app.auto.add(&format!("recovery.item.{i}"), r.rect, &label); if r.clicked() { @@ -80,20 +80,20 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.add_space(18.0); let id = items[choice]["id"].as_str().unwrap_or("").to_string(); ui.horizontal(|ui| { - if button(app, ui, "recovery.discard", "Discard", false) { + if button(app, ui, "recovery.discard", tl!("Discard"), false) { if let Err(e) = app.session.execute("file.discardRecovery", json!({ "id": id })) { app.ui.status = e.to_string(); } choice = 0; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if button(app, ui, "recovery.recover", "Recover", true) { + if button(app, ui, "recovery.recover", tl!("Recover"), true) { match app.session.execute("file.recover", json!({ "id": id })) { Ok(_) => open = false, Err(e) => app.ui.status = e.to_string(), } } - if button(app, ui, "recovery.later", "Not Now", false) { + if button(app, ui, "recovery.later", tl!("Not Now"), false) { open = false; } }); @@ -111,20 +111,20 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(460.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { - ui.label(RichText::new("Revert").size(18.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Revert")).size(18.0).strong().color(t.text)); ui.add_space(6.0); ui.separator(); ui.add_space(6.0); - ui.label(RichText::new(format!("Are you sure you want to discard your changes to '{file}'?")).size(13.5).color(t.text)); + ui.label(RichText::new(tlf!("Are you sure you want to discard your changes to '{file}'?", file)).size(13.5).color(t.text)); ui.add_space(18.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if button(app, ui, "revert.yes", "Yes", true) { + if button(app, ui, "revert.yes", tl!("Yes"), true) { if let Err(e) = app.session.execute("file.revert", json!({})) { app.ui.status = e.to_string(); } open = false; } - if button(app, ui, "revert.no", "No", false) { + if button(app, ui, "revert.no", tl!("No"), false) { open = false; } }); diff --git a/crates/ui-egui/src/panels/frame_export.rs b/crates/ui-egui/src/panels/frame_export.rs new file mode 100644 index 000000000..4790c440a --- /dev/null +++ b/crates/ui-egui/src/panels/frame_export.rs @@ -0,0 +1,263 @@ +//! Monitor Export Frame settings, with captured target/time and a destination folder. +use crate::{ + FilmcraftApp, + icons::{self, Icon}, +}; +use filmcraft_engine::keyboard::StillFormat; +use filmcraft_engine::keyboard::{FrameExportTarget, frame_export_target}; +use serde_json::{Value, json}; + +#[derive(Clone)] +struct Draft { + target: FrameExportTarget, + timecode: String, + name: String, + format: StillFormat, + depth: u8, + folder: String, + import: bool, + replace: Option, +} +fn draft_id() -> egui::Id { + egui::Id::new("frame-export-draft") +} +fn clean(name: &str) -> String { + name.trim().chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() +} + +pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Result { + let target = frame_export_target(&app.session, params).map_err(|e| e.to_string())?; + let (rate, drop_frame) = if target.source { + let view = filmcraft_engine::clip_ops::source_view(&app.session, target.item).ok_or("Source clip is unavailable")?; + (view.rate, false) + } else { + let sequence = app.session.project.sequence(target.item).ok_or("Program sequence is unavailable")?; + (sequence.settings.frame_rate, sequence.settings.drop_frame) + }; + let timecode = filmcraft_time::format_time(target.time, rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); + if target.source { + app.stop_source(); + } else if app.playback.playing { + app.stop(); + } + let folder = ctx.data(|m| m.get_temp::(egui::Id::new("frame-export-folder"))).unwrap_or_else(|| { + if cfg!(target_arch = "wasm32") { + return "/exports".into(); + } + app.session.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| ".".into()) + }); + let name = if target.source { std::path::Path::new(&target.name).file_stem().and_then(|s| s.to_str()).unwrap_or(&target.name) } else { &target.name }; + let name = clean(name); + ctx.data_mut(|m| { + m.insert_temp(draft_id(), Some(Draft { target, timecode, name, format: StillFormat::Png, depth: 8, folder, import: false, replace: None })) + }); + ctx.request_repaint(); + Ok(json!({"dialog":"exportFrame"})) +} + +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; + let mut close = false; + let mut apply = false; + let mut browse = false; + let mut replace = false; + let mut elems = Vec::new(); + let mut popup_rects = Vec::new(); + let frame = egui::Frame::new().fill(app.tokens.panel_bg).inner_margin(0).corner_radius(6); + let position_id = egui::Id::new("frame-export-position"); + let position = ctx.data(|m| m.get_temp::(position_id)); + let mut movement = egui::Vec2::ZERO; + // Only the custom white header moves the window; form controls keep their own gestures. + let window_id = egui::Id::new("frame-export-window"); + let mut window = egui::Window::new("Export Frame") + .id(window_id) + .title_bar(false) + .movable(false) + .collapsible(false) + .resizable(false) + .frame(frame) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()); + if let Some(position) = position { + window = window.current_pos(position); + } + let shown = window.show(ctx, |ui| { + ui.set_width(440.0); + let (bar, _) = ui.allocate_exact_size(egui::vec2(440.0, 30.0), egui::Sense::hover()); + ui.painter().rect_filled(bar, egui::CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, egui::Color32::WHITE); + ui.painter().text( + bar.left_center() + egui::vec2(10.0, 0.0), + egui::Align2::LEFT_CENTER, + "Export Frame", + egui::FontId::proportional(13.0), + egui::Color32::from_gray(90), + ); + let x = egui::Rect::from_center_size(bar.right_center() - egui::vec2(16.0, 0.0), egui::vec2(24.0, 24.0)); + let header = egui::Rect::from_min_max(bar.min, egui::pos2(x.left(), bar.bottom())); + let drag = ui.interact(header, egui::Id::new("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + if drag.dragged() { + movement = drag.drag_delta(); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + elems.push(("exportFrame.header".into(), header, "Move Export Frame".into())); + let response = ui.interact(x, egui::Id::new("export-frame-close"), egui::Sense::click()).on_hover_text("Close"); + icons::paint(ui.painter(), x.shrink(7.0), Icon::Close, egui::Color32::from_gray(90)); + elems.push(("exportFrame.close".to_string(), x, "Close".to_string())); + close = response.clicked(); + egui::Frame::NONE.inner_margin(14).show(ui, |ui| { + egui::Grid::new("frame-export-settings").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label("Name:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + elems.push(("exportFrame.name".into(), r.rect, d.name.clone())); + ui.end_row(); + ui.label("Format:"); + let combo = egui::ComboBox::from_id_salt("frame-export-format").width(292.0).selected_text(d.format.label()).show_ui(ui, |ui| { + for format in StillFormat::ALL { + let r = ui.selectable_value(&mut d.format, format, format.label()); + elems.push((format!("exportFrame.format.{}", format.extension()), r.rect, format.label().into())); + } + ui.min_rect() + }); + if let Some(rect) = combo.inner { + popup_rects.push(rect); + } + elems.push(("exportFrame.format".into(), combo.response.rect, d.format.label().into())); + ui.end_row(); + if !d.format.supports_16() { + d.depth = 8; + } + ui.label("Depth:"); + let combo = egui::ComboBox::from_id_salt("frame-export-depth").width(292.0).selected_text(format!("{} Bit", d.depth)).show_ui(ui, |ui| { + for bits in [8, 16] { + let r = ui.add_enabled(bits == 8 || d.format.supports_16(), egui::Button::selectable(d.depth == bits, format!("{bits} Bit"))); + if r.clicked() { + d.depth = bits; + } + elems.push((format!("exportFrame.depth.{bits}"), r.rect, format!("{bits} Bit"))); + } + ui.min_rect() + }); + if let Some(rect) = combo.inner { + popup_rects.push(rect); + } + elems.push(("exportFrame.depth".into(), combo.response.rect, format!("{} Bit", d.depth))); + ui.end_row(); + ui.label("Path:"); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)); + elems.push(("exportFrame.path".into(), r.rect, d.folder.clone())); + ui.end_row(); + }); + ui.add_space(12.0); + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.import, "Import into project"); + elems.push(("exportFrame.import".into(), r.rect, d.import.to_string())); + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 26.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add_enabled(app.hooks.pick_folder_at.is_some() || app.hooks.pick_folder.is_some(), egui::Button::new("Browse…")); + browse = r.clicked(); + elems.push(("exportFrame.browse".into(), r.rect, "Browse".into())); + }); + }); + ui.add_space(12.0); + if let Some(path) = d.replace.clone() { + ui.label(format!("This file already exists: {path}")); + ui.horizontal(|ui| { + let r = ui.button("Replace"); + replace = r.clicked(); + elems.push(("exportFrame.replace".into(), r.rect, "Replace".into())); + let r = ui.button("Keep existing file"); + if r.clicked() { + d.replace = None; + } + elems.push(("exportFrame.keep".into(), r.rect, "Keep existing file".into())); + }); + } else { + ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 26.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.button("Cancel"); + close |= r.clicked(); + elems.push(("exportFrame.cancel".into(), r.rect, "Cancel".into())); + let r = ui.add_enabled( + !d.name.trim().is_empty() && !d.folder.trim().is_empty(), + egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent), + ); + apply = r.clicked(); + elems.push(("exportFrame.export".into(), r.rect, "OK".into())); + }); + } + ui.add_space(12.0); + ui.separator(); + ui.label(format!("{} — {} × {}", if d.target.source { "Source" } else { "Program" }, d.target.width, d.target.height)); + ui.label(&d.target.name); + let r = ui.label(format!("Frame time: {}", d.timecode)); + elems.push(("exportFrame.timecode".into(), r.rect, d.timecode.clone())); + }); + }); + if let Some(shown) = &shown { + // A press outside the dialog and its own dropdowns cancels the draft. Background + // controls still receive the same press, so switching modes or menus works normally. + close |= ctx.input(|i| { + i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !shown.response.rect.contains(p) && !popup_rects.iter().any(|r| r.contains(p))) + }); + } + if shown.is_some() && movement != egui::Vec2::ZERO { + // Keep the unrounded requested pivot between drag frames so subpixel deltas cannot accumulate rounding drift. + if let Some(pivot) = position.or_else(|| egui::containers::AreaState::load(ctx, window_id).and_then(|state| state.pivot_pos)) { + ctx.data_mut(|m| m.insert_temp(position_id, pivot + movement)); + } + ctx.request_repaint(); + } + for (id, rect, label) in elems { + app.auto.add(&id, rect, &label); + } + close |= ctx.input(|i| i.key_pressed(egui::Key::Escape)); + if browse && !close { + let folder = filmcraft_engine::export_tools::expand_home(d.folder.trim()); + let picked = match app.hooks.pick_folder_at.as_mut() { + Some(pick) => pick(&folder), + None => app.hooks.pick_folder.as_mut().and_then(|pick| pick()), + }; + if let Some(path) = picked { + d.folder = path; + d.replace = None; + } + } + if (apply || replace) && !close { + let mut stem = clean(&d.name); + let lower = stem.to_ascii_lowercase(); + for suffix in [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"] { + if lower.ends_with(suffix) { + stem.truncate(stem.len().saturating_sub(suffix.len())); + break; + } + } + let ext = d.format.extension(); + let suffix = format!(".{ext}"); + let name = if stem.to_ascii_lowercase().ends_with(&suffix) { stem } else { format!("{stem}{suffix}") }; + let folder = filmcraft_engine::export_tools::expand_home(d.folder.trim()); + let path = std::path::Path::new(&folder).join(name).to_string_lossy().into_owned(); + let allowed = match app.session.services.file_size(&path) { + Ok(_) => replace && d.replace.as_deref() == Some(path.as_str()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => true, + Err(e) => { + app.ui.status = format!("Cannot check export destination: {e}"); + false + } + }; + if !allowed && app.session.services.file_size(&path).is_ok() { + d.replace = Some(path.clone()); + } + if allowed { + let mut params = + json!({"path":path,"format":ext,"depth":d.depth,"import":d.import,"target":if d.target.source{"source"}else{"program"},"time":d.target.time.0}); + params[if d.target.source { "item" } else { "sequence" }] = json!(d.target.item.0); + match app.session.execute("file.exportFrame", params) { + Ok(result) => { + app.ui.status = format!("Exported frame to {}", result["path"].as_str().unwrap_or_default()); + ctx.data_mut(|m| m.insert_temp(egui::Id::new("frame-export-folder"), d.folder.clone())); + close = true; + } + Err(e) => app.ui.status = e.to_string(), + } + } + } + ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) })); +} diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 654728bcb..1c7c63304 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -1104,14 +1104,14 @@ impl Ctx<'_> { _ => 0, }; let mut sel = cur; - let r = egui::ComboBox::from_id_salt(("gfxc", self.clip.0, self.layer, id)).selected_text(opts.get(cur).copied().unwrap_or("")).width(width).show_ui( - vui, - |ui| { + let r = egui::ComboBox::from_id_salt(("gfxc", self.clip.0, self.layer, id)) + .selected_text(opts.get(cur).map_or("", |o| crate::i18n::t(o))) + .width(width) + .show_ui(vui, |ui| { for (i, o) in opts.iter().enumerate() { - ui.selectable_value(&mut sel, i, *o); + ui.selectable_value(&mut sel, i, crate::i18n::t(o)); } - }, - ); + }); self.auto(id, r.response.rect, id); if sel != cur { self.set(id, json!(sel)); @@ -1136,7 +1136,7 @@ fn section_header(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, t: &Tok if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); app.auto.add(&format!("graphics.section.{}", name.replace(' ', "")), r, name); if resp.clicked() { if open { @@ -1243,9 +1243,9 @@ pub fn graphic_selected(app: &FilmcraftApp) -> bool { pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it)) = selected_graphic(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a graphic clip, or use the Type tool (T) on the Program monitor"); + crate::dock::placeholder(ui, rect, &t, tl!("Select a graphic clip, or use the Type tool (T) on the Program monitor")); let br = Rect::from_center_size(rect.center() + vec2(0.0, 30.0), vec2(150.0, 26.0)); - let resp = ui.put(br, egui::Button::new("Create new graphic")); + let resp = ui.put(br, egui::Button::new(tl!("Create new graphic"))); app.auto.add("graphics.createNew", br, "Create new graphic"); if resp.clicked() && let Err(e) = app.session.execute("graphics.newText", json!({"text": "New Text"})) @@ -1272,24 +1272,24 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if section(ui, app, "Layers", &t) { ui.horizontal(|ui| { ui.add_space(14.0); - for (label, tip, cmd, p) in [ - ("T", "New Text Layer", "graphics.newText", json!({"clip": clip.0, "text": "New Text"})), - ("", "New Rectangle", "graphics.newShape", json!({"clip": clip.0, "shape": "rectangle"})), - ("", "New Ellipse", "graphics.newShape", json!({"clip": clip.0, "shape": "ellipse", "size": [200, 200]})), + for (label, key, tip, cmd, p) in [ + ("T", "NewTextLayer", tl!("New Text Layer"), "graphics.newText", json!({"clip": clip.0, "text": tl!("New Text")})), + ("", "NewRectangle", tl!("New Rectangle"), "graphics.newShape", json!({"clip": clip.0, "shape": "rectangle"})), + ("", "NewEllipse", tl!("New Ellipse"), "graphics.newShape", json!({"clip": clip.0, "shape": "ellipse", "size": [200, 200]})), ] { let r = letter_button(ui, label, tip, false, &t, Tokens::semibold(13.0)); if label.is_empty() { let ir = Rect::from_center_size(r.rect.center(), vec2(14.0, 14.0)); - icons::paint(ui.painter(), ir, if tip == "New Rectangle" { Icon::Rectangle } else { Icon::Ellipse }, t.text_dim); + icons::paint(ui.painter(), ir, if key == "NewRectangle" { Icon::Rectangle } else { Icon::Ellipse }, t.text_dim); } - autos.push((format!("graphics.{}", tip.replace(' ', "")), r.rect, tip.into())); + autos.push((format!("graphics.{key}"), r.rect, tip.into())); if r.clicked() { actions.push((cmd.into(), p)); } } ui.add_space(8.0); if let Some(l) = sel { - for (label, tip, to) in [("↑", "Bring Forward", "forward"), ("↓", "Send Backward", "backward")] { + for (label, tip, to) in [("↑", tl!("Bring Forward"), "forward"), ("↓", tl!("Send Backward"), "backward")] { let r = letter_button(ui, label, tip, false, &t, Tokens::ui(13.0)); autos.push((format!("graphics.arrange.{to}"), r.rect, tip.into())); if r.clicked() { @@ -1299,7 +1299,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (dr, dresp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); icons::paint(ui.painter(), dr.shrink(4.0), Icon::Trash, if dresp.hovered() { t.text } else { t.text_dim }); autos.push(("graphics.deleteLayer".into(), dr, "Delete Layer".into())); - if dresp.on_hover_text("Delete Layer").clicked() { + if dresp.on_hover_text(tl!("Delete Layer")).clicked() { actions.push(("graphics.deleteLayer".into(), json!({"clip": clip.0, "layer": l}))); } } @@ -1338,7 +1338,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::graphics_templates::responsive_time(app, ui, clip, &it, &mut autos, &mut actions); } ui.add_space(12.0); - ui.label(egui::RichText::new("Select a layer to edit its properties.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Select a layer to edit its properties.")).color(t.text_faint)); return; }; let e = &it.effects[idx[l]]; @@ -1360,12 +1360,12 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.horizontal(|ui| { ui.add_space(14.0); for (how, tip) in [ - ("left", "Align Left"), - ("hcenter", "Align Center Horizontally"), - ("right", "Align Right"), - ("top", "Align Top"), - ("vcenter", "Align Center Vertically"), - ("bottom", "Align Bottom"), + ("left", tl!("Align Left")), + ("hcenter", tl!("Align Center Horizontally")), + ("right", tl!("Align Right")), + ("top", tl!("Align Top")), + ("vcenter", tl!("Align Center Vertically")), + ("bottom", tl!("Align Bottom")), ] { let r = glyph_button(ui, how, tip, false, &t); cx.autos.push((format!("graphics.align.{how}"), r.rect, tip.into())); @@ -1374,7 +1374,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.add_space(6.0); - for (how, axis, tip) in [("dh", "horizontal", "Distribute Horizontally"), ("dv", "vertical", "Distribute Vertically")] { + for (how, axis, tip) in [("dh", "horizontal", tl!("Distribute Horizontally")), ("dv", "vertical", tl!("Distribute Vertically"))] { let r = glyph_button(ui, how, tip, false, &t); cx.autos.push((format!("graphics.distribute.{axis}"), r.rect, tip.into())); if r.clicked() { @@ -1382,11 +1382,11 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.point(ui, "Position", "position"); - cx.point(ui, "Anchor point", "anchor"); - cx.number(ui, "Scale", "scale", 0.5, (0.0, 100_000.0), 0, " %"); - cx.number(ui, "Rotation", "rotation", 0.5, (-36_000.0, 36_000.0), 1, " °"); - cx.number(ui, "Opacity", "opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.point(ui, tl!("Position"), "position"); + cx.point(ui, tl!("Anchor point"), "anchor"); + cx.number(ui, tl!("Scale"), "scale", 0.5, (0.0, 100_000.0), 0, " %"); + cx.number(ui, tl!("Rotation"), "rotation", 0.5, (-36_000.0, 36_000.0), 1, " °"); + cx.number(ui, tl!("Opacity"), "opacity", 0.5, (0.0, 100.0), 0, " %"); } // ---- text let text_open = e.effect == graphic::TEXT_LAYER && { @@ -1396,7 +1396,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let wresp = ui.interact(wr, egui::Id::new(("gfx-text-props", clip.0, l)), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.text } else { t.text_dim }); cx.autos.push(("graphics.textProperties".into(), wr, "Text Properties".into())); - if wresp.on_hover_text("Text Properties").clicked() { + if wresp.on_hover_text(tl!("Text Properties")).clicked() { let flag = |id: &str| pv(e, id, mt).as_bool().unwrap_or(false); let ligatures = flag("ligatures"); app.ui.text_props_dialog = Some(TextPropsDialog { @@ -1434,7 +1434,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => String::new(), }; let fams = filmcraft_text::families(); - let (_, mut vui) = cx.row(ui, "Font"); + let (_, mut vui) = cx.row(ui, tl!("Font")); let r = egui::ComboBox::from_id_salt(("gfx-font", clip.0, l)).selected_text(&family).width(170.0).height(400.0).show_ui(&mut vui, |ui| { if !filmcraft_text::fonts::system_scanned() { filmcraft_text::fonts::scan_system(); @@ -1447,7 +1447,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); cx.auto("font", r.response.rect, "Font"); let styles: Vec = fams.iter().find(|(f, _)| f.eq_ignore_ascii_case(&family)).map(|(_, s)| s.clone()).unwrap_or_default(); - let (_, mut vui) = cx.row(ui, "Font Style"); + let (_, mut vui) = cx.row(ui, tl!("Font Style")); let r = egui::ComboBox::from_id_salt(("gfx-style", clip.0, l)).selected_text(&style).width(170.0).show_ui(&mut vui, |ui| { for st in styles.iter().map(String::as_str).chain(["Bold", "Italic", "Bold Italic"]).filter({ let mut seen = Vec::new(); @@ -1466,44 +1466,46 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); cx.auto("font_style", r.response.rect, "Font Style"); - cx.number(ui, "Font Size", "size", 0.5, (1.0, 2000.0), 0, ""); + cx.number(ui, tl!("Font Size"), "size", 0.5, (1.0, 2000.0), 0, ""); let align = match pv(e, "align", mt) { ParamValue::Choice(c) => c, _ => 0, }; - let (_, mut vui) = cx.row(ui, "Alignment"); - for (i, (how, tip)) in [("pl", "Left Align Text"), ("pc", "Center Align Text"), ("pr", "Right Align Text"), ("pj", "Justify")].iter().enumerate() { + let (_, mut vui) = cx.row(ui, tl!("Alignment")); + for (i, (how, tip)) in + [("pl", tl!("Left Align Text")), ("pc", tl!("Center Align Text")), ("pr", tl!("Right Align Text")), ("pj", tl!("Justify"))].iter().enumerate() + { let r = glyph_button(&mut vui, how, tip, align == i as u32, &t); cx.auto(&format!("align.{i}"), r.rect, tip); if r.clicked() { cx.set("align", json!(i)); } } - cx.number(ui, "Tracking", "tracking", 1.0, (-1000.0, 10_000.0), 0, ""); - cx.number(ui, "Leading", "leading", 0.5, (-5000.0, 5000.0), 0, ""); - cx.number(ui, "Baseline Shift", "baseline_shift", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, tl!("Tracking"), "tracking", 1.0, (-1000.0, 10_000.0), 0, ""); + cx.number(ui, tl!("Leading"), "leading", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, tl!("Baseline Shift"), "baseline_shift", 0.5, (-5000.0, 5000.0), 0, ""); let vertical = pv(e, "vertical", mt).as_bool().unwrap_or(false); - let (_, mut vui) = cx.row(ui, "Orientation"); + let (_, mut vui) = cx.row(ui, tl!("Orientation")); let mut value = vertical; - let response = vui.checkbox(&mut value, "Vertical Text"); + let response = vui.checkbox(&mut value, tl!("Vertical Text")); cx.auto("vertical", response.rect, "Vertical Text"); if response.changed() { cx.set("vertical", json!(value)); } - cx.number(ui, "Text Box Width", "box_width", 2.0, (0.0, 100_000.0), 0, ""); - cx.number(ui, "Text Box Height", "box_height", 2.0, (0.0, 100_000.0), 0, ""); - let (_, mut vui) = cx.row(ui, "Style"); + cx.number(ui, tl!("Text Box Width"), "box_width", 2.0, (0.0, 100_000.0), 0, ""); + cx.number(ui, tl!("Text Box Height"), "box_height", 2.0, (0.0, 100_000.0), 0, ""); + let (_, mut vui) = cx.row(ui, tl!("Style")); let flag = |id: &str| pv(e, id, mt).as_bool().unwrap_or(false); let caps = match pv(e, "caps", mt) { ParamValue::Choice(c) => c, _ => 0, }; for (id, label, tip, on, font) in [ - ("faux_bold", "T", "Faux Bold", flag("faux_bold"), Tokens::semibold(14.0)), - ("faux_italic", "T", "Faux Italic", flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)), - ("all_caps", "TT", "All Caps", caps == 1, Tokens::ui(12.0)), - ("small_caps", "Tt", "Small Caps", caps == 2, Tokens::ui(12.0)), - ("underline", "U", "Underline", flag("underline"), Tokens::ui(13.0)), + ("faux_bold", "T", tl!("Faux Bold"), flag("faux_bold"), Tokens::semibold(14.0)), + ("faux_italic", "T", tl!("Faux Italic"), flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)), + ("all_caps", "TT", tl!("All Caps"), caps == 1, Tokens::ui(12.0)), + ("small_caps", "Tt", tl!("Small Caps"), caps == 2, Tokens::ui(12.0)), + ("underline", "U", tl!("Underline"), flag("underline"), Tokens::ui(13.0)), ] { let r = letter_button(&mut vui, label, tip, on, &t, font); if id == "faux_italic" { @@ -1520,25 +1522,40 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - let (_, mut vui) = cx.row(ui, "OpenType"); - cx.check(&mut vui, "kerning", "Kerning"); - cx.check(&mut vui, "ligatures", "Ligatures"); + let (_, mut vui) = cx.row(ui, tl!("OpenType")); + cx.check(&mut vui, "kerning", tl!("Kerning")); + cx.check(&mut vui, "ligatures", tl!("Ligatures")); } if e.effect == graphic::SHAPE_LAYER && section(ui, app, "Shape", &t) { - let (_, mut vui) = cx.row(ui, "Shape"); + let (_, mut vui) = cx.row(ui, tl!("Shape")); cx.choice(&mut vui, "shape", graphic::SHAPE_OPTS, 120.0); - cx.point(ui, "Size", "size"); - cx.number(ui, "Corner Radius", "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); - cx.number(ui, "Polygon Sides", "sides", 0.05, (3.0, 64.0), 0, ""); + cx.point(ui, tl!("Size"), "size"); + cx.number(ui, tl!("Corner Radius"), "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); + cx.number(ui, tl!("Polygon Sides"), "sides", 0.05, (3.0, 64.0), 0, ""); } // ---- appearance if section(ui, app, "Appearance", &t) { - let (_, mut vui) = cx.row(ui, "Fill"); - cx.check(&mut vui, "fill", ""); + let (_, mut vui) = cx.row(ui, tl!("Fill")); + let fill_on = cx.check(&mut vui, "fill", ""); cx.color(&mut vui, "fill_color"); - for (on, col, w, kind, label) in - [("stroke", "stroke_color", "stroke_width", "stroke_type", "Stroke"), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", "Stroke 2")] - { + if fill_on { + vui.add_space(6.0); + cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0); + } + let fill_kind = match pv(e, "fill_kind", mt) { + ParamValue::Choice(c) => c, + _ => 0, + }; + if fill_on && fill_kind == 1 { + let (_, mut vui) = cx.row(ui, tl!("Gradient")); + cx.color(&mut vui, "gradient_start"); + cx.color(&mut vui, "gradient_end"); + cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °"); + } + for (on, col, w, kind, label) in [ + ("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")), + ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")), + ] { let (_, mut vui) = cx.row(ui, label); cx.check(&mut vui, on, ""); cx.color(&mut vui, col); @@ -1547,23 +1564,23 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { vui.add_space(6.0); cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); } - let (_, mut vui) = cx.row(ui, "Background"); + let (_, mut vui) = cx.row(ui, tl!("Background")); let bg = cx.check(&mut vui, "background", ""); cx.color(&mut vui, "background_color"); if bg { - cx.number(ui, " Opacity", "background_opacity", 0.5, (0.0, 100.0), 0, " %"); - cx.number(ui, " Size", "background_size", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Corner Radius", "background_radius", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Opacity"), "background_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, tl!(" Size"), "background_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Corner Radius"), "background_radius", 0.5, (0.0, 1000.0), 0, ""); } - let (_, mut vui) = cx.row(ui, "Shadow"); + let (_, mut vui) = cx.row(ui, tl!("Shadow")); let sh = cx.check(&mut vui, "shadow", ""); cx.color(&mut vui, "shadow_color"); if sh { - cx.number(ui, " Opacity", "shadow_opacity", 0.5, (0.0, 100.0), 0, " %"); - cx.number(ui, " Angle", "shadow_angle", 0.5, (-36_000.0, 36_000.0), 0, " °"); - cx.number(ui, " Distance", "shadow_distance", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Size", "shadow_size", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Blur", "shadow_blur", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Opacity"), "shadow_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, tl!(" Angle"), "shadow_angle", 0.5, (-36_000.0, 36_000.0), 0, " °"); + cx.number(ui, tl!(" Distance"), "shadow_distance", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Size"), "shadow_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Blur"), "shadow_blur", 0.5, (0.0, 1000.0), 0, ""); } } actions.append(&mut cx.actions); @@ -1585,9 +1602,10 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Vec<(String, Rect, &str)> = Vec::new(); let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); let mut ok = false; - let name = |paragraph: bool| if paragraph { "Paragraph Text" } else { "Point Text" }; - egui::Window::new("Text Properties").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label("Text Layer Type"); + let name = |paragraph: bool| if paragraph { tl!("Paragraph Text") } else { tl!("Point Text") }; + let id = egui::Id::new("gfx-text-properties"); + egui::Window::new(tl!("Text Properties")).id(id).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + ui.label(tl!("Text Layer Type")); let r = egui::ComboBox::from_id_salt("gfx-text-type").selected_text(name(d.paragraph)).width(190.0).show_ui(ui, |ui| { for paragraph in [false, true] { // vertical text has no box to wrap in @@ -1600,15 +1618,15 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { }); elems.push(("graphics.textProperties.type".into(), r.response.rect, "Text Layer Type")); ui.add_space(6.0); - ui.label("Text Styling"); - let r = ui.checkbox(&mut d.ligatures, "Ligatures"); + ui.label(tl!("Text Styling")); + let r = ui.checkbox(&mut d.ligatures, tl!("Ligatures")); elems.push(("graphics.textProperties.ligatures".into(), r.rect, "Ligatures")); ui.add_space(6.0); ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); elems.push(("graphics.textProperties.cancel".into(), r.rect, "Cancel")); close |= r.clicked(); - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); elems.push(("graphics.textProperties.ok".into(), r.rect, "OK")); ok |= r.clicked(); }); diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index eb402ee8c..d66c346a8 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -143,7 +143,7 @@ pub fn essential_graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) let head = Rect::from_min_size(rect.min, vec2(rect.width(), 30.0)); ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); let mut elems: Elems = Vec::new(); - for (i, (id, label)) in [("browse", "Browse"), ("edit", "Edit")].into_iter().enumerate() { + for (i, (id, label)) in [("browse", tl!("Browse")), ("edit", tl!("Edit"))].into_iter().enumerate() { let r = Rect::from_min_size(pos2(head.min.x + 8.0 + i as f32 * 80.0, head.min.y), vec2(76.0, 30.0)); if tab_button(ui, r, label, tab == id, &t).clicked() { app.ui.gfx_templates.tab = id.into(); @@ -175,32 +175,31 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.set_clip_rect(rect); let st = &mut app.ui.gfx_templates; b.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search templates").desired_width((ui.available_width() - 230.0).max(110.0))); + let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))); elems.push(("gfxTemplates.search".into(), r.rect, "Search".into())); - let cur = if st.category.is_empty() { "All Categories".to_string() } else { st.category.clone() }; + let cur = if st.category.is_empty() { tl!("All Categories").to_string() } else { crate::i18n::t(&st.category).to_string() }; let r = egui::ComboBox::from_id_salt("gfx-cat").selected_text(cur).width(130.0).show_ui(ui, |ui| { - ui.selectable_value(&mut st.category, String::new(), "All Categories"); + ui.selectable_value(&mut st.category, String::new(), tl!("All Categories")); for c in &cats { - ui.selectable_value(&mut st.category, c.clone(), c); + ui.selectable_value(&mut st.category, c.clone(), crate::i18n::t(c)); } }); elems.push(("gfxTemplates.category".into(), r.response.rect, "Category".into())); - let r = ui.button("Install…").on_hover_text("Install Motion Graphics Template (.fcgt)"); + let r = ui.button(tl!("Install…")).on_hover_text(tl!("Install Motion Graphics Template (.fcgt)")); elems.push(("gfxTemplates.install".into(), r.rect, "Install".into())); install = r.clicked(); }); b.add_space(6.0); - let q = st.query.to_ascii_lowercase(); + let q = st.query.to_lowercase(); let shown: Vec<&LibraryEntry> = lib .iter() .filter(|e| { let tt = &e.template; (st.category.is_empty() || tt.category == st.category) - && (q.is_empty() - || tt.name.to_ascii_lowercase().contains(&q) - || tt.category.to_ascii_lowercase().contains(&q) - || tt.description.to_ascii_lowercase().contains(&q) - || tt.tags.iter().any(|x| x.to_ascii_lowercase().contains(&q))) + && (crate::i18n::matches_query(&tt.name, &q) + || crate::i18n::matches_query(&tt.category, &q) + || crate::i18n::matches_query(&tt.description, &q) + || tt.tags.iter().any(|x| crate::i18n::matches_query(x, &q))) }) .collect(); let selected = st.selected.clone(); @@ -213,7 +212,7 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { &mut b, |ui| { if shown.is_empty() { - ui.label(egui::RichText::new("No templates match.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("No templates match.")).color(t.text_faint)); } for row in shown.chunks(cols) { ui.horizontal(|ui| { @@ -239,14 +238,15 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let k = (ir.width() / sz.x).min(ir.height() / sz.y); let tr = Rect::from_center_size(ir.center(), sz * k); ui.painter().image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 6.0), Align2::LEFT_TOP, &e.template.name, Tokens::ui(11.5), t.text); - let sub = if e.path.is_some() { format!("{} · My Template", e.template.category) } else { e.template.category.clone() }; + ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 6.0), Align2::LEFT_TOP, crate::i18n::t(&e.template.name), Tokens::ui(11.5), t.text); + let category = crate::i18n::t(&e.template.category); + let sub = if e.path.is_some() { tlf!("{category} · My Template", category) } else { category.to_string() }; ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 20.0), Align2::LEFT_TOP, sub, Tokens::ui(10.0), t.text_faint); if on { ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } elems.push((format!("gfxTemplates.item.{}", e.template.id), r, e.template.name.clone())); - let resp = resp.on_hover_text(&e.template.description); + let resp = resp.on_hover_text(crate::i18n::t(&e.template.description)); if resp.drag_started() { crate::panels::start_drag_template(ui, &e.template.id, &e.template.name); } @@ -264,13 +264,13 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.add_space(4.0); let sel_entry = selected.as_ref().and_then(|s| lib.iter().find(|e| &e.template.id == s)); b.horizontal(|ui| { - let r = ui.add_enabled(sel_entry.is_some(), egui::Button::new("Apply")); + let r = ui.add_enabled(sel_entry.is_some(), egui::Button::new(tl!("Apply"))); elems.push(("gfxTemplates.apply".into(), r.rect, "Apply".into())); if r.clicked() { apply = selected.clone(); } let user = sel_entry.is_some_and(|e| e.path.is_some()); - let r = ui.add_enabled(user, egui::Button::new("Remove")); + let r = ui.add_enabled(user, egui::Button::new(tl!("Remove"))); elems.push(("gfxTemplates.remove".into(), r.rect, "Remove".into())); if r.clicked() && let Some(id) = &selected @@ -278,7 +278,7 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("graphics.template.remove".into(), json!({"template": id}))); } if let Some(e) = sel_entry { - ui.label(egui::RichText::new(format!("{} editable properties", e.template.controls.len())).color(t.text_faint)); + ui.label(egui::RichText::new(tlf!("{n} editable properties", n = e.template.controls.len())).color(t.text_faint)); } }); if let Some(s) = new_sel { @@ -320,7 +320,7 @@ fn hex(c: [f32; 4]) -> String { fn row_label(ui: &mut egui::Ui, label: &str, t: &Tokens) -> egui::Ui { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(label), Tokens::ui(12.0), t.text_dim); ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 130.0, r.min.y + 3.0), pos2(r.max.x - 6.0, r.max.y - 3.0))) @@ -341,7 +341,7 @@ pub fn template_controls( ) -> bool { let t = app.tokens; let Some(link) = it.graphic.as_ref().and_then(|m| m.template.as_ref()) else { return false }; - ui.label(egui::RichText::new(format!(" Template: {}", link.name)).color(t.text_faint).size(11.0)); + ui.label(egui::RichText::new(tlf!(" Template: {name}", name = link.name)).color(t.text_faint).size(11.0)); for c in &link.controls { let Some(e) = it.effects.iter().find(|e| graphic::is_layer(e) && filmcraft_project::gtemplate::layer_uid(e) == c.layer) else { continue }; let id = format!("gfxTemplates.control.{}", c.id); @@ -352,7 +352,7 @@ pub fn template_controls( && let Some(ParamValue::Text(s0)) = &pv && s0.contains('\n') { - ui.label(egui::RichText::new(format!(" {}", c.name)).color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(format!(" {}", crate::i18n::t(&c.name))).color(t.text_dim).size(12.0)); let mut s = s0.clone(); let rows = s.lines().count().clamp(2, 8); let r = ui @@ -458,7 +458,7 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: let rate = app.session.sequence_rate(); let m = it.graphic.as_deref().cloned().unwrap_or_default(); let frames = |x: Tick| rate.frame_at(rate.snap_nearest(x)) as f64; - for (key, label, v) in [("intro", "Intro Duration", m.intro), ("outro", "Outro Duration", m.outro)] { + for (key, label, v) in [("intro", tl!("Intro Duration"), m.intro), ("outro", tl!("Outro Duration"), m.outro)] { let mut vui = row_label(ui, label, &t); let mut f = frames(v); let r = vui.add(egui::DragValue::new(&mut f).speed(0.2).range(0.0..=100_000.0).suffix(" fr")); @@ -467,11 +467,11 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: actions.push(("graphics.setResponsiveTime".into(), json!({"clip": clip.0, format!("{key}Frames"): f.round()}))); } } - let mut vui = row_label(ui, "Roll", &t); + let mut vui = row_label(ui, tl!("Roll"), &t); let mut mode = m.roll.mode; - let r = egui::ComboBox::from_id_salt(("gfx-roll", clip.0)).selected_text(mode.label()).width(120.0).show_ui(&mut vui, |ui| { + let r = egui::ComboBox::from_id_salt(("gfx-roll", clip.0)).selected_text(crate::i18n::t(mode.label())).width(120.0).show_ui(&mut vui, |ui| { for x in RollMode::ALL { - ui.selectable_value(&mut mode, x, x.label()); + ui.selectable_value(&mut mode, x, crate::i18n::t(x.label())); } }); autos.push(("graphics.roll.mode".into(), r.response.rect, "Roll".into())); @@ -488,7 +488,9 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: return; } let mut vui = row_label(ui, "", &t); - for (key, label, v) in [("startOffScreen", "Start Off Screen", m.roll.start_off_screen), ("endOffScreen", "End Off Screen", m.roll.end_off_screen)] { + for (key, label, v) in + [("startOffScreen", tl!("Start Off Screen"), m.roll.start_off_screen), ("endOffScreen", tl!("End Off Screen"), m.roll.end_off_screen)] + { let mut b = v; let r = vui.checkbox(&mut b, label); autos.push((format!("graphics.roll.{key}"), r.rect, label.into())); @@ -497,10 +499,10 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: } } for (key, label, v) in [ - ("preroll", "Preroll", m.roll.preroll), - ("easeIn", "Ease In", m.roll.ease_in), - ("easeOut", "Ease Out", m.roll.ease_out), - ("postroll", "Postroll", m.roll.postroll), + ("preroll", tl!("Preroll"), m.roll.preroll), + ("easeIn", tl!("Ease In"), m.roll.ease_in), + ("easeOut", tl!("Ease Out"), m.roll.ease_out), + ("postroll", tl!("Postroll"), m.roll.postroll), ] { let mut vui = row_label(ui, label, &t); let mut f = frames(v); @@ -527,19 +529,20 @@ pub fn responsive_position( let Some(&ei) = idx.get(layer) else { return }; let pin = it.effects[ei].layer.as_ref().and_then(|x| x.pin.clone()); let target_name = match pin.as_ref().map(|p| p.to) { - None => "None".to_string(), - Some(filmcraft_project::PinTarget::Frame) => "Video Frame".to_string(), + None => tl!("None").to_string(), + Some(filmcraft_project::PinTarget::Frame) => tl!("Video Frame").to_string(), Some(filmcraft_project::PinTarget::Layer(uid)) => (0..idx.len()) .find(|&i| filmcraft_project::gtemplate::layer_uid(&it.effects[idx[i]]) == uid) .map(|i| layer_display_name(&it.effects[idx[i]], i)) .unwrap_or_default(), }; - let mut vui = row_label(ui, "Pin To", &t); + let mut vui = row_label(ui, tl!("Pin To"), &t); let r = egui::ComboBox::from_id_salt(("gfx-pin", clip.0, layer)).selected_text(&target_name).width(150.0).show_ui(&mut vui, |ui| { - if ui.selectable_label(pin.is_none(), "None").clicked() { + if ui.selectable_label(pin.is_none(), tl!("None")).clicked() { actions.push(("graphics.pin".into(), json!({"clip": clip.0, "layer": layer, "to": "none"}))); } - if ui.selectable_label(target_name == "Video Frame", "Video Frame").clicked() { + let on_frame = matches!(pin.as_ref().map(|p| p.to), Some(filmcraft_project::PinTarget::Frame)); + if ui.selectable_label(on_frame, tl!("Video Frame")).clicked() { actions.push(("graphics.pin".into(), json!({"clip": clip.0, "layer": layer, "to": "frame"}))); } for (i, &e) in idx.iter().enumerate() { @@ -553,7 +556,7 @@ pub fn responsive_position( }); autos.push(("graphics.pin.to".into(), r.response.rect, "Pin To".into())); let Some(p) = pin else { return }; - let mut vui = row_label(ui, "Pinned Edges", &t); + let mut vui = row_label(ui, tl!("Pinned Edges"), &t); let mut edges = [p.left, p.top, p.right, p.bottom]; let to = match p.to { filmcraft_project::PinTarget::Frame => json!("frame"), @@ -586,7 +589,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val let empty = params.as_object().is_none_or(|m| m.is_empty()); match id { "graphics.template.install" if params.get("path").is_none() => { - let path = app.hooks.pick_open_file.as_mut().and_then(|f| f("FilmCraft Graphics Template", &["fcgt"]))?; + let path = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("FilmCraft Graphics Template"), &["fcgt"]))?; let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); invalidate(ctx); Some(r) @@ -602,15 +605,15 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val let n = layer_display_name(e, i); if e.effect == graphic::TEXT_LAYER { controls.push(ExportControl { layer: i, param: "text".into(), name: n.clone(), on: true }); - controls.push(ExportControl { layer: i, param: "font".into(), name: format!("{n} Font"), on: false }); - controls.push(ExportControl { layer: i, param: "size".into(), name: format!("{n} Size"), on: false }); + controls.push(ExportControl { layer: i, param: "font".into(), name: tlf!("{n} Font", n), on: false }); + controls.push(ExportControl { layer: i, param: "size".into(), name: tlf!("{n} Size", n), on: false }); } - controls.push(ExportControl { layer: i, param: "fill_color".into(), name: format!("{n} Color"), on: false }); - controls.push(ExportControl { layer: i, param: "position".into(), name: format!("{n} Position"), on: false }); - controls.push(ExportControl { layer: i, param: "enabled".into(), name: format!("Show {n}"), on: false }); + controls.push(ExportControl { layer: i, param: "fill_color".into(), name: tlf!("{n} Color", n), on: false }); + controls.push(ExportControl { layer: i, param: "position".into(), name: tlf!("{n} Position", n), on: false }); + controls.push(ExportControl { layer: i, param: "enabled".into(), name: tlf!("Show {n}", n), on: false }); } app.ui.gfx_templates.export = - Some(ExportDraft { clip: clip.0, name: it.name.clone(), category: "My Templates".into(), controls, ..Default::default() }); + Some(ExportDraft { clip: clip.0, name: it.name.clone(), category: tl!("My Templates").into(), controls, ..Default::default() }); Some(Ok(json!({"dialog": "exportTemplate"}))) } "file.replaceFonts" if empty => { @@ -633,28 +636,29 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.gfx_templates.export.clone() else { return }; let mut elems: Elems = Vec::new(); let mut action: Option = None; - egui::Window::new("Export As Motion Graphics Template") + egui::Window::new(tl!("Export As Motion Graphics Template")) + .id(egui::Id::new("Export As Motion Graphics Template")) .collapsible(false) .resizable(false) .default_width(420.0) .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { egui::Grid::new("gfx-export-grid").num_columns(2).show(ui, |ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); elems.push(("exportTemplate.name".into(), r.rect, "Name".into())); ui.end_row(); - ui.label("Category:"); + ui.label(tl!("Category:")); let r = ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)); elems.push(("exportTemplate.category".into(), r.rect, "Category".into())); ui.end_row(); - ui.label("Description:"); + ui.label(tl!("Description:")); let r = ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)); elems.push(("exportTemplate.description".into(), r.rect, "Description".into())); ui.end_row(); }); ui.separator(); - ui.label("Editable properties:"); + ui.label(tl!("Editable properties:")); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for (n, c) in d.controls.iter_mut().enumerate() { ui.horizontal(|ui| { @@ -665,14 +669,14 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { }); } }); - ui.label(egui::RichText::new("Saved as a FilmCraft graphics template (.fcgt) in your templates folder.").weak().size(11.0)); + ui.label(egui::RichText::new(tl!("Saved as a FilmCraft graphics template (.fcgt) in your templates folder.")).weak().size(11.0)); if !d.error.is_empty() { ui.colored_label(Color32::from_rgb(0xff, 0x80, 0x80), &d.error); } ui.horizontal(|ui| { - let ok = ui.button("Export"); + let ok = ui.button(tl!("Export")); elems.push(("exportTemplate.ok".into(), ok.rect, "Export".into())); - let cancel = ui.button("Cancel"); + let cancel = ui.button(tl!("Cancel")); elems.push(("exportTemplate.cancel".into(), cancel.rect, "Cancel".into())); if ok.clicked() { action = Some(true); @@ -692,7 +696,7 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute("graphics.template.export", p) { Ok(v) => { app.ui.gfx_templates.export = None; - app.ui.status = format!("Exported graphics template to {}", v["path"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Exported graphics template to {path}", path = v["path"].as_str().unwrap_or_default()); invalidate(ctx); } Err(e) => { @@ -711,15 +715,19 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let used = filmcraft_engine::graphic_templates::fonts_used(&app.session); let mut elems: Elems = Vec::new(); let mut action: Option = None; - egui::Window::new("Replace Fonts in Projects").collapsible(false).resizable(false).default_width(440.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( - ctx, - |ui| { - ui.label("Fonts used in the project:"); + egui::Window::new(tl!("Replace Fonts in Projects")) + .id(egui::Id::new("Replace Fonts in Projects")) + .collapsible(false) + .resizable(false) + .default_width(440.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tl!("Fonts used in the project:")); egui::Grid::new("gfx-fonts-used").striped(true).num_columns(3).show(ui, |ui| { for (f, st, n) in &used { let missing = filmcraft_text::resolve(f, st).missing; ui.label(if missing { - egui::RichText::new(format!("{f} (missing)")).color(Color32::from_rgb(0xff, 0xa0, 0x60)) + egui::RichText::new(tlf!("{f} (missing)", f)).color(Color32::from_rgb(0xff, 0xa0, 0x60)) } else { egui::RichText::new(f) }); @@ -732,14 +740,14 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut fams: Vec = used.iter().map(|u| u.0.clone()).collect(); fams.dedup(); ui.horizontal(|ui| { - ui.label("Replace:"); + ui.label(tl!("Replace:")); let r = egui::ComboBox::from_id_salt("gfx-rf-from").selected_text(&d.from).width(160.0).show_ui(ui, |ui| { for f in &fams { ui.selectable_value(&mut d.from, f.clone(), f); } }); elems.push(("replaceFonts.from".into(), r.response.rect, "Replace".into())); - ui.label("with:"); + ui.label(tl!("with:")); let r = egui::ComboBox::from_id_salt("gfx-rf-to").selected_text(&d.to).width(160.0).height(360.0).show_ui(ui, |ui| { for (f, _) in filmcraft_text::families() { ui.selectable_value(&mut d.to, f.clone(), &f); @@ -751,9 +759,9 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.colored_label(Color32::from_rgb(0xff, 0x80, 0x80), &d.error); } ui.horizontal(|ui| { - let ok = ui.button("OK"); + let ok = ui.button(tl!("OK")); elems.push(("replaceFonts.ok".into(), ok.rect, "OK".into())); - let cancel = ui.button("Cancel"); + let cancel = ui.button(tl!("Cancel")); elems.push(("replaceFonts.cancel".into(), cancel.rect, "Cancel".into())); if ok.clicked() { action = Some(true); @@ -762,8 +770,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { action = Some(false); } }); - }, - ); + }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -771,7 +778,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { Some(true) => match app.session.execute("file.replaceFonts", json!({"from": d.from, "to": d.to})) { Ok(v) => { app.ui.gfx_templates.replace = None; - app.ui.status = format!("Replaced {} font uses", v["replaced"]); + app.ui.status = tlf!("Replaced {n} font uses", n = v["replaced"]); } Err(e) => { d.error = e.to_string(); diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 813eea973..5d78e93e6 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -9,20 +9,20 @@ use crate::theme::Tokens; pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; ui.painter().rect_filled(rect, t.radius, t.panel_bg); - ui.painter().text(rect.min + vec2(24.0, 30.0), Align2::LEFT_CENTER, "Import", Tokens::semibold(22.0), t.text); + ui.painter().text(rect.min + vec2(24.0, 30.0), Align2::LEFT_CENTER, tl!("Import"), Tokens::semibold(22.0), t.text); ui.painter().text( rect.min + vec2(24.0, 58.0), Align2::LEFT_CENTER, - "Add media to your project. Drop files anywhere in the window, or choose a source below.", + tl!("Add media to your project. Drop files anywhere in the window, or choose a source below."), Tokens::ui(13.0), t.text_dim, ); let ctx = ui.ctx().clone(); let cards = [ - ("Browse files…", "file.import", "Movies, audio and stills from disk"), - ("Demo footage", "file.importDemoFootage", "Six procedural 1080p clips with sound"), - ("Demo project", "file.openDemoProject", "A cut sequence with transitions, effects and music"), - ("Bars and Tone", "file.newBarsAndTone", "SMPTE HD bars with 1 kHz reference tone"), + (tl!("Browse files…"), "file.import", tl!("Movies, audio and stills from disk")), + (tl!("Demo footage"), "file.importDemoFootage", tl!("Six procedural 1080p clips with sound")), + (tl!("Demo project"), "file.openDemoProject", tl!("A cut sequence with transitions, effects and music")), + (tl!("Bars and Tone"), "file.newBarsAndTone", tl!("SMPTE HD bars with 1 kHz reference tone")), ]; let cw = 260.0; for (i, (title, cmd, sub)) in cards.iter().enumerate() { @@ -48,11 +48,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ArtCraft wordmark (first-party trademark, docs/brand/), then the section title. let logo = crate::brand::paint_wordmark(ui, rect.min + vec2(24.0, y), 15.0, app.ui.dark); let tx = logo.map_or(rect.min.x + 24.0, |r| r.max.x + 12.0); - ui.painter().text(pos2(tx, rect.min.y + y), Align2::LEFT_CENTER, "Community", Tokens::semibold(15.0), t.text); + ui.painter().text(pos2(tx, rect.min.y + y), Align2::LEFT_CENTER, tl!("Community"), Tokens::semibold(15.0), t.text); let mut x = rect.min.x + 24.0; for (i, (id, label, url)) in crate::links::ALL.iter().take(4).enumerate() { let primary = i == 0; - let label = if primary { "Join us on Discord" } else { *label }; + let label = if primary { tl!("Join us on Discord") } else { crate::i18n::t(label) }; let g = ui.painter().layout_no_wrap(label.to_string(), Tokens::ui(13.0), t.text); let r = Rect::from_min_size(pos2(x, rect.min.y + y + 18.0), vec2(g.size().x + 44.0, 34.0)); let resp = ui.interact(r, egui::Id::new(("imp-link", *id)), Sense::click()).on_hover_text(*url); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index cd07bbe1f..205cdafa9 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -67,28 +67,29 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + let shown = if d.omf { tl!("OMF Export Settings") } else { tl!("AAF Export Settings") }; + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { ui.set_min_width(380.0); egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { if d.omf { - ui.label("OMF Title:"); + ui.label(tl!("OMF Title:")); let r = ui.text_edit_singleline(&mut d.title); elems.push((format!("{p}.title"), r.rect, d.title.clone())); ui.end_row(); } else { ui.label(""); - let r = ui.checkbox(&mut d.mixdown_video, "Mixdown video"); + let r = ui.checkbox(&mut d.mixdown_video, tl!("Mixdown video")); elems.push((format!("{p}.mixdownVideo"), r.rect, d.mixdown_video.to_string())); ui.end_row(); } - ui.label("Audio:"); + ui.label(tl!("Audio:")); ui.horizontal(|ui| { - let modes: &[(&'static str, &str)] = if d.omf { - &[("embedded", "Encapsulate"), ("separate", "Separate Audio")] + let modes: Vec<(&'static str, &str)> = if d.omf { + vec![("embedded", tl!("Encapsulate")), ("separate", tl!("Separate Audio"))] } else { - &[("embedded", "Embed"), ("separate", "Separate Files"), ("linked", "Link to Media")] + vec![("embedded", tl!("Embed")), ("separate", tl!("Separate Files")), ("linked", tl!("Link to Media"))] }; - for (k, label) in modes { + for (k, label) in &modes { let r = ui.radio(d.audio == *k, *label); if r.clicked() { d.audio = k; @@ -97,7 +98,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Audio File Format:"); + ui.label(tl!("Audio File Format:")); ui.horizontal(|ui| { let formats: &[(&'static str, &str)] = if d.omf { &[("wav", "Broadcast Wave"), ("aiff", "AIFF")] } else { &[("wav", "Broadcast Wave"), ("aiff", "AIFF"), ("mxf", "OP-Atom MXF")] }; @@ -110,7 +111,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Sample Rate:"); + ui.label(tl!("Sample Rate:")); ui.horizontal(|ui| { for r in RATES { let b = ui.radio(d.sample_rate == r, format!("{r}")); @@ -121,7 +122,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Bits per Sample:"); + ui.label(tl!("Bits per Sample:")); ui.horizontal(|ui| { for b in [16u16, 24] { let r = ui.radio(d.bits == b, format!("{b}")); @@ -132,32 +133,32 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Rendering:"); + ui.label(tl!("Rendering:")); let linked = d.audio == "linked"; - let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, "Trim audio files")); + let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, tl!("Trim audio files"))); elems.push((format!("{p}.trimAudio"), r.rect, d.trim.to_string())); ui.end_row(); - ui.label("Handle Frames:"); + ui.label(tl!("Handle Frames:")); let r = ui.add_enabled(d.trim && !linked, egui::DragValue::new(&mut d.handles).range(0..=10_000)); elems.push((format!("{p}.handles"), r.rect, d.handles.to_string())); ui.end_row(); ui.label(""); - let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, "Render audio clip effects")); + let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, tl!("Render audio clip effects"))); elems.push((format!("{p}.renderAudioEffects"), r.rect, d.render_effects.to_string())); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.breakout, "Breakout to mono"); + let r = ui.checkbox(&mut d.breakout, tl!("Breakout to mono")); elems.push((format!("{p}.breakoutToMono"), r.rect, d.breakout.to_string())); ui.end_row(); }); ui.add_space(8.0); ui.horizontal(|ui| { - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); elems.push((format!("{p}.cancel"), c.rect, "Cancel".into())); if c.clicked() { close = true; } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); elems.push((format!("{p}.ok"), o.rect, "OK".into())); if o.clicked() { apply = true; @@ -195,7 +196,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } let cmd = if d.omf { "file.exportOmf" } else { "file.exportAaf" }; match app.session.execute(cmd, params) { - Ok(v) => app.ui.status = format!("Exported {}", v["path"].as_str().unwrap_or_default()), + Ok(v) => app.ui.status = tlf!("Exported {path}", path = v["path"].as_str().unwrap_or_default()), Err(e) => app.ui.status = e.to_string(), } } diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index a8b015d01..674769d6f 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -178,8 +178,20 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val v.pan = [0.0, 0.0]; Ok(json!({"zoom": v.zoom})) } - "playback.inToOutPreroll" => play_range(app, true), - "playback.toOut" => play_range(app, false), + "playback.inToOutPreroll" => { + if crate::menus::targets_source(app, params) { + app.play_source_range(false, true).map(|_| Value::Null) + } else { + play_range(app, true) + } + } + "playback.toOut" => { + if crate::menus::targets_source(app, params) { + app.play_source_range(true, false).map(|_| Value::Null) + } else { + play_range(app, false) + } + } "timeline.expandAllTracks" | "timeline.minimizeAllTracks" => Ok(all_heights(app, id == "timeline.expandAllTracks")), "timeline.increaseVideoHeight" | "timeline.decreaseVideoHeight" | "timeline.increaseAudioHeight" | "timeline.decreaseAudioHeight" => { let d = if id.contains("increase") { HEIGHT_STEP } else { -HEIGHT_STEP }; @@ -466,7 +478,8 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result { let n = words.len(); let paras = filmcraft_edit::transcript::paragraphs(&words, filmcraft_time::Tick::from_seconds_f64(1.5)); let para_of = |i: usize| paras.iter().position(|p| p.contains(&i)).unwrap_or(0); - let (anchor, cur) = app.ui.transcript_sel.unwrap_or_else(|| { + // a selection left over from a longer transcript (e.g. after `transcript.extract` over the control channel) counts as none, as when drawing + let (anchor, cur) = app.ui.transcript_sel.filter(|(a, b)| *a < n && *b < n).unwrap_or_else(|| { let i = filmcraft_edit::transcript::word_at(&words, app.session.playhead()).unwrap_or(0); (i, i) }); @@ -501,3 +514,32 @@ fn text_panel(app: &mut FilmcraftApp, op: &str) -> Result { app.session.set_playhead(words[to].start); Ok(json!({"selection": [anchor.min(to), anchor.max(to)], "word": to})) } + +#[cfg(test)] +mod transcript_selection_tests { + use serde_json::{Value, json}; + + #[test] + fn prev_word_after_the_transcript_shrank_under_the_selection_does_not_panic() { + let mut session = filmcraft_engine::Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let a = session.active_sequence().unwrap().audio_tracks[0].items[0].clone(); + let tk = |s: f64| a.source_in.0 + (s * filmcraft_time::TICKS_PER_SECOND as f64) as i64; + let words: Vec = ["one", "two", "three", "four"] + .iter() + .enumerate() + .map(|(i, w)| json!({"text": w, "start": tk(0.5 + i as f64 * 0.5), "end": tk(0.9 + i as f64 * 0.5)})) + .collect(); + session.execute("transcript.set", json!({"item": a.item.0, "transcript": {"language": "en", "words": words}})).unwrap(); + let mut app = crate::FilmcraftApp::new(session); + app.ui.text_tab = "Transcript".into(); + app.ui.transcript_sel = Some((3, 3)); + // the transcript shrinks behind the panel's back, as with `transcript.extract` over the control channel + app.session.execute("transcript.extract", json!({"from": 0, "to": 1})).unwrap(); + assert_eq!(filmcraft_engine::transcript::sequence_words(&app.session).len(), 2); + let r = super::text_panel(&mut app, "prevWord").unwrap(); + let (sa, sb) = app.ui.transcript_sel.unwrap(); + assert!(sa < 2 && sb < 2, "selection {sa}..{sb} outside the 2-word transcript"); + assert_eq!(r["word"], json!(sb)); + } +} diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index f9043f129..ff35ec199 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -30,15 +30,15 @@ fn selected(app: &FilmcraftApp) -> Option<(ClipId, filmcraft_project::TrackItem) pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it)) = selected(app) else { - crate::dock::placeholder(ui, rect, &t, "(no clip selected)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(12.0, 6.0))).id_salt("lumetri2")); let Some(idx) = it.effects.iter().position(|e| e.effect == "lumetri") else { bui.add_space(8.0); - bui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + bui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); bui.add_space(8.0); - if bui.button("Add Lumetri Color to clip").clicked() { + if bui.button(tl!("Add Lumetri Color to clip")).clicked() { let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})); } return; @@ -52,7 +52,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Actions = Vec::new(); // header: clip context + fx toggle bui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + ui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let fx = ui.add(egui::Button::new(egui::RichText::new("fx").italics().color(if e.enabled { t.text } else { t.text_faint })).frame(false)); if fx.clicked() { @@ -65,25 +65,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut lut_actions: Vec<(&'static str, Value)> = Vec::new(); egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut bui, |ui| { if open_section(ui, app, &e, "Basic Correction", "basic_on", "basic", &mut sections) { - lut_combo(ui, app, &e, "input_lut", "Input LUT", true, &mut lut_actions); - sub(ui, &t, "Color"); - slider(ui, &e, mt, "temperature", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "saturation", "Saturation", None, &t, &mut actions, clip, idx); - sub(ui, &t, "Light"); + lut_combo(ui, app, &e, "input_lut", tl!("Input LUT"), true, &mut lut_actions); + sub(ui, &t, tl!("Color")); + slider(ui, &e, mt, "temperature", tl!("Temperature"), Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "tint", tl!("Tint"), Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "saturation", tl!("Saturation"), None, &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Light")); for (id, label) in [ - ("exposure", "Exposure"), - ("contrast", "Contrast"), - ("highlights", "Highlights"), - ("shadows", "Shadows"), - ("whites", "Whites"), - ("blacks", "Blacks"), + ("exposure", tl!("Exposure")), + ("contrast", tl!("Contrast")), + ("highlights", tl!("Highlights")), + ("shadows", tl!("Shadows")), + ("whites", tl!("Whites")), + ("blacks", tl!("Blacks")), ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } if hdr { - sub(ui, &t, "HDR"); - for (id, label) in [("hdr_white", "HDR White"), ("hdr_specular", "HDR Specular")] { + sub(ui, &t, tl!("HDR")); + for (id, label) in [("hdr_white", tl!("HDR White")), ("hdr_specular", tl!("HDR Specular"))] { let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); auto_rows.push((format!("lumetri.param.{id}"), r, label)); } @@ -99,28 +99,30 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .and_then(|p| if let filmcraft_project::ParamKind::Choice(o) = p.kind { Some(o) } else { None }) .unwrap_or(&[]); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Look").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Look")).color(t.text_dim))); let mut sel = look; egui::ComboBox::from_id_salt("lumetri-look") - .selected_text(opts.get(look).copied().unwrap_or("None")) + .selected_text(opts.get(look).map_or(tl!("None"), |o| crate::i18n::t(o))) .width((ui.available_width() - 40.0).max(60.0)) .show_ui(ui, |ui| { for (i, o) in opts.iter().enumerate() { - if ui.selectable_value(&mut sel, i, *o).changed() { + if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set(&mut actions, "look", json!(i)); } } }); }); - lut_combo(ui, app, &e, "look_lut", "Look LUT", false, &mut lut_actions); - slider(ui, &e, mt, "look_intensity", "Intensity", None, &t, &mut actions, clip, idx); - sub(ui, &t, "Adjustments"); - for (id, label) in [("faded_film", "Faded Film"), ("sharpen", "Sharpen"), ("vibrance", "Vibrance"), ("creative_sat", "Saturation")] { + lut_combo(ui, app, &e, "look_lut", tl!("Look LUT"), false, &mut lut_actions); + slider(ui, &e, mt, "look_intensity", tl!("Intensity"), None, &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Adjustments")); + for (id, label) in + [("faded_film", tl!("Faded Film")), ("sharpen", tl!("Sharpen")), ("vibrance", tl!("Vibrance")), ("creative_sat", tl!("Saturation"))] + { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } } if open_section(ui, app, &e, "Curves", "curves_on", "curves", &mut sections) { - sub(ui, &t, "RGB Curves"); + sub(ui, &t, tl!("RGB Curves")); let key = egui::Id::new("lumetri-curve-channel"); let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); ui.horizontal(|ui| { @@ -146,16 +148,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ][ch]; curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx); if hdr { - let r = slider(ui, &e, mt, "curves_hdr_range", "HDR Range", None, &t, &mut actions, clip, idx); + let r = slider(ui, &e, mt, "curves_hdr_range", tl!("HDR Range"), None, &t, &mut actions, clip, idx); auto_rows.push(("lumetri.param.curves_hdr_range".into(), r, "HDR Range")); } - sub(ui, &t, "Hue Saturation Curves"); + sub(ui, &t, tl!("Hue Saturation Curves")); for (id, label) in [ - ("hue_vs_sat", "Hue vs Sat"), - ("hue_vs_hue", "Hue vs Hue"), - ("hue_vs_luma", "Hue vs Luma"), - ("luma_vs_sat", "Luma vs Sat"), - ("sat_vs_sat", "Sat vs Sat"), + ("hue_vs_sat", tl!("Hue vs Sat")), + ("hue_vs_hue", tl!("Hue vs Hue")), + ("hue_vs_luma", tl!("Hue vs Luma")), + ("luma_vs_sat", tl!("Luma vs Sat")), + ("sat_vs_sat", tl!("Sat vs Sat")), ] { ui.label(egui::RichText::new(label).color(t.text_dim)); curve_editor(ui, &e, id, true, Color32::WHITE, &t, &mut actions, clip, idx); @@ -165,9 +167,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = ((ui.available_width() - 24.0) / 3.0).min(120.0); ui.horizontal(|ui| { for (id, lid, label) in [ - ("wheel_shadows", "wheel_shadows_l", "Shadows"), - ("wheel_midtones", "wheel_midtones_l", "Midtones"), - ("wheel_highlights", "wheel_highlights_l", "Highlights"), + ("wheel_shadows", "wheel_shadows_l", tl!("Shadows")), + ("wheel_midtones", "wheel_midtones_l", tl!("Midtones")), + ("wheel_highlights", "wheel_highlights_l", tl!("Highlights")), ] { ui.vertical(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim)); @@ -178,14 +180,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { match_controls(ui, app, &mut lut_actions); } if open_section(ui, app, &e, "HSL Secondary", "hsl_on", "hsl", &mut sections) { - sub(ui, &t, "Key"); - slider(ui, &e, mt, "hsl_hue", "Hue", Some(Gradient::Hue), &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Key")); + slider(ui, &e, mt, "hsl_hue", tl!("Hue"), Some(Gradient::Hue), &t, &mut actions, clip, idx); for (id, label) in [ - ("hsl_hue_range", "Hue Range"), - ("hsl_sat_min", "Saturation Min"), - ("hsl_luma_min", "Luma Min"), - ("hsl_luma_max", "Luma Max"), - ("hsl_soft", "Soften"), + ("hsl_hue_range", tl!("Hue Range")), + ("hsl_sat_min", tl!("Saturation Min")), + ("hsl_luma_min", tl!("Luma Min")), + ("hsl_luma_max", tl!("Luma Max")), + ("hsl_soft", tl!("Soften")), ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } @@ -194,31 +196,35 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => 0, }; ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Show Mask").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Show Mask")).color(t.text_dim))); let mut sel = mask; - egui::ComboBox::from_id_salt("hsl-mask").selected_text(["Off", "Color/Gray", "Color/Black", "White/Black"][mask.min(3)]).show_ui(ui, |ui| { - for (i, o) in ["Off", "Color/Gray", "Color/Black", "White/Black"].iter().enumerate() { + let modes = [tl!("Off"), tl!("Color/Gray"), tl!("Color/Black"), tl!("White/Black")]; + egui::ComboBox::from_id_salt("hsl-mask").selected_text(modes[mask.min(3)]).show_ui(ui, |ui| { + for (i, o) in modes.iter().enumerate() { if ui.selectable_value(&mut sel, i, *o).changed() { set(&mut actions, "hsl_show_mask", json!(i)); } } }); }); - sub(ui, &t, "Refine"); - for (id, label) in [("hsl_denoise", "Denoise"), ("hsl_blur", "Blur")] { + sub(ui, &t, tl!("Refine")); + for (id, label) in [("hsl_denoise", tl!("Denoise")), ("hsl_blur", tl!("Blur"))] { let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); auto_rows.push((format!("lumetri.param.{id}"), r, label)); } - sub(ui, &t, "Correction"); - slider(ui, &e, mt, "hsl_temp", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_sat", "Saturation", None, &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_hue_shift", "Hue Shift", Some(Gradient::Hue), &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Correction")); + slider(ui, &e, mt, "hsl_temp", tl!("Temperature"), Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_tint", tl!("Tint"), Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_sat", tl!("Saturation"), None, &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_hue_shift", tl!("Hue Shift"), Some(Gradient::Hue), &t, &mut actions, clip, idx); } if open_section(ui, app, &e, "Vignette", "vignette_on", "vignette", &mut sections) { - for (id, label) in - [("vignette_amount", "Amount"), ("vignette_midpoint", "Midpoint"), ("vignette_roundness", "Roundness"), ("vignette_feather", "Feather")] - { + for (id, label) in [ + ("vignette_amount", tl!("Amount")), + ("vignette_midpoint", tl!("Midpoint")), + ("vignette_roundness", tl!("Roundness")), + ("vignette_feather", tl!("Feather")), + ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } } @@ -249,7 +255,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } if p.get("browse").is_some() { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("LUT", &["cube", "3dl"])) else { continue }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("LUT"), &["cube", "3dl"])) else { continue }; p = json!({"path": path}); } p["clip"] = json!(clip.0); @@ -285,7 +291,7 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para }; let cmd: &'static str = if input { "lumetri.setInputLut" } else { "lumetri.setLook" }; let shown = filmcraft_render::luts::label(Some(&app.session.project), &cur); - let mut entries: Vec<(String, String)> = vec![(String::new(), "None".into())]; + let mut entries: Vec<(String, String)> = vec![(String::new(), tl!("None").into())]; let builtins: Vec<&filmcraft_render::luts::Builtin> = if input { filmcraft_render::luts::input_builtins().collect() } else { @@ -305,7 +311,7 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para } } ui.separator(); - if ui.selectable_label(false, "Browse…").clicked() { + if ui.selectable_label(false, tl!("Browse…")).clicked() { out.push((cmd, json!({"browse": true}))); } }, @@ -329,7 +335,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, on: bool) -> ( if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); // enable switch (section bypass) let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0)); let sw_resp = ui.interact(sw.expand(3.0), egui::Id::new(("lumetri-switch", name)), Sense::click()); @@ -613,7 +619,7 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & /// Apply Match (`lumetri.applyMatch`). fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'static str, Value)>) { let t = app.tokens; - sub(ui, &t, "Color Match"); + sub(ui, &t, tl!("Color Match")); let key = egui::Id::new("lumetri-match-ref"); let fd_key = egui::Id::new("lumetri-match-face"); let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string()); @@ -621,7 +627,7 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st // Comparison View: the Program monitor shows the reference frame next to the current one, and // Apply Match uses that reference let comparing = app.ui.program.display_mode() == Some(crate::state::DisplayMode::Comparison); - let b = ui.selectable_label(comparing, "Comparison View"); + let b = ui.selectable_label(comparing, tl!("Comparison View")); app.auto.add("lumetri.match.comparisonView", b.rect, "Comparison View"); if b.clicked() { out.push(("view.display", json!({"display": if comparing { "composite" } else { "comparison" }}))); @@ -631,13 +637,13 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st tc = filmcraft_time::format_time(r, q.settings.frame_rate, q.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); } ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Reference").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Reference")).color(t.text_dim))); let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)); app.auto.add("lumetri.match.reference", r.rect, "Reference timecode"); }); - let r = ui.checkbox(&mut face, "Face Detection (protect skin tones)"); + let r = ui.checkbox(&mut face, tl!("Face Detection (protect skin tones)")); app.auto.add("lumetri.match.faceDetection", r.rect, "Face Detection"); - let b = ui.button("Apply Match"); + let b = ui.button(tl!("Apply Match")); app.auto.add("lumetri.match.apply", b.rect, "Apply Match"); if b.clicked() { let p = match reference { diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 86893f61f..1d50f9ab0 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -46,10 +46,13 @@ pub fn effect_rows( // creation tools: ellipse, 4-point polygon, free-draw Bézier let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx); - for (i, (icon, kind, tip)) in - [(Icon::Ellipse, "ellipse", "Create ellipse mask"), (Icon::Rectangle, "polygon", "Create 4-point polygon mask"), (Icon::Pen, "pen", "Free draw bezier")] - .into_iter() - .enumerate() + for (i, (icon, kind, tip)) in [ + (Icon::Ellipse, "ellipse", tl!("Create ellipse mask")), + (Icon::Rectangle, "polygon", tl!("Create 4-point polygon mask")), + (Icon::Pen, "pen", tl!("Free draw bezier")), + ] + .into_iter() + .enumerate() { let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0)); let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); @@ -63,7 +66,8 @@ pub fn effect_rows( if kind == "pen" { app.ui.mask_pen = if on { None } else { Some(crate::state::MaskPenDraft { clip: clip.0, effect: idx, points: vec![] }) }; if !on { - app.ui.status = "Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close".into(); + app.ui.status = + tl!("Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close").into(); } } else { actions.push(("masks.add".into(), json!({"clip": clip.0, "effect": idx, "shape": kind}))); @@ -102,9 +106,9 @@ pub fn effect_rows( let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; - egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(mode.label()).width(88.0).show_ui(&mut mui, |ui| { + egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { - if ui.selectable_value(&mut mode, md, md.label()).changed() { + if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); } } @@ -124,7 +128,7 @@ pub fn effect_rows( } } resp.context_menu(|ui| { - if ui.button("Delete Mask").clicked() { + if ui.button(tl!("Delete Mask")).clicked() { actions.push(("masks.remove".into(), json!({"clip": clip.0, "effect": idx, "mask": k}))); ui.close(); } @@ -144,7 +148,7 @@ pub fn effect_rows( } // Inverted let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); - ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, "Inverted", Tokens::ui(12.0), t.text); + ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); let mut inv = m.inverted; @@ -359,9 +363,11 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let selected = sel_v == Some(i); painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE }); painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); - let r = ui - .interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()) - .on_hover_text("Drag: move · Alt-click: smooth/corner · Cmd-click: delete"); + let r = ui.interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") { + tl!("Drag: move · Alt-click: smooth/corner · Cmd-click: delete") + } else { + tl!("Drag: move · Alt-click: smooth/corner · Ctrl-click: delete") + }); app.auto.add(&format!("program.mask.vertex.{i}"), vr, "Mask vertex"); if r.drag_started() { ui.data_mut(|d| d.insert_temp(sel_v_id, i)); @@ -542,13 +548,13 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec let target = filmcraft_engine::masks::MaskSel { clip, effect, mask: k }; let running = app.session.mask_jobs.iter().find(|j| j.target == target).map(|j| j.job); for (icon, dir, frames, id, tip) in [ - (Icon::TrackMaskBack, "backward", None, "back", "Track selected mask backward"), - (Icon::TrackMaskBackFrame, "backward", Some(1), "backFrame", "Track selected mask backward 1 frame"), - (Icon::TrackMaskFwdFrame, "forward", Some(1), "fwdFrame", "Track selected mask forward 1 frame"), - (Icon::TrackMaskFwd, "forward", None, "fwd", "Track selected mask forward"), + (Icon::TrackMaskBack, "backward", None, "back", tl!("Track selected mask backward")), + (Icon::TrackMaskBackFrame, "backward", Some(1), "backFrame", tl!("Track selected mask backward 1 frame")), + (Icon::TrackMaskFwdFrame, "forward", Some(1), "fwdFrame", tl!("Track selected mask forward 1 frame")), + (Icon::TrackMaskFwd, "forward", None, "fwd", tl!("Track selected mask forward")), ] { let (r, resp) = ui.allocate_exact_size(vec2(20.0, 18.0), Sense::click()); - let resp = resp.on_hover_text(if running.is_some() { "Stop tracking" } else { tip }); + let resp = resp.on_hover_text(if running.is_some() { tl!("Stop tracking") } else { tip }); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -571,10 +577,10 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec let (r, resp) = ui.allocate_exact_size(vec2(20.0, 18.0), Sense::click()); icons::paint(ui.painter(), r.shrink(3.0), Icon::Wrench, if resp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{base}.trackMethod"), r, "Tracking method"); - let resp = resp.on_hover_text(format!("Tracking method: {}", m.track_method.label())); + let resp = resp.on_hover_text(tlf!("Tracking method: {method}", method = crate::i18n::t(m.track_method.label()))); egui::Popup::menu(&resp).show(|ui| { for tm in filmcraft_project::TrackMethod::ALL { - if ui.selectable_label(tm == m.track_method, tm.label()).clicked() { + if ui.selectable_label(tm == m.track_method, crate::i18n::t(tm.label())).clicked() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": effect, "mask": k, "trackMethod": tm.label()}))); } } diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index f72b5cf49..c19ed5ce2 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -100,9 +100,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let bar = Rect::from_min_size(rect.min + vec2(6.0, 4.0), vec2(rect.width() - 12.0, 24.0)); let mut x = bar.min.x; let nav = [ - (Icon::ChevronLeft, "back", "Go Back", !app.session.browser.back.is_empty()), - (Icon::ChevronRight, "forward", "Go Forward", !app.session.browser.forward.is_empty()), - (Icon::ArrowUp, "up", "Up One Level", mb::parent(&dir).is_some()), + (Icon::ChevronLeft, "back", tl!("Go Back"), !app.session.browser.back.is_empty()), + (Icon::ChevronRight, "forward", tl!("Go Forward"), !app.session.browser.forward.is_empty()), + (Icon::ArrowUp, "up", tl!("Up One Level"), mb::parent(&dir).is_some()), ]; for (icon, id, tip, enabled) in nav { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); @@ -138,7 +138,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // file types let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0)); let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types)); - let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text("File Types Displayed"); + let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text(tl!("File Types Displayed")); app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed"); egui::Popup::menu(&fresp).show(|ui| { for (k, l) in mb::FILE_TYPES { @@ -150,7 +150,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.separator(); - ui.menu_button("File Extension", |ui| { + ui.menu_button(tl!("File Extension"), |ui| { for e in mb::extensions() { if ui.selectable_label(prefs.file_types == e, format!(".{e}")).clicked() { exec(app, &ctx, "mediaBrowser.settings", json!({"fileTypes": e})); @@ -160,7 +160,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); }); x = fr.max.x + 6.0; - for (icon, v, tip) in [(Icon::ListView, "list", "List View"), (Icon::IconView, "thumbnails", "Thumbnail View")] { + for (icon, v, tip) in [(Icon::ListView, "list", tl!("List View")), (Icon::IconView, "thumbnails", tl!("Thumbnail View"))] { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip); @@ -179,19 +179,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let row2 = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 4.0), vec2(bar.width(), 20.0)); let mut ingest = app.session.project.settings.ingest.enabled; let mut c = ui.new_child(egui::UiBuilder::new().max_rect(row2).id_salt("mb-row2").layout(egui::Layout::left_to_right(egui::Align::Center))); - let r = c.checkbox(&mut ingest, "Ingest"); + let r = c.checkbox(&mut ingest, tl!("Ingest")); app.auto.add("mediaBrowser.ingest", r.rect, "Ingest"); if r.changed() { exec(app, &ctx, "project.ingestSettings", json!({"enabled": ingest})); } - let w = c.add(egui::Button::new(egui::RichText::new("⚙").size(12.0)).frame(false)).on_hover_text("Open Ingest Settings"); + let w = c.add(egui::Button::new(egui::RichText::new("⚙").size(12.0)).frame(false)).on_hover_text(tl!("Open Ingest Settings")); app.auto.add("mediaBrowser.ingestSettings", w.rect, "Open Ingest Settings"); if w.clicked() { exec(app, &ctx, "project.ingestSettings", json!({})); } c.add_space(12.0); let mut seq = prefs.import_as_image_sequence; - let r = c.checkbox(&mut seq, "Import as Image Sequence"); + let r = c.checkbox(&mut seq, tl!("Import as Image Sequence")); app.auto.add("mediaBrowser.importAsImageSequence", r.rect, "Import as Image Sequence"); if r.changed() { exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": seq})); @@ -219,12 +219,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let n = entries.iter().filter(|e| !e.is_dir).count(); let sel = app.session.browser.selection.len(); - let status = if sel > 0 { format!("{sel} of {n} items selected") } else { format!("{n} items") }; + let status = if sel > 0 { tlf!("{sel} of {total} items selected", sel, total = n) } else { tlf!("{total} items", total = n) }; ui.painter().text(pos2(list_r.min.x + 6.0, rect.max.y - 12.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.0), t.text_dim); app.auto.add("mediaBrowser.count", Rect::from_min_size(pos2(list_r.min.x, rect.max.y - 22.0), vec2(160.0, 20.0)), &status); } Err(e) => { - ui.painter().text(list_r.center(), Align2::CENTER_CENTER, format!("Cannot open {dir}: {e}"), Tokens::ui(11.5), t.text_dim); + ui.painter().text(list_r.center(), Align2::CENTER_CENTER, tlf!("Cannot open {dir}: {e}", dir, e), Tokens::ui(11.5), t.text_dim); } } if prefs.view == "thumbnails" { @@ -251,10 +251,10 @@ fn tree(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, dir: &str) { let mut go: Option = None; egui::ScrollArea::vertical().id_salt("mb-tree-scroll").auto_shrink([false, false]).show(&mut child, |ui| { let sections: [(&str, &str, Icon); 4] = [ - ("favorites", "Favorites", Icon::Star), - ("localDrives", "Local Drives", Icon::Drive), - ("network", "Network", Icon::Network), - ("recent", "Recent Directories", Icon::Clock), + ("favorites", tl!("Favorites"), Icon::Star), + ("localDrives", tl!("Local Drives"), Icon::Drive), + ("network", tl!("Network"), Icon::Network), + ("recent", tl!("Recent Directories"), Icon::Clock), ]; for (key, title, icon) in sections { let list: Vec<(String, String)> = roots[key] @@ -264,7 +264,7 @@ fn tree(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, dir: &str) { let hr = ui.label(egui::RichText::new(title).size(11.0).color(app.tokens.text_dim).strong()); app.auto.add(&format!("mediaBrowser.tree.{key}"), hr.rect, title); if list.is_empty() { - ui.label(egui::RichText::new(" (none)").size(10.5).color(app.tokens.text_faint)); + ui.label(egui::RichText::new(tl!(" (none)")).size(10.5).color(app.tokens.text_faint)); } for (name, path) in list { let expandable = matches!(key, "localDrives" | "network" | "favorites"); @@ -328,18 +328,18 @@ fn node( } resp.context_menu(|ui| { let fav = app.session.prefs.media_browser.favorites.iter().any(|f| f == path); - let label = if fav { "Remove from Favorites" } else { "Add to Favorites" }; + let label = if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }; let b = ui.button(label); app.auto.add("mediaBrowser.treeMenu.favorite", b.rect, label); if b.clicked() { exec(app, ctx, "mediaBrowser.favorite", json!({"path": path, "remove": fav})); ui.close(); } - if ui.button("Import").clicked() { + if ui.button(tl!("Import")).clicked() { exec(app, ctx, "mediaBrowser.import", json!({"paths": [path]})); ui.close(); } - if section == "recent" && ui.button("Clear Recent Directories").clicked() { + if section == "recent" && ui.button(tl!("Clear Recent Directories")).clicked() { exec(app, ctx, "mediaBrowser.clearRecent", json!({})); ui.close(); } @@ -399,14 +399,14 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } resp.context_menu(|ui| { let paths: Vec = if app.session.browser.selection.contains(&e.path) { app.session.browser.selection.clone() } else { vec![e.path.clone()] }; - let b = ui.button("Import"); + let b = ui.button(tl!("Import")); app.auto.add("mediaBrowser.entryMenu.import", b.rect, "Import"); if b.clicked() { exec(app, &ctx, "mediaBrowser.import", json!({"paths": paths, "imageSequence": false})); ui.close(); } if !e.is_dir { - let b = ui.button("Open In Source Monitor"); + let b = ui.button(tl!("Open In Source Monitor")); app.auto.add("mediaBrowser.entryMenu.openInSource", b.rect, "Open In Source Monitor"); if b.clicked() { exec(app, &ctx, "mediaBrowser.openInSource", json!({"path": e.path})); @@ -414,7 +414,7 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } } if e.numbered { - let b = ui.button("Import as Image Sequence"); + let b = ui.button(tl!("Import as Image Sequence")); app.auto.add("mediaBrowser.entryMenu.importSequence", b.rect, "Import as Image Sequence"); if b.clicked() { exec(app, &ctx, "mediaBrowser.import", json!({"paths": [e.path], "imageSequence": true})); @@ -423,12 +423,12 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } if e.is_dir { let fav = app.session.prefs.media_browser.favorites.contains(&e.path); - if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() { + if ui.button(if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }).clicked() { exec(app, &ctx, "mediaBrowser.favorite", json!({"path": e.path, "remove": fav})); ui.close(); } } - if ui.button("Reveal in Finder").clicked() { + if ui.button(tl!("Reveal in Finder")).clicked() { let _ = crate::panels::menu_dialogs::open_path(app, &ctx, &e.path, true); ui.close(); } @@ -551,12 +551,12 @@ fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], c let mut x = header.min.x + 4.0 - out.state.offset.x; for (c, w) in columns.iter().zip(&widths) { let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0)); - hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, c, Tokens::ui(11.0), t.text_dim); + hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(c), Tokens::ui(11.0), t.text_dim); if cr.intersects(header) { let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click()); app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c); resp.context_menu(|ui| { - if ui.button("Edit Columns…").clicked() { + if ui.button(tl!("Edit Columns…")).clicked() { app.ui.media_browser.edit_columns = Some(columns.to_vec()); ui.close(); } @@ -731,52 +731,53 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { app.auto.add(&format!("mediaBrowser.menu.{id}"), r.rect, label); r.clicked() }; - if item(app, ui, "editColumns", "Edit Columns…", true, None, "") { + if item(app, ui, "editColumns", tl!("Edit Columns…"), true, None, "") { app.ui.media_browser.edit_columns = Some(prefs.columns.clone()); close = true; } - if item(app, ui, "importAsImageSequence", "Import as Image Sequence", true, Some(prefs.import_as_image_sequence), "") { + if item(app, ui, "importAsImageSequence", tl!("Import as Image Sequence"), true, Some(prefs.import_as_image_sequence), "") { exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": !prefs.import_as_image_sequence})); close = true; } - if item(app, ui, "hoverScrub", "Hover Scrub", true, Some(prefs.hover_scrub), "") { + if item(app, ui, "hoverScrub", tl!("Hover Scrub"), true, Some(prefs.hover_scrub), "") { exec(app, &ctx, "mediaBrowser.settings", json!({"hoverScrub": !prefs.hover_scrub})); close = true; } - item(app, ui, "newPanel", "New Media Browser Panel", false, None, ""); + item(app, ui, "newPanel", tl!("New Media Browser Panel"), false, None, ""); let mprefs = app.session.prefs.media.clone(); - if item(app, ui, "createFolderForImportedProjects", "Create Folder For Imported Projects", true, Some(mprefs.create_folder_for_imported_projects), "") { + if item(app, ui, "createFolderForImportedProjects", tl!("Create Folder For Imported Projects"), true, Some(mprefs.create_folder_for_imported_projects), "") + { let mut p = app.session.prefs.clone(); p.media.create_folder_for_imported_projects = !mprefs.create_folder_for_imported_projects; let _ = app.session.set_prefs(p); close = true; } - if item(app, ui, "allowDuplicateMedia", "Allow Duplicate Media During Project Import", true, Some(mprefs.allow_duplicate_media), "") { + if item(app, ui, "allowDuplicateMedia", tl!("Allow Duplicate Media During Project Import"), true, Some(mprefs.allow_duplicate_media), "") { let mut p = app.session.prefs.clone(); p.media.allow_duplicate_media = !mprefs.allow_duplicate_media; let _ = app.session.set_prefs(p); close = true; } - if item(app, ui, "refresh", "Refresh", true, None, "") { + if item(app, ui, "refresh", tl!("Refresh"), true, None, "") { refresh(&ctx); app.session.browser.probes.clear(); close = true; } ui.separator(); let fav = prefs.favorites.contains(&dir); - if item(app, ui, "favorite", if fav { "Remove from Favorites" } else { "Add to Favorites" }, true, None, "") { + if item(app, ui, "favorite", if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }, true, None, "") { exec(app, &ctx, "mediaBrowser.favorite", json!({"remove": fav})); close = true; } - if item(app, ui, "clearRecent", "Clear Recent Directories", !prefs.recent.is_empty(), None, "") { + if item(app, ui, "clearRecent", tl!("Clear Recent Directories"), !prefs.recent.is_empty(), None, "") { exec(app, &ctx, "mediaBrowser.clearRecent", json!({})); close = true; } - if item(app, ui, "import", "Import", has_sel, None, "") { + if item(app, ui, "import", tl!("Import"), has_sel, None, "") { exec(app, &ctx, "mediaBrowser.import", json!({})); close = true; } - if item(app, ui, "openInSource", "Open In Source Monitor", has_sel, None, "Shift+O") { + if item(app, ui, "openInSource", tl!("Open In Source Monitor"), has_sel, None, "Shift+O") { exec(app, &ctx, "mediaBrowser.openInSource", json!({})); close = true; } @@ -788,14 +789,17 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut cols) = app.ui.media_browser.edit_columns.clone() else { return }; let mut close = false; let mut ok = false; - egui::Window::new("Edit Columns").id(egui::Id::new("mb-edit-columns")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( - ctx, - |ui| { + egui::Window::new(tl!("Edit Columns")) + .id(egui::Id::new("mb-edit-columns")) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { let mut mv: Option<(usize, isize)> = None; for c in mb::ALL_COLUMNS { ui.horizontal(|ui| { let mut on = cols.iter().any(|x| x == c); - let r = ui.add_enabled(c != "Name", egui::Checkbox::new(&mut on, c)); + let r = ui.add_enabled(c != "Name", egui::Checkbox::new(&mut on, crate::i18n::t(c))); app.auto.add(&format!("mediaBrowser.columns.{c}"), r.rect, c); if r.changed() { if on { @@ -819,15 +823,14 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.separator(); ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); app.auto.add("mediaBrowser.columns.cancel", r.rect, "Cancel"); close |= r.clicked(); - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); app.auto.add("mediaBrowser.columns.ok", r.rect, "OK"); ok = r.clicked(); }); - }, - ); + }); if ok { let r = app.session.execute("mediaBrowser.settings", json!({"columns": cols})); if let Err(e) = r { diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index 474e085bc..de3370d29 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -62,7 +62,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Option Vec<(u64, String, String, String, &'stati let m = it.as_media()?; let filmcraft_project::MediaRef::File { path } = &m.media else { return None }; let fname = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); - Some((id.0, it.name.clone(), fname, path.clone(), if m.offline { "Offline" } else { "Missing" })) + Some((id.0, it.name.clone(), fname, path.clone(), if m.offline { tl!("Offline") } else { tl!("Missing") })) }) .collect() } @@ -161,124 +161,135 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let accent = app.tokens.accent; let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone()); let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p)); - egui::Window::new("Link Media").collapsible(false).resizable(false).default_width(760.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("{} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", rows.len())); - ui.add_space(6.0); - egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { - for h in ["Clip Name", "File Name", "File Path", "Status"] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { - let r = ui.selectable_label(d.row == k, name); - push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); - if r.clicked() && d.row != k { - d.row = k; - d.candidates.clear(); - d.candidate = None; - } - ui.label(fname); - ui.label(RichText::new(path).small()); - ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); - ui.end_row(); - } - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label("Match file properties:"); - for (id, label, v) in [ - ("fileName", "File Name", &mut d.file_name), - ("extension", "File Extension", &mut d.extension), - ("clipId", "Clip ID (fingerprint)", &mut d.clip_id), - ("duration", "Duration", &mut d.duration), - ("mediaStart", "Media Start", &mut d.media_start), - ("metadata", "Frame Size / Rate", &mut d.metadata), - ] { - let r = ui.checkbox(v, label); - push(&mut elems, format!("linkMedia.match.{id}"), &r, label); - } - }); - ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.align_timecode, "Align Timecode"); - push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); - let r = ui.checkbox(&mut d.relink_others, "Relink others automatically"); - push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label("Search in:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text("folder")); - push(&mut elems, "linkMedia.folder", &r, "folder"); - let r = ui.button("Browse…"); - push(&mut elems, "linkMedia.browse", &r, "Browse…"); - if r.clicked() { - action = Some("browse"); - } - let r = ui.checkbox(&mut d.exact_name, "Exact name matches only"); - push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); - let r = ui.button("Search"); - push(&mut elems, "linkMedia.search", &r, "Search"); - if r.clicked() { - action = Some("search"); - } - }); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(520.0); - if d.candidates.is_empty() { - ui.label(RichText::new("No candidates yet — Search a folder or Locate the file.").weak()); - } - for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { - let mark = match (ok, idm) { - (true, Some(true)) => "✔ same file", - (true, _) => "✔ matches", - (false, Some(false)) => "✖ different file", - _ => "✖", - }; - let text = format!("{mark} {path}"); - let r = ui.selectable_label( - d.candidate == Some(k), - RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), - ); - let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; - push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); - if r.clicked() { - d.candidate = Some(k); + let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0); + egui::Window::new(tl!("Link Media")) + .id(egui::Id::new("Link Media")) + .collapsible(false) + .resizable(false) + .default_width(760.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len())); + ui.add_space(6.0); + egui::ScrollArea::vertical().id_salt("link-media-rows-scroll").max_height(rows_max_h).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { + for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { + ui.label(RichText::new(h).strong()); } + ui.end_row(); + for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { + let r = ui.selectable_label(d.row == k, name); + push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); + if r.clicked() && d.row != k { + d.row = k; + d.candidates.clear(); + d.candidate = None; + } + ui.label(fname); + ui.label(RichText::new(path).small()); + ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); + ui.end_row(); + } + }); + }); + ui.separator(); + ui.horizontal(|ui| { + ui.label(tl!("Match file properties:")); + for (id, label, v) in [ + ("fileName", tl!("File Name"), &mut d.file_name), + ("extension", tl!("File Extension"), &mut d.extension), + ("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id), + ("duration", tl!("Duration"), &mut d.duration), + ("mediaStart", tl!("Media Start"), &mut d.media_start), + ("metadata", tl!("Frame Size / Rate"), &mut d.metadata), + ] { + let r = ui.checkbox(v, label); + push(&mut elems, format!("linkMedia.match.{id}"), &r, label); } }); - let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); - ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); - match &preview { - Some(t) => { - let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); - ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); - } - None => { - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "Preview", crate::theme::Tokens::ui(11.0), Color32::GRAY); - } - } - elems.push(("linkMedia.preview".into(), rect, "preview".into())); - }); - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - for (id, label) in [("offlineAll", "Offline All"), ("offline", "Offline"), ("cancel", "Cancel"), ("locate", "Locate…")] { - let r = ui.button(label); - push(&mut elems, format!("linkMedia.{id}"), &r, label); + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode")); + push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); + let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically")); + push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); + }); + ui.separator(); + ui.horizontal(|ui| { + ui.label(tl!("Search in:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text(tl!("Folder"))); + push(&mut elems, "linkMedia.folder", &r, "folder"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "linkMedia.browse", &r, "Browse…"); if r.clicked() { - action = Some(id); + action = Some("browse"); } + let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only")); + push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); + let r = ui.button(tl!("Search")); + push(&mut elems, "linkMedia.search", &r, "Search"); + if r.clicked() { + action = Some("search"); + } + }); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(520.0); + if d.candidates.is_empty() { + ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak()); + } + egui::ScrollArea::vertical().id_salt("link-media-candidates-scroll").max_height(108.0).auto_shrink([false, true]).show(ui, |ui| { + for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { + let mark = match (ok, idm) { + (true, Some(true)) => tl!("✔ same file"), + (true, _) => tl!("✔ matches"), + (false, Some(false)) => tl!("✖ different file"), + _ => "✖", + }; + let text = format!("{mark} {path}"); + let r = ui.selectable_label( + d.candidate == Some(k), + RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), + ); + let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; + push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); + if r.clicked() { + d.candidate = Some(k); + } + } + }); + }); + let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); + match &preview { + Some(t) => { + let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); + ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY); + } + } + elems.push(("linkMedia.preview".into(), rect, "preview".into())); + }); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); } - let r = ui.add_enabled(d.candidate.is_some(), egui::Button::new(RichText::new("Link").color(Color32::WHITE)).fill(accent)); - push(&mut elems, "linkMedia.link", &r, "Link"); - if r.clicked() { - action = Some("link"); - } + ui.add_space(6.0); + ui.horizontal(|ui| { + for (id, label) in [("offlineAll", tl!("Offline All")), ("offline", tl!("Offline")), ("cancel", tl!("Cancel")), ("locate", tl!("Locate…"))] { + let r = ui.button(label); + push(&mut elems, format!("linkMedia.{id}"), &r, label); + if r.clicked() { + action = Some(id); + } + } + let r = ui.add_enabled(d.candidate.is_some(), egui::Button::new(RichText::new(tl!("Link")).color(Color32::WHITE)).fill(accent)); + push(&mut elems, "linkMedia.link", &r, "Link"); + if r.clicked() { + action = Some("link"); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -295,7 +306,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { d.message.clear(); d.candidates.clear(); d.candidate = None; - app.ui.status = format!("Linked {} clip(s)", v["relinked"].as_array().map_or(0, Vec::len)); + app.ui.status = tlf!("Linked {n} clip(s)", n = v["relinked"].as_array().map_or(0, Vec::len)); } Err(e) => d.message = e.to_string(), } @@ -326,7 +337,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { }) .unwrap_or_default(); d.candidate = d.candidates.iter().position(|c| c.1); - d.message = if d.candidates.is_empty() { "Nothing found with that name.".into() } else { String::new() }; + d.message = if d.candidates.is_empty() { tl!("Nothing found with that name.").into() } else { String::new() }; } Err(e) => d.message = e.to_string(), } @@ -373,27 +384,32 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let mut ok = false; let n = app.session.state.project_selection.len(); - egui::Window::new("Make Offline").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again.")); - let r = ui.radio(!delete, "Media files remain on disk"); - push(&mut elems, "makeOffline.keep", &r, "Media files remain on disk"); - if r.clicked() { - delete = false; - } - let r = ui.radio(delete, "Media files are deleted"); - push(&mut elems, "makeOffline.delete", &r, "Media files are deleted"); - if r.clicked() { - delete = true; - } - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "makeOffline.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "makeOffline.ok", &r, "OK"); - ok = r.clicked(); + egui::Window::new(tl!("Make Offline")) + .id(egui::Id::new("Make Offline")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again.", n)); + let r = ui.radio(!delete, tl!("Media files remain on disk")); + push(&mut elems, "makeOffline.keep", &r, "Media files remain on disk"); + if r.clicked() { + delete = false; + } + let r = ui.radio(delete, tl!("Media files are deleted")); + push(&mut elems, "makeOffline.delete", &r, "Media files are deleted"); + if r.clicked() { + delete = true; + } + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "makeOffline.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "makeOffline.ok", &r, "OK"); + ok = r.clicked(); + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -412,36 +428,42 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let mut ok = false; let mut browse = false; - egui::Window::new("Create Proxies").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("{} clip(s) selected.", d.items.len())); - ui.add_space(4.0); - ui.label(RichText::new("Format and size").strong()); - for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| p.proxy) { - let r = ui.radio(d.preset == p.id, p.label); - push(&mut elems, format!("proxies.preset.{}", p.id), &r, p.label); - if r.clicked() { - d.preset = p.id.into(); + egui::Window::new(tl!("Create Proxies")) + .id(egui::Id::new("Create Proxies")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("{n} clip(s) selected.", n = d.items.len())); + ui.add_space(4.0); + ui.label(RichText::new(tl!("Format and size")).strong()); + for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| p.proxy) { + let r = ui.radio(d.preset == p.id, p.label); + push(&mut elems, format!("proxies.preset.{}", p.id), &r, p.label); + if r.clicked() { + d.preset = p.id.into(); + } } - } - ui.add_space(4.0); - ui.label(RichText::new("Destination").strong()); - ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text("Next to the original media, in a Proxies folder")); - push(&mut elems, "proxies.destination", &r, "destination"); - let r = ui.button("Browse…"); - push(&mut elems, "proxies.browse", &r, "Browse…"); - browse = r.clicked(); + ui.add_space(4.0); + ui.label(RichText::new(tl!("Destination")).strong()); + ui.horizontal(|ui| { + let r = ui + .add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder"))); + push(&mut elems, "proxies.destination", &r, "destination"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "proxies.browse", &r, "Browse…"); + browse = r.clicked(); + }); + ui.label(RichText::new(tl!("Proxies are made in the background and attached when done. Export always uses full-resolution media.")).weak()); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "proxies.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(app.tokens.accent)); + push(&mut elems, "proxies.ok", &r, "OK"); + ok = r.clicked(); + }); }); - ui.label(RichText::new("Proxies are made in the background and attached when done. Export always uses full-resolution media.").weak()); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "proxies.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(app.tokens.accent)); - push(&mut elems, "proxies.ok", &r, "OK"); - ok = r.clicked(); - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -451,7 +473,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { if ok { let p = json!({"items": d.items, "preset": d.preset, "destination": if d.destination.is_empty() { Value::Null } else { json!(d.destination) }}); match app.session.execute("media.createProxies", p) { - Ok(v) => app.ui.status = format!("Creating {} proxy file(s)…", v["outputs"].as_array().map_or(0, Vec::len)), + Ok(v) => app.ui.status = tlf!("Creating {n} proxy file(s)…", n = v["outputs"].as_array().map_or(0, Vec::len)), Err(e) => app.ui.status = e.to_string(), } keep = false; @@ -480,92 +502,97 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let (mut ok, mut calc, mut browse) = (false, false, false); let seqs: Vec<(u64, String)> = app.session.project.sequences().map(|i| (i.id.0, i.name.clone())).collect(); - egui::Window::new("Project Manager").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(RichText::new("Sequences").strong()); - for (id, name) in &seqs { - let mut on = d.sequences.contains(id); - let r = ui.checkbox(&mut on, name); - push(&mut elems, format!("pm.seq.{id}"), &r, name.clone()); - if r.changed() { - if on { - d.sequences.push(*id); - } else { - d.sequences.retain(|s| s != id); - } - } - } - ui.separator(); - ui.label(RichText::new("Resulting Project").strong()); - for (id, label) in [("collect", "Collect Files and Copy to New Location"), ("consolidate", "Consolidate and Transcode")] { - let r = ui.radio(d.mode == id, label); - push(&mut elems, format!("pm.mode.{id}"), &r, label); - if r.clicked() { - d.mode = id.into(); - } - } - if d.mode == "consolidate" { - ui.indent("pm-presets", |ui| { - for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| !p.proxy) { - let r = ui.radio(d.preset == p.id, p.label); - push(&mut elems, format!("pm.preset.{}", p.id), &r, p.label); - if r.clicked() { - d.preset = p.id.into(); + egui::Window::new(tl!("Project Manager")) + .id(egui::Id::new("Project Manager")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new(tl!("Sequences")).strong()); + for (id, name) in &seqs { + let mut on = d.sequences.contains(id); + let r = ui.checkbox(&mut on, name); + push(&mut elems, format!("pm.seq.{id}"), &r, name.clone()); + if r.changed() { + if on { + d.sequences.push(*id); + } else { + d.sequences.retain(|s| s != id); } } + } + ui.separator(); + ui.label(RichText::new(tl!("Resulting Project")).strong()); + for (id, label) in [("collect", tl!("Collect Files and Copy to New Location")), ("consolidate", tl!("Consolidate and Transcode"))] { + let r = ui.radio(d.mode == id, label); + push(&mut elems, format!("pm.mode.{id}"), &r, label); + if r.clicked() { + d.mode = id.into(); + } + } + if d.mode == "consolidate" { + ui.indent("pm-presets", |ui| { + for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| !p.proxy) { + let r = ui.radio(d.preset == p.id, p.label); + push(&mut elems, format!("pm.preset.{}", p.id), &r, p.label); + if r.clicked() { + d.preset = p.id.into(); + } + } + }); + } + ui.separator(); + ui.label(RichText::new(tl!("Options")).strong()); + let r = ui.checkbox(&mut d.exclude_unused, tl!("Exclude Unused Clips")); + push(&mut elems, "pm.excludeUnused", &r, "Exclude Unused Clips"); + ui.horizontal(|ui| { + ui.add_enabled_ui(d.mode == "consolidate", |ui| { + ui.label(tl!("Include Handles:")); + let r = ui.add(egui::DragValue::new(&mut d.handles).range(0..=600).suffix(tl!(" frames"))); + push(&mut elems, "pm.handles", &r, format!("{} frames", d.handles)); + }); }); - } - ui.separator(); - ui.label(RichText::new("Options").strong()); - let r = ui.checkbox(&mut d.exclude_unused, "Exclude Unused Clips"); - push(&mut elems, "pm.excludeUnused", &r, "Exclude Unused Clips"); - ui.horizontal(|ui| { - ui.add_enabled_ui(d.mode == "consolidate", |ui| { - ui.label("Include Handles:"); - let r = ui.add(egui::DragValue::new(&mut d.handles).range(0..=600).suffix(" frames")); - push(&mut elems, "pm.handles", &r, format!("{} frames", d.handles)); + ui.add_enabled_ui(d.mode == "collect", |ui| { + let r = ui.checkbox(&mut d.include_proxies, tl!("Include Proxies")); + push(&mut elems, "pm.includeProxies", &r, "Include Proxies"); + }); + let r = ui.checkbox(&mut d.include_previews, tl!("Include Preview Files")); + push(&mut elems, "pm.includePreviews", &r, "Include Preview Files"); + ui.separator(); + ui.label(RichText::new(tl!("Destination Path")).strong()); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); + push(&mut elems, "pm.destination", &r, "destination"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "pm.browse", &r, "Browse…"); + browse = r.clicked(); + }); + ui.horizontal(|ui| { + let text = match d.estimate { + Some((a, b, n)) => tlf!("Disk space: original {original} · resulting {result} ({n} files)", original = mb(a), result = mb(b), n), + None => tl!("Disk space: —").into(), + }; + let r = ui.label(&text); + push(&mut elems, "pm.sizes", &r, text); + let r = ui.button(tl!("Calculate")); + push(&mut elems, "pm.calculate", &r, "Calculate"); + calc = r.clicked(); + }); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); + } + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "pm.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.add_enabled( + !d.destination.is_empty() && !d.sequences.is_empty(), + egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(app.tokens.accent), + ); + push(&mut elems, "pm.ok", &r, "OK"); + ok = r.clicked(); }); }); - ui.add_enabled_ui(d.mode == "collect", |ui| { - let r = ui.checkbox(&mut d.include_proxies, "Include Proxies"); - push(&mut elems, "pm.includeProxies", &r, "Include Proxies"); - }); - let r = ui.checkbox(&mut d.include_previews, "Include Preview Files"); - push(&mut elems, "pm.includePreviews", &r, "Include Preview Files"); - ui.separator(); - ui.label(RichText::new("Destination Path").strong()); - ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); - push(&mut elems, "pm.destination", &r, "destination"); - let r = ui.button("Browse…"); - push(&mut elems, "pm.browse", &r, "Browse…"); - browse = r.clicked(); - }); - ui.horizontal(|ui| { - let text = match d.estimate { - Some((a, b, n)) => format!("Disk space: original {} · resulting {} ({n} files)", mb(a), mb(b)), - None => "Disk space: —".into(), - }; - let r = ui.label(&text); - push(&mut elems, "pm.sizes", &r, text); - let r = ui.button("Calculate"); - push(&mut elems, "pm.calculate", &r, "Calculate"); - calc = r.clicked(); - }); - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "pm.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.add_enabled( - !d.destination.is_empty() && !d.sequences.is_empty(), - egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(app.tokens.accent), - ); - push(&mut elems, "pm.ok", &r, "OK"); - ok = r.clicked(); - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -595,7 +622,7 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { if ok { match app.session.execute("file.projectManager", pm_params(&d, false)) { Ok(v) => { - app.ui.status = format!("Project Manager: writing {}", v["project"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Project Manager: writing {path}", path = v["project"].as_str().unwrap_or_default()); keep = false; } Err(e) => d.message = e.to_string(), diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 9c4222002..e3431398a 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -52,23 +52,23 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp /// Dialog title, automation prefix and OK button text of a command's dialog. fn meta(command: &str) -> Option<(&'static str, &'static str, &'static str)> { Some(match command { - "edit.find" => ("Find", "find", "Find"), - "file.newSearchBin" => ("Create Search Bin", "searchBin", "OK"), - "project.settings" => ("Project Settings", "projectSettings", "OK"), - "file.mediaProperties" => ("Properties", "properties", "Close"), - "clip.editOffline" => ("Edit Offline File", "editOffline", "OK"), - "clip.sourceSettings" => ("Source Settings", "sourceSettings", "OK"), - "clip.updateMetadata" => ("Update Metadata", "updateMetadata", "OK"), - "clip.automateToSequence" => ("Automate To Sequence", "automate", "OK"), - "clip.sceneEditDetection" => ("Scene Edit Detection", "sceneDetect", "Analyze"), - "sequence.normalizeMixTrack" => ("Normalize Mix Track", "normalizeMix", "OK"), - "sequence.simplify" => ("Simplify Sequence", "simplify", "Simplify"), - "sequence.transcribe" => ("Transcribe Sequence", "transcribe", "Transcribe"), - "file.saveAsTemplate" => ("Save as Template", "saveTemplate", "Save"), - "markers.addFlashCue" => ("Flash Cue Marker", "flashCue", "OK"), - "help.systemCompatibilityReport" => ("System Compatibility Report", "systemReport", "Close"), - "file.newColorMatte" => ("New Color Matte", "colorMatte", "OK"), - "project.matteColor" => ("Color Matte Color", "matteColor", "OK"), + "edit.find" => (tl!("Find"), "find", tl!("Find")), + "file.newSearchBin" => (tl!("Create Search Bin"), "searchBin", tl!("OK")), + "project.settings" => (tl!("Project Settings"), "projectSettings", tl!("OK")), + "file.mediaProperties" => (tl!("Properties"), "properties", tl!("Close")), + "clip.editOffline" => (tl!("Edit Offline File"), "editOffline", tl!("OK")), + "clip.sourceSettings" => (tl!("Source Settings"), "sourceSettings", tl!("OK")), + "clip.updateMetadata" => (tl!("Update Metadata"), "updateMetadata", tl!("OK")), + "clip.automateToSequence" => (tl!("Automate To Sequence"), "automate", tl!("OK")), + "clip.sceneEditDetection" => (tl!("Scene Edit Detection"), "sceneDetect", tl!("Analyze")), + "sequence.normalizeMixTrack" => (tl!("Normalize Mix Track"), "normalizeMix", tl!("OK")), + "sequence.simplify" => (tl!("Simplify Sequence"), "simplify", tl!("Simplify")), + "sequence.transcribe" => (tl!("Transcribe Sequence"), "transcribe", tl!("Transcribe")), + "file.saveAsTemplate" => (tl!("Save as Template"), "saveTemplate", tl!("Save")), + "markers.addFlashCue" => (tl!("Flash Cue Marker"), "flashCue", tl!("OK")), + "help.systemCompatibilityReport" => (tl!("System Compatibility Report"), "systemReport", tl!("Close")), + "file.newColorMatte" => (tl!("New Color Matte"), "colorMatte", tl!("OK")), + "project.matteColor" => (tl!("Color Matte Color"), "matteColor", tl!("OK")), _ => return None, }) } @@ -156,7 +156,8 @@ pub fn system_report(app: &FilmcraftApp) -> Value { }); let ok = gpu.is_some(); r["gpu"] = gpu.unwrap_or(Value::Null); - let detail = if ok { r["gpu"]["name"].as_str().unwrap_or("wgpu adapter").to_string() } else { "no GPU adapter: the CPU compositor is used".to_string() }; + let detail = + if ok { r["gpu"]["name"].as_str().unwrap_or("wgpu adapter").to_string() } else { tl!("no GPU adapter: the CPU compositor is used").to_string() }; if let Some(c) = r["checks"].as_array_mut() { c.push(json!({"name": "GPU acceleration", "ok": ok, "detail": detail})); } @@ -208,9 +209,9 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val return Some(Err(e)); } let (filter, ext, name) = if id == "file.exportAle" { - ("Avid Log Exchange", "ale", format!("{}.ale", app.session.project.name)) + (tl!("Avid Log Exchange"), "ale", format!("{}.ale", app.session.project.name)) } else { - ("FilmCraft Project", "fcproj", format!("{} Selection.fcproj", app.session.project.name)) + (tl!("FilmCraft Project"), "fcproj", tlf!("{name} Selection.fcproj", name = app.session.project.name)) }; let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &name)) else { return Some(Ok(Value::Null)); @@ -225,7 +226,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val "file.mediaPropertiesFile" if params.get("path").is_none() => { let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS.iter().chain(filmcraft_media::AUDIO_EXTENSIONS).chain(filmcraft_media::STILL_EXTENSIONS).copied().collect(); - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Media", &exts)) else { return Some(Ok(Value::Null)) }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Media"), &exts)) else { return Some(Ok(Value::Null)) }; let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); if let Ok(v) = &r { app.ui.extras.dialog = @@ -324,7 +325,7 @@ fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value "clip.sceneEditDetection" => (json!({"applyCuts": true, "createSubclips": false, "generateMarkers": false, "sensitivity": 50.0}), Value::Null), "sequence.normalizeMixTrack" => (json!({"db": 0.0}), Value::Null), "sequence.simplify" => { - let name = s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| format!("{} (Simplified)", i.name)).unwrap_or_default(); + let name = s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| tlf!("{name} (Simplified)", name = i.name)).unwrap_or_default(); ( json!({"name": name, "removeDisabled": true, "removeEmptyTracks": true, "closeGaps": false, "moveClipsDown": false, "removeVideoEffects": false, "removeAudioEffects": false, "removeText": false, "keep": "both"}), Value::Null, @@ -411,9 +412,9 @@ fn combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, label: &str, value: &mu } let cur = value.as_str().unwrap_or_default().to_string(); let shown = options.iter().find(|o| o.0 == cur).map(|o| o.1.clone()).unwrap_or(cur.clone()); - let r = egui::ComboBox::from_id_salt(id).selected_text(shown).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(&shown)).show_ui(ui, |ui| { for (k, l) in options { - if ui.selectable_label(cur == *k, l).clicked() { + if ui.selectable_label(cur == *k, crate::i18n::t(l)).clicked() { *value = json!(k); } } @@ -452,21 +453,31 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut action: Option<&'static str> = None; let mut browse: Option = None; let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); - egui::Window::new(title).collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + // the title is translated; the window keeps one id whatever the interface language + let id = egui::Id::new(("menu-dialog", pre)); + egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { "edit.find" => { - radios(ui, &mut elems, pre, p, "scope", "Find in:", &[("project", "Project"), ("timeline", "Timeline")]); + radios(ui, &mut elems, pre, p, "scope", tl!("Find in:"), &[("project", tl!("Project")), ("timeline", tl!("Timeline"))]); ui.separator(); if p["scope"] == "timeline" { let mut t = p["rows"][0]["text"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { - ui.label("Find:"); + ui.label(tl!("Find:")); let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(240.0)); push(&mut elems, "find.row.0.text", &r, "Find"); }); p["rows"][0]["text"] = json!(t); - radios(ui, &mut elems, pre, p, "in", "Search:", &[("all", "Clip Names and Markers"), ("clips", "Clip Names"), ("markers", "Markers")]); + radios( + ui, + &mut elems, + pre, + p, + "in", + tl!("Search:"), + &[("all", tl!("Clip Names and Markers")), ("clips", tl!("Clip Names")), ("markers", tl!("Markers"))], + ); } else { let cols = pairs(&COLUMNS); let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); @@ -483,15 +494,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { p["rows"][n] = row; } ui.horizontal(|ui| { - ui.label("Match:"); - for (v, l) in [(true, "All"), (false, "Any")] { + ui.label(tl!("Match:")); + for (v, key, l) in [(true, "all", tl!("All")), (false, "any", tl!("Any"))] { let r = ui.radio(p["matchAll"].as_bool() == Some(v), l); - push(&mut elems, format!("find.match.{}", l.to_ascii_lowercase()), &r, l); + push(&mut elems, format!("find.match.{key}"), &r, l); if r.clicked() { p["matchAll"] = json!(v); } } - check(ui, &mut elems, pre, p, "caseSensitive", "Case Sensitive"); + check(ui, &mut elems, pre, p, "caseSensitive", tl!("Case Sensitive")); }); } if let Some(m) = d.info.get("result").and_then(Value::as_str) { @@ -499,16 +510,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } "file.newSearchBin" => { - text(ui, &mut elems, pre, p, "name", "Name:", 220.0); - combo(ui, &mut elems, "searchBin.column", "Search:", &mut p["column"], &pairs(&COLUMNS)); + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 220.0); + combo(ui, &mut elems, "searchBin.column", tl!("Search:"), &mut p["column"], &pairs(&COLUMNS)); let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); combo(ui, &mut elems, "searchBin.operator", "", &mut p["operator"], &ops); - text(ui, &mut elems, pre, p, "text", "Find:", 220.0); + text(ui, &mut elems, pre, p, "text", tl!("Find:"), 220.0); } "project.settings" => { - ui.label(format!("Project: {}", d.info["project"].as_str().unwrap_or_default())); + ui.label(tlf!("Project: {name}", name = d.info["project"].as_str().unwrap_or_default())); ui.horizontal(|ui| { - for (k, l) in [("general", "General"), ("scratchDisks", "Scratch Disks"), ("ingest", "Ingest Settings")] { + for (k, l) in [("general", tl!("General")), ("scratchDisks", tl!("Scratch Disks")), ("ingest", tl!("Ingest Settings"))] { let r = ui.selectable_label(p["tab"].as_str() == Some(k), l); push(&mut elems, format!("projectSettings.tab.{k}"), &r, l); if r.clicked() { @@ -520,51 +531,51 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { match p["tab"].as_str().unwrap_or("general") { "scratchDisks" => { for (k, l) in [ - ("captured", "Captured and Generated:"), - ("videoPreviews", "Video Previews:"), - ("audioPreviews", "Audio Previews:"), - ("autoSave", "Project Auto Save:"), + ("captured", tl!("Captured and Generated:")), + ("videoPreviews", tl!("Video Previews:")), + ("audioPreviews", tl!("Audio Previews:")), + ("autoSave", tl!("Project Auto Save:")), ] { ui.label(RichText::new(l).strong()); ui.horizontal(|ui| { let mut v = p[k].as_str().unwrap_or_default().to_string(); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Same as Project").desired_width(240.0)); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(tl!("Same as Project")).desired_width(240.0)); push(&mut elems, format!("projectSettings.scratch.{k}"), &r, l); if r.changed() { p[k] = if v.is_empty() { Value::Null } else { json!(v) }; } - let r = ui.button("Browse…"); + let r = ui.button(tl!("Browse…")); push(&mut elems, format!("projectSettings.scratch.{k}.browse"), &r, "Browse"); if r.clicked() { browse = Some(k.to_string()); } - let r = ui.button("Same as Project"); + let r = ui.button(tl!("Same as Project")); push(&mut elems, format!("projectSettings.scratch.{k}.same"), &r, "Same as Project"); if r.clicked() { p[k] = Value::Null; } }); let path = d.info["scratch"][k]["path"].as_str().unwrap_or_default(); - ui.label(RichText::new(format!("Path: {path}")).weak().small()); + ui.label(RichText::new(tlf!("Path: {path}", path)).weak().small()); } } "ingest" => { let mut ing = p["ingest"].clone(); let mut on = ing["enabled"].as_bool().unwrap_or(false); - let r = ui.checkbox(&mut on, "Ingest"); + let r = ui.checkbox(&mut on, tl!("Ingest")); push(&mut elems, "projectSettings.ingest.enabled", &r, "Ingest"); ing["enabled"] = json!(on); let actions = vec![ - ("copy".to_string(), "Copy".to_string()), - ("transcode".to_string(), "Transcode".to_string()), - ("createProxies".to_string(), "Create Proxies".to_string()), - ("copyAndCreateProxies".to_string(), "Copy and Create Proxies".to_string()), + ("copy".to_string(), tl!("Copy").to_string()), + ("transcode".to_string(), tl!("Transcode").to_string()), + ("createProxies".to_string(), tl!("Create Proxies").to_string()), + ("copyAndCreateProxies".to_string(), tl!("Copy and Create Proxies").to_string()), ]; - combo(ui, &mut elems, "projectSettings.ingest.action", "Action:", &mut ing["action"], &actions); + combo(ui, &mut elems, "projectSettings.ingest.action", tl!("Action:"), &mut ing["action"], &actions); ui.horizontal(|ui| { - ui.label("Destination:"); + ui.label(tl!("Destination:")); let mut v = ing["destination"].as_str().unwrap_or_default().to_string(); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Next to the media").desired_width(220.0)); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(tl!("Next to the media")).desired_width(220.0)); push(&mut elems, "projectSettings.ingest.destination", &r, "Destination"); if r.changed() { ing["destination"] = if v.is_empty() { Value::Null } else { json!(v) }; @@ -573,29 +584,29 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { p["ingest"] = ing; } _ => { - ui.label(RichText::new("Video Rendering and Playback").strong()); + ui.label(RichText::new(tl!("Video Rendering and Playback")).strong()); let rs = vec![ (filmcraft_engine::project_tools::RENDERER_GPU.to_string(), filmcraft_engine::project_tools::RENDERER_GPU.to_string()), (filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string(), filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string()), ]; - combo(ui, &mut elems, "projectSettings.renderer", "Renderer:", &mut p["renderer"], &rs); - ui.label(RichText::new("Video").strong()); + combo(ui, &mut elems, "projectSettings.renderer", tl!("Renderer:"), &mut p["renderer"], &rs); + ui.label(RichText::new(tl!("Video")).strong()); let vd: Vec<(String, String)> = filmcraft_time::TimeDisplay::ALL.iter().map(|t| (t.label().to_string(), t.label().to_string())).collect(); - combo(ui, &mut elems, "projectSettings.videoDisplay", "Display Format:", &mut p["videoDisplay"], &vd); - ui.label(RichText::new("Audio").strong()); + combo(ui, &mut elems, "projectSettings.videoDisplay", tl!("Display Format:"), &mut p["videoDisplay"], &vd); + ui.label(RichText::new(tl!("Audio")).strong()); combo( ui, &mut elems, "projectSettings.audioDisplay", - "Display Format:", + tl!("Display Format:"), &mut p["audioDisplay"], - &pairs(&["Audio Samples", "Milliseconds"]), + &[("Audio Samples".to_string(), tl!("Audio Samples").to_string()), ("Milliseconds".to_string(), tl!("Milliseconds").to_string())], ); - ui.label(RichText::new("Capture").strong()); - combo(ui, &mut elems, "projectSettings.captureFormat", "Capture Format:", &mut p["captureFormat"], &pairs(&["DV", "HDV"])); - ui.label(RichText::new("Action and Title Safe Areas").strong()); - for (k, l) in [("titleSafe", "Title Safe Area"), ("actionSafe", "Action Safe Area")] { + ui.label(RichText::new(tl!("Capture")).strong()); + combo(ui, &mut elems, "projectSettings.captureFormat", tl!("Capture Format:"), &mut p["captureFormat"], &pairs(&["DV", "HDV"])); + ui.label(RichText::new(tl!("Action and Title Safe Areas")).strong()); + for (k, l) in [("titleSafe", tl!("Title Safe Area")), ("actionSafe", tl!("Action Safe Area"))] { ui.horizontal(|ui| { ui.label(l); for (i, axis) in ["horizontal", "vertical"].iter().enumerate() { @@ -617,20 +628,20 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.label(RichText::new(v["name"].as_str().unwrap_or_default()).strong()); let line = |ui: &mut egui::Ui, k: &str, val: String| { if !val.is_empty() && val != "null" { - ui.label(format!("{k}: {val}")); + ui.label(format!("{}: {val}", crate::i18n::t(k))); } }; let s = |x: &Value| x.as_str().map(str::to_string).unwrap_or_else(|| if x.is_null() { String::new() } else { x.to_string() }); - line(ui, "File Path", s(&v["path"])); - line(ui, "Type", format!("{} ({})", s(&v["type"]), s(&v["container"]))); - line(ui, "File Size", v["fileSize"].as_u64().map(|b| format!("{:.2} MB", b as f64 / 1e6)).unwrap_or_default()); - line(ui, "Total Duration", s(&v["duration"]["timecode"])); - line(ui, "Average Data Rate", v["dataRateKbps"].as_f64().map(|k| format!("{k} kbit/s")).unwrap_or_default()); + line(ui, tl!("File Path"), s(&v["path"])); + line(ui, tl!("Type"), format!("{} ({})", s(&v["type"]), s(&v["container"]))); + line(ui, tl!("File Size"), v["fileSize"].as_u64().map(|b| format!("{:.2} MB", b as f64 / 1e6)).unwrap_or_default()); + line(ui, tl!("Total Duration"), s(&v["duration"]["timecode"])); + line(ui, tl!("Average Data Rate"), v["dataRateKbps"].as_f64().map(|k| format!("{k} kbit/s")).unwrap_or_default()); if v["video"].is_object() { let x = &v["video"]; line( ui, - "Video", + tl!("Video"), format!( "{} · {} x {} ({}) · {} fps · {}", s(&x["codec"]), @@ -641,76 +652,96 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { s(&x["pixelFormat"]) ), ); - line(ui, "Colour", s(&x["color"])); + line(ui, tl!("Colour"), s(&x["color"])); } if v["audio"].is_object() { let x = &v["audio"]; - line(ui, "Audio", format!("{} · {} Hz · {} channel(s)", s(&x["codec"]), x["sampleRate"], x["channels"])); + line( + ui, + tl!("Audio"), + tlf!("{codec} · {rate} Hz · {n} channel(s)", codec = s(&x["codec"]), rate = x["sampleRate"], n = x["channels"]), + ); } ui.separator(); } }); } "clip.editOffline" => { - text(ui, &mut elems, pre, p, "mediaName", "Media Name:", 220.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0); - text(ui, &mut elems, pre, p, "description", "Description:", 220.0); - text(ui, &mut elems, pre, p, "scene", "Scene:", 220.0); - text(ui, &mut elems, pre, p, "shot", "Shot/Take:", 220.0); - text(ui, &mut elems, pre, p, "logNote", "Log Note:", 220.0); + text(ui, &mut elems, pre, p, "mediaName", tl!("Media Name:"), 220.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 220.0); + text(ui, &mut elems, pre, p, "description", tl!("Description:"), 220.0); + text(ui, &mut elems, pre, p, "scene", tl!("Scene:"), 220.0); + text(ui, &mut elems, pre, p, "shot", tl!("Shot/Take:"), 220.0); + text(ui, &mut elems, pre, p, "logNote", tl!("Log Note:"), 220.0); } "clip.sourceSettings" => { - ui.label(format!("Codec: {}", d.info["codec"].as_str().unwrap_or_default())); + ui.label(tlf!("Codec: {codec}", codec = d.info["codec"].as_str().unwrap_or_default())); ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); } "clip.updateMetadata" => { - ui.label(format!("Write the metadata of the selected clip(s) ({}) to XMP files next to their media?", d.info["count"])); - ui.label(RichText::new("Existing XMP files written by other applications are left unchanged.").weak()); + ui.label(tlf!("Write the metadata of the selected clip(s) ({n}) to XMP files next to their media?", n = d.info["count"])); + ui.label(RichText::new(tl!("Existing XMP files written by other applications are left unchanged.")).weak()); } "clip.automateToSequence" => { - ui.label(format!("To {}", d.info["sequence"].as_str().unwrap_or_default())); - radios(ui, &mut elems, pre, p, "ordering", "Ordering:", &[("sort", "Sort Order"), ("selection", "Selection Order")]); - radios(ui, &mut elems, pre, p, "placement", "Placement:", &[("sequentially", "Sequentially"), ("unnumberedMarkers", "At Unnumbered Markers")]); - radios(ui, &mut elems, pre, p, "method", "Method:", &[("insert", "Insert Edit"), ("overwrite", "Overwrite Edit")]); - number(ui, &mut elems, pre, p, "overlapFrames", "Clip Overlap:", 0.0..=600.0, " frames"); - number(ui, &mut elems, pre, p, "stillFrames", "Frames per Still:", 1.0..=100_000.0, " frames"); - ui.label(RichText::new("Transitions").strong()); - check(ui, &mut elems, pre, p, "audioTransition", "Apply Default Audio Transition"); - check(ui, &mut elems, pre, p, "videoTransition", "Apply Default Video Transition"); - ui.label(RichText::new("Ignore Options").strong()); - check(ui, &mut elems, pre, p, "ignoreAudio", "Ignore Audio"); - check(ui, &mut elems, pre, p, "ignoreVideo", "Ignore Video"); + ui.label(tlf!("To {name}", name = d.info["sequence"].as_str().unwrap_or_default())); + radios(ui, &mut elems, pre, p, "ordering", tl!("Ordering:"), &[("sort", tl!("Sort Order")), ("selection", tl!("Selection Order"))]); + radios( + ui, + &mut elems, + pre, + p, + "placement", + tl!("Placement:"), + &[("sequentially", tl!("Sequentially")), ("unnumberedMarkers", tl!("At Unnumbered Markers"))], + ); + radios(ui, &mut elems, pre, p, "method", tl!("Method:"), &[("insert", tl!("Insert Edit")), ("overwrite", tl!("Overwrite Edit"))]); + number(ui, &mut elems, pre, p, "overlapFrames", tl!("Clip Overlap:"), 0.0..=600.0, " frames"); + number(ui, &mut elems, pre, p, "stillFrames", tl!("Frames per Still:"), 1.0..=100_000.0, " frames"); + ui.label(RichText::new(tl!("Transitions")).strong()); + check(ui, &mut elems, pre, p, "audioTransition", tl!("Apply Default Audio Transition")); + check(ui, &mut elems, pre, p, "videoTransition", tl!("Apply Default Video Transition")); + ui.label(RichText::new(tl!("Ignore Options")).strong()); + check(ui, &mut elems, pre, p, "ignoreAudio", tl!("Ignore Audio")); + check(ui, &mut elems, pre, p, "ignoreVideo", tl!("Ignore Video")); } "clip.sceneEditDetection" => { - check(ui, &mut elems, pre, p, "applyCuts", "Apply a cut at each detected cut point"); - check(ui, &mut elems, pre, p, "createSubclips", "Create a subclip for each detected cut point"); - check(ui, &mut elems, pre, p, "generateMarkers", "Generate clip markers at each detected cut point"); + check(ui, &mut elems, pre, p, "applyCuts", tl!("Apply a cut at each detected cut point")); + check(ui, &mut elems, pre, p, "createSubclips", tl!("Create a subclip for each detected cut point")); + check(ui, &mut elems, pre, p, "generateMarkers", tl!("Generate clip markers at each detected cut point")); ui.horizontal(|ui| { - ui.label("Sensitivity:"); + ui.label(tl!("Sensitivity:")); let mut v = p["sensitivity"].as_f64().unwrap_or(50.0); let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0)); push(&mut elems, "sceneDetect.sensitivity", &r, "Sensitivity"); p["sensitivity"] = json!(v); }); } - "sequence.normalizeMixTrack" => number(ui, &mut elems, pre, p, "db", "Normalize Mix Track to:", -96.0..=24.0, " dB"), + "sequence.normalizeMixTrack" => number(ui, &mut elems, pre, p, "db", tl!("Normalize Mix Track to:"), -96.0..=24.0, " dB"), "sequence.simplify" => { - text(ui, &mut elems, pre, p, "name", "Sequence Name:", 240.0); - ui.label(RichText::new("Remove").strong()); - check(ui, &mut elems, pre, p, "removeDisabled", "Disabled clips"); - check(ui, &mut elems, pre, p, "removeEmptyTracks", "Empty tracks"); - check(ui, &mut elems, pre, p, "closeGaps", "Gaps"); - check(ui, &mut elems, pre, p, "removeVideoEffects", "Video effects"); - check(ui, &mut elems, pre, p, "removeAudioEffects", "Audio effects"); - check(ui, &mut elems, pre, p, "removeText", "Text (graphics and captions)"); - ui.label(RichText::new("Clips").strong()); - check(ui, &mut elems, pre, p, "moveClipsDown", "Move clips to the lowest track possible"); - radios(ui, &mut elems, pre, p, "keep", "Keep:", &[("both", "Video and Audio"), ("video", "Video Only"), ("audio", "Audio Only")]); + text(ui, &mut elems, pre, p, "name", tl!("Sequence Name:"), 240.0); + ui.label(RichText::new(tl!("Remove")).strong()); + check(ui, &mut elems, pre, p, "removeDisabled", tl!("Disabled clips")); + check(ui, &mut elems, pre, p, "removeEmptyTracks", tl!("Empty tracks")); + check(ui, &mut elems, pre, p, "closeGaps", tl!("Gaps")); + check(ui, &mut elems, pre, p, "removeVideoEffects", tl!("Video effects")); + check(ui, &mut elems, pre, p, "removeAudioEffects", tl!("Audio effects")); + check(ui, &mut elems, pre, p, "removeText", tl!("Text (graphics and captions)")); + ui.label(RichText::new(tl!("Clips")).strong()); + check(ui, &mut elems, pre, p, "moveClipsDown", tl!("Move clips to the lowest track possible")); + radios( + ui, + &mut elems, + pre, + p, + "keep", + tl!("Keep:"), + &[("both", tl!("Video and Audio")), ("video", tl!("Video Only")), ("audio", tl!("Audio Only"))], + ); } "sequence.transcribe" => { let langs = pairs(&["auto", "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "nl", "ru"]); - combo(ui, &mut elems, "transcribe.language", "Language:", &mut p["language"], &langs); - let mut tracks: Vec<(String, String)> = vec![("mix".into(), "Mix".into())]; + combo(ui, &mut elems, "transcribe.language", tl!("Language:"), &mut p["language"], &langs); + let mut tracks: Vec<(String, String)> = vec![("mix".into(), tl!("Mix").into())]; tracks.extend( d.info["tracks"] .as_array() @@ -718,23 +749,23 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .unwrap_or_default() .iter() .filter_map(|t| t.as_str()) - .map(|t| (t.to_string(), format!("Audio on track {t}"))), + .map(|t| (t.to_string(), tlf!("Audio on track {t}", t))), ); - combo(ui, &mut elems, "transcribe.track", "Audio analysis:", &mut p["track"], &tracks); - check(ui, &mut elems, pre, p, "diarize", "Recognize when different speakers are talking"); + combo(ui, &mut elems, "transcribe.track", tl!("Audio analysis:"), &mut p["track"], &tracks); + check(ui, &mut elems, pre, p, "diarize", tl!("Recognize when different speakers are talking")); } - "file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", "Template Name:", 240.0), + "file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", tl!("Template Name:"), 240.0), "file.newColorMatte" => { - color(ui, &mut elems, pre, p, "color", "Color:"); - text(ui, &mut elems, pre, p, "name", "Name:", 220.0); - number(ui, &mut elems, pre, p, "width", "Width:", 1.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "height", "Height:", 1.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "seconds", "Duration:", 0.04..=36000.0, " s"); + color(ui, &mut elems, pre, p, "color", tl!("Color:")); + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 220.0); + number(ui, &mut elems, pre, p, "width", tl!("Width:"), 1.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "height", tl!("Height:"), 1.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "seconds", tl!("Duration:"), 0.04..=36000.0, " s"); } - "project.matteColor" => color(ui, &mut elems, pre, p, "color", "Color:"), + "project.matteColor" => color(ui, &mut elems, pre, p, "color", tl!("Color:")), "markers.addFlashCue" => { - text(ui, &mut elems, pre, p, "name", "Name:", 220.0); - text(ui, &mut elems, pre, p, "comment", "Comments:", 220.0); + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 220.0); + text(ui, &mut elems, pre, p, "comment", tl!("Comments:"), 220.0); } "help.systemCompatibilityReport" => { let r = &d.info; @@ -757,15 +788,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .iter() .map(|x| { x.as_str().map(str::to_string).unwrap_or_else(|| { - format!("{}{}", x["format"].as_str().unwrap_or_default(), if x["available"] == false { " (unavailable)" } else { "" }) + format!("{}{}", x["format"].as_str().unwrap_or_default(), if x["available"] == false { tl!(" (unavailable)") } else { "" }) }) }) .collect(); ui.label(format!("{k}: {}", items.join(", "))); }; - list(ui, "Decoders", &r["decoders"]); - list(ui, "Containers", &r["containers"]); - list(ui, "Export formats", &r["exportFormats"]); + list(ui, tl!("Decoders"), &r["decoders"]); + list(ui, tl!("Containers"), &r["containers"]); + list(ui, tl!("Export formats"), &r["exportFormats"]); } _ => {} } @@ -775,7 +806,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.horizontal(|ui| { if !info_only { - let r = ui.button(if d.command == "edit.find" { "Done" } else { "Cancel" }); + let r = ui.button(if d.command == "edit.find" { tl!("Done") } else { tl!("Cancel") }); push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); if r.clicked() { action = Some("cancel"); @@ -828,7 +859,7 @@ fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, St q["rows"] = json!([p["rows"][0]]); } let r = s.execute("edit.find", q).map_err(|e| e.to_string())?; - Ok(Some(format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["matches"].as_u64().unwrap_or(0)))) + Ok(Some(tlf!("Match {n} of {total}", n = r["index"].as_u64().unwrap_or(0) + 1, total = r["matches"].as_u64().unwrap_or(0)))) } "project.settings" => { s.execute( @@ -849,12 +880,12 @@ fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, St } "file.saveAsTemplate" => { let r = s.execute("file.saveAsTemplate", p.clone()).map_err(|e| e.to_string())?; - app.ui.status = format!("Saved template {}", r["path"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Saved template {path}", path = r["path"].as_str().unwrap_or_default()); Ok(None) } "clip.sceneEditDetection" => { s.execute(&d.command, p.clone()).map_err(|e| e.to_string())?; - app.ui.status = "Scene Edit Detection: analysing in the background (see Progress)".into(); + app.ui.status = tl!("Scene Edit Detection: analysing in the background (see Progress)").into(); Ok(None) } cmd => { @@ -918,7 +949,7 @@ pub fn search_bin_rows( } } resp.context_menu(|ui| { - if ui.button("Delete Search Bin").clicked() { + if ui.button(tl!("Delete Search Bin")).clicked() { actions.push(("project.deleteSearchBin".into(), json!({"bin": sb.id.0}))); ui.close(); } diff --git a/crates/ui-egui/src/panels/metadata.rs b/crates/ui-egui/src/panels/metadata.rs index 875eca67d..c8c7f5584 100644 --- a/crates/ui-egui/src/panels/metadata.rs +++ b/crates/ui-egui/src/panels/metadata.rs @@ -33,7 +33,7 @@ pub fn field_id(name: &str) -> String { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(item) = target(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to view its metadata."); + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to view its metadata.")); app.auto.add("metadata.empty", rect, "Select a clip to view its metadata."); return; }; @@ -45,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut commit: Option<(String, String)> = None; // header: "Clip: name" with a rule (like the Info panel) let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); - let g = ui.painter().layout_no_wrap(format!("Clip: {name}"), Tokens::semibold(14.0), t.text); + let g = ui.painter().layout_no_wrap(tlf!("Clip: {name}", name), Tokens::semibold(14.0), t.text); let gw = g.size().x; ui.painter().galley(egui::pos2(hr.min.x, hr.center().y - g.size().y / 2.0), g, t.text); ui.painter().line_segment([egui::pos2(hr.min.x + gw + 10.0, hr.center().y), egui::pos2(hr.max.x, hr.center().y)], egui::Stroke::new(2.0, t.separator)); @@ -57,22 +57,24 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if f.section != section { section = f.section; ui.add_space(4.0); - let r = ui.label(RichText::new(section).color(t.text_dim).strong()); + let r = ui.label(RichText::new(crate::i18n::t(section)).color(t.text_dim).strong()); elems.push((format!("metadata.section.{section}"), r.rect, section.to_string())); } ui.horizontal(|ui| { - ui.add_sized(vec2(label_w, 20.0), egui::Label::new(RichText::new(format!("{}:", f.name)).color(t.text_dim)).truncate()); + ui.add_sized(vec2(label_w, 20.0), egui::Label::new(RichText::new(format!("{}:", crate::i18n::t(&f.name))).color(t.text_dim)).truncate()); if f.name == "Label" { let cur = Label::from_name(&f.value).unwrap_or(Label::Violet); let mut sel = cur; - let r = - egui::ComboBox::from_id_salt(("md-label", item.0)).selected_text(cur.name()).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(("md-label", item.0)) + .selected_text(crate::i18n::t(cur.name())) + .width(ui.available_width().min(200.0)) + .show_ui(ui, |ui| { for l in Label::ALL { let c = l.rgb(); let rr = ui.horizontal(|ui| { let (sw, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); ui.painter().rect_filled(sw, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); - ui.selectable_value(&mut sel, l, l.name()) + ui.selectable_value(&mut sel, l, crate::i18n::t(l.name())) }); elems.push((format!("metadata.label.{}", l.name()), rr.inner.rect, l.name().to_string())); } diff --git a/crates/ui-egui/src/panels/meters.rs b/crates/ui-egui/src/panels/meters.rs index b542e4693..03555f2d4 100644 --- a/crates/ui-egui/src/panels/meters.rs +++ b/crates/ui-egui/src/panels/meters.rs @@ -61,7 +61,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { (l.momentary(), l.short_term(), l.integrated(), l.true_peak_dbtp()) }); let lr = Rect::from_min_max(pos2(rect.min.x + 4.0, rect.max.y - lufs_h), pos2(rect.max.x - 4.0, rect.max.y - 4.0)); - ui.painter().rect_filled(lr, 2.0, egui::Color32::from_rgb(0x14, 0x14, 0x14)); + ui.painter().rect_filled(lr, 2.0, t.meter_readout_bg); let rows = [("M", lufs_text(m)), ("S", lufs_text(s)), ("I", lufs_text(i)), ("TP", lufs_text(tp))]; for (k, (label, val)) in rows.iter().enumerate() { let y = lr.min.y + 8.0 + k as f32 * 14.0; @@ -84,7 +84,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp))); } - ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, egui::Color32::BLACK); + ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, t.meter_bg); let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0); for c in 0..nch { let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height())); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index d9f55b67f..63e494b84 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -23,16 +23,16 @@ pub fn history(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), if dim { t.text_faint } else { t.text }); resp.clicked() }; - if row(ui, "Open", undo.is_empty(), false) { + if row(ui, tl!("Open"), undo.is_empty(), false) { target = Some(-(undo.len() as i64)); } for (i, l) in undo.iter().enumerate() { - if row(ui, l, i + 1 == undo.len(), false) { + if row(ui, crate::i18n::t(l), i + 1 == undo.len(), false) { target = Some(i as i64 + 1 - undo.len() as i64); } } for (i, l) in redo.iter().enumerate() { - if row(ui, l, false, true) { + if row(ui, crate::i18n::t(l), false, true) { target = Some(i as i64 + 1); } } @@ -62,7 +62,7 @@ pub const MARKER_FILTER: [filmcraft_project::Label; 7] = { pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let hidden: Vec<[u8; 3]> = app.session.state.hidden_marker_colors.iter().map(|l| l.marker_rgb()).collect(); @@ -82,7 +82,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_stroke(r.shrink(2.5), 3.0, egui::Stroke::new(1.0, col), egui::StrokeKind::Inside); } elems.push((format!("markers.filter.{}", l.name()), r, format!("{} markers {}", l.name(), if on { "shown" } else { "hidden" }))); - if resp.on_hover_text(format!("Show or hide {} markers", l.name())).clicked() { + if resp.on_hover_text(tlf!("Show or hide {color} markers", color = crate::i18n::t(l.name()))).clicked() { toggle = Some((l, !on)); } } @@ -98,7 +98,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let c = m.color.marker_rgb(); ui.painter().rect_filled(Rect::from_min_size(r.min + vec2(4.0, 6.0), vec2(4.0, 26.0)), 2.0, Color32::from_rgb(c[0], c[1], c[2])); - let name = if m.name.is_empty() { "Marker" } else { &m.name }; + let name = if m.name.is_empty() { tl!("Marker") } else { &m.name }; ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 12.0), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); let mut tc = format_time(m.start, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000); if m.duration > filmcraft_time::Tick::ZERO { @@ -106,7 +106,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { tc = format!("{tc} ({d})"); } if m.kind == filmcraft_project::MarkerKind::Chapter { - tc = format!("{tc} Chapter"); + tc = tlf!("{tc} Chapter", tc); } ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 27.0), Align2::LEFT_CENTER, tc, Tokens::mono(11.0), t.hot_text); elems.push((format!("markers.row.{}", m.id.0), r, name.to_string())); @@ -115,9 +115,9 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if seq.markers.is_empty() { - ui.label(egui::RichText::new("No markers. Press M to add one.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("No markers. Press M to add one.")).color(t.text_faint)); } else if shown == 0 { - ui.label(egui::RichText::new("All markers are hidden by the colour filter.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("All markers are hidden by the colour filter.")).color(t.text_faint)); } }); for (id, r, l) in elems { @@ -145,17 +145,17 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; if let Some(item) = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).cloned() { ui.label(egui::RichText::new(&item.name).strong()); - line(ui, "Type:", item.type_label().to_string()); + line(ui, tl!("Type:"), crate::i18n::t(item.type_label()).to_string()); if let Some(m) = item.as_media() { if let Some(v) = &m.info.video { - line(ui, "Video:", format!("{} fps, {} x {} ({:.4})", v.frame_rate.label(), v.width, v.height, v.par.0 as f32 / v.par.1 as f32)); - line(ui, "Codec:", v.codec.clone()); + line(ui, tl!("Video:"), format!("{} fps, {} x {} ({:.4})", v.frame_rate.label(), v.width, v.height, v.par.0 as f32 / v.par.1 as f32)); + line(ui, tl!("Codec:"), v.codec.clone()); } - if let Some(a) = &m.info.audio { - line(ui, "Audio:", format!("{} Hz - {} ch - {}", a.sample_rate, a.channels, a.codec)); + if let Some(a) = m.info.audio() { + line(ui, tl!("Audio:"), format!("{} Hz - {} ch - {}", a.sample_rate, a.channels, a.codec)); } } - line(ui, "Duration:", format_time(item.duration(), item.frame_rate(), false, TimeDisplay::Timecode, 48000)); + line(ui, tl!("Duration:"), format_time(item.duration(), item.frame_rate(), false, TimeDisplay::Timecode, 48000)); ui.separator(); } if let Some(seq) = app.session.active_sequence() { @@ -163,19 +163,19 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(name).strong()); line( ui, - "Settings:", + tl!("Settings:"), format!("{}x{} · {} fps · {} Hz", seq.settings.width, seq.settings.height, seq.settings.frame_rate.label(), seq.settings.sample_rate), ); - line(ui, "Playhead:", format_time(app.session.playhead(), seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000)); + line(ui, tl!("Playhead:"), format_time(app.session.playhead(), seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000)); for (i, tr) in seq.video_tracks.iter().enumerate().rev() { let at = tr.item_at(app.session.playhead()).map(|x| x.name.clone()).unwrap_or_default(); - line(ui, &format!("Video {}:", i + 1), at); + line(ui, &tlf!("Video {n}:", n = i + 1), at); } for (i, tr) in seq.audio_tracks.iter().enumerate() { let at = tr.item_at(app.session.playhead()).map(|x| x.name.clone()).unwrap_or_default(); - line(ui, &format!("Audio {}:", i + 1), at); + line(ui, &tlf!("Audio {n}:", n = i + 1), at); } } ui.separator(); - line(ui, "UI:", format!("{:.0} fps · {} frames queued", app.fps, app.frames.queue_len())); + line(ui, tl!("UI:"), tlf!("{fps} fps · {n} frames queued", fps = format!("{:.0}", app.fps), n = app.frames.queue_len())); } diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 77cd4f0c6..db2c92d79 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -128,7 +128,7 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap 2 { 1.0 } else { 2.0 }; let w = (r.width() - gap * (n as f32 - 1.0)) / n as f32; @@ -138,7 +138,7 @@ fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Toke crate::widgets::meter_bar(p, br, v[0], v[1], t); // clip light let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0)); - p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { Color32::from_gray(0x30) }); + p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { t.meter_clip_off }); } } @@ -203,7 +203,7 @@ fn targets(seq: &Sequence, id: TrackId) -> Vec<&Track> { pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let meters = poll_meters(app, ui); @@ -212,7 +212,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y)); // effects / sends disclosure at the left edge let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0)); - let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text("Show/Hide Effects and Sends"); + let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends"); if aresp.clicked() { @@ -220,19 +220,19 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // panel menu: Show/Hide Tracks, Meter Input(s) Only let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0)); - let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text("Audio Track Mixer menu"); + let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu"); let show_hide_id = egui::Id::new("mixer-show-hide-open"); let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false); egui::Popup::menu(&mresp).show(|ui| { - let r = ui.button("Show/Hide Tracks…"); + let r = ui.button(tl!("Show/Hide Tracks…")); app.auto.add("mixer.menu.showHide", r.rect, "Show/Hide Tracks…"); if r.clicked() { show_hide = true; } let on = app.ui.mixer_meter_input_only; - let r = ui.selectable_label(on, "Meter Input(s) Only"); + let r = ui.selectable_label(on, tl!("Meter Input(s) Only")); app.auto.add("mixer.menu.meterInputOnly", r.rect, "Meter Input(s) Only"); if r.clicked() { app.ui.mixer_meter_input_only = !on; @@ -241,34 +241,37 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_hide { let mut open = true; let mut close = false; - egui::Window::new("Show/Hide Tracks").id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show(ui.ctx(), |ui| { - for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { - let label = strip_label(&seq, id); - let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default(); - let mut shown = !app.ui.mixer_hidden.contains(&id.0); - let r = ui.checkbox(&mut shown, format!("{label} {name}")); - app.auto.add(&format!("mixer.showHide.{label}"), r.rect, &format!("Show {label}")); - if r.changed() { - if shown { - app.ui.mixer_hidden.retain(|h| *h != id.0); - } else if !app.ui.mixer_hidden.contains(&id.0) { - app.ui.mixer_hidden.push(id.0); + egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( + ui.ctx(), + |ui| { + for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { + let label = strip_label(&seq, id); + let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default(); + let mut shown = !app.ui.mixer_hidden.contains(&id.0); + let r = ui.checkbox(&mut shown, format!("{label} {name}")); + app.auto.add(&format!("mixer.showHide.{label}"), r.rect, &format!("Show {label}")); + if r.changed() { + if shown { + app.ui.mixer_hidden.retain(|h| *h != id.0); + } else if !app.ui.mixer_hidden.contains(&id.0) { + app.ui.mixer_hidden.push(id.0); + } } } - } - ui.horizontal(|ui| { - let r = ui.button("Show All"); - app.auto.add("mixer.showHide.all", r.rect, "Show All"); - if r.clicked() { - app.ui.mixer_hidden.clear(); - } - let r = ui.button("OK"); - app.auto.add("mixer.showHide.ok", r.rect, "OK"); - if r.clicked() { - close = true; - } - }); - }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Show All")); + app.auto.add("mixer.showHide.all", r.rect, "Show All"); + if r.clicked() { + app.ui.mixer_hidden.clear(); + } + let r = ui.button(tl!("OK")); + app.auto.add("mixer.showHide.ok", r.rect, "OK"); + if r.clicked() { + close = true; + } + }); + }, + ); show_hide = open && !close; } ui.data_mut(|d| d.insert_temp(show_hide_id, show_hide)); @@ -302,7 +305,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ky = bar.min.y + (bar.height() - kh) * voff / max_voff; let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh)); let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag()); - ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff); } @@ -316,7 +319,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kx = bar.min.x + (bar.width() - kw) * off / max_off; let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag()); - ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); } @@ -366,7 +369,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let ap = format!("mixer.{label}"); let is_master = id == MASTER_STRIP; let sid = json!(id.0); - ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + ui.painter().rect_filled(sr, 3.0, t.control_surface); ui.painter().line_segment([pos2(sr.max.x + 1.0, sr.min.y), pos2(sr.max.x + 1.0, sr.max.y)], Stroke::new(1.0, t.separator)); let x = sr.min.x; let w = sr.width(); @@ -390,7 +393,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s match fx { Some(e) => { if crate::panels::audio_fx_editor::has_editor(&e.effect) { - let r0 = ui.button("Edit…"); + let r0 = ui.button(tl!("Edit…")); app.auto.add(&format!("{ap}.fx.{k}.edit"), r0.rect, "Edit…"); if r0.clicked() { open_editor = Some(crate::panels::audio_fx_editor::FxTarget::Insert { strip: id.0, slot: k }); @@ -398,18 +401,18 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s ui.separator(); } let on = e.enabled; - let r1 = ui.selectable_label(!on, "Bypass"); + let r1 = ui.selectable_label(!on, tl!("Bypass")); app.auto.add(&format!("{ap}.fx.{k}.bypass"), r1.rect, "Bypass"); if r1.clicked() { cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "enabled": !on})); } - let r2 = ui.selectable_label(e.post_fader, "Post-Fader"); + let r2 = ui.selectable_label(e.post_fader, tl!("Post-Fader")); app.auto.add(&format!("{ap}.fx.{k}.postFader"), r2.rect, "Post-Fader"); if r2.clicked() { cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "postFader": !e.post_fader})); } ui.separator(); - let r3 = ui.button("Remove Effect"); + let r3 = ui.button(tl!("Remove Effect")); app.auto.add(&format!("{ap}.fx.{k}.remove"), r3.rect, "Remove Effect"); if r3.clicked() { cx.cmd("mixer.removeInsert", json!({"strip": sid, "slot": k})); @@ -446,7 +449,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } None => { - ui.label("Fill the slots above first"); + ui.label(tl!("Fill the slots above first")); } } }); @@ -466,7 +469,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); let snd = tr.mixer.sends.get(k); let text = match snd { - Some(s) => format!("{}{} {}", if s.pre_fader { "pre " } else { "" }, strip_label(seq, s.target), db_text(s.level_db)), + Some(s) => format!("{}{} {}", if s.pre_fader { tl!("pre ") } else { "" }, strip_label(seq, s.target), db_text(s.level_db)), None => String::new(), }; if is_master { @@ -487,18 +490,18 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s if sl.changed() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "levelDb": lvl})); } - let r1 = ui.selectable_label(s.pre_fader, "Pre-Fader"); + let r1 = ui.selectable_label(s.pre_fader, tl!("Pre-Fader")); app.auto.add(&format!("{ap}.send.{k}.preFader"), r1.rect, "Pre-Fader"); if r1.clicked() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "preFader": !s.pre_fader})); } - let r2 = ui.selectable_label(s.muted, "Mute Send"); + let r2 = ui.selectable_label(s.muted, tl!("Mute Send")); app.auto.add(&format!("{ap}.send.{k}.mute"), r2.rect, "Mute Send"); if r2.clicked() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "muted": !s.muted})); } ui.separator(); - let r3 = ui.button("Remove Send"); + let r3 = ui.button(tl!("Remove Send")); app.auto.add(&format!("{ap}.send.{k}.remove"), r3.rect, "Remove Send"); if r3.clicked() { cx.cmd("mixer.removeSend", json!({"strip": sid, "send": k})); @@ -514,7 +517,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } if seq.submix_tracks.iter().all(|s| s.id != id) { - let r = ui.button("New Submix"); + let r = ui.button(tl!("New Submix")); app.auto.add(&format!("{ap}.send.{k}.newSubmix"), r.rect, "New Submix"); if r.clicked() { let n = seq.submix_tracks.len(); @@ -524,7 +527,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } None => { - ui.label("Fill the slots above first"); + ui.label(tl!("Fill the slots above first")); } } }); @@ -536,11 +539,11 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- input / output if !is_master { let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let iresp = crate::widgets::dropdown_text(ui, ir, tr.mixer.input_map.label(), &t, egui::Id::new((&ap, "input"))); + let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::new((&ap, "input"))); app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping"); egui::Popup::menu(&iresp).show(|ui| { for m in InputMap::ALL { - let r = ui.selectable_label(m == tr.mixer.input_map, m.label()); + let r = ui.selectable_label(m == tr.mixer.input_map, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.input.{}", m.label()), r.rect, m.label()); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "inputMap": m.label()})); @@ -549,12 +552,13 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s }); y += 24.0; let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let out_label = tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| "Mix".into())).unwrap_or_else(|| "Mix".into()); + let out_label = + tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| tl!("Mix").into())).unwrap_or_else(|| tl!("Mix").into()); let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output"))); app.auto.add(&format!("{ap}.output"), or, "Track output assignment"); let tg = targets(seq, id); egui::Popup::menu(&oresp).show(|ui| { - let r = ui.selectable_label(tr.mixer.output.is_none(), "Mix"); + let r = ui.selectable_label(tr.mixer.output.is_none(), tl!("Mix")); app.auto.add(&format!("{ap}.output.Mix"), r.rect, "Mix"); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "output": "Mix"})); @@ -576,8 +580,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let pan = shown(app, &tr, LANE_PAN, cx.now); let kc = pos2(sr.center().x, y + 20.0); let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text("Pan / balance (drag; double-click: centre)"); - let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) }; + let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); + let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim }; ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring)); @@ -613,14 +617,14 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- automation mode let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let mode = tr.mixer.mode; - let mresp = crate::widgets::dropdown_text(ui, mr, mode.label(), &t, egui::Id::new((&ap, "mode"))); + let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); if cx.recording && mode.writes() { ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside); } app.auto.add(&format!("{ap}.mode"), mr, &format!("Automation mode: {}", mode.label())); egui::Popup::menu(&mresp).show(|ui| { for m in AutomationMode::ALL { - let r = ui.selectable_label(m == mode, m.label()); + let r = ui.selectable_label(m == mode, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label()); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "mode": m.label()})); @@ -654,14 +658,14 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let is_sub = seq.submix_tracks.iter().any(|s| s.id == id); if is_sub { let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe"))) - .on_hover_text("Solo safe"); + .on_hover_text(tl!("Solo safe")); app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe})); } } else { let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R"))) - .on_hover_text("Enable track for recording"); + .on_hover_text(tl!("Enable track for recording")); app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "recordArm": !tr.mixer.record_arm})); @@ -686,15 +690,16 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s small_text(ui, pos2(x + 26.0, yy), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); ui.painter().line_segment([pos2(x + 28.0, yy), pos2(x + 31.0, yy)], Stroke::new(1.0, t.text_faint)); } - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); - ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_gray(0x50)), StrokeKind::Inside); + ui.painter().rect_filled(track, 2.0, t.control_well); + ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, t.control_border), StrokeKind::Inside); let vol = shown(app, &tr, LANE_VOLUME, cx.now); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); - let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text("Volume (drag; double-click: 0 dB)"); + let fresp = + ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); - let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) }; - ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); + let col = if fresp.dragged() || held { t.accent } else { t.control_handle }; + ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, col), StrokeKind::Inside); ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, col)); app.auto.add(&format!("{ap}.fader"), cap, &format!("Volume {}", db_text(vol))); @@ -744,7 +749,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let nr = Rect::from_min_max(pos2(x, sr.max.y - name_h), sr.max); ui.painter().line_segment([pos2(nr.min.x + 4.0, nr.min.y), pos2(nr.max.x - 4.0, nr.min.y)], Stroke::new(1.0, t.separator)); if is_master { - small_text(ui, nr.center(), Align2::CENTER_CENTER, "Mix", 12.0, t.text); + small_text(ui, nr.center(), Align2::CENTER_CENTER, tl!("Mix"), 12.0, t.text); } else { small_text(ui, pos2(nr.min.x + 6.0, nr.center().y), Align2::LEFT_CENTER, &label, 12.0, t.text); let name = ui.painter().with_clip_rect(Rect::from_min_max(pos2(nr.min.x + 26.0, nr.min.y), nr.max).intersect(ui.clip_rect())); @@ -761,16 +766,17 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let px = shown(app, tr, LANE_PAN51_X, cx.now); let py = shown(app, tr, LANE_PAN51_Y, cx.now); let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0)); - let resp = ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text("5.1 panner (drag the puck; double-click: front centre)"); + let resp = + ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); let p = ui.painter(); - p.rect_filled(sq, 3.0, Color32::from_gray(0x16)); - p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside); + p.rect_filled(sq, 3.0, t.panner_bg); + p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside); let inner = sq.shrink(5.0); let to_pos = |x: f64, y: f64| pos2(inner.center().x + (x / 100.0) as f32 * inner.width() / 2.0, inner.center().y - (y / 100.0) as f32 * inner.height() / 2.0); // speakers for (x, y) in [(-100.0, 100.0), (0.0, 100.0), (100.0, 100.0), (-100.0, -100.0), (100.0, -100.0)] { - p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, Color32::from_gray(0x90)); + p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, t.panner_speaker); } let puck = to_pos(px, py); p.circle_filled(puck, 4.0, if resp.dragged() { t.accent } else { t.hot_text }); @@ -798,7 +804,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let center = shown(app, tr, LANE_PAN51_CENTER, cx.now); let lfe = shown(app, tr, LANE_PAN51_LFE, cx.now); let col_x = sq.max.x + 4.0; - small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, "Center", 9.0, t.text_dim); + small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim); let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); @@ -827,12 +833,12 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) ui.painter().text(pos2(row.max.x - 12.0, row.center().y), Align2::RIGHT_CENTER, dur, Tokens::mono(12.0), t.text_dim); let playing = app.playback.playing; let buttons: [(Icon, &str, &str); 6] = [ - (Icon::GoToIn, "markers.goToIn", "Go to In Point"), - (Icon::GoToOut, "markers.goToOut", "Go to Out Point"), - (if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"), - (Icon::Play, "playback.inToOut", "Play In to Out"), - (Icon::Loop, "playback.loop", "Loop"), - (Icon::Mic, "voiceover.recordToggle", "Record (voice-over on the record-enabled track)"), + (Icon::GoToIn, "markers.goToIn", tl!("Go to In Point")), + (Icon::GoToOut, "markers.goToOut", tl!("Go to Out Point")), + (if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", tl!("Play-Stop Toggle (Space)")), + (Icon::Play, "playback.inToOut", tl!("Play In to Out")), + (Icon::Loop, "playback.loop", tl!("Loop")), + (Icon::Mic, "voiceover.recordToggle", tl!("Record (voice-over on the record-enabled track)")), ]; let bw = 30.0; let mut x = row.center().x - bw * buttons.len() as f32 / 2.0; @@ -878,7 +884,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let meters = poll_meters(app, ui); @@ -894,7 +900,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0)); let label = format!("A{}", i + 1); let ap = format!("clipMixer.{label}"); - ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + ui.painter().rect_filled(sr, 3.0, t.control_surface); let clip = tr.item_at(now).filter(|c| c.enabled); let active = clip.is_some(); let dimc = |c: Color32| if active { c } else { c.gamma_multiply(0.4) }; @@ -924,9 +930,9 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kc = pos2(sr.center().x, sr.min.y + 36.0); let kr = Rect::from_center_size(kc, vec2(30.0, 30.0)); let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()); - ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim))); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); - ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim))); small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text)); small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text)); small_text(ui, pos2(kc.x, kc.y + 26.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text)); @@ -944,11 +950,11 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // automation mode (Clip Mixer) let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id); let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0)); - let mdresp = crate::widgets::dropdown_text(ui, mdr, mode.label(), &t, egui::Id::new((&ap, "mode"))); + let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label())); egui::Popup::menu(&mdresp).show(|ui| { for m in AutomationMode::ALL { - let r = ui.selectable_label(m == mode, m.label()); + let r = ui.selectable_label(m == mode, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label()); if r.clicked() { acts.push(("clipMixer.setMode".into(), json!({"track": tr.id.0, "mode": m.label()}))); @@ -969,7 +975,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo}))); } let kresp = - crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text("Write keyframes"); + crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text(tl!("Write keyframes")); app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes"); if kresp.clicked() { write_kf.insert(tr.id.0, !kf); @@ -983,13 +989,13 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) }; small_text(ui, pos2(sr.min.x + 26.0, ypos(d)), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); } - ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); + ui.painter().rect_filled(track, 2.0, t.control_well); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()); - ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); - ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(Color32::from_gray(0xe0))), StrokeKind::Inside); - ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(Color32::from_gray(0xe0)))); + ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); + ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside); + ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle))); app.auto.add(&format!("{ap}.fader"), cap, &format!("Clip volume {}", db_text(vol))); if fresp.dragged() && active && fresp.drag_delta().y != 0.0 { set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started()); @@ -1017,6 +1023,29 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { mod tests { use super::*; + #[test] + fn meter_well_and_inactive_clip_light_follow_theme() { + for kind in [crate::theme::ThemeKind::Dark, crate::theme::ThemeKind::Medium, crate::theme::ThemeKind::Light] { + let t = Tokens::for_kind(kind); + let ctx = egui::Context::default(); + let area = Rect::from_min_size(pos2(20.0, 20.0), vec2(40.0, 100.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw_meters(ui, area, &[[-90.0; 2]], 1, &t)); + out.textures_delta.clear(); + let rectangles: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) => Some(r), + _ => None, + }) + .collect(); + let well = rectangles.iter().find(|r| r.rect == area).expect("painted meter well"); + assert_eq!(well.fill, t.meter_bg, "meter well did not follow {kind:?}"); + let light = rectangles.iter().find(|r| r.rect.height() == 4.0).expect("painted clip light"); + assert_eq!(light.fill, t.meter_clip_off, "clip light did not follow {kind:?}"); + } + } + #[test] fn taper_roundtrips_and_is_monotonic() { let mut last = f64::NEG_INFINITY; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f630aac1d..6fff6966e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod essential_sound; pub mod events; pub mod export_mode; pub mod file_dialogs; +pub mod frame_export; pub mod graphics; pub mod graphics_templates; pub mod import_mode; @@ -36,6 +37,7 @@ pub mod project_views; pub mod reference; pub mod remix; pub mod scopes; +pub mod sequence_settings; pub mod settings; pub mod shortcuts_dialog; pub mod text; @@ -43,6 +45,8 @@ pub mod timecode; pub mod timeline; pub mod timeline_automation; pub mod timeline_captions; +pub mod timeline_hit; +pub mod timeline_volume; pub mod tools; pub mod trim_monitor; pub mod tts; @@ -58,7 +62,7 @@ use crate::theme::Tokens; /// ` · 2:05 left`: the time a job has left, as it follows a percentage. pub fn left_text(d: std::time::Duration) -> String { - format!(" · {} left", filmcraft_engine::export::format_eta(d)) + tlf!(" · {time} left", time = filmcraft_engine::export::format_eta(d)) } /// [`left_text`] for a job or queue item whose JSON has `etaSeconds`, nothing while it is null. @@ -96,13 +100,17 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::Text => text::show(app, ui, rect), PanelKind::EssentialSound => essential_sound::show(app, ui, rect), PanelKind::TextToSpeech => tts::show(app, ui, rect), - other => crate::dock::placeholder(ui, rect, &app.tokens, &format!("{} — coming in a later milestone", other.title())), + other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))), } } +pub mod source_drag; +pub mod source_range; + #[derive(Clone, Debug)] enum DragPayload { Item(ItemId), + Source(source_drag::SourceDrag), Effect(String), /// A graphics template (id, name) from Essential Graphics ▸ Browse. Template(String, String), @@ -112,6 +120,15 @@ fn payload_id() -> egui::Id { egui::Id::new("filmcraft-drag-payload") } +pub fn start_drag_source(ui: &egui::Ui, source: source_drag::SourceDrag) { + ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Source(source))))); +} +pub fn dragged_source(ui: &egui::Ui) -> Option { + match payload(ui) { + Some(DragPayload::Source(s)) => Some(s), + _ => None, + } +} pub fn start_drag_item(ui: &egui::Ui, item: ItemId) { ui.ctx().data_mut(|d| d.insert_temp(payload_id(), Some(DragPayloadBox(DragPayload::Item(item))))); } @@ -136,6 +153,7 @@ fn payload(ui: &egui::Ui) -> Option { pub fn dragged_project_item(ui: &egui::Ui) -> Option { match payload(ui) { Some(DragPayload::Item(i)) => Some(i), + Some(DragPayload::Source(s)) => Some(s.item), _ => None, } } @@ -156,6 +174,15 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { if let Some(p) = ctx.pointer_hover_pos() { let label = match &pl { DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + DragPayload::Source(s) => { + let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source"); + let mode = match (s.video, s.audio) { + (true, true) => "video + audio", + (true, false) => "video", + _ => "audio", + }; + format!("{name} · {mode} · {:.2} s", s.range.duration.seconds()) + } DragPayload::Template(_, name) => name.clone(), DragPayload::Effect(e) => match e.strip_prefix("preset:") { Some(name) => name.to_string(), @@ -200,41 +227,41 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { // the Timeline's tabs are its open sequences: Close Panel closes the active one and // keeps the panel (Premiere's wording and behaviour) if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { - let r = ui.button("Close Panel"); + let r = ui.button(tl!("Close Panel")); app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); if r.clicked() { let _ = app.session.execute("sequence.close", serde_json::json!({})); close = true; } - let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new("Close Other Timeline Panels")); + let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); if r.clicked() { let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); close = true; } - } else if ui.button("Close Panel").clicked() { + } else if ui.button(tl!("Close Panel")).clicked() { app.ui.dock.close(p); close = true; } - if ui.button("Maximize Frame").clicked() { + if ui.button(tl!("Maximize Frame")).clicked() { app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; close = true; } - if ui.button("Restore Workspace").clicked() { + if ui.button(tl!("Restore Workspace")).clicked() { let w = app.ui.workspace.clone(); app.set_workspace(&w); close = true; } if p == PanelKind::Timeline { ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new("Reveal Sequence in Project")); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); if r.clicked() { let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); close = true; } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Video Thumbnails"); - ui.checkbox(&mut app.ui.timeline.show_waveforms, "Audio Waveforms"); + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); } if p == PanelKind::Project { ui.separator(); diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index d98b9b4db..0ed5aea91 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -3,7 +3,9 @@ //! Frames come from the background [`FrameServer`](crate::frames::FrameServer) at the *smaller* of //! the playback resolution and the on-screen resolution (so a small monitor never pays for 4K). //! While playing we prefetch the next frames in priority order; while scrubbing the exact frame is -//! requested first and the nearest cached frame is shown until it arrives (no black flashes). +//! requested first and the nearest cached frame is shown until it arrives (no black flashes), or +//! failing that the frame asked for a refresh earlier, so the picture follows a scrub or a value +//! that is dragged instead of waiting for the mouse to rest. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use filmcraft_project::ItemKind; @@ -33,12 +35,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let (target, frame_size, rate, time, duration, drop_frame, mark_in, mark_out, name) = match which { Which::Program => { let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; // A damaged project can name an active sequence that no longer exists. let Some(q) = app.session.active_sequence() else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; ( @@ -55,7 +57,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } Which::Source => { let Some(item) = app.session.state.source_item else { - crate::dock::placeholder(ui, rect, &t, "(no clips)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no clips)")); return; }; let Some(pi) = app.session.project.item(item) else { return }; @@ -66,10 +68,33 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) Some(ItemKind::Sequence(s)) => (s.settings.width, s.settings.height), _ => (1920, 1080), }; + let mut view = view; + if let Some(range) = crate::panels::source_range::preview(app, &ctx) { + view.mark_in = Some(range.start); + view.mark_out = Some(Tick(range.end().0.saturating_sub(view.rate.frame_duration().0)).max(view.start)); + } origin = view.start; (Target::Item(item), size, view.rate, app.session.state.source_playhead, view.end, false, view.mark_in, view.mark_out, pi.name.clone()) } }; + // Display implicit full-clip marks without writing them into the project on open. + let draft = crate::panels::source_range::preview_for(app, &ctx, which); + let (mark_in, mark_out) = if which == Which::Source { + ( + Some(draft.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin))), + Some( + draft + .map(|r| Tick(r.end().0.saturating_sub(rate.frame_duration().0))) + .unwrap_or_else(|| mark_out.unwrap_or_else(|| (duration - rate.frame_duration()).max(origin))), + ), + ) + } else { + ( + mark_in.map(|m| draft.map(|r| r.start).unwrap_or(m)), + mark_out.map(|m| draft.map(|r| Tick(r.end().0.saturating_sub(rate.frame_duration().0))).unwrap_or(m)), + ) + }; + let range_start = mark_in.unwrap_or(origin); let prefix = if which == Which::Program { "program" } else { "source" }; let mv = monitor_view::view(app, which).clone(); let display = mv.display_mode().unwrap_or(DisplayMode::Composite); @@ -145,7 +170,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.set_clip_rect(saved_clip.intersect(if mv.zoom.is_some() && !compare { video_area } else { picture_area.expand(12.0) })); if show_picture { ui.painter().rect_filled(pic, 0.0, t.monitor_bg); - let playing = which == Which::Program && app.playback.playing; + let playing = if which == Which::Program { app.playback.playing } else { app.source_playback.clock.playing }; let res = mv.effective_res(playing); let screen_scale = (pic.width() * ppp / frame_size.0 as f32).min(1.0); let scale = quantize_scale(res.scale().min(screen_scale.max(1.0 / 32.0))); @@ -167,34 +192,44 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let key = FrameKey { target, frame, size: size_key, revision: rev, draft }; let project = app.session.project.clone(); if playing { - let preroll = app.playback.preroll.is_some(); - app.frames.schedule_playback(key, rate, scale, &project, app.playback.speed, preroll); + let preroll = which == Which::Program && app.playback.preroll.is_some(); + let speed = if which == Which::Program { app.playback.speed } else { app.source_playback.clock.speed }; + app.frames.schedule_playback(key, rate, scale, &project, speed, preroll); if preroll { app.playback.preroll_ready = app.frames.preroll_ready(key, app.playback.speed, rate.frame_at(duration) - 1); } } else { app.frames.request(key, rate.tick_of(frame), scale, &project, 0); } + let asked = asked_before(app, prefix, key, playing); let tex_name = if channel { format!("monitor-{prefix}-{display:?}") } else { format!("monitor-{prefix}") }; - let (shown, exact) = if use_gpu { - let exact = app.frames.get_plan(&key).map(|p| (key, p)); - let is_exact = exact.is_some(); - let tex = match exact.or_else(|| app.frames.nearest_plan(key, 6)) { - Some((k, plan)) => app.gpu_present(k, &plan).map(|(id, _)| id), - None => app.gpu.as_ref().and_then(|g| g.texture), - }; - (tex, is_exact) + let (shown, on_screen) = if use_gpu { + let found = app + .frames + .get_plan(&key) + .map(|p| (key, p)) + .or_else(|| app.frames.nearest_plan(key, 6)) + .or_else(|| asked.iter().find_map(|k| app.frames.get_plan(k).map(|p| (*k, p)))); + match found { + Some((k, plan)) => (app.gpu_present(k, &plan).map(|(id, _)| id), Some(k)), + None => (app.gpu.as_ref().and_then(|g| g.texture), app.gpu.as_ref().and_then(|g| g.last_key)), + } } else { - cpu_texture(app, &ctx, &tex_name, key, display) + cpu_texture(app, &ctx, &tex_name, key, &asked, display) }; + let exact = on_screen == Some(key); + if which == Which::Program { + app.program_shown = on_screen; + } if !playing && !exact { app.monitor_inexact = true; } if playing { - if std::mem::take(&mut app.playback.hidden) { - app.playback.meter.resync(frame, exact); + let clock = if which == Which::Program { &mut app.playback } else { &mut app.source_playback.clock }; + if std::mem::take(&mut clock.hidden) { + clock.meter.resync(frame, exact); } else { - app.playback.meter.refresh(frame, exact); + clock.meter.refresh(frame, exact); } } if let Some(tex) = shown { @@ -207,7 +242,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.frames.request(rkey, rate.tick_of(rf), scale, &project, 1); let rpic = fit(ra, frame_size.0 as f32, frame_size.1 as f32); ui.painter().rect_filled(rpic, 0.0, t.monitor_bg); - if let (Some(tex), _) = cpu_texture(app, &ctx, &format!("monitor-{prefix}-ref"), rkey, DisplayMode::Composite) { + if let (Some(tex), _) = cpu_texture(app, &ctx, &format!("monitor-{prefix}-ref"), rkey, &[], DisplayMode::Composite) { ui.painter().image(tex, rpic, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.auto.add("program.compare.reference", rpic, "reference frame"); @@ -224,7 +259,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.painter().line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::from_white_alpha(120))); } if display != DisplayMode::Composite && !compare { - let label = format!("{display:?}"); + let label = match display { + DisplayMode::Composite => tl!("Composite"), + DisplayMode::Alpha => tl!("Alpha"), + DisplayMode::Red => tl!("Red"), + DisplayMode::Green => tl!("Green"), + DisplayMode::Blue => tl!("Blue"), + DisplayMode::AudioWaveform => tl!("Audio Waveform"), + DisplayMode::Comparison => tl!("Comparison"), + DisplayMode::VideoAndWaveform => tl!("Video and Waveform"), + }; ui.painter().text(pos2(video_area.max.x - 8.0, video_area.min.y + 6.0), Align2::RIGHT_TOP, label, Tokens::ui(10.0), t.text_dim); } } @@ -234,12 +278,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.painter().circle_filled(pos2(video_area.min.x + 10.0, video_area.min.y + 10.0), 4.0, c); } // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. - let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), Sense::click()); + let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); if show_picture { monitor_view::pan_input(app, ui, which, video_area, pic); } if which == Which::Program && show_picture { + eyedropper(app, ui, &pic_resp, pic, target, rate, time); crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); crate::panels::masks::monitor_overlay(app, ui, pic, frame_size); } @@ -250,6 +295,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) if let Some((top, left)) = ruler_rects { monitor_view::rulers(app, ui, which, top, left, video_area, pic, frame_size); } + if which == Which::Source && show_picture && pic_resp.drag_started() && app.ui.tool != crate::state::Tool::Hand { + crate::panels::source_drag::begin(app, ui, true, true); + } if pic_resp.double_clicked() && which == Which::Source { // (Premiere opens the clip's settings; we show info) app.ui.status = name.clone(); @@ -258,20 +306,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // ---- controls row: timecode | zoom | res | wrench | duration let row1 = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.max.y - controls_h + 2.0), vec2(rect.width() - 28.0, 26.0)); let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000); - ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode); - app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc); - let dur_tc = format_time( - mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - mark_in.unwrap_or(Tick::ZERO), - rate, - drop_frame, - TimeDisplay::Timecode, - 48000, - ); + let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height())); + let min = if which == Which::Program { Tick::ZERO } else { origin }; + match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { + Some(Ok(to)) => { + let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" }; + if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) { + app.ui.status = e.to_string(); + } + } + Some(Err(e)) => app.ui.status = e, + None => {} + } + app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc); + let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000); ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim); // zoom + resolution dropdowns centred-ish let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0)); let zoom_label = match mv.zoom { - None => "Fit".to_string(), + None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), }; let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom"))); @@ -279,11 +332,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0)); - let rresp = crate::widgets::dropdown_text(ui, rr, res.label(), &t, egui::Id::new((prefix, "res"))); + if which == Which::Source && rr.min.x > zr.max.x + 12.0 { + crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0))); + } + let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); egui::Popup::menu(&rresp).show(|ui| { for r in PlaybackRes::ALL { - if ui.selectable_label(r == res, r.label()).clicked() { + if ui.selectable_label(r == res, crate::i18n::t(r.label())).clicked() { monitor_view::view_mut(app, which).res = r; } } @@ -296,38 +352,48 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.set_min_width(240.0); monitor_view::wrench_items(app, ui, which); let mv = monitor_view::view_mut(app, which); - ui.checkbox(&mut mv.safe_margins, "Safe Margins"); - ui.checkbox(&mut mv.show_transport, "Show Transport Controls"); + ui.checkbox(&mut mv.safe_margins, tl!("Safe Margins")); + ui.checkbox(&mut mv.show_transport, tl!("Show Transport Controls")); + if which == Which::Source { + let mut looping = app.source_playback.clock.looping; + let response = ui.checkbox(&mut looping, tl!("Loop marked range")); + app.auto.add("source.settings.loop", response.rect, "Loop marked range"); + if response.changed() + && let Err(e) = crate::menus::invoke(app, &ctx, "playback.loop", json!({"monitor":"source"})) + { + app.ui.status = e; + } + } if which == Which::Program { ui.separator(); - ui.checkbox(&mut app.ui.show_scopes, "Lumetri Scopes"); - ui.checkbox(&mut app.playback.looping, "Loop"); + ui.checkbox(&mut app.ui.show_scopes, tl!("Lumetri Scopes")); + ui.checkbox(&mut app.playback.looping, tl!("Loop")); ui.separator(); let mut follows = app.session.state.multicam_audio_follows_video; - if ui.checkbox(&mut follows, "Multi-Camera Audio Follows Video").changed() { + if ui.checkbox(&mut follows, tl!("Multi-Camera Audio Follows Video")).changed() { let _ = app.session.execute("multicam.audioFollowsVideo", json!({"enabled": follows})); } - ui.checkbox(&mut app.ui.multicam_record, "Multi-Camera Record"); + ui.checkbox(&mut app.ui.multicam_record, tl!("Multi-Camera Record")); let v = app.session.state.multicam_view.clone(); for (cmd, label, on) in [ - ("multicam.selectionTopDown", "Multi-Camera Selection Top Down", v.top_down), - ("multicam.showPreviewMonitor", "Show Multi-Camera Preview Monitor", v.show_preview), - ("multicam.autoAdjustQuality", "Auto-Adjust Multi-Camera Playback Quality", v.auto_quality), - ("multicam.transmitView", "Transmit Multi-Camera View", v.transmit), + ("multicam.selectionTopDown", tl!("Multi-Camera Selection Top Down"), v.top_down), + ("multicam.showPreviewMonitor", tl!("Show Multi-Camera Preview Monitor"), v.show_preview), + ("multicam.autoAdjustQuality", tl!("Auto-Adjust Multi-Camera Playback Quality"), v.auto_quality), + ("multicam.transmitView", tl!("Transmit Multi-Camera View"), v.transmit), ] { let mut b = on; if ui.checkbox(&mut b, label).changed() { let _ = app.session.execute(cmd, json!({"enabled": b})); } } - ui.menu_button("Multi-Camera Layout", |ui| { - for (k, label) in [("auto", "Automatic"), ("2x2", "2 × 2"), ("3x3", "3 × 3"), ("4x4", "4 × 4")] { + ui.menu_button(tl!("Multi-Camera Layout"), |ui| { + for (k, label) in [("auto", tl!("Automatic")), ("2x2", "2 × 2"), ("3x3", "3 × 3"), ("4x4", "4 × 4")] { if ui.selectable_label(v.layout_name() == k, label).clicked() { let _ = app.session.execute("multicam.gridLayout", json!({"layout": k})); } } }); - if ui.button("Edit Cameras…").clicked() { + if ui.button(tl!("Edit Cameras…")).clicked() { let _ = crate::panels::multicam::route(app, "multicam.editCamerasDialog", &json!({})); ui.close(); } @@ -342,20 +408,156 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) transport(app, ui, row3, which); } -/// A monitor texture from the CPU frame path: the exact frame, else the nearest cached one, with -/// the display mode's channel mapping. Returns (texture, exact). -fn cpu_texture(app: &mut FilmcraftApp, ctx: &egui::Context, name: &str, key: FrameKey, mode: DisplayMode) -> (Option, bool) { - let upload = |app: &mut FilmcraftApp, k: FrameKey, img: &crate::frames::Rgba| { - if mode.is_channel() { app.texture_for_mapped(ctx, name, k, img, |i| monitor_view::channel_view(i, mode)) } else { app.texture_for(ctx, name, k, img) } - }; - if let Some(img) = app.frames.get(&key) { - return (Some(upload(app, key, &img)), true); +/// How many of a paused monitor's latest requests are remembered ([`asked_before`]): about a +/// second of refreshes, so a frame that took several refreshes to render is still known when it +/// is ready. +const ASKED_KEYS: usize = 64; + +/// Note that the monitor `prefix` asks for `key`, and return the frames it asked for on the passes +/// before, newest first: the stand-ins for `key` while it renders. +/// +/// A value that is dragged asks for a new revision on every pass, and a scrub for a new frame, so +/// the frame asked for is never the one that is ready; the one asked for a pass earlier usually +/// is, and showing it keeps the picture one pass behind the mouse instead of frozen until the +/// mouse rests. Playback has its own schedule and remembers nothing (what was on screen before +/// Play is no stand-in for where it stopped). +fn asked_before(app: &mut FilmcraftApp, prefix: &str, key: FrameKey, playing: bool) -> Vec { + let asked = app.monitor_asked.entry(prefix.to_string()).or_default(); + if playing { + asked.clear(); + return Vec::new(); } - let tex = match app.frames.nearest(key.target, key.frame, key.size, key.revision, 6) { - Some(img) => Some(upload(app, FrameKey { frame: key.frame - 1, ..key }, &img)), - None => app.texture_existing(name).map(|(id, _)| id), + // another sequence or clip: its pictures are not this one's + asked.retain(|k| k.target == key.target && *k != key); + let before: Vec = asked.iter().rev().copied().collect(); + asked.push_back(key); + while asked.len() > ASKED_KEYS { + asked.pop_front(); + } + before +} + +/// A monitor texture from the CPU frame path: the exact frame, else the nearest cached one before +/// it, else the newest ready one of `asked` (see [`asked_before`]), with the display mode's +/// channel mapping. Returns the texture and the frame it shows. +fn cpu_texture( + app: &mut FilmcraftApp, + ctx: &egui::Context, + name: &str, + key: FrameKey, + asked: &[FrameKey], + mode: DisplayMode, +) -> (Option, Option) { + let found = app + .frames + .get(&key) + .map(|img| (key, img)) + .or_else(|| (1..=6).map(|d| FrameKey { frame: key.frame.saturating_sub(d), ..key }).find_map(|k| app.frames.get(&k).map(|img| (k, img)))) + .or_else(|| asked.iter().find_map(|k| app.frames.get(k).map(|img| (*k, img)))); + match found { + Some((k, img)) => { + let tex = if mode.is_channel() { + app.texture_for_mapped(ctx, name, k, &img, |i| monitor_view::channel_view(i, mode)) + } else { + app.texture_for(ctx, name, k, &img) + }; + (Some(tex), Some(k)) + } + None => (app.texture_existing(name).map(|(id, _)| id), app.texture_key(name)), + } +} + +/// A click of an armed eyedropper waiting for its frame to render. +#[derive(Clone)] +struct EyedropperPick { + /// The clicked point in the picture, 0..1 across and down. + uv: (f32, f32), + key: FrameKey, + time: Tick, + /// The project with the armed effect switched off, so a colour that effect keys out can be picked. + project: std::sync::Arc, +} + +/// Output scale of the frame an eyedropper samples (half size is plenty and quick to render). +const EYEDROPPER_SCALE: f32 = 0.5; + +/// Eyedropper: while a colour parameter's eyedropper is armed (Effect Controls), the Program picture +/// takes a crosshair and a click fills that parameter with the colour under the pointer. The colour is +/// read from a render of the sequence at the playhead with the armed effect switched off (an Ultra Key +/// has already removed the green it is asked to key); Esc disarms. +fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) { + let Some(armed) = app.ui.eyedropper.clone() else { return }; + let ctx = ui.ctx().clone(); + let pending_id = egui::Id::new("eyedropper-pending"); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.eyedropper = None; + ctx.data_mut(|d| d.remove::(pending_id)); + return; + } + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && let (Some(pos), Target::Sequence(seq)) = (resp.interact_pointer_pos(), target) + && pic.width() > 0.0 + && pic.height() > 0.0 + { + let uv = (((pos.x - pic.min.x) / pic.width()).clamp(0.0, 1.0), ((pos.y - pic.min.y) / pic.height()).clamp(0.0, 1.0)); + let mut project = (*app.session.project).clone(); + if let Some(e) = + project.sequence_mut(seq).and_then(|q| q.find_item_mut(filmcraft_project::ClipId(armed.clip))).and_then(|(_, it)| it.effects.get_mut(armed.effect)) + { + e.enabled = false; + } + let frame = rate.frame_at(time); + // a revision of its own, so this render never replaces what the monitor caches + let revision = app.session.revision ^ 0x5eed_d70b_0000_0000 ^ armed.clip.rotate_left(17) ^ armed.effect as u64; + let key = FrameKey { target, frame, size: (EYEDROPPER_SCALE * 1000.0) as u32, revision, draft: false }; + ctx.data_mut(|d| d.insert_temp(pending_id, EyedropperPick { uv, key, time: rate.tick_of(frame), project: std::sync::Arc::new(project) })); + } + let Some(pick) = ctx.data(|d| d.get_temp::(pending_id)) else { return }; + app.frames.request(pick.key, pick.time, EYEDROPPER_SCALE, &pick.project, 0); + let Some(img) = app.frames.get(&pick.key) else { + ctx.request_repaint(); + return; }; - (tex, false) + ctx.data_mut(|d| d.remove::(pending_id)); + app.ui.eyedropper = None; + let Some(rgb) = sample_rgb(&img, pick.uv) else { + app.ui.status = tl!("Eyedropper: no picture to sample").to_string(); + return; + }; + // the parameter stores linear values, like the colour picker beside it + let lin = egui::Rgba::from(Color32::from_rgb(rgb[0], rgb[1], rgb[2])); + let mut params = json!({"clip": armed.clip, "effect": armed.effect, "param": armed.param, "value": [lin.r(), lin.g(), lin.b(), 1.0]}); + if let Some(k) = armed.mask { + params["mask"] = json!(k); + } + if let Err(e) = app.session.execute("effects.setParam", params) { + app.ui.status = e.to_string(); + } +} + +/// The average colour of the 5×5 pixels around `uv` (0..1 across and down) of a frame, so a noisy +/// green screen gives its true green. `None` for an empty frame. +fn sample_rgb(img: &crate::frames::Rgba, uv: (f32, f32)) -> Option<[u8; 3]> { + if img.w == 0 || img.h == 0 || img.px.len() < img.w.saturating_mul(img.h).saturating_mul(4) { + return None; + } + let cx = ((uv.0 * img.w as f32) as usize).min(img.w - 1); + let cy = ((uv.1 * img.h as f32) as usize).min(img.h - 1); + let (mut sum, mut n) = ([0u32; 3], 0u32); + for y in cy.saturating_sub(2)..=(cy + 2).min(img.h - 1) { + for x in cx.saturating_sub(2)..=(cx + 2).min(img.w - 1) { + let i = (y * img.w + x) * 4; + for (c, s) in sum.iter_mut().enumerate() { + *s += u32::from(*img.px.get(i + c)?); + } + n += 1; + } + } + let avg = |c: usize| u8::try_from(sum[c].checked_div(n)?).ok(); + Some([avg(0)?, avg(1)?, avg(2)?]) } pub fn quantize_scale(s: f32) -> f32 { @@ -383,10 +585,13 @@ fn mini_timeline( mark_out: Option, ) { let t = app.tokens; - let p = ui.painter(); let duration = end; let dur = (end - origin).0.max(1) as f64; let xof = |tk: Tick| bar.min.x + (((tk - origin).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width(); + let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag()); + app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar"); + let interaction = crate::panels::source_range::interact(app, ui, bar, which); + let p = ui.painter(); // ticks: minor 4 pt, major 10 pt, ~20 pt spacing let n = ((bar.width() / 20.0) as i32).max(2); for i in 0..=n { @@ -394,10 +599,40 @@ fn mini_timeline( let h = if i % 5 == 0 { 8.0 } else { 3.5 }; p.line_segment([pos2(x, bar.max.y - h), pos2(x, bar.max.y)], Stroke::new(1.0, t.text_faint)); } + let a = xof(interaction.range.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin))); + // Out includes its frame; both the shading and its bracket end at the following boundary. + let b = xof(interaction.range.map(|r| r.end()).unwrap_or_else(|| mark_out.map(|o| Tick(o.0.saturating_add(rate.frame_duration().0))).unwrap_or(duration))) + .max(a); if mark_in.is_some() || mark_out.is_some() { - let a = xof(mark_in.unwrap_or(origin)); - let b = xof(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration)); - p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgb(0x5c, 0x5c, 0x5c)); + p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x5c, 0x5c, 0x5c, 128)); + } + if mark_in.is_some() || mark_out.is_some() { + // Curly braces sit inside the gray band, with their waist at its exact range edge. + let top = bar.min.y + 8.0; + let bottom = bar.max.y; + let mid = (top + bottom) * 0.5; + for (x, direction, hot) in [(a, 1.0_f32, interaction.in_hot), (b, -1.0_f32, interaction.out_hot)] { + if hot || (direction > 0.0 && mark_in.is_none()) || (direction < 0.0 && mark_out.is_none()) { + continue; + } + let point = |offset: f32, y: f32| pos2(x + direction * offset, y); + p.line( + vec![ + point(4.0, top), + point(2.8, top), + point(2.0, top + 1.0), + point(2.0, mid - 2.0), + point(1.4, mid - 1.0), + point(0.0, mid), + point(1.4, mid + 1.0), + point(2.0, mid + 2.0), + point(2.0, bottom - 1.0), + point(2.8, bottom), + point(4.0, bottom), + ], + Stroke::new(1.0, t.playhead), + ); + } } // markers: the sequence's (Program), the clip's or the subclip's inherited ones (Source) let markers = match which { @@ -426,13 +661,23 @@ fn mini_timeline( Stroke::NONE, )); p.line_segment([pos2(x, hy + 8.0), pos2(x, bar.max.y)], Stroke::new(1.0, t.playhead)); - let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag()); - app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar"); - if (resp.dragged() || resp.clicked()) - && let Some(pos) = resp.interact_pointer_pos() - { - let f = ((pos.x - bar.min.x) / bar.width()).clamp(0.0, 1.0) as f64; - let tk = rate.snap(origin + Tick((f * dur) as i64)); + if interaction.in_hot { + crate::panels::source_range::trim_cue(p, a, bar, 4.0); + } + if interaction.out_hot { + crate::panels::source_range::trim_cue(p, b, bar, -4.0); + } + let scrub = interaction.scrub.or_else(|| { + if !interaction.handled && (resp.dragged() || resp.clicked()) { + resp.interact_pointer_pos().map(|pos| { + let f = ((pos.x - bar.min.x) / bar.width().max(1.0)).clamp(0.0, 1.0) as f64; + rate.snap(origin + Tick((f * dur) as i64)) + }) + } else { + None + } + }); + if let Some(tk) = scrub { match which { Which::Program => { app.stop(); @@ -450,33 +695,33 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) let src = which == Which::Source; let buttons: Vec<(Icon, &str, &str)> = if src { vec![ - (Icon::Marker, "markers.add", "Add Marker (M)"), - (Icon::MarkIn, "src.markIn", "Mark In (I)"), - (Icon::MarkOut, "src.markOut", "Mark Out (O)"), - (Icon::GoToIn, "src.goIn", "Go to In (Shift+I)"), - (Icon::StepBack, "src.stepBack", "Step Back 1 Frame (Left)"), - (Icon::Play, "src.play", "Play-Stop Toggle (Space)"), - (Icon::StepFwd, "src.stepFwd", "Step Forward 1 Frame (Right)"), - (Icon::GoToOut, "src.goOut", "Go to Out (Shift+O)"), - (Icon::Insert, "source.insert", "Insert (,)"), - (Icon::Overwrite, "source.overwrite", "Overwrite (.)"), - (Icon::Camera, "exportFrame", "Export Frame (Shift+E)"), - (Icon::Proxy, "media.toggleProxies", "Toggle Proxies"), + (Icon::Marker, "markers.add", tl!("Add Marker (M)")), + (Icon::MarkIn, "src.markIn", tl!("Mark In (I)")), + (Icon::MarkOut, "src.markOut", tl!("Mark Out (O)")), + (Icon::GoToIn, "src.goIn", tl!("Go to In (Shift+I)")), + (Icon::StepBack, "src.stepBack", tl!("Step Back 1 Frame (Left)")), + (if app.source_playback.clock.playing { Icon::Pause } else { Icon::Play }, "source.playback.toggle", tl!("Play-Stop Toggle (Space)")), + (Icon::StepFwd, "src.stepFwd", tl!("Step Forward 1 Frame (Right)")), + (Icon::GoToOut, "src.goOut", tl!("Go to Out (Shift+O)")), + (Icon::Insert, "source.insert", tl!("Insert (,)")), + (Icon::Overwrite, "source.overwrite", tl!("Overwrite (.)")), + (Icon::Camera, "exportFrame", tl!("Export Frame")), + (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] } else { vec![ - (Icon::Marker, "markers.add", "Add Marker (M)"), - (Icon::MarkIn, "markers.markIn", "Mark In (I)"), - (Icon::MarkOut, "markers.markOut", "Mark Out (O)"), - (Icon::GoToIn, "markers.goToIn", "Go to In (Shift+I)"), - (Icon::StepBack, "playhead.stepBack", "Step Back 1 Frame (Left)"), - (if app.playback.playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"), - (Icon::StepFwd, "playhead.stepForward", "Step Forward 1 Frame (Right)"), - (Icon::GoToOut, "markers.goToOut", "Go to Out (Shift+O)"), - (Icon::Lift, "sequence.lift", "Lift (;)"), - (Icon::Extract, "sequence.extract", "Extract (')"), - (Icon::Camera, "exportFrame", "Export Frame (Shift+E)"), - (Icon::Proxy, "media.toggleProxies", "Toggle Proxies"), + (Icon::Marker, "markers.add", tl!("Add Marker (M)")), + (Icon::MarkIn, "markers.markIn", tl!("Mark In (I)")), + (Icon::MarkOut, "markers.markOut", tl!("Mark Out (O)")), + (Icon::GoToIn, "markers.goToIn", tl!("Go to In (Shift+I)")), + (Icon::StepBack, "playhead.stepBack", tl!("Step Back 1 Frame (Left)")), + (if app.playback.playing { Icon::Pause } else { Icon::Play }, "playback.toggle", tl!("Play-Stop Toggle (Space)")), + (Icon::StepFwd, "playhead.stepForward", tl!("Step Forward 1 Frame (Right)")), + (Icon::GoToOut, "markers.goToOut", tl!("Go to Out (Shift+O)")), + (Icon::Lift, "sequence.lift", tl!("Lift (;)")), + (Icon::Extract, "sequence.extract", tl!("Extract (')")), + (Icon::Camera, "exportFrame", tl!("Export Frame")), + (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; let bw = 30.0; @@ -510,12 +755,9 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) source_nav(app, cmd); Ok(serde_json::Value::Null) } - "src.play" => Ok(serde_json::Value::Null), - "exportFrame" => { - app.ui.status = "Export Frame: use Export mode (M6)".into(); - Ok(serde_json::Value::Null) - } - c => crate::menus::invoke(app, &ctx, c, json!({})), + + "exportFrame" => crate::menus::invoke(app, &ctx, "file.exportFrame", json!({"monitor": if src { "source" } else { "program" }})), + c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})), }; if let Err(e) = r { app.ui.status = e; @@ -525,7 +767,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } // button editor "+" let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text("Button Editor"); + let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); } @@ -537,8 +779,41 @@ fn source_nav(app: &mut FilmcraftApp, cmd: &str) { let t = match cmd { "src.goIn" => v.mark_in.unwrap_or(v.start), "src.goOut" => v.mark_out.unwrap_or(v.end - rate.frame_duration()), - "src.stepBack" => cur - rate.frame_duration(), - _ => cur + rate.frame_duration(), + _ if rate.frame_duration() == Tick::ZERO => cur, + "src.stepBack" => rate.tick_of(rate.frame_at(cur).saturating_sub(1)), + _ => rate.tick_of(rate.frame_at(cur).saturating_add(1)), }; let _ = app.session.execute("source.setPlayhead", json!({"time": t.0})); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::frames::Rgba; + + fn flat(w: usize, h: usize, rgba: [u8; 4]) -> Rgba { + Rgba { w, h, px: rgba.iter().copied().cycle().take(w * h * 4).collect() } + } + + #[test] + fn eyedropper_averages_the_pixels_around_the_point() { + let mut img = flat(8, 8, [0, 200, 0, 255]); + // one noisy pixel next to the centre is averaged away, not picked + img.px[(4 * 8 + 5) * 4 + 1] = 0; + assert_eq!(sample_rgb(&img, (0.5, 0.5)), Some([0, 192, 0])); + } + + #[test] + fn eyedropper_clamps_to_the_picture_edges() { + let img = flat(4, 3, [10, 20, 30, 255]); + for uv in [(0.0, 0.0), (1.0, 1.0), (0.999, 0.0), (0.0, 0.999)] { + assert_eq!(sample_rgb(&img, uv), Some([10, 20, 30])); + } + } + + #[test] + fn eyedropper_rejects_empty_and_truncated_frames() { + assert_eq!(sample_rgb(&Rgba { w: 0, h: 0, px: vec![] }, (0.5, 0.5)), None); + assert_eq!(sample_rgb(&Rgba { w: 4, h: 4, px: vec![0; 10] }, (0.5, 0.5)), None); + } +} diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index cf33d04ec..0f866a1ec 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -179,7 +179,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option { - p.text(area.center(), Align2::CENTER_CENTER, "(no audio)", Tokens::ui(12.0), t.text_dim); + p.text(area.center(), Align2::CENTER_CENTER, tl!("(no audio)"), Tokens::ui(12.0), t.text_dim); } None => { - p.text(area.center(), Align2::CENTER_CENTER, "Generating waveform…", Tokens::ui(12.0), t.text_dim); + p.text(area.center(), Align2::CENTER_CENTER, tl!("Generating waveform…"), Tokens::ui(12.0), t.text_dim); ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } } @@ -597,7 +597,13 @@ pub fn waveform( p.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.playhead)); let resp = ui.interact(area, egui::Id::new("source-waveform"), Sense::click_and_drag()); app.auto.add("source.waveform", area, "audio waveform"); + // Horizontal waveform drags scrub; dragging vertically lifts the marked audio to the timeline. + let delta = resp.drag_delta(); + if resp.dragged() && delta.y.abs() > delta.x.abs().max(6.0) && crate::panels::dragged_source(ui).is_none() { + crate::panels::source_drag::begin(app, ui, false, true); + } if (resp.clicked() || resp.dragged()) + && crate::panels::dragged_source(ui).is_none() && let Some(pos) = resp.interact_pointer_pos() { let f = ((pos.x - area.min.x) / area.width()).clamp(0.0, 1.0) as f64; @@ -613,11 +619,13 @@ pub fn compare_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, rate: f let t = app.tokens; let rf = Tick(app.ui.program.compare_ref.unwrap_or(app.session.playhead().0)); let tc = format_time(rf, rate, drop_frame, TimeDisplay::Timecode, 48000); - ui.painter().text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, format!("Reference {tc}"), Tokens::ui(11.0), t.text_dim); + ui.painter().text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, tlf!("Reference {tc}", tc), Tokens::ui(11.0), t.text_dim); let mut x = bar.max.x; - for (key, label, tip) in - [("set", "Set", "Set the reference to the playhead"), ("next", "▶", "Reference: next frame"), ("prev", "◀", "Reference: previous frame")] - { + for (key, label, tip) in [ + ("set", tl!("Set"), tl!("Set the reference to the playhead")), + ("next", "▶", tl!("Reference: next frame")), + ("prev", "◀", tl!("Reference: previous frame")), + ] { let r = Rect::from_min_max(pos2(x - 34.0, bar.min.y + 1.0), pos2(x - 2.0, bar.max.y - 1.0)); x -= 34.0; let resp = ui.interact(r, egui::Id::new(("compare", key)), Sense::click()).on_hover_text(tip); @@ -647,45 +655,45 @@ pub fn wrench_items(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which) { let v = view(app, w).clone(); let mode = v.display_mode(); let mut out: Picks = Vec::new(); - pick(ui, &mut out, "display.composite", "Composite Video", mode == Some(DisplayMode::Composite)); - ui.menu_button("RGBA Channels", |ui| { + pick(ui, &mut out, "display.composite", tl!("Composite Video"), mode == Some(DisplayMode::Composite)); + ui.menu_button(tl!("RGBA Channels"), |ui| { for (k, l, m) in [ - ("alpha", "Alpha", DisplayMode::Alpha), - ("red", "Red", DisplayMode::Red), - ("green", "Green", DisplayMode::Green), - ("blue", "Blue", DisplayMode::Blue), + ("alpha", tl!("Alpha"), DisplayMode::Alpha), + ("red", tl!("Red"), DisplayMode::Red), + ("green", tl!("Green"), DisplayMode::Green), + ("blue", tl!("Blue"), DisplayMode::Blue), ] { pick(ui, &mut out, &format!("display.{k}"), l, mode == Some(m)); } }); if w == Which::Program { - pick(ui, &mut out, "display.multicam", "Multi-Camera", v.multicam); - pick(ui, &mut out, "display.comparison", "Comparison View", mode == Some(DisplayMode::Comparison)); + pick(ui, &mut out, "display.multicam", tl!("Multi-Camera"), v.multicam); + pick(ui, &mut out, "display.comparison", tl!("Comparison View"), mode == Some(DisplayMode::Comparison)); } else { - pick(ui, &mut out, "display.audioWaveform", "Audio Waveform", mode == Some(DisplayMode::AudioWaveform)); - pick(ui, &mut out, "display.videoAndWaveform", "Video and Audio Waveform Split", mode == Some(DisplayMode::VideoAndWaveform)); + pick(ui, &mut out, "display.audioWaveform", tl!("Audio Waveform"), mode == Some(DisplayMode::AudioWaveform)); + pick(ui, &mut out, "display.videoAndWaveform", tl!("Video and Audio Waveform Split"), mode == Some(DisplayMode::VideoAndWaveform)); } ui.separator(); - ui.menu_button("Playback Resolution", |ui| { + ui.menu_button(tl!("Playback Resolution"), |ui| { for (k, r) in RES_NAMES { - pick(ui, &mut out, &format!("playbackRes.{k}"), r.label(), v.res == r); + pick(ui, &mut out, &format!("playbackRes.{k}"), crate::i18n::t(r.label()), v.res == r); } }); - ui.menu_button("Paused Resolution", |ui| { + ui.menu_button(tl!("Paused Resolution"), |ui| { for (k, r) in RES_NAMES { - pick(ui, &mut out, &format!("pausedRes.{k}"), r.label(), v.paused_res == r); + pick(ui, &mut out, &format!("pausedRes.{k}"), crate::i18n::t(r.label()), v.paused_res == r); } }); - pick(ui, &mut out, "highQualityPlayback", "High Quality Playback", v.high_quality); + pick(ui, &mut out, "highQualityPlayback", tl!("High Quality Playback"), v.high_quality); ui.separator(); - pick(ui, &mut out, "showRulers", "Show Rulers", v.show_rulers); - pick(ui, &mut out, "showGuides", "Show Guides", v.show_guides); + pick(ui, &mut out, "showRulers", tl!("Show Rulers"), v.show_rulers); + pick(ui, &mut out, "showGuides", tl!("Show Guides"), v.show_guides); if !v.guides.is_empty() { - pick(ui, &mut out, "lockGuides", "Lock Guides", v.lock_guides); - pick(ui, &mut out, "clearGuides", "Clear Guides", false); + pick(ui, &mut out, "lockGuides", tl!("Lock Guides"), v.lock_guides); + pick(ui, &mut out, "clearGuides", tl!("Clear Guides"), false); } if w == Which::Program { - pick(ui, &mut out, "snapInProgramMonitor", "Snap in Program Monitor", v.snap); + pick(ui, &mut out, "snapInProgramMonitor", tl!("Snap in Program Monitor"), v.snap); } ui.separator(); let pfx = prefix(w); @@ -703,7 +711,7 @@ pub fn zoom_menu(app: &mut FilmcraftApp, resp: &egui::Response, w: Which) { let mut out: Picks = Vec::new(); egui::Popup::menu(resp).show(|ui| { ui.set_min_width(80.0); - pick(ui, &mut out, "fit", "Fit", zoom.is_none()); + pick(ui, &mut out, "fit", tl!("Fit"), zoom.is_none()); ui.separator(); for (k, z) in ZOOMS { pick(ui, &mut out, k, &format!("{k}%"), zoom == Some(z)); @@ -731,100 +739,103 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); let mut act: Option<(String, Value)> = None; let templates: Vec = app.session.prefs.guides.templates.iter().map(|t| t.name.clone()).collect(); - let title = match &d { - GuideDialog::Add { .. } => "Add Guide", - GuideDialog::SaveTemplate { .. } => "Save Guides as Template", - GuideDialog::Manage { .. } => "Manage Guides", + let (title, shown) = match &d { + GuideDialog::Add { .. } => ("Add Guide", tl!("Add Guide")), + GuideDialog::SaveTemplate { .. } => ("Save Guides as Template", tl!("Save Guides as Template")), + GuideDialog::Manage { .. } => ("Manage Guides", tl!("Manage Guides")), }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| match &mut d { - GuideDialog::Add { vertical, position, source } => { - let m = if *source { "source" } else { "program" }; - ui.horizontal(|ui| { - ui.label("Orientation:"); - let r = ui.radio(*vertical, "Vertical"); - push(&mut elems, "guides.add.vertical", &r, "Vertical"); - if r.clicked() { - *vertical = true; - } - let r = ui.radio(!*vertical, "Horizontal"); - push(&mut elems, "guides.add.horizontal", &r, "Horizontal"); - if r.clicked() { - *vertical = false; - } - }); - ui.horizontal(|ui| { - ui.label("Position:"); - let r = ui.add(egui::DragValue::new(position).speed(1.0).suffix(" px")); - push(&mut elems, "guides.add.position", &r, "Position"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "guides.add.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "guides.add.ok", &r, "OK"); - if r.clicked() { - act = Some(( - "view.addGuide".into(), - json!({"monitor": m, "orientation": if *vertical { "vertical" } else { "horizontal" }, "position": *position}), - )); - } - }); - } - GuideDialog::SaveTemplate { name, source } => { - let m = if *source { "source" } else { "program" }; - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(name); - push(&mut elems, "guides.save.name", &r, "Name"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "guides.save.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "guides.save.ok", &r, "OK"); - if r.clicked() { - act = Some(("view.guideTemplates.save".into(), json!({"monitor": m, "name": name.clone()}))); - } - }); - } - GuideDialog::Manage { selected, source } => { - let m = if *source { "source" } else { "program" }; - if templates.is_empty() { - ui.label("No saved guide templates."); + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( + ctx, + |ui| match &mut d { + GuideDialog::Add { vertical, position, source } => { + let m = if *source { "source" } else { "program" }; + ui.horizontal(|ui| { + ui.label(tl!("Orientation:")); + let r = ui.radio(*vertical, tl!("Vertical")); + push(&mut elems, "guides.add.vertical", &r, "Vertical"); + if r.clicked() { + *vertical = true; + } + let r = ui.radio(!*vertical, tl!("Horizontal")); + push(&mut elems, "guides.add.horizontal", &r, "Horizontal"); + if r.clicked() { + *vertical = false; + } + }); + ui.horizontal(|ui| { + ui.label(tl!("Position:")); + let r = ui.add(egui::DragValue::new(position).speed(1.0).suffix(" px")); + push(&mut elems, "guides.add.position", &r, "Position"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "guides.add.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "guides.add.ok", &r, "OK"); + if r.clicked() { + act = Some(( + "view.addGuide".into(), + json!({"monitor": m, "orientation": if *vertical { "vertical" } else { "horizontal" }, "position": *position}), + )); + } + }); } - for (i, n) in templates.iter().enumerate() { - let r = ui.selectable_label(*selected == Some(i), n); - push(&mut elems, &format!("guides.manage.row.{i}"), &r, n); - if r.clicked() { - *selected = Some(i); - } + GuideDialog::SaveTemplate { name, source } => { + let m = if *source { "source" } else { "program" }; + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(name); + push(&mut elems, "guides.save.name", &r, "Name"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "guides.save.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "guides.save.ok", &r, "OK"); + if r.clicked() { + act = Some(("view.guideTemplates.save".into(), json!({"monitor": m, "name": name.clone()}))); + } + }); } - ui.separator(); - ui.horizontal(|ui| { - let sel = selected.and_then(|i| templates.get(i)).cloned(); - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Apply")); - push(&mut elems, "guides.manage.apply", &r, "Apply"); - if r.clicked() - && let Some(n) = &sel - { - act = Some(("view.guideTemplates.apply".into(), json!({"monitor": m, "name": n}))); + GuideDialog::Manage { selected, source } => { + let m = if *source { "source" } else { "program" }; + if templates.is_empty() { + ui.label(tl!("No saved guide templates.")); } - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Delete")); - push(&mut elems, "guides.manage.delete", &r, "Delete"); - if r.clicked() - && let Some(n) = &sel - { - act = Some(("view.guideTemplates.delete".into(), json!({"name": n}))); - *selected = None; + for (i, n) in templates.iter().enumerate() { + let r = ui.selectable_label(*selected == Some(i), n); + push(&mut elems, &format!("guides.manage.row.{i}"), &r, n); + if r.clicked() { + *selected = Some(i); + } } - let r = ui.button("Close"); - push(&mut elems, "guides.manage.close", &r, "Close"); - close |= r.clicked(); - }); - } - }); + ui.separator(); + ui.horizontal(|ui| { + let sel = selected.and_then(|i| templates.get(i)).cloned(); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Apply"))); + push(&mut elems, "guides.manage.apply", &r, "Apply"); + if r.clicked() + && let Some(n) = &sel + { + act = Some(("view.guideTemplates.apply".into(), json!({"monitor": m, "name": n}))); + } + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Delete"))); + push(&mut elems, "guides.manage.delete", &r, "Delete"); + if r.clicked() + && let Some(n) = &sel + { + act = Some(("view.guideTemplates.delete".into(), json!({"name": n}))); + *selected = None; + } + let r = ui.button(tl!("Close")); + push(&mut elems, "guides.manage.close", &r, "Close"); + close |= r.clicked(); + }); + } + }, + ); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs index 5cec515ba..b5403b3d6 100644 --- a/crates/ui-egui/src/panels/multicam.rs +++ b/crates/ui-egui/src/panels/multicam.rs @@ -53,7 +53,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option { app.ui.multicam_record = params.get("enabled").and_then(Value::as_bool).unwrap_or(!app.ui.multicam_record); - app.ui.status = format!("Multi-Camera Record {}", if app.ui.multicam_record { "on" } else { "off" }); + app.ui.status = if app.ui.multicam_record { tl!("Multi-Camera Record on") } else { tl!("Multi-Camera Record off") }.to_string(); return Some(Ok(json!({"record": app.ui.multicam_record}))); } _ => {} @@ -73,8 +73,8 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option = app.session.state.project_selection.iter().map(|i| i.0).collect(); let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default(); let name = match kind { - "merge" => format!("{first} - Merged"), - "multicam" => format!("{first} Multicam"), + "merge" => tlf!("{first} - Merged", first), + "multicam" => tlf!("{first} Multicam", first), _ => String::new(), }; app.ui.sync_dialog = Some(SyncDraft { kind: kind.into(), items, name, ..Default::default() }); @@ -85,120 +85,131 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp elems.push((id.into(), r.rect, label.into())); } -const METHODS: [(&str, &str); 5] = [("in", "In Points"), ("out", "Out Points"), ("timecode", "Timecode"), ("marker", "Clip Marker"), ("audio", "Audio")]; +/// Synchronize Point choices: (key, label). +fn methods() -> [(&'static str, &'static str); 5] { + [("in", tl!("In Points")), ("out", tl!("Out Points")), ("timecode", tl!("Timecode")), ("marker", tl!("Clip Marker")), ("audio", tl!("Audio"))] +} /// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.sync_dialog.clone() else { return }; let (title, pre) = match d.kind.as_str() { - "merge" => ("Merge Clips", "merge"), - "multicam" => ("Create Multi-Camera Source Sequence", "mcam"), - _ => ("Synchronize Clips", "sync"), + "merge" => (tl!("Merge Clips"), "merge"), + "multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"), + _ => (tl!("Synchronize Clips"), "sync"), }; let mut elems: Elems = Vec::new(); let mut action: Option<&str> = None; let accent = app.tokens.accent; let names: Vec = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect(); - egui::Window::new(title).collapsible(false).resizable(false).default_width(440.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - if pre != "sync" { - ui.horizontal(|ui| { - ui.label(if pre == "merge" { "Clip Name:" } else { "Sequence Name:" }); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); - push(&mut elems, format!("{pre}.name"), &r, "name"); - }); - ui.label(RichText::new(format!("{} clip(s): {}", names.len(), names.join(", "))).weak().small()); - ui.add_space(4.0); - } else { - let n = app.session.state.selection.len(); - ui.label(RichText::new(format!("{n} clip(s) selected; the clip on the reference track stays put.")).weak().small()); - } - ui.group(|ui| { - ui.set_min_width(400.0); - ui.label(RichText::new("Synchronize Point").strong()); - for (m, label) in METHODS { + egui::Window::new(title) + .id(egui::Id::new(("sync-dialog", pre))) + .collapsible(false) + .resizable(false) + .default_width(440.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + if pre != "sync" { ui.horizontal(|ui| { - let r = ui.radio(d.method == m, label); - push(&mut elems, format!("{pre}.method.{m}"), &r, label); - if r.clicked() { - d.method = m.into(); - } - match m { - "timecode" => { - let r = ui.add_enabled(d.method == "timecode", egui::Checkbox::new(&mut d.ignore_hours, "Ignore Hours")); - push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); - } - "marker" => { - let r = - ui.add_enabled(d.method == "marker", egui::TextEdit::singleline(&mut d.marker).hint_text("first marker").desired_width(140.0)); - push(&mut elems, format!("{pre}.marker"), &r, "marker name"); - } - _ => {} - } + ui.label(if pre == "merge" { tl!("Clip Name:") } else { tl!("Sequence Name:") }); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + push(&mut elems, format!("{pre}.name"), &r, "name"); }); + ui.label(RichText::new(tlf!("{n} clip(s): {names}", n = names.len(), names = names.join(", "))).weak().small()); + ui.add_space(4.0); + } else { + let n = app.session.state.selection.len(); + ui.label(RichText::new(tlf!("{n} clip(s) selected; the clip on the reference track stays put.", n)).weak().small()); } - ui.horizontal(|ui| { - ui.label(if pre == "merge" { "Offset Audio by:" } else { "Offset:" }); - let r = ui.add(egui::DragValue::new(&mut d.offset).range(-100_000..=100_000).suffix(" frames")); - push(&mut elems, format!("{pre}.offset"), &r, "offset"); - }); - }); - match pre { - "sync" => { - ui.horizontal(|ui| { - ui.label("Reference track:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text("lowest (V1…)").desired_width(90.0)); - push(&mut elems, "sync.track", &r, "reference track"); - }); - } - "merge" => { - let r = ui.checkbox(&mut d.remove_video_audio, "Remove Audio from AV Clip"); - push(&mut elems, "merge.removeVideoAudio", &r, "Remove Audio from AV Clip"); - } - _ => { - ui.group(|ui| { - ui.set_min_width(400.0); - ui.label(RichText::new("Audio").strong()); + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new(tl!("Synchronize Point")).strong()); + for (m, label) in methods() { ui.horizontal(|ui| { - for (a, label) in [("camera1", "Camera 1"), ("all", "All Cameras"), ("switch", "Switch Audio")] { - let r = ui.radio(d.audio == a, label); - push(&mut elems, format!("mcam.audio.{a}"), &r, label); - if r.clicked() { - d.audio = a.into(); - } + let r = ui.radio(d.method == m, label); + push(&mut elems, format!("{pre}.method.{m}"), &r, label); + if r.clicked() { + d.method = m.into(); } - }); - ui.label(RichText::new("Camera Names").strong()); - ui.horizontal(|ui| { - for (a, label) in [("clip", "Clip Names"), ("track", "Enumerate Cameras"), ("metadata", "Camera Angle Metadata")] { - let r = ui.radio(d.camera_names == a, label); - push(&mut elems, format!("mcam.cameraNames.{a}"), &r, label); - if r.clicked() { - d.camera_names = a.into(); + match m { + "timecode" => { + let r = ui.add_enabled(d.method == "timecode", egui::Checkbox::new(&mut d.ignore_hours, tl!("Ignore Hours"))); + push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); } + "marker" => { + let r = ui.add_enabled( + d.method == "marker", + egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + ); + push(&mut elems, format!("{pre}.marker"), &r, "marker name"); + } + _ => {} } }); + } + ui.horizontal(|ui| { + ui.label(if pre == "merge" { tl!("Offset Audio by:") } else { tl!("Offset:") }); + let r = ui.add(egui::DragValue::new(&mut d.offset).range(-100_000..=100_000).suffix(tl!(" frames"))); + push(&mut elems, format!("{pre}.offset"), &r, "offset"); }); - let r = ui.checkbox(&mut d.processed_bin, "Move source clips to Processed Clips bin"); - push(&mut elems, "mcam.processedBin", &r, "Move source clips to Processed Clips bin"); + }); + match pre { + "sync" => { + ui.horizontal(|ui| { + ui.label(tl!("Reference track:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)); + push(&mut elems, "sync.track", &r, "reference track"); + }); + } + "merge" => { + let r = ui.checkbox(&mut d.remove_video_audio, tl!("Remove Audio from AV Clip")); + push(&mut elems, "merge.removeVideoAudio", &r, "Remove Audio from AV Clip"); + } + _ => { + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new(tl!("Audio")).strong()); + ui.horizontal(|ui| { + for (a, label) in [("camera1", tl!("Camera 1")), ("all", tl!("All Cameras")), ("switch", tl!("Switch Audio"))] { + let r = ui.radio(d.audio == a, label); + push(&mut elems, format!("mcam.audio.{a}"), &r, label); + if r.clicked() { + d.audio = a.into(); + } + } + }); + ui.label(RichText::new(tl!("Camera Names")).strong()); + ui.horizontal(|ui| { + for (a, label) in [("clip", tl!("Clip Names")), ("track", tl!("Enumerate Cameras")), ("metadata", tl!("Camera Angle Metadata"))] { + let r = ui.radio(d.camera_names == a, label); + push(&mut elems, format!("mcam.cameraNames.{a}"), &r, label); + if r.clicked() { + d.camera_names = a.into(); + } + } + }); + }); + let r = ui.checkbox(&mut d.processed_bin, tl!("Move source clips to Processed Clips bin")); + push(&mut elems, "mcam.processedBin", &r, "Move source clips to Processed Clips bin"); + } } - } - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent)); - push(&mut elems, format!("{pre}.ok"), &r, "OK"); - if r.clicked() { - action = Some("ok"); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); } + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(accent)); + push(&mut elems, format!("{pre}.ok"), &r, "OK"); + if r.clicked() { + action = Some("ok"); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -244,9 +255,9 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute(cmd, p) { Ok(v) => { app.ui.status = match pre { - "sync" => format!("Synchronized {} clip group(s)", v["moved"]), - "merge" => "Merged clip created".into(), - _ => format!("Multi-camera source sequence with {} camera(s)", v["cameras"].as_array().map_or(0, Vec::len)), + "sync" => tlf!("Synchronized {n} clip group(s)", n = v["moved"]), + "merge" => tl!("Merged clip created").into(), + _ => tlf!("Multi-camera source sequence with {n} camera(s)", n = v["cameras"].as_array().map_or(0, Vec::len)), }; app.ui.sync_dialog = None; return; @@ -284,7 +295,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let playhead = app.session.playhead(); let info = filmcraft_engine::multicam::inspect_at(&app.session, playhead); let Some(src) = info["source"].as_u64().map(ItemId) else { - ui.painter().text(area.center(), Align2::CENTER_CENTER, "No multi-camera clip at the playhead", Tokens::ui(12.0), t.text_dim); + ui.painter().text(area.center(), Align2::CENTER_CENTER, tl!("No multi-camera clip at the playhead"), Tokens::ui(12.0), t.text_dim); return; }; let Some(q) = app.session.project.sequence(src) else { return }; @@ -339,7 +350,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let cell_i = k - lay.first; let cell = Rect::from_min_size(pic.min + vec2((cell_i % cols) as f32 * cw, (cell_i / cols) as f32 * ch), vec2(cw, ch)); let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default(); - let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(format!("Camera {} — {name}", k + 1)); + let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name); if resp.clicked() { // modifiers held now, or carried by the click event (synthetic input) @@ -366,11 +377,15 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { if paged { let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); let mid = bar.center(); - ui.painter().text(mid, Align2::CENTER_CENTER, format!("Page {} of {}", lay.page + 1, lay.pages), Tokens::ui(11.0), t.text_dim); + ui.painter().text(mid, Align2::CENTER_CENTER, tlf!("Page {page} of {pages}", page = lay.page + 1, pages = lay.pages), Tokens::ui(11.0), t.text_dim); for (id, label, dx, cmd) in [("pagePrev", "◀", -70.0, "multicam.prevPage"), ("pageNext", "▶", 70.0, "multicam.nextPage")] { let r = Rect::from_center_size(mid + vec2(dx, 0.0), vec2(26.0, 18.0)); let enabled = if cmd == "multicam.prevPage" { lay.page > 0 } else { lay.page + 1 < lay.pages }; - let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { "Previous page" } else { "Next page" }); + let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { + tl!("Previous page") + } else { + tl!("Next page") + }); ui.painter().rect_filled(r, 3.0, if resp.hovered() && enabled { t.hover } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(10.0), if enabled { t.text } else { t.text_faint }); app.auto.add(&format!("program.multicam.{id}"), r, label); @@ -382,7 +397,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { // record indicator let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0)); let rec_on = app.ui.multicam_record; - let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text("Multi-Camera Record On/Off (0)"); + let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); app.auto.add("program.multicam.record", rr, "Multi-Camera Record"); let dot = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) @@ -393,7 +408,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { }; ui.painter().rect_filled(rr, 3.0, Color32::from_black_alpha(140)); ui.painter().circle_filled(rr.left_center() + vec2(9.0, 0.0), 4.5, dot); - ui.painter().text(rr.left_center() + vec2(18.0, 0.0), Align2::LEFT_CENTER, if recording { "REC" } else { "Record" }, Tokens::ui(10.5), Color32::WHITE); + ui.painter().text(rr.left_center() + vec2(18.0, 0.0), Align2::LEFT_CENTER, if recording { "REC" } else { tl!("Record") }, Tokens::ui(10.5), Color32::WHITE); if resp.clicked() { app.ui.multicam_record = !app.ui.multicam_record; } @@ -435,45 +450,51 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { } let th = thumb_w * q.settings.height.max(1) as f32 / q.settings.width.max(1) as f32; let tokens = app.tokens; - egui::Window::new("Edit Cameras").collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(RichText::new("Cameras shown in the Multi-Camera view (uncheck to hide).").weak().small()); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for a in 0..d.names.len() { - ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.enabled[a], ""); - push(&mut elems, format!("editCameras.enabled.{a}"), &r, "Enabled"); - let (tr, _) = ui.allocate_exact_size(vec2(thumb_w, th), Sense::hover()); - ui.painter().rect_filled(tr, 2.0, tokens.monitor_bg); - match thumbs[a] { - (_, Some(tex)) => { - ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + egui::Window::new(tl!("Edit Cameras")) + .id(egui::Id::new("Edit Cameras")) + .collapsible(false) + .resizable(false) + .default_width(420.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new(tl!("Cameras shown in the Multi-Camera view (uncheck to hide).")).weak().small()); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for a in 0..d.names.len() { + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.enabled[a], ""); + push(&mut elems, format!("editCameras.enabled.{a}"), &r, "Enabled"); + let (tr, _) = ui.allocate_exact_size(vec2(thumb_w, th), Sense::hover()); + ui.painter().rect_filled(tr, 2.0, tokens.monitor_bg); + match thumbs[a] { + (_, Some(tex)) => { + ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + (false, None) => { + ui.painter().text(tr.center(), Align2::CENTER_CENTER, tl!("Audio"), Tokens::ui(10.0), tokens.text_dim); + } + _ => {} } - (false, None) => { - ui.painter().text(tr.center(), Align2::CENTER_CENTER, "Audio", Tokens::ui(10.0), tokens.text_dim); - } - _ => {} - } - elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); - ui.label(format!("{}", a + 1)); - let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); - push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); - }); - } + elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); + ui.label(format!("{}", a + 1)); + let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); + push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); + }); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "editCameras.cancel", &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(tokens.accent)); + push(&mut elems, "editCameras.ok", &r, "OK"); + if r.clicked() { + action = Some("ok"); + } + }); }); - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "editCameras.cancel", &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(tokens.accent)); - push(&mut elems, "editCameras.ok", &r, "OK"); - if r.clicked() { - action = Some("ok"); - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -511,7 +532,7 @@ pub fn on_stop(app: &mut FilmcraftApp) { if app.session.mcrec.active() { let t = app.session.playhead(); match app.session.execute("multicam.recordStop", json!({"time": t.0})) { - Ok(v) if v["cuts"].as_u64().unwrap_or(0) > 0 => app.ui.status = format!("Recorded {} multi-camera cut(s)", v["cuts"]), + Ok(v) if v["cuts"].as_u64().unwrap_or(0) > 0 => app.ui.status = tlf!("Recorded {n} multi-camera cut(s)", n = v["cuts"]), Ok(_) => {} Err(e) => app.ui.status = e.to_string(), } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 46b94db98..61e6bb18f 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -18,7 +18,7 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O let t = app.tokens; let mut action = None; for p in app.session.presets.all() { - if !filter.is_empty() && !p.name.to_ascii_lowercase().contains(filter) { + if !crate::i18n::matches_query(&p.name, filter) { continue; } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click_and_drag()); @@ -32,9 +32,9 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O Icon::Sparkle, if p.builtin { t.text_dim } else { t.accent }, ); - ui.painter().text(pos2(x + 12.0, r.center().y), Align2::LEFT_CENTER, &p.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x + 12.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(&p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.preset.{}", p.name), r, &p.name); - let tip = if p.description.is_empty() { p.name.clone() } else { p.description.clone() }; + let tip = crate::i18n::t(if p.description.is_empty() { &p.name } else { &p.description }).to_string(); let resp = resp.on_hover_text(tip); if resp.drag_started() { crate::panels::start_drag_effect(ui, &format!("preset:{}", p.name)); @@ -43,15 +43,15 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O action = Some(("presets.apply".to_string(), p.name.clone())); } resp.context_menu(|ui| { - if !p.builtin && ui.button("Delete Preset").clicked() { + if !p.builtin && ui.button(tl!("Delete Preset")).clicked() { action = Some(("presets.delete".to_string(), p.name.clone())); ui.close(); } - if ui.button("Export Preset…").clicked() { + if ui.button(tl!("Export Preset…")).clicked() { action = Some(("presets.export".to_string(), p.name.clone())); ui.close(); } - if ui.button("Import Presets…").clicked() { + if ui.button(tl!("Import Presets…")).clicked() { action = Some(("presets.import".to_string(), String::new())); ui.close(); } @@ -66,11 +66,11 @@ pub fn run(app: &mut FilmcraftApp, cmd: &str, name: &str) { "presets.apply" => app.session.execute(cmd, json!({"preset": name})), "presets.delete" => app.session.execute(cmd, json!({"name": name})), "presets.export" => { - let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f("Effect presets", &["json"], &format!("{name}.json"))) else { return }; + let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"], &format!("{name}.json"))) else { return }; app.session.execute(cmd, json!({"path": path, "names": [name]})) } "presets.import" => { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Effect presets", &["json"])) else { return }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"])) else { return }; app.session.execute(cmd, json!({"path": path})) } _ => return, @@ -90,35 +90,43 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let mut close = false; let mut apply = false; - egui::Window::new("Save Preset").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(&mut d.name); - app.auto.add("savePreset.name", r.rect, "Name"); - }); - ui.add_space(4.0); - ui.label(egui::RichText::new("Type").strong()); - for (key, label) in [("scale", "Scale"), ("anchorIn", "Anchor to In Point"), ("anchorOut", "Anchor to Out Point"), ("none", "Without keyframes")] { - let r = ui.radio(d.keyframes == key, label); - app.auto.add(&format!("savePreset.keyframes.{key}"), r.rect, label); - if r.clicked() { - d.keyframes = key.into(); + egui::Window::new(tl!("Save Preset")).id(egui::Id::new("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( + ctx, + |ui| { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(&mut d.name); + app.auto.add("savePreset.name", r.rect, "Name"); + }); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Type")).strong()); + for (key, label) in [ + ("scale", tl!("Scale")), + ("anchorIn", tl!("Anchor to In Point")), + ("anchorOut", tl!("Anchor to Out Point")), + ("none", tl!("Without keyframes")), + ] { + let r = ui.radio(d.keyframes == key, label); + app.auto.add(&format!("savePreset.keyframes.{key}"), r.rect, label); + if r.clicked() { + d.keyframes = key.into(); + } } - } - ui.add_space(4.0); - ui.label("Description:"); - let r = ui.text_edit_multiline(&mut d.description); - app.auto.add("savePreset.description", r.rect, "Description"); - ui.add_space(6.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - app.auto.add("savePreset.cancel", c.rect, "Cancel"); - let o = ui.add_enabled(!d.name.trim().is_empty(), egui::Button::new("OK")); - app.auto.add("savePreset.ok", o.rect, "OK"); - apply = o.clicked(); - close = apply || c.clicked(); - }); - }); + ui.add_space(4.0); + ui.label(tl!("Description:")); + let r = ui.text_edit_multiline(&mut d.description); + app.auto.add("savePreset.description", r.rect, "Description"); + ui.add_space(6.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + app.auto.add("savePreset.cancel", c.rect, "Cancel"); + let o = ui.add_enabled(!d.name.trim().is_empty(), egui::Button::new(tl!("OK"))); + app.auto.add("savePreset.ok", o.rect, "OK"); + apply = o.clicked(); + close = apply || c.clicked(); + }); + }, + ); app.ui.save_preset = if close || !open { None } else { Some(d.clone()) }; if apply { let r = app.session.execute( @@ -126,7 +134,7 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { json!({"clip": d.clip, "effects": d.effects, "name": d.name.trim(), "description": d.description, "keyframes": d.keyframes}), ); match r { - Ok(_) => app.ui.status = format!("Saved preset \u{201c}{}\u{201d}", d.name.trim()), + Ok(_) => app.ui.status = tlf!("Saved preset “{name}”", name = d.name.trim()), Err(e) => app.ui.status = e.to_string(), } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 399a12b87..09b040694 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -277,10 +277,10 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.separator)); let mut x = strip.min.x + 6.0; let active = shown_inst(app); - let mut entries: Vec<(Option, String)> = vec![(None, format!("Project: {}", app.session.project.name))]; + let mut entries: Vec<(Option, String)> = vec![(None, tlf!("Project: {name}", name = app.session.project.name))]; for k in docked { let name = app.session.project.root.find_bin(BinId(app.ui.project_panel.tabs[*k].bin)).map(|b| b.name.clone()).unwrap_or_default(); - entries.push((Some(*k), format!("Bin: {name}"))); + entries.push((Some(*k), tlf!("Bin: {name}", name))); } let mut close = None; for (k, label) in entries { @@ -304,7 +304,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u } if let Some(k) = k { let cr = Rect::from_center_size(pos2(r.max.x - 11.0, r.center().y), vec2(12.0, 12.0)); - let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text("Close"); + let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); app.auto.add(&format!("project.tab.{k}.close"), cr, "Close"); icons::paint(ui.painter(), cr.shrink(2.0), Icon::Close, if cresp.hovered() { t.text } else { t.text_dim }); if cresp.clicked() || resp.middle_clicked() { @@ -334,7 +334,7 @@ pub fn floating(app: &mut FilmcraftApp, ctx: &egui::Context) { }; let mut open = true; let root = app.ui.project_panel.tabs[k].bin == app.session.project.root.id.0; - egui::Window::new(if root { format!("Project: {}", app.session.project.name) } else { format!("Bin: {name}") }) + egui::Window::new(if root { tlf!("Project: {name}", name = app.session.project.name) } else { tlf!("Bin: {name}", name) }) .id(egui::Id::new(("bin-window", k))) .open(&mut open) .default_size(vec2(520.0, 360.0)) @@ -380,7 +380,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions Inst::Tab(k) => BinId(app.ui.project_panel.tabs[k].bin), }; let up = Rect::from_min_size(pos2(head.min.x, head.center().y - 9.0), vec2(22.0, 18.0)); - let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text("Up one level"); + let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); app.auto.add(&format!("{pre}.up"), up, "Up one level"); let can_up = v.bin != top; if up_resp.hovered() && can_up { @@ -408,11 +408,11 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions let search_rect = Rect::from_min_size(row.min, vec2(sw, 22.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(search_rect).id_salt((&pre, "search"))); let mut q = app.ui.project_search.clone(); - crate::widgets::search_field(&mut child, &mut q, "Search", search_rect.width(), &t); + crate::widgets::search_field(&mut child, &mut q, tl!("Search"), search_rect.width(), &t); app.ui.project_search = q; app.auto.add(&format!("{pre}.search"), search_rect, "Search"); let find = Rect::from_min_size(pos2(search_rect.max.x + 8.0, row.min.y), vec2(24.0, 22.0)); - let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text("Find…"); + let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); app.auto.add(&format!("{pre}.find"), find, "Find…"); if fr.hovered() { ui.painter().rect_filled(find, 3.0, t.hover); @@ -424,7 +424,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions let filter = app.ui.project_search.to_ascii_lowercase(); let total = count_items(app, v.bin); let sel = app.session.state.project_selection.len(); - let count = if sel > 0 { format!("{sel} of {total} items selected") } else { format!("{total} items") }; + let count = if sel > 0 { tlf!("{sel} of {total} items selected", sel, total) } else { tlf!("{total} items", total) }; ui.painter().text(pos2(row.max.x, row.center().y - 1.0), Align2::RIGHT_CENTER, &count, Tokens::ui(fs - 1.0), t.text_dim); app.auto.add(&format!("{pre}.count"), Rect::from_min_max(pos2(row.max.x - 160.0, row.min.y), row.max), &count); @@ -495,7 +495,7 @@ fn preview_area(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, pre: &str) { ui.painter().rect_filled(r, 4.0, t.tl_header_bg); app.auto.add(&format!("{pre}.previewArea"), r, "Preview Area"); let Some(id) = app.session.state.project_selection.first().copied() else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, "Select an item to preview it", Tokens::ui(11.0), t.text_faint); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Select an item to preview it"), Tokens::ui(11.0), t.text_faint); return; }; let Some(it) = app.session.project.item(id).cloned() else { return }; @@ -513,10 +513,10 @@ fn preview_area(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, pre: &str) { let (vu, au) = filmcraft_engine::project_panel::usage(p, id); let lines = [ it.name.clone(), - format!("{}{}", it.type_label(), if it.has_video() { format!(", {}", col("Video Info")) } else { String::new() }), + format!("{}{}", crate::i18n::t(it.type_label()), if it.has_video() { format!(", {}", col("Video Info")) } else { String::new() }), [col("Media Duration"), col("Frame Rate")].into_iter().filter(|s| !s.is_empty()).collect::>().join(", "), col("Audio Info"), - format!("Video Usage: {vu} Audio Usage: {au}"), + tlf!("Video Usage: {vu} Audio Usage: {au}", vu, au), ]; let mut y = r.min.y + 12.0; for (i, l) in lines.iter().enumerate() { @@ -539,9 +539,11 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let t = app.tokens; let pre = &v.prefix; let mut x = bar.min.x + 6.0; - for (icon, mode, tip) in - [(Icon::ListView, ViewMode::List, "List View"), (Icon::IconView, ViewMode::Icon, "Icon View"), (Icon::Freeform, ViewMode::Freeform, "Freeform View")] - { + for (icon, mode, tip) in [ + (Icon::ListView, ViewMode::List, tl!("List View")), + (Icon::IconView, ViewMode::Icon, tl!("Icon View")), + (Icon::Freeform, ViewMode::Freeform, tl!("Freeform View")), + ] { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(26.0, 22.0)); let resp = ui.interact(r, egui::Id::new((pre, "pv", tip)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.view.{mode:?}"), r, tip); @@ -575,7 +577,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action // Sort Icons (Icon view) if v.mode == ViewMode::Icon { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text("Sort Icons"); + let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); app.auto.add(&format!("{pre}.sortIcons"), r, "Sort Icons"); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -585,11 +587,11 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action } let mut rx = bar.max.x - 6.0; for (icon, id, tip) in [ - (Icon::Trash, "project.delete", "Clear (Delete)"), - (Icon::NewItem, "new-item", "New Item"), - (Icon::Folder, "file.newBin", "New Bin"), - (Icon::Search, "find", "Find"), - (Icon::Automate, "clip.automateToSequence", "Automate to Sequence"), + (Icon::Trash, "project.delete", tl!("Clear (Delete)")), + (Icon::NewItem, "new-item", tl!("New Item")), + (Icon::Folder, "file.newBin", tl!("New Bin")), + (Icon::Search, "find", tl!("Find")), + (Icon::Automate, "clip.automateToSequence", tl!("Automate to Sequence")), ] { let r = Rect::from_min_size(pos2(rx - 24.0, bar.min.y + 5.0), vec2(24.0, 22.0)); let resp = ui.interact(r, egui::Id::new((pre, "pb", id)), Sense::click()).on_hover_text(tip); @@ -607,7 +609,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); } - "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = "Select items to clear".into(), + "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(), _ => actions.push((id.into(), json!({}))), } } @@ -628,7 +630,7 @@ pub const NEW_ITEMS: [(&str, &str); 8] = [ pub fn new_item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions: &mut Actions) { for (label, c) in NEW_ITEMS { - let b = ui.button(label); + let b = ui.button(crate::i18n::t(label)); app.auto.add(&format!("{pre}.newItem.{c}"), b.rect, label); if b.clicked() { actions.push((c.into(), json!({}))); @@ -639,9 +641,9 @@ pub fn new_item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actio fn sort_icons_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions: &mut Actions) { let cur = app.session.prefs.project_panel.view.icon_sort.clone(); - let mut entries: Vec<(String, String)> = vec![("".into(), "User Order".into())]; + let mut entries: Vec<(String, String)> = vec![("".into(), tl!("User Order").into())]; for c in ["Name", "Label", "Media Type", "Frame Rate", "Media Start", "Media End", "Media Duration", "Video Info", "Audio Info", "Date Created"] { - entries.push((c.into(), c.into())); + entries.push((c.into(), crate::i18n::t(c).into())); } for (col, label) in entries { let on = cur.column == col; @@ -653,7 +655,7 @@ fn sort_icons_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions } } ui.separator(); - let r = ui.selectable_label(cur.descending, "Descending"); + let r = ui.selectable_label(cur.descending, tl!("Descending")); app.auto.add(&format!("{pre}.sortIcons.descending"), r.rect, "Descending"); if r.clicked() { actions.push(("project.view.set".into(), json!({"iconSort": {"descending": !cur.descending}}))); @@ -684,45 +686,47 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { app.auto.add(&format!("project.menu.{id}"), r.rect, label); r.clicked() }; - if item(ui, "closeProject", "Close Project", true, s_close.as_deref()) { + if item(ui, "closeProject", tl!("Close Project"), true, s_close.as_deref()) { actions.push(("file.closeProject".into(), json!({}))); } - item(ui, "closeAllOtherProjects", "Close All Other Projects", false, None); - if item(ui, "saveProject", "Save Project", true, s_save.as_deref()) { + item(ui, "closeAllOtherProjects", tl!("Close All Other Projects"), false, None); + if item(ui, "saveProject", tl!("Save Project"), true, s_save.as_deref()) { actions.push(("file.save".into(), json!({}))); } - item(ui, "refreshProject", "Refresh Project", false, None); - if item(ui, "revealProject", "Reveal Project in Finder…", has_project_path, None) { + item(ui, "refreshProject", tl!("Refresh Project"), false, None); + if item(ui, "revealProject", tl!("Reveal Project in Finder…"), has_project_path, None) { actions.push(("projectPanel.revealProject".into(), json!({}))); } ui.separator(); - if item(ui, "newBin", "New Bin", true, s_bin.as_deref()) { + if item(ui, "newBin", tl!("New Bin"), true, s_bin.as_deref()) { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); } - if item(ui, "newBinFromSelection", "New Bin From Selection", !app.session.state.project_selection.is_empty(), Some("Shift+B")) { + if item(ui, "newBinFromSelection", tl!("New Bin From Selection"), !app.session.state.project_selection.is_empty(), Some("Shift+B")) { actions.push(("file.newBinFromSelection".into(), json!({}))); } - if item(ui, "newSearchBin", "New Search Bin", true, None) { + if item(ui, "newSearchBin", tl!("New Search Bin"), true, None) { actions.push(("file.newSearchBin".into(), json!({}))); } - item(ui, "newProjectShortcut", "New Project Shortcut", false, None); - if item(ui, "rename", "Rename", has_sel, None) { + item(ui, "newProjectShortcut", tl!("New Project Shortcut"), false, None); + if item(ui, "rename", tl!("Rename"), has_sel, None) { actions.push(("projectPanel.rename".into(), json!({}))); } - if item(ui, "delete", "Delete", !app.session.state.project_selection.is_empty(), Some("Backspace")) { + if item(ui, "delete", tl!("Delete"), !app.session.state.project_selection.is_empty(), Some("Backspace")) { actions.push(("project.delete".into(), json!({}))); } ui.separator(); - if item(ui, "automateToSequence", "Automate to Sequence…", !app.session.state.project_selection.is_empty(), None) { + if item(ui, "automateToSequence", tl!("Automate to Sequence…"), !app.session.state.project_selection.is_empty(), None) { actions.push(("clip.automateToSequence".into(), json!({}))); } - if item(ui, "find", "Find…", true, s_find.as_deref()) { + if item(ui, "find", tl!("Find…"), true, s_find.as_deref()) { actions.push(("edit.find".into(), json!({}))); } ui.separator(); } - for (mode, label, key) in [(ViewMode::List, "List", "Cmd+PageUp"), (ViewMode::Icon, "Icon", "Cmd+PageDown"), (ViewMode::Freeform, "Freeform", "")] { + for (mode, label, key) in + [(ViewMode::List, tl!("List"), "Cmd+PageUp"), (ViewMode::Icon, tl!("Icon"), "Cmd+PageDown"), (ViewMode::Freeform, tl!("Freeform"), "")] + { let r = ui.add(egui::Button::selectable(v.mode == mode, label).shortcut_text(key)); app.auto.add(&format!("project.menu.view.{mode:?}"), r.rect, label); if r.clicked() { @@ -732,11 +736,11 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } ui.separator(); for (key, label, on, shortcut) in [ - ("previewArea", "Preview Area", pp.preview_area, ""), - ("thumbnails", "Thumbnails", pp.thumbnails, ""), - ("thumbnailsShowEffects", "Thumbnails show effects applied", pp.thumbnails_show_effects, ""), - ("hoverScrub", "Hover Scrub", pp.hover_scrub, "Shift+H"), - ("thumbnailControlsAllDevices", "Thumbnail controls for all pointing devices", pp.thumbnail_controls_all_devices, ""), + ("previewArea", tl!("Preview Area"), pp.preview_area, ""), + ("thumbnails", tl!("Thumbnails"), pp.thumbnails, ""), + ("thumbnailsShowEffects", tl!("Thumbnails show effects applied"), pp.thumbnails_show_effects, ""), + ("hoverScrub", tl!("Hover Scrub"), pp.hover_scrub, "Shift+H"), + ("thumbnailControlsAllDevices", tl!("Thumbnail controls for all pointing devices"), pp.thumbnail_controls_all_devices, ""), ] { let r = ui.add(egui::Button::selectable(on, label).shortcut_text(shortcut)); app.auto.add(&format!("project.menu.{key}"), r.rect, label); @@ -746,9 +750,9 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } } ui.separator(); - let fr = ui.menu_button("Font Size", |ui| { + let fr = ui.menu_button(tl!("Font Size"), |ui| { for f in FontSize::ALL { - let r = ui.selectable_label(pp.view.font_size == f, f.label()); + let r = ui.selectable_label(pp.view.font_size == f, crate::i18n::t(f.label())); app.auto.add(&format!("project.menu.fontSize.{f:?}"), r.rect, f.label()); if r.clicked() { actions.push(("project.view.set".into(), json!({"fontSize": f}))); @@ -758,27 +762,27 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { }); app.auto.add("project.menu.fontSize", fr.response.rect, "Font Size"); ui.separator(); - let r = ui.button("Refresh Sort Order"); + let r = ui.button(tl!("Refresh Sort Order")); app.auto.add("project.menu.refreshSortOrder", r.rect, "Refresh Sort Order"); if r.clicked() { actions.push(("project.sort".into(), json!({"column": pp.view.sort.column, "descending": pp.view.sort.descending}))); close = true; } ui.separator(); - let r = ui.button("Metadata Display…"); + let r = ui.button(tl!("Metadata Display…")); app.auto.add("project.menu.metadataDisplay", r.rect, "Metadata Display…"); if r.clicked() { actions.push(("projectPanel.metadataDisplay".into(), json!({}))); close = true; } let current = pp.current_preset.filter(|i| pp.presets.get(*i).is_some_and(Option::is_some)); - let r = ui.add_enabled(current.is_some(), egui::Button::new("Save Current View Preset")); + let r = ui.add_enabled(current.is_some(), egui::Button::new(tl!("Save Current View Preset"))); app.auto.add("project.menu.saveViewPreset", r.rect, "Save Current View Preset"); if r.clicked() { actions.push(("project.viewPreset.save".into(), json!({}))); close = true; } - let r = ui.button("Save As New View Preset"); + let r = ui.button(tl!("Save As New View Preset")); app.auto.add("project.menu.saveViewPresetAs", r.rect, "Save As New View Preset"); if r.clicked() { actions.push(("projectPanel.saveViewPresetAs".into(), json!({}))); @@ -786,9 +790,9 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } let any = pp.presets.iter().any(Option::is_some); let rr = ui.add_enabled_ui(any, |ui| { - ui.menu_button("Restore View Preset", |ui| { + ui.menu_button(tl!("Restore View Preset"), |ui| { for (i, p) in pp.presets.iter().enumerate() { - let label = p.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| format!("Project View Preset {}", i + 1)); + let label = p.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| tlf!("Project View Preset {n}", n = i + 1)); let r = ui.add_enabled(p.is_some(), egui::Button::selectable(pp.current_preset == Some(i), label.clone())); app.auto.add(&format!("project.menu.restoreViewPreset.{}", i + 1), r.rect, &label); if r.clicked() { @@ -799,14 +803,14 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { }) }); app.auto.add("project.menu.restoreViewPreset", rr.response.rect, "Restore View Preset"); - let r = ui.add_enabled(any, egui::Button::new("Manage Saved View Presets")); + let r = ui.add_enabled(any, egui::Button::new(tl!("Manage Saved View Presets"))); app.auto.add("project.menu.manageViewPresets", r.rect, "Manage Saved View Presets"); if r.clicked() { actions.push(("projectPanel.manageViewPresets".into(), json!({}))); close = true; } ui.separator(); - let r = ui.add_enabled(v.mode == ViewMode::Freeform, egui::Button::new("Freeform View Options…")); + let r = ui.add_enabled(v.mode == ViewMode::Freeform, egui::Button::new(tl!("Freeform View Options…"))); app.auto.add("project.menu.freeformOptions", r.rect, "Freeform View Options…"); if r.clicked() { actions.push(("projectPanel.freeformOptions".into(), json!({}))); @@ -866,7 +870,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, op: &str, params: &Val } "saveViewPresetAs" => { let n = app.session.prefs.project_panel.presets.iter().position(Option::is_none).map_or(1, |i| i + 1); - app.ui.project_panel.dialog = Some(ProjectDialog::SavePresetAs { name: format!("Project View Preset {n}") }); + app.ui.project_panel.dialog = Some(ProjectDialog::SavePresetAs { name: tlf!("Project View Preset {n}", n) }); Ok(Value::Null) } "manageViewPresets" => { diff --git a/crates/ui-egui/src/panels/project_dialogs.rs b/crates/ui-egui/src/panels/project_dialogs.rs index 17b6c7a3b..c16d3a6ef 100644 --- a/crates/ui-egui/src/panels/project_dialogs.rs +++ b/crates/ui-egui/src/panels/project_dialogs.rs @@ -18,11 +18,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut run: Option<(String, Value)> = None; let title = match &d { - ProjectDialog::MetadataDisplay { .. } => "Metadata Display", - ProjectDialog::SavePresetAs { .. } => "Save As New View Preset", - ProjectDialog::ManagePresets { .. } => "Manage Saved View Presets", - ProjectDialog::FreeformOptions { .. } => "Freeform View Options", - ProjectDialog::SaveArrangement { .. } => "Save Arrangement", + ProjectDialog::MetadataDisplay { .. } => tl!("Metadata Display"), + ProjectDialog::SavePresetAs { .. } => tl!("Save As New View Preset"), + ProjectDialog::ManagePresets { .. } => tl!("Manage Saved View Presets"), + ProjectDialog::FreeformOptions { .. } => tl!("Freeform View Options"), + ProjectDialog::SaveArrangement { .. } => tl!("Save Arrangement"), }; let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]); // a fixed size keeps the two-list dialog from shifting while its lists settle @@ -34,14 +34,14 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::MetadataDisplay { columns, filter } => metadata_display(app, ui, columns, filter, &mut run), ProjectDialog::SavePresetAs { name } => { ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); push(app, "projectDialog.name", &r, "Name"); }); if ui.input(|i| i.key_pressed(egui::Key::Enter)) { run = Some(("project.viewPreset.saveAs".into(), json!({"name": name}))); } - ui.label(egui::RichText::new("Saves the view, columns, sort, thumbnail and font size.").weak().size(11.0)); + ui.label(egui::RichText::new(tl!("Saves the view, columns, sort, thumbnail and font size.")).weak().size(11.0)); if ok_cancel(app, ui, &mut close) { run = Some(("project.viewPreset.saveAs".into(), json!({"name": name}))); } @@ -49,19 +49,19 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::ManagePresets { selected, name } => manage_presets(app, ui, selected, name, &mut run, &mut close), ProjectDialog::FreeformOptions { options } => { egui::Grid::new("ff-options").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { - ui.label("Grid spacing:"); + ui.label(tl!("Grid spacing:")); let r = ui.add(egui::DragValue::new(&mut options.grid).range(4.0..=200.0).suffix(" pt")); push(app, "projectDialog.grid", &r, "Grid spacing"); ui.end_row(); - ui.label("Default clip size:"); + ui.label(tl!("Default clip size:")); let r = ui.add(egui::DragValue::new(&mut options.card_size).range(48.0..=480.0).suffix(" pt")); push(app, "projectDialog.cardSize", &r, "Default clip size"); ui.end_row(); }); for (id, label, v) in [ - ("snap", "Snap to grid", &mut options.snap), - ("showNames", "Show clip names", &mut options.show_names), - ("showDurations", "Show durations", &mut options.show_durations), + ("snap", tl!("Snap to grid"), &mut options.snap), + ("showNames", tl!("Show clip names"), &mut options.show_names), + ("showDurations", tl!("Show durations"), &mut options.show_durations), ] { let r = ui.checkbox(v, label); push(app, &format!("projectDialog.{id}"), &r, label); @@ -72,7 +72,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ProjectDialog::SaveArrangement { name, bin } => { ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); push(app, "projectDialog.name", &r, "Name"); }); @@ -107,12 +107,12 @@ fn ok_cancel(app: &mut FilmcraftApp, ui: &mut egui::Ui, close: &mut bool) -> boo ui.separator(); let mut ok = false; ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); push(app, "projectDialog.cancel", &r, "Cancel"); if r.clicked() { *close = true; } - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); push(app, "projectDialog.ok", &r, "OK"); ok = r.clicked(); }); @@ -122,8 +122,8 @@ fn ok_cancel(app: &mut FilmcraftApp, ui: &mut egui::Ui, close: &mut bool) -> boo fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec, filter: &mut String, run: &mut Option<(String, Value)>) { let all = filmcraft_engine::project_panel::all_columns(&app.session.project); ui.horizontal(|ui| { - ui.label("Show:"); - let r = ui.add(egui::TextEdit::singleline(filter).hint_text("Search fields").desired_width(220.0)); + ui.label(tl!("Show:")); + let r = ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)); push(app, "projectDialog.filter", &r, "Search fields"); }); ui.add_space(4.0); @@ -131,13 +131,13 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec ui.horizontal_top(|ui| { // every field, checked when shown ui.vertical(|ui| { - ui.label(egui::RichText::new("Project Metadata").strong()); + ui.label(egui::RichText::new(tl!("Project Metadata")).strong()); egui::ScrollArea::vertical().id_salt("md-all").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| { ui.set_min_width(220.0); for c in all.iter().filter(|c| f.is_empty() || c.to_ascii_lowercase().contains(&f)) { let mut on = columns.contains(c); let name_col = c == "Name"; - let r = ui.add_enabled(!name_col, egui::Checkbox::new(&mut on, c.as_str())); + let r = ui.add_enabled(!name_col, egui::Checkbox::new(&mut on, crate::i18n::t(c))); push(app, &format!("projectDialog.field.{c}"), &r, c); if r.changed() { if on { @@ -149,7 +149,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec } }); ui.horizontal(|ui| { - let r = ui.button("Add Property…"); + let r = ui.button(tl!("Add Property…")); push(app, "projectDialog.addProperty", &r, "Add Property…"); if r.clicked() && !filter.trim().is_empty() && !columns.iter().any(|c| c.eq_ignore_ascii_case(filter.trim())) { // a custom metadata field (typed in the search box) @@ -160,7 +160,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec ui.separator(); // the shown columns, in order ui.vertical(|ui| { - ui.label(egui::RichText::new("Column order").strong()); + ui.label(egui::RichText::new(tl!("Column order")).strong()); egui::ScrollArea::vertical().id_salt("md-order").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| { ui.set_min_width(200.0); let mut mv: Option<(usize, isize)> = None; @@ -170,7 +170,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec push(app, &format!("projectDialog.up.{c}"), &up, "Move up"); let down = ui.add_enabled(i > 0 && i + 1 < columns.len(), egui::Button::new("▼").small()); push(app, &format!("projectDialog.down.{c}"), &down, "Move down"); - ui.label(c); + ui.label(crate::i18n::t(c)); if up.clicked() { mv = Some((i, -1)); } @@ -203,7 +203,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz let p = presets.get(i).cloned().flatten(); let label = match &p { Some(p) => format!("{}. {}", i + 1, p.name), - None => format!("{}. (empty)", i + 1), + None => tlf!("{n}. (empty)", n = i + 1), }; let r = ui.selectable_label(*selected == i, label.clone()); push(app, &format!("projectDialog.preset.{}", i + 1), &r, &label); @@ -215,27 +215,27 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz }); let filled = presets.get(*selected).is_some_and(Option::is_some); ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)); push(app, "projectDialog.name", &r, "Name"); - let b = ui.add_enabled(filled, egui::Button::new("Rename")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Rename"))); push(app, "projectDialog.rename", &b, "Rename"); if b.clicked() { *run = Some(("project.viewPreset.rename".into(), json!({"slot": *selected + 1, "name": name}))); } }); ui.horizontal(|ui| { - let b = ui.add_enabled(filled, egui::Button::new("Restore")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Restore"))); push(app, "projectDialog.restore", &b, "Restore"); if b.clicked() { *run = Some(("project.viewPreset.restore".into(), json!({"slot": *selected + 1}))); } - let b = ui.add_enabled(filled, egui::Button::new("Delete")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Delete"))); push(app, "projectDialog.delete", &b, "Delete"); if b.clicked() { *run = Some(("project.viewPreset.delete".into(), json!({"slot": *selected + 1}))); } - let b = ui.button("Overwrite with Current View"); + let b = ui.button(tl!("Overwrite with Current View")); push(app, "projectDialog.overwrite", &b, "Overwrite with Current View"); if b.clicked() { *run = Some(( @@ -245,7 +245,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz } }); ui.separator(); - let r = ui.button("Done"); + let r = ui.button(tl!("Done")); push(app, "projectDialog.ok", &r, "Done"); if r.clicked() { *close = true; diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index e24f11b1c..7a54afaea 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -50,12 +50,12 @@ pub fn media_badge(app: &FilmcraftApp, it: &filmcraft_project::ProjectItem) -> B let Some(m) = target.and_then(|t| t.as_media()) else { return b }; let id = target.map(|t| t.id).unwrap_or(it.id); if m.offline { - (b.offline, b.offline_text) = (true, "Offline"); + (b.offline, b.offline_text) = (true, tl!("Offline")); } else if app.session.offline.missing.contains(&id) { - (b.offline, b.offline_text) = (true, "Media missing"); + (b.offline, b.offline_text) = (true, tl!("Media missing")); } else if let Some(st) = app.session.media.offline_status(id) { b.offline = true; - b.offline_text = if st.reason == filmcraft_render::offline::OfflineReason::Unreadable { "Unreadable" } else { "Media missing" }; + b.offline_text = if st.reason == filmcraft_render::offline::OfflineReason::Unreadable { tl!("Unreadable") } else { tl!("Media missing") }; } b.proxy = m.proxy.is_some(); b.proxy_on = app.session.media.use_proxies(); @@ -203,45 +203,47 @@ pub fn item_interactions( fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemKind, actions: &mut Actions) { let sel = || json!({"items": [id.0]}); - if ui.button("Rename").clicked() { + if ui.button(tl!("Rename")).clicked() { actions.push(("projectPanel.rename".into(), json!({"item": id.0}))); ui.close(); } - if ui.button("Open in Source Monitor").clicked() { + if ui.button(tl!("Open in Source Monitor")).clicked() { actions.push(("source.open".into(), json!({"item": id.0}))); ui.close(); } - if matches!(kind, ItemKind::Sequence(_)) && ui.button("Open in Timeline").clicked() { + if matches!(kind, ItemKind::Sequence(_)) && ui.button(tl!("Open in Timeline")).clicked() { actions.push(("sequence.open".into(), json!({"item": id.0}))); ui.close(); } - if ui.button("New Sequence From Clip").clicked() { + if ui.button(tl!("New Sequence From Clip")).clicked() { actions.push(("file.newSequence".into(), json!({"fromItem": id.0}))); ui.close(); } - if ui.button("Duplicate").clicked() { + if ui.button(tl!("Duplicate")).clicked() { actions.push(("project.select".into(), sel())); actions.push(("edit.duplicate".into(), json!({}))); ui.close(); } - if ui.button("Set Poster Frame").clicked() { + if ui.button(tl!("Set Poster Frame")).clicked() { // the hover-scrubbed time when there is one let h = app.ui.project_panel.hover.filter(|h| h.item == id.0); actions.push(("project.select".into(), sel())); actions.push(("clip.setPosterFrame".into(), h.map(|h| json!({"item": id.0, "time": h.time})).unwrap_or(json!({})))); ui.close(); } - if ui.button("Interpret Footage…").clicked() { + if ui.button(tl!("Interpret Footage…")).clicked() { actions.push(("clip.interpretFootage".into(), json!({"items": [id.0]}))); ui.close(); } // subclips: Make Subclip (media), Edit Subclip and Convert to Master Clip (subclips) - let subclip_items: &[(&str, &str, &str)] = match kind { - ItemKind::Media(_) => &[("makeSubclip", "Make Subclip…", "clip.makeSubclip")], - ItemKind::Subclip { .. } => &[("editSubclip", "Edit Subclip…", "clip.editSubclip"), ("convertToMaster", "Convert to Master Clip", "clip.editSubclip")], - _ => &[], + let subclip_items: Vec<(&str, &str, &str)> = match kind { + ItemKind::Media(_) => vec![("makeSubclip", tl!("Make Subclip…"), "clip.makeSubclip")], + ItemKind::Subclip { .. } => { + vec![("editSubclip", tl!("Edit Subclip…"), "clip.editSubclip"), ("convertToMaster", tl!("Convert to Master Clip"), "clip.editSubclip")] + } + _ => Vec::new(), }; - for (key, label, cmd) in subclip_items { + for (key, label, cmd) in &subclip_items { let b = ui.button(*label); app.auto.add(&format!("project.itemMenu.{key}"), b.rect, label); if b.clicked() { @@ -251,9 +253,9 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK ui.close(); } } - ui.menu_button("Label", |ui| { + ui.menu_button(tl!("Label"), |ui| { for l in filmcraft_project::Label::ALL { - if ui.button(l.name()).clicked() { + if ui.button(crate::i18n::t(l.name())).clicked() { actions.push(("project.select".into(), json!({"items": [id.0]}))); actions.push(("edit.label".into(), json!({"label": l.name()}))); ui.close(); @@ -262,19 +264,19 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK }); if matches!(kind, ItemKind::Media(m) if matches!(m.media, filmcraft_project::MediaRef::File { .. })) { ui.separator(); - for (label, cmd) in [("Link Media…", "media.linkMedia"), ("Make Offline…", "media.makeOffline")] { + for (label, cmd) in [(tl!("Link Media…"), "media.linkMedia"), (tl!("Make Offline…"), "media.makeOffline")] { if ui.button(label).clicked() { actions.push(("project.select".into(), sel())); actions.push((cmd.into(), json!({}))); ui.close(); } } - ui.menu_button("Proxy", |ui| { + ui.menu_button(tl!("Proxy"), |ui| { for (label, cmd) in [ - ("Create Proxies…", "media.createProxies"), - ("Attach Proxies…", "media.attachProxies"), - ("Reconnect Full Resolution Media…", "media.reconnectFullRes"), - ("Detach Proxies", "media.detachProxies"), + (tl!("Create Proxies…"), "media.createProxies"), + (tl!("Attach Proxies…"), "media.attachProxies"), + (tl!("Reconnect Full Resolution Media…"), "media.reconnectFullRes"), + (tl!("Detach Proxies"), "media.detachProxies"), ] { if ui.button(label).clicked() { actions.push(("project.select".into(), json!({"items": [id.0]}))); @@ -285,14 +287,14 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK }); } ui.separator(); - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { actions.push(("project.delete".into(), json!({"items": [id.0]}))); ui.close(); } } fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actions: &mut Actions) { - for (label, how) in [("Open in Place", "inPlace"), ("Open in New Tab", "newTab"), ("Open in New Window", "newWindow")] { + for (label, how) in [(tl!("Open in Place"), "inPlace"), (tl!("Open in New Tab"), "newTab"), (tl!("Open in New Window"), "newWindow")] { let b = ui.button(label); app.auto.add(&format!("{}.binMenu.{how}", v.prefix), b.rect, label); if b.clicked() { @@ -304,11 +306,11 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio } } ui.separator(); - if ui.button("Rename").clicked() { + if ui.button(tl!("Rename")).clicked() { actions.push(("projectPanel.rename".into(), json!({"bin": bin}))); ui.close(); } - if ui.button("New Bin").clicked() { + if ui.button(tl!("New Bin")).clicked() { actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin}))); ui.close(); } @@ -442,12 +444,12 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let vis = cr.intersect(hr); if vis.width() > 1.0 { let id = egui::Id::new((&v.prefix, "col", &c.name)); - let resp = ui.interact(vis, id, Sense::click()).on_hover_text(&c.name); + let resp = ui.interact(vis, id, Sense::click()).on_hover_text(crate::i18n::t(&c.name)); app.auto.add(&format!("{}.list.header.{}", v.prefix, c.name), vis, &c.name); if resp.hovered() { hp.rect_filled(cr, 0.0, t.hover); } - let g = hp.layout_no_wrap(c.name.clone(), Tokens::ui(f - 0.5), t.text_dim); + let g = hp.layout_no_wrap(crate::i18n::t(&c.name).to_string(), Tokens::ui(f - 0.5), t.text_dim); let gw = g.size().x; hp.with_clip_rect(cr.shrink2(vec2(4.0, 0.0)).intersect(hr)).galley(pos2(cr.min.x + 6.0, cr.center().y - g.size().y / 2.0), g, t.text_dim); if sort.column == c.name { @@ -461,7 +463,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View actions.push(("project.sort".into(), json!({"column": c.name}))); } resp.context_menu(|ui| { - if ui.button("Metadata Display…").clicked() { + if ui.button(tl!("Metadata Display…")).clicked() { actions.push(("projectPanel.metadataDisplay".into(), json!({}))); ui.close(); } @@ -508,19 +510,19 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mu fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option, actions: &mut Actions) { for (label, cmd) in [ - ("New Bin", "file.newBin"), - ("New Search Bin", "file.newSearchBin"), - ("Find…", "edit.find"), - ("Automate to Sequence…", "clip.automateToSequence"), - ("Import…", "file.import"), + (tl!("New Bin"), "file.newBin"), + (tl!("New Search Bin"), "file.newSearchBin"), + (tl!("Find…"), "edit.find"), + (tl!("Automate to Sequence…"), "clip.automateToSequence"), + (tl!("Import…"), "file.import"), ] { if ui.button(label).clicked() { - let p = if cmd == "file.newBin" { json!({"name": "New Bin", "parent": bin}) } else { json!({}) }; + let p = if cmd == "file.newBin" { json!({"name": tl!("New Bin"), "parent": bin}) } else { json!({}) }; actions.push((cmd.into(), p)); ui.close(); } } - ui.menu_button("New Item", |ui| crate::panels::project::new_item_menu(app, ui, &v.prefix, actions)); + ui.menu_button(tl!("New Item"), |ui| crate::panels::project::new_item_menu(app, ui, &v.prefix, actions)); } #[allow(clippy::too_many_arguments)] @@ -619,9 +621,10 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, let p = ui.painter().with_clip_rect(cr.shrink2(vec2(2.0, 0.0)).intersect(ui.clip_rect())); if c.name == "Label" { p.rect_filled(Rect::from_center_size(pos2(cr.min.x + 12.0, cr.center().y), vec2(10.0, 10.0)), 2.0, label_color(app, it.label)); - p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, it.label.name(), Tokens::ui(lc.font - 0.5), t.text_dim); + p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(it.label.name()), Tokens::ui(lc.font - 0.5), t.text_dim); } else { let (text, _) = pp::cell(&app.session.project, &it, &c.name); + let text = if c.name == "Media Type" { crate::i18n::t(&text).to_string() } else { text }; p.text(pos2(cr.min.x + 6.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(lc.font - 0.5), t.text_dim); } x += c.width; @@ -773,7 +776,7 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie let nr = Rect::from_min_max(pos2(th.min.x, th.max.y + 2.0), pos2(th.max.x, th.max.y + 20.0)); if !rename_field(app, ui, nr, None, Some(b.id.0), &v.prefix) { ui.painter().with_clip_rect(nr).text(pos2(th.min.x, th.max.y + 11.0), Align2::LEFT_CENTER, &b.name, Tokens::ui(f - 0.5), t.text); - ui.painter().text(pos2(th.max.x, th.max.y + 11.0), Align2::RIGHT_CENTER, format!("{} items", n.len()), Tokens::ui(f - 1.5), t.text_dim); + ui.painter().text(pos2(th.max.x, th.max.y + 11.0), Align2::RIGHT_CENTER, tlf!("{n} items", n = n.len()), Tokens::ui(f - 1.5), t.text_dim); } app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name); accept_bin_drop(app, ui, th, b.id.0, actions); @@ -956,27 +959,27 @@ fn cards_stack(cards: &[Card], item: u64) -> Option { fn card_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, item: u64, actions: &mut Actions) { let sel: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); let items: Vec = if sel.contains(&item) { sel.clone() } else { vec![item] }; - let b = ui.add_enabled(items.len() > 1, egui::Button::new("Stack")); + let b = ui.add_enabled(items.len() > 1, egui::Button::new(tl!("Stack"))); app.auto.add(&format!("{}.cardMenu.stack", v.prefix), b.rect, "Stack"); if b.clicked() { actions.push(("project.freeform.stack".into(), json!({"items": items}))); ui.close(); } let stacked = app.session.project.item(ItemId(item)).is_some_and(|i| i.metadata.contains_key(pp::FREEFORM_STACK)); - let b = ui.add_enabled(stacked, egui::Button::new("Unstack")); + let b = ui.add_enabled(stacked, egui::Button::new(tl!("Unstack"))); app.auto.add(&format!("{}.cardMenu.unstack", v.prefix), b.rect, "Unstack"); if b.clicked() { actions.push(("project.freeform.unstack".into(), json!({"items": items}))); ui.close(); } - ui.menu_button("Clip Size", |ui| { - for (label, step) in [("Larger", 1), ("Smaller", -1)] { + ui.menu_button(tl!("Clip Size"), |ui| { + for (label, step) in [(tl!("Larger"), 1), (tl!("Smaller"), -1)] { if ui.button(label).clicked() { actions.push(("project.freeform.resize".into(), json!({"items": items, "step": step}))); ui.close(); } } - if ui.button("Default").clicked() { + if ui.button(tl!("Default")).clicked() { actions.push(("project.freeform.resize".into(), json!({"items": items, "size": app.session.prefs.project_panel.freeform.card_size}))); ui.close(); } @@ -997,10 +1000,10 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< ui.close(); } } - entry(app, ui, v, actions, "alignToGrid", "Align to Grid", "project.freeform.alignToGrid", json!({"bin": bin})); - entry(app, ui, v, actions, "resetToGrid", "Reset to Grid", "project.freeform.reset", json!({"bin": bin})); + entry(app, ui, v, actions, "alignToGrid", tl!("Align to Grid"), "project.freeform.alignToGrid", json!({"bin": bin})); + entry(app, ui, v, actions, "resetToGrid", tl!("Reset to Grid"), "project.freeform.reset", json!({"bin": bin})); ui.separator(); - entry(app, ui, v, actions, "saveArrangement", "Save Arrangement…", "projectPanel.saveArrangement", json!({})); + entry(app, ui, v, actions, "saveArrangement", tl!("Save Arrangement…"), "projectPanel.saveArrangement", json!({})); let names: Vec = app .session .execute("project.freeform.arrangements", json!({"bin": bin})) @@ -1008,7 +1011,7 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< .and_then(|v| serde_json::from_value(v["arrangements"].clone()).ok()) .unwrap_or_default(); ui.add_enabled_ui(!names.is_empty(), |ui| { - ui.menu_button("Restore Arrangement", |ui| { + ui.menu_button(tl!("Restore Arrangement"), |ui| { for n in &names { if ui.button(n).clicked() { actions.push(("project.freeform.restoreArrangement".into(), json!({"name": n, "bin": bin}))); @@ -1016,7 +1019,7 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< } } }); - ui.menu_button("Delete Arrangement", |ui| { + ui.menu_button(tl!("Delete Arrangement"), |ui| { for n in &names { if ui.button(n).clicked() { actions.push(("project.freeform.deleteArrangement".into(), json!({"name": n, "bin": bin}))); diff --git a/crates/ui-egui/src/panels/reference.rs b/crates/ui-egui/src/panels/reference.rs index be1bd4251..4c5c70f68 100644 --- a/crates/ui-egui/src/panels/reference.rs +++ b/crates/ui-egui/src/panels/reference.rs @@ -31,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; let Some(frame) = frame(app) else { return }; @@ -116,19 +116,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { elems.push((id.to_string(), r, label.to_string())); resp.on_hover_text(label).clicked() }; - if button(ui, "reference.stepBack", "Step Back 1 Frame", Some(Icon::StepBack), false, &mut elems) { + if button(ui, "reference.stepBack", tl!("Step Back 1 Frame"), Some(Icon::StepBack), false, &mut elems) { step = -1; } - if button(ui, "reference.stepForward", "Step Forward 1 Frame", Some(Icon::StepFwd), false, &mut elems) { + if button(ui, "reference.stepForward", tl!("Step Forward 1 Frame"), Some(Icon::StepFwd), false, &mut elems) { step = 1; } - let matched = button(ui, "reference.matchPlayhead", "Match Playhead", None, false, &mut elems); - let gang = button(ui, "reference.gang", "Gang to Program", None, st.ganged, &mut elems); + let matched = button(ui, "reference.matchPlayhead", tl!("Match Playhead"), None, false, &mut elems); + let gang = button(ui, "reference.gang", tl!("Gang to Program"), None, st.ganged, &mut elems); // display dropdown at the right let dr = Rect::from_min_size(pos2(bar.max.x - 150.0, bar.center().y - 11.0), vec2(142.0, 22.0)); let mut display = st.display; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(dr)); - let label = |d: RefDisplay| if d == RefDisplay::Composite { "Composite Video" } else { "Lumetri Scopes" }; + let label = |d: RefDisplay| if d == RefDisplay::Composite { tl!("Composite Video") } else { tl!("Lumetri Scopes") }; let cb = egui::ComboBox::from_id_salt("reference-display").selected_text(label(display)).width(134.0).show_ui(&mut child, |ui| { for d in [RefDisplay::Composite, RefDisplay::Scopes] { let r = ui.selectable_value(&mut display, d, label(d)); diff --git a/crates/ui-egui/src/panels/remix.rs b/crates/ui-egui/src/panels/remix.rs index 9a105bf23..459fcdd92 100644 --- a/crates/ui-egui/src/panels/remix.rs +++ b/crates/ui-egui/src/panels/remix.rs @@ -31,7 +31,7 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context) -> Result = Vec::new(); - egui::Window::new("Remix Properties").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_min_width(320.0); - egui::Grid::new("remix-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Target Duration:"); - let r = ui.add(egui::DragValue::new(&mut d.seconds).speed(0.1).range(1.0..=36_000.0).suffix(" s").max_decimals(2)); - elems.push(("remixProperties.duration".into(), r.rect, format!("{:.2} s", d.seconds))); - ui.end_row(); - ui.label("Segments:"); - let r = ui.add(egui::Slider::new(&mut d.segments, 0.0..=100.0).step_by(1.0).text("Fewer · More")); - elems.push(("remixProperties.segments".into(), r.rect, format!("{:.0}", d.segments))); - ui.end_row(); - ui.label("Variations:"); - let r = ui.add(egui::Slider::new(&mut d.variations, 0.0..=100.0).step_by(1.0).text("Fewer · More")); - elems.push(("remixProperties.variations".into(), r.rect, format!("{:.0}", d.variations))); - ui.end_row(); + egui::Window::new(tl!("Remix Properties")) + .id(egui::Id::new("Remix Properties")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(320.0); + egui::Grid::new("remix-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label(tl!("Target Duration:")); + let r = ui.add(egui::DragValue::new(&mut d.seconds).speed(0.1).range(1.0..=36_000.0).suffix(" s").max_decimals(2)); + elems.push(("remixProperties.duration".into(), r.rect, format!("{:.2} s", d.seconds))); + ui.end_row(); + ui.label(tl!("Segments:")); + let r = ui.add(egui::Slider::new(&mut d.segments, 0.0..=100.0).step_by(1.0).text(tl!("Fewer · More"))); + elems.push(("remixProperties.segments".into(), r.rect, format!("{:.0}", d.segments))); + ui.end_row(); + ui.label(tl!("Variations:")); + let r = ui.add(egui::Slider::new(&mut d.variations, 0.0..=100.0).step_by(1.0).text(tl!("Fewer · More"))); + elems.push(("remixProperties.variations".into(), r.rect, format!("{:.0}", d.variations))); + ui.end_row(); + }); + ui.add_space(6.0); + let i = ui.weak(&d.info); + elems.push(("remixProperties.info".into(), i.rect, d.info.clone())); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("remixProperties.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + close = true; + } + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("remixProperties.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); }); - ui.add_space(6.0); - let i = ui.weak(&d.info); - elems.push(("remixProperties.info".into(), i.rect, d.info.clone())); - ui.add_space(8.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("remixProperties.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - close = true; - } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("remixProperties.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -89,7 +94,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { if apply { let p = json!({"clip": d.clip, "seconds": d.seconds, "segments": d.segments, "variations": d.variations}); match app.session.execute("clip.remix.properties", p) { - Ok(v) => app.ui.status = format!("Remixed to {:.2} s", v["seconds"].as_f64().unwrap_or(0.0)), + Ok(v) => app.ui.status = tlf!("Remixed to {seconds} s", seconds = format!("{:.2}", v["seconds"].as_f64().unwrap_or(0.0))), Err(e) => app.ui.status = e.to_string(), } close = true; diff --git a/crates/ui-egui/src/panels/scopes.rs b/crates/ui-egui/src/panels/scopes.rs index c5a81519e..f68b0575a 100644 --- a/crates/ui-egui/src/panels/scopes.rs +++ b/crates/ui-egui/src/panels/scopes.rs @@ -96,7 +96,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sf = frame.and_then(|f| frame_signal(app, &ctx, "program", f, &st, prio)); match &sf { Some(sf) => draw(app, ui, area, &st.shown, sf, &st, "scopes"), - None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { "(no sequence)" }), + None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { tl!("(no sequence)") }), } // right-click anywhere on the scopes: the settings menu let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click()); @@ -110,7 +110,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option "Rec. 2100".to_string(), - s => s.label().to_string(), + s => crate::i18n::t(s.label()).to_string(), }; let lr = ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, &label, Tokens::ui(12.0), t.text); app.auto.add("scopes.space", lr, &label); @@ -119,19 +119,19 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option format!("Parade ({})", st.parade_type.label()), - ScopeKind::Waveform => format!("Waveform ({})", st.waveform_type.label()), - k => k.label().to_string(), + ScopeKind::Parade => tlf!("Parade ({kind})", kind = crate::i18n::t(st.parade_type.label())), + ScopeKind::Waveform => tlf!("Waveform ({kind})", kind = crate::i18n::t(st.waveform_type.label())), + k => crate::i18n::t(k.label()).to_string(), }; pick(ui, &mut out, &format!("scopes.menu.{}", k.name()), &label, st.shown.contains(&k)); } @@ -182,38 +182,38 @@ pub fn settings_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let r = ui.menu_button(label, |ui| f(ui, out)); out.push((format!("scopes.menu.{id}"), r.response.rect, label.into(), false)); }; - sub(ui, &mut out, "paradeTypes", "Parade Type", &mut |ui, out| { + sub(ui, &mut out, "paradeTypes", tl!("Parade Type"), &mut |ui, out| { for p in ParadeType::ALL { - pick(ui, out, &format!("scopes.menu.paradeType.{}", serde_name(&p)), p.label(), st.parade_type == p); + pick(ui, out, &format!("scopes.menu.paradeType.{}", serde_name(&p)), crate::i18n::t(p.label()), st.parade_type == p); } }); - sub(ui, &mut out, "waveformTypes", "Waveform Type", &mut |ui, out| { + sub(ui, &mut out, "waveformTypes", tl!("Waveform Type"), &mut |ui, out| { for w in WaveformType::ALL { - pick(ui, out, &format!("scopes.menu.waveformType.{}", serde_name(&w)), w.label(), st.waveform_type == w); + pick(ui, out, &format!("scopes.menu.waveformType.{}", serde_name(&w)), crate::i18n::t(w.label()), st.waveform_type == w); } }); - sub(ui, &mut out, "vectorscopeTargets", "Vectorscope Targets", &mut |ui, out| { + sub(ui, &mut out, "vectorscopeTargets", tl!("Vectorscope Targets"), &mut |ui, out| { for v in [Targets::Percent75, Targets::Percent100] { - pick(ui, out, &format!("scopes.menu.targets.{}", serde_name(&v)), v.label(), st.targets == v); + pick(ui, out, &format!("scopes.menu.targets.{}", serde_name(&v)), crate::i18n::t(v.label()), st.targets == v); } }); ui.separator(); - sub(ui, &mut out, "colorSpaces", "Colour Space", &mut |ui, out| { + sub(ui, &mut out, "colorSpaces", tl!("Colour Space"), &mut |ui, out| { for c in ColorSpace::ALL { - pick(ui, out, &format!("scopes.menu.colorSpace.{}", serde_name(&c)), c.label(), st.color_space == c); + pick(ui, out, &format!("scopes.menu.colorSpace.{}", serde_name(&c)), crate::i18n::t(c.label()), st.color_space == c); } }); - sub(ui, &mut out, "brightnesses", "Brightness", &mut |ui, out| { + sub(ui, &mut out, "brightnesses", tl!("Brightness"), &mut |ui, out| { for b in Brightness::ALL { - pick(ui, out, &format!("scopes.menu.brightness.{}", serde_name(&b)), b.label(), st.brightness == b); + pick(ui, out, &format!("scopes.menu.brightness.{}", serde_name(&b)), crate::i18n::t(b.label()), st.brightness == b); } }); - sub(ui, &mut out, "scales", "Signal Scale", &mut |ui, out| { + sub(ui, &mut out, "scales", tl!("Signal Scale"), &mut |ui, out| { for s in Scale::ALL { - pick(ui, out, &format!("scopes.menu.scale.{}", s.label().replace(' ', "")), s.label(), st.scale == s); + pick(ui, out, &format!("scopes.menu.scale.{}", s.label().replace(' ', "")), crate::i18n::t(s.label()), st.scale == s); } }); - pick(ui, &mut out, "scopes.menu.clamp", "Clamp Signal", st.clamp); + pick(ui, &mut out, "scopes.menu.clamp", tl!("Clamp Signal"), st.clamp); let mut close = false; for (id, r, label, clicked) in out { app.auto.add(&id, r, &label); @@ -391,8 +391,8 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, } } let title = match k { - ScopeKind::Parade => format!("Parade ({})", st.parade_type.label()), - _ => format!("Waveform ({})", st.waveform_type.label()), + ScopeKind::Parade => tlf!("Parade ({kind})", kind = crate::i18n::t(st.parade_type.label())), + _ => tlf!("Waveform ({kind})", kind = crate::i18n::t(st.waveform_type.label())), }; ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, title, Tokens::ui(9.0), Color32::from_gray(110)); if nits_axis(st, sf) { @@ -409,7 +409,7 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, if let Some(tex) = tex { ui.painter().image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, "Histogram", Tokens::ui(9.0), Color32::from_gray(110)); + ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, tl!("Histogram"), Tokens::ui(9.0), Color32::from_gray(110)); if nits_axis(st, sf) { app.auto.add(&format!("{prefix}.hdrWaveform"), plot, "HDR levels (cd/m²)"); } diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs new file mode 100644 index 000000000..7c3e77a31 --- /dev/null +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -0,0 +1,630 @@ +//! Sequence ▸ Sequence Settings…, laid out as Premiere Pro's dialog: General, Color Management and +//! VR Properties tabs, Cancel / OK. +//! +//! File ▸ New ▸ Sequence… opens the same dialog as New Sequence ([`open_new`]): the General tab +//! starting from the default settings, and a Tracks tab with the number of video and audio +//! tracks (`tracks.video`, `tracks.audio`); OK runs `file.newSequence`. +//! +//! What it changes (one `sequence.settings` command, one undo step): Timebase, Frame Size (with +//! "Scale motion effects proportionally when changing frame size", on by default), drop-frame or +//! non-drop-frame timecode for the NTSC rates, audio Channel Format and Sample Rate, Maximum Render +//! Quality, and the Color Management tab (working colour space, wide gamut, auto tone map). +//! +//! Premiere settings FilmCraft has no equivalent for yet are shown greyed out with their current +//! value and a "not supported yet" hint, rather than editable and silently ignored: Editing Mode, +//! Pixel Aspect Ratio (stored, not rendered), Fields, the Frames and Feet + Frames display +//! formats, Number of Channels, audio Display Format, the Video Previews format, codec and size, +//! Maximum Bit Depth, and the VR Properties tab. Composite in Linear Color is shown on and greyed: +//! FilmCraft always composites in linear light. +//! +//! Automation ids (`sequenceSettings.` + …): `tab.general`, `tab.color`, `tab.vr`; General: +//! `name`, `editingMode`, `timebase` (+ `timebase.option./` while open), `width`, `height`, +//! `aspect`, `scaleMotion`, `par`, `fields`, `videoDisplay` (+ `videoDisplay.option.`), +//! `channelFormat` (+ `channelFormat.option.`), `channels`, `sampleRate` +//! (+ `sampleRate.option.`), `audioDisplay`, `previewFormat`, `previewCodec`, `previewWidth`, +//! `previewHeight`, `maxBitDepth`, `maxRenderQuality`, `linearColor`; Color Management: +//! `workingSpace` (+ `workingSpace.option.`), `wideGamut`, `autoToneMap`; VR Properties: +//! `vr.projection`, `vr.layout`, `vr.horizontal`, `vr.vertical`; `ok`, `cancel`. + +use filmcraft_color::WorkingSpace; +use filmcraft_project::{AudioChannels, SequenceSettings}; +use filmcraft_time::FrameRate; +use serde_json::{Map, Value, json}; + +use crate::FilmcraftApp; +use crate::i18n::t; +use crate::state::SequenceSettingsDraft; + +const NOT_YET: &str = "Not supported in FilmCraft yet"; +const LINEAR: &str = "FilmCraft always composites in linear light"; + +/// The Timebase list (frames per second as num/den), Premiere's common rates. +const TIMEBASES: [(i64, i64); 15] = [ + (10, 1), + (12, 1), + (25, 2), + (15, 1), + (24_000, 1001), + (24, 1), + (25, 1), + (30_000, 1001), + (30, 1), + (48, 1), + (50, 1), + (60_000, 1001), + (60, 1), + (120_000, 1001), + (120, 1), +]; +const SAMPLE_RATES: [u32; 5] = [32_000, 44_100, 48_000, 88_200, 96_000]; +const MIXES: [&str; 4] = ["Stereo", "Mono", "5.1", "Adaptive"]; + +fn mix_name(c: AudioChannels) -> &'static str { + match c { + AudioChannels::Mono => "Mono", + AudioChannels::Stereo => "Stereo", + AudioChannels::Surround51 => "5.1", + AudioChannels::Adaptive => "Adaptive", + } +} + +/// Fill the draft from the active sequence and open the dialog on the General tab. +pub fn open(app: &mut FilmcraftApp) { + let Some(q) = app.session.active_sequence() else { return }; + let st = &q.settings; + let name = app.session.state.active_sequence.and_then(|id| app.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); + app.ui.sequence_settings = SequenceSettingsDraft { + tab: "general".into(), + name, + new_sequence: false, + video_tracks: 3, + audio_tracks: 3, + fps_num: st.frame_rate.num, + fps_den: st.frame_rate.den, + width: st.width, + height: st.height, + scale_motion: true, + drop_frame: st.drop_frame && st.frame_rate.supports_drop_frame(), + mix: mix_name(st.audio_master).into(), + sample_rate: st.sample_rate, + max_render_quality: st.max_render_quality, + working_space: st.color.working.id().into(), + wide_gamut: st.color.wide_gamut, + auto_tone_map: st.color.auto_tone_map, + }; + app.dialog = Some(crate::Dialog::SequenceSettings); +} + +/// File ▸ New ▸ Sequence…: the dialog as New Sequence, from the default settings, named like +/// `file.newSequence` would name it, with three video and three audio tracks. +pub fn open_new(app: &mut FilmcraftApp) { + let st = SequenceSettings::default(); + let n = app.session.project.sequences().count() + 1; + app.ui.sequence_settings = SequenceSettingsDraft { + tab: "general".into(), + new_sequence: true, + video_tracks: 3, + audio_tracks: 3, + fps_num: st.frame_rate.num, + fps_den: st.frame_rate.den, + width: st.width, + height: st.height, + scale_motion: true, + drop_frame: st.drop_frame && st.frame_rate.supports_drop_frame(), + mix: mix_name(st.audio_master).into(), + sample_rate: st.sample_rate, + max_render_quality: st.max_render_quality, + working_space: st.color.working.id().into(), + wide_gamut: st.color.wide_gamut, + auto_tone_map: st.color.auto_tone_map, + name: format!("Sequence {n:02}"), + }; + app.dialog = Some(crate::Dialog::SequenceSettings); +} + +/// The `file.newSequence` parameters for a New Sequence draft. Settings `file.newSequence` doesn't +/// take (drop-frame timecode, Maximum Render Quality) are the second map, for `sequence.settings` +/// on the new sequence; empty when they are the defaults. +fn new_sequence_params(d: &SequenceSettingsDraft) -> (Map, Map) { + let mut p = Map::new(); + let name = d.name.trim(); + if !name.is_empty() { + p.insert("name".into(), json!(name)); + } + p.insert("width".into(), json!(d.width)); + p.insert("height".into(), json!(d.height)); + p.insert("fps".into(), json!(rate(d).as_f64())); + p.insert("sampleRate".into(), json!(d.sample_rate)); + p.insert("mix".into(), json!(d.mix)); + p.insert("video".into(), json!(d.video_tracks.min(256))); + p.insert("audio".into(), json!(d.audio_tracks.min(256))); + // what's left: only the settings `file.newSequence` has no parameter for + let after = SequenceSettings { frame_rate: rate(d), width: d.width, height: d.height, sample_rate: d.sample_rate, ..Default::default() }; + let mut rest = changes(d, &after, name); + rest.retain(|k, _| matches!(k.as_str(), "dropFrame" | "maxRenderQuality")); + (p, rest) +} + +/// The Tracks tab of New Sequence: how many video and audio tracks the sequence starts with. +fn tracks(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, elems: &mut Elems) { + for (id, label, n) in [("video", "Video:", &mut d.video_tracks), ("audio", "Audio:", &mut d.audio_tracks)] { + ui.horizontal(|ui| { + ui.label(t(label)); + let r = ui.add(egui::DragValue::new(n).range(0..=256)); + elems.push((format!("sequenceSettings.tracks.{id}"), r.rect, n.to_string())); + ui.label(tl!("tracks")); + }); + } +} + +fn rate(d: &SequenceSettingsDraft) -> FrameRate { + FrameRate { num: d.fps_num, den: d.fps_den }.sane() +} + +/// "16:9" for a frame size, or "1.90:1" when the reduced ratio has no small integer form. +fn aspect(w: u32, h: u32) -> String { + let g = gcd(w, h).max(1); + let (a, b) = (w / g, h / g); + if a <= 64 && b <= 64 { + format!("{a}:{b}") + } else if h > 0 { + format!("{:.2}:1", f64::from(w) / f64::from(h)) + } else { + String::new() + } +} + +fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +fn timebase_label(r: FrameRate) -> String { + tlf!("{rate} frames/second", rate = r.label()) +} + +/// The Video Display Format choices for a timebase: drop-frame / non-drop-frame timecode for the +/// NTSC rates, plain timecode otherwise (`(id, label)`). +fn timecode_formats(r: FrameRate) -> Vec<(&'static str, String)> { + if r.supports_drop_frame() { + vec![("df", tlf!("{rate} fps Drop-Frame Timecode", rate = r.label())), ("ndf", tlf!("{rate} fps Non Drop-Frame Timecode", rate = r.label()))] + } else { + vec![("tc", tlf!("{rate} fps Timecode", rate = r.label()))] + } +} + +fn par_label(par: (u32, u32)) -> String { + if par.0 == par.1 { + tl!("Square Pixels (1.0)").into() + } else if par.1 > 0 { + tlf!("Custom ({ratio})", ratio = format!("{:.4}", f64::from(par.0) / f64::from(par.1))) + } else { + tl!("Custom").into() + } +} + +/// The parameters for `sequence.settings` that differ from `cur` (and the sequence's name +/// `cur_name`); empty when nothing changed. A blank name keeps the current one. +fn changes(d: &SequenceSettingsDraft, cur: &SequenceSettings, cur_name: &str) -> Map { + let mut p = Map::new(); + let name = d.name.trim(); + if !name.is_empty() && name != cur_name { + p.insert("name".into(), json!(name)); + } + let r = rate(d); + if r != cur.frame_rate { + p.insert("fps".into(), json!(r.as_f64())); + } + if (d.width, d.height) != (cur.width, cur.height) { + p.insert("width".into(), json!(d.width)); + p.insert("height".into(), json!(d.height)); + p.insert("scaleMotion".into(), json!(d.scale_motion)); + } + let df = d.drop_frame && r.supports_drop_frame(); + if df != (cur.drop_frame && cur.frame_rate.supports_drop_frame()) { + p.insert("dropFrame".into(), json!(df)); + } + if d.mix != mix_name(cur.audio_master) { + p.insert("mix".into(), json!(d.mix)); + } + if d.sample_rate != cur.sample_rate { + p.insert("sampleRate".into(), json!(d.sample_rate)); + } + if d.max_render_quality != cur.max_render_quality { + p.insert("maxRenderQuality".into(), json!(d.max_render_quality)); + } + if d.working_space != cur.color.working.id() { + p.insert("workingSpace".into(), json!(d.working_space)); + } + if d.wide_gamut != cur.color.wide_gamut { + p.insert("wideGamut".into(), json!(d.wide_gamut)); + } + if d.auto_tone_map != cur.color.auto_tone_map { + p.insert("autoToneMap".into(), json!(d.auto_tone_map)); + } + p +} + +type Elems = Vec<(String, egui::Rect, String)>; + +/// One labelled row: the label right-aligned in a `label_w` column, then the control. +fn row(ui: &mut egui::Ui, label_w: f32, label: &str, enabled: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.horizontal(|ui| { + let h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(label_w, h), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled(enabled, egui::Label::new(t(label))); + }); + add(ui) + }) + .inner +} + +/// A greyed-out list showing `value`, with the "not supported yet" hint (or `hint`). +fn fixed_combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, value: &str, width: f32, hint: &str) { + let r = ui + .add_enabled_ui(false, |ui| egui::ComboBox::from_id_salt(("seq-settings-fixed", id)).selected_text(t(value)).width(width).show_ui(ui, |_| {}).response) + .inner + .on_disabled_hover_text(t(hint)); + elems.push((format!("sequenceSettings.{id}"), r.rect, value.to_string())); +} + +/// A greyed-out read-only value (a number field in Premiere), with the hint. +fn fixed_value(ui: &mut egui::Ui, elems: &mut Elems, id: &str, value: &str) { + let r = ui.add_enabled(false, egui::Button::new(value).min_size(egui::vec2(64.0, 0.0))).on_disabled_hover_text(t(NOT_YET)); + elems.push((format!("sequenceSettings.{id}"), r.rect, value.to_string())); +} + +fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { + ui.add_space(6.0); + ui.label(egui::RichText::new(t(title)).strong()); + ui.group(|ui| { + ui.set_width(ui.available_width()); + add(ui); + }); +} + +fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSettings, audio_samples: bool, elems: &mut Elems) { + let font = egui::TextStyle::Body.resolve(ui.style()); + let label_w = ["Preview File Format:", "Pixel Aspect Ratio:", "Number of Channels:"] + .iter() + .map(|l| ui.painter().layout_no_wrap(t(l).to_string(), font.clone(), egui::Color32::WHITE).size().x) + .fold(0.0, f32::max); + let list_w = 260.0; + row(ui, label_w, "Sequence Name:", true, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)); + elems.push(("sequenceSettings.name".into(), r.rect, d.name.clone())); + }); + row(ui, label_w, "Editing Mode:", false, |ui| fixed_combo(ui, elems, "editingMode", "Custom", list_w, NOT_YET)); + row(ui, label_w, "Timebase:", true, |ui| { + let r = rate(d); + let mut choices: Vec<(i64, i64)> = TIMEBASES.to_vec(); + if !choices.contains(&(r.num, r.den)) { + choices.insert(0, (r.num, r.den)); + } + let shown = timebase_label(r); + // no row limit of our own: the list grows to egui's popup limit (`Spacing::default_area_size`, + // 400 px), so every rate up to 119.88 shows without scrolling (the default 200 px stopped + // before 29.97) + let resp = egui::ComboBox::from_id_salt("seq-settings-timebase").selected_text(&shown).width(list_w).height(f32::INFINITY).show_ui(ui, |ui| { + for (num, den) in choices { + let label = timebase_label(FrameRate { num, den }); + let o = ui.selectable_label((d.fps_num, d.fps_den) == (num, den), &label); + // a new timebase starts on its usual timecode (drop-frame for the NTSC rates); + // picking the current one again keeps the Display Format chosen for it + if o.clicked() && (d.fps_num, d.fps_den) != (num, den) { + d.fps_num = num; + d.fps_den = den; + d.drop_frame = FrameRate { num, den }.supports_drop_frame(); + } + elems.push((format!("sequenceSettings.timebase.option.{num}/{den}"), o.rect, label)); + } + }); + elems.push(("sequenceSettings.timebase".into(), resp.response.rect, shown)); + }); + section(ui, "Video", |ui| { + row(ui, label_w, "Frame Size:", true, |ui| { + let w = ui.add(egui::DragValue::new(&mut d.width).range(1..=filmcraft_project::MAX_FRAME_SIDE).speed(1.0)); + elems.push(("sequenceSettings.width".into(), w.rect, d.width.to_string())); + ui.label(tl!("horizontal")); + let h = ui.add(egui::DragValue::new(&mut d.height).range(1..=filmcraft_project::MAX_FRAME_SIDE).speed(1.0)); + elems.push(("sequenceSettings.height".into(), h.rect, d.height.to_string())); + ui.label(tl!("vertical")); + let a = aspect(d.width, d.height); + let ar = ui.label(&a); + elems.push(("sequenceSettings.aspect".into(), ar.rect, a)); + }); + let r = ui.checkbox(&mut d.scale_motion, tl!("Scale motion effects proportionally when changing frame size")); + elems.push(("sequenceSettings.scaleMotion".into(), r.rect, d.scale_motion.to_string())); + row(ui, label_w, "Pixel Aspect Ratio:", false, |ui| fixed_combo(ui, elems, "par", &par_label(cur.par), list_w, NOT_YET)); + row(ui, label_w, "Fields:", false, |ui| fixed_combo(ui, elems, "fields", "No Fields (Progressive Scan)", list_w, NOT_YET)); + row(ui, label_w, "Display Format:", true, |ui| { + let r = rate(d); + let formats = timecode_formats(r); + let chosen = if r.supports_drop_frame() { if d.drop_frame { "df" } else { "ndf" } } else { "tc" }; + let shown = formats.iter().find(|(id, _)| *id == chosen).map(|(_, l)| l.clone()).unwrap_or_default(); + let resp = egui::ComboBox::from_id_salt("seq-settings-video-display").selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for (id, label) in &formats { + let o = ui.selectable_label(*id == chosen, label); + if o.clicked() { + d.drop_frame = *id == "df"; + } + elems.push((format!("sequenceSettings.videoDisplay.option.{id}"), o.rect, label.clone())); + } + for label in ["Feet + Frames 16mm", "Feet + Frames 35mm", "Frames"] { + ui.add_enabled(false, egui::Button::selectable(false, t(label))).on_disabled_hover_text(t(NOT_YET)); + } + }); + elems.push(("sequenceSettings.videoDisplay".into(), resp.response.rect, shown)); + }); + }); + section(ui, "Audio", |ui| { + row(ui, label_w, "Channel Format:", true, |ui| { + let shown = d.mix.clone(); + let resp = egui::ComboBox::from_id_salt("seq-settings-mix").selected_text(t(&shown)).width(120.0).show_ui(ui, |ui| { + for m in MIXES { + let o = ui.selectable_value(&mut d.mix, m.to_string(), t(m)); + elems.push((format!("sequenceSettings.channelFormat.option.{m}"), o.rect, m.to_string())); + } + }); + elems.push(("sequenceSettings.channelFormat".into(), resp.response.rect, shown)); + ui.add_enabled(false, egui::Label::new(tl!("Number of Channels:"))); + let n = match d.mix.as_str() { + "Mono" => "1", + "5.1" => "6", + "Adaptive" => "—", + _ => "2", + }; + fixed_combo(ui, elems, "channels", n, 60.0, NOT_YET); + }); + row(ui, label_w, "Sample Rate:", true, |ui| { + let mut rates = SAMPLE_RATES.to_vec(); + if !rates.contains(&d.sample_rate) { + rates.insert(0, d.sample_rate); + } + let shown = format!("{} Hz", d.sample_rate); + let resp = egui::ComboBox::from_id_salt("seq-settings-sample-rate").selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for hz in rates { + let label = format!("{hz} Hz"); + let o = ui.selectable_value(&mut d.sample_rate, hz, &label); + elems.push((format!("sequenceSettings.sampleRate.option.{hz}"), o.rect, label)); + } + }); + elems.push(("sequenceSettings.sampleRate".into(), resp.response.rect, shown)); + }); + let shown = if audio_samples { "Audio Samples" } else { "Milliseconds" }; + row(ui, label_w, "Display Format:", false, |ui| fixed_combo(ui, elems, "audioDisplay", shown, list_w, NOT_YET)); + }); + section(ui, "Video Previews", |ui| { + row(ui, label_w, "Preview File Format:", false, |ui| { + fixed_combo(ui, elems, "previewFormat", "QuickTime", list_w, NOT_YET); + ui.add_enabled(false, egui::Button::new(tl!("Configure…"))).on_disabled_hover_text(t(NOT_YET)); + }); + row(ui, label_w, "Codec:", false, |ui| fixed_combo(ui, elems, "previewCodec", &cur.preview_codec, list_w, NOT_YET)); + row(ui, label_w, "Width:", false, |ui| fixed_value(ui, elems, "previewWidth", &d.width.to_string())); + row(ui, label_w, "Height:", false, |ui| { + fixed_value(ui, elems, "previewHeight", &d.height.to_string()); + ui.add_enabled(false, egui::Button::new(tl!("Reset"))).on_disabled_hover_text(t(NOT_YET)); + }); + ui.horizontal(|ui| { + let mut bit_depth = cur.max_bit_depth; + let r = ui.add_enabled(false, egui::Checkbox::new(&mut bit_depth, tl!("Maximum Bit Depth"))).on_disabled_hover_text(t(NOT_YET)); + elems.push(("sequenceSettings.maxBitDepth".into(), r.rect, bit_depth.to_string())); + let r = ui.checkbox(&mut d.max_render_quality, tl!("Maximum Render Quality")); + elems.push(("sequenceSettings.maxRenderQuality".into(), r.rect, d.max_render_quality.to_string())); + }); + let mut linear = true; + let r = ui.add_enabled(false, egui::Checkbox::new(&mut linear, tl!("Composite in Linear Color"))).on_disabled_hover_text(t(LINEAR)); + elems.push(("sequenceSettings.linearColor".into(), r.rect, "true".into())); + }); +} + +fn color(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, elems: &mut Elems) { + let font = egui::TextStyle::Body.resolve(ui.style()); + let label_w = ui.painter().layout_no_wrap(tl!("Working Color Space:").to_string(), font, egui::Color32::WHITE).size().x; + row(ui, label_w, "Working Color Space:", true, |ui| { + let shown = WorkingSpace::parse(&d.working_space).map_or(d.working_space.clone(), |w| t(w.label()).to_string()); + let resp = egui::ComboBox::from_id_salt("seq-settings-working-space").selected_text(&shown).width(220.0).show_ui(ui, |ui| { + for w in WorkingSpace::ALL { + let o = ui.selectable_value(&mut d.working_space, w.id().to_string(), t(w.label())); + elems.push((format!("sequenceSettings.workingSpace.option.{}", w.id()), o.rect, w.label().to_string())); + } + }); + elems.push(("sequenceSettings.workingSpace".into(), resp.response.rect, shown)); + }); + ui.add_space(4.0); + let r = ui.checkbox(&mut d.wide_gamut, tl!("Wide gamut color (composite in BT.2020)")); + elems.push(("sequenceSettings.wideGamut".into(), r.rect, d.wide_gamut.to_string())); + let r = ui.checkbox(&mut d.auto_tone_map, tl!("Auto Tone Map Media (HDR and log into SDR)")); + elems.push(("sequenceSettings.autoToneMap".into(), r.rect, d.auto_tone_map.to_string())); +} + +fn vr(ui: &mut egui::Ui, elems: &mut Elems) { + let font = egui::TextStyle::Body.resolve(ui.style()); + let label_w = ui.painter().layout_no_wrap(tl!("Captured View:").to_string(), font, egui::Color32::WHITE).size().x; + row(ui, label_w, "Projection:", false, |ui| fixed_combo(ui, elems, "vr.projection", "None", 200.0, NOT_YET)); + row(ui, label_w, "Layout:", false, |ui| fixed_combo(ui, elems, "vr.layout", "Monoscopic", 200.0, NOT_YET)); + row(ui, label_w, "Captured View:", false, |ui| { + fixed_value(ui, elems, "vr.horizontal", "0°"); + ui.add_enabled(false, egui::Label::new(tl!("Horizontal"))); + fixed_value(ui, elems, "vr.vertical", "0°"); + ui.add_enabled(false, egui::Label::new(tl!("Vertical"))); + }); +} + +/// Draw the dialog. Returns whether it stays open. +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { + let new_sequence = app.ui.sequence_settings.new_sequence; + // New Sequence compares against the defaults it started from; Sequence Settings against the + // active sequence + let (cur, cur_name) = if new_sequence { + (SequenceSettings::default(), String::new()) + } else { + let Some(q) = app.session.active_sequence() else { return false }; + let name = app.session.state.active_sequence.and_then(|id| app.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); + (q.settings.clone(), name) + }; + let audio_samples = app.session.project.settings.audio_display_samples; + let accent = app.tokens.accent; + let mut d = app.ui.sequence_settings.clone(); + let mut keep = true; + let mut apply = false; + let mut elems: Elems = Vec::new(); + let max_h = ctx.content_rect().height() * 0.8; + let title = if new_sequence { tl!("New Sequence") } else { tl!("Sequence Settings") }; + let tabs: &[(&str, &str)] = if new_sequence { + &[("general", "General"), ("tracks", "Tracks")] + } else { + &[("general", "General"), ("color", "Color Management"), ("vr", "VR Properties")] + }; + egui::Window::new(title) + .id(egui::Id::new("Sequence Settings")) + .collapsible(false) + .resizable(false) + .default_width(620.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_width(620.0); + ui.horizontal(|ui| { + for &(id, label) in tabs { + let r = ui.selectable_label(d.tab == id, t(label)); + if r.clicked() { + d.tab = id.to_string(); + } + elems.push((format!("sequenceSettings.tab.{id}"), r.rect, label.to_string())); + } + }); + ui.separator(); + egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| match d.tab.as_str() { + "color" if !new_sequence => color(ui, &mut d, &mut elems), + "tracks" if new_sequence => tracks(ui, &mut d, &mut elems), + "vr" if !new_sequence => vr(ui, &mut elems), + _ => general(ui, &mut d, &cur, audio_samples, &mut elems), + }); + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(accent)); + elems.push(("sequenceSettings.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + let c = ui.button(tl!("Cancel")); + elems.push(("sequenceSettings.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; + } + }); + }); + for (id, r, l) in elems { + app.auto.add(&id, r, &l); + } + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + keep = false; + } + if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() { + apply = true; + } + if apply && new_sequence { + let (p, rest) = new_sequence_params(&d); + let r = app + .session + .execute("file.newSequence", Value::Object(p)) + .and_then(|_| if rest.is_empty() { Ok(Value::Null) } else { app.session.execute("sequence.settings", Value::Object(rest)) }); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + app.ui.sequence_settings = d; + return false; + } + if apply { + let p = changes(&d, &cur, &cur_name); + // nothing changed: OK closes the dialog and adds no undo step + if !p.is_empty() + && let Err(e) = app.session.execute("sequence.settings", Value::Object(p)) + { + app.ui.status = e.to_string(); + } + keep = false; + } + app.ui.sequence_settings = d; + keep +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aspect_labels() { + assert_eq!(aspect(1920, 1080), "16:9"); + assert_eq!(aspect(640, 360), "16:9"); + assert_eq!(aspect(1080, 1920), "9:16"); + assert_eq!(aspect(1440, 1080), "4:3"); + assert_eq!(aspect(4096, 2160), "1.90:1"); + } + + #[test] + fn display_formats_follow_the_timebase() { + assert_eq!(timecode_formats(FrameRate::FPS_23_976), vec![("tc", "23.976 fps Timecode".to_string())]); + let ntsc = timecode_formats(FrameRate::FPS_29_97); + assert_eq!(ntsc.iter().map(|(id, _)| *id).collect::>(), vec!["df", "ndf"]); + assert_eq!(ntsc[0].1, "29.97 fps Drop-Frame Timecode"); + } + + #[test] + fn new_sequence_sends_its_settings_to_the_command() { + let d = SequenceSettingsDraft { + new_sequence: true, + name: " Shorts ".into(), + width: 1080, + height: 1920, + video_tracks: 2, + audio_tracks: 4, + ..Default::default() + }; + let (p, rest) = new_sequence_params(&d); + assert_eq!( + Value::Object(p), + json!({"name":"Shorts","width":1080,"height":1920,"fps":24_000.0/1001.0,"sampleRate":48_000,"mix":"Stereo","video":2,"audio":4}) + ); + assert!(rest.is_empty(), "everything else is the default: {rest:?}"); + // what file.newSequence has no parameter for follows on the new sequence; a blank name + // lets the command name it + let d = SequenceSettingsDraft { + new_sequence: true, + name: " ".into(), + fps_num: 30_000, + fps_den: 1001, + drop_frame: true, + max_render_quality: true, + ..Default::default() + }; + let (p, rest) = new_sequence_params(&d); + assert!(p.get("name").is_none()); + assert_eq!(Value::Object(rest), json!({"dropFrame":true,"maxRenderQuality":true})); + } + + #[test] + fn only_changed_settings_are_sent() { + let cur = SequenceSettings::default(); + let mut d = SequenceSettingsDraft::default(); + assert!(changes(&d, &cur, "").is_empty(), "the defaults match: nothing to send"); + d.width = 1280; + d.height = 720; + d.max_render_quality = true; + let p = changes(&d, &cur, ""); + assert_eq!(Value::Object(p), json!({"width":1280,"height":720,"scaleMotion":true,"maxRenderQuality":true})); + // drop-frame only travels for a rate that has it + let mut d = SequenceSettingsDraft { drop_frame: true, ..Default::default() }; + assert!(changes(&d, &cur, "").is_empty()); + (d.fps_num, d.fps_den) = (30_000, 1001); + assert_eq!(Value::Object(changes(&d, &cur, "")), json!({"fps":30_000.0/1001.0,"dropFrame":true})); + // the name travels when it changed; blank or unchanged it doesn't + let d = SequenceSettingsDraft { name: " Rough Cut ".into(), ..Default::default() }; + assert_eq!(Value::Object(changes(&d, &cur, "Sequence 01")), json!({"name":"Rough Cut"})); + assert!(changes(&d, &cur, "Rough Cut").is_empty()); + let d = SequenceSettingsDraft { name: " ".into(), ..Default::default() }; + assert!(changes(&d, &cur, "Sequence 01").is_empty()); + } +} diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 564fbb594..241c59755 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -154,7 +154,7 @@ fn group(ui: &mut Ui, t: &Tokens, title: &str, add: impl FnOnce(&mut Ui)) { add(ui); }); let rect = r.response.rect; - let galley = ui.painter().layout_no_wrap(title.to_string(), Tokens::ui(12.0), t.text_dim); + let galley = ui.painter().layout_no_wrap(crate::i18n::t(title).to_string(), Tokens::ui(12.0), t.text_dim); let pos = rect.left_top() + vec2(12.0, -galley.size().y / 2.0); ui.painter().rect_filled(Rect::from_min_size(pos - vec2(4.0, 0.0), galley.size() + vec2(8.0, 0.0)), 0.0, t.panel_bg); ui.painter().galley(pos, galley, t.text_dim); @@ -166,14 +166,14 @@ fn field_label(ui: &mut Ui, text: &str) { let w = LABEL_W.min(ui.available_width() * 0.45); ui.allocate_ui_with_layout(vec2(w, 24.0), Layout::right_to_left(Align::Center), |ui| { if !text.is_empty() { - ui.label(RichText::new(format!("{text}:")).size(12.5)); + ui.label(RichText::new(format!("{}:", crate::i18n::t(text))).size(12.5)); } }); } fn unit(ui: &mut Ui, u: &str) { if !u.is_empty() { - ui.label(RichText::new(u).size(12.5)); + ui.label(RichText::new(crate::i18n::t(u)).size(12.5)); } } @@ -191,15 +191,18 @@ fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[ let shown = opts.iter().find(|o| o.0 == current).map(|o| o.1.clone()).unwrap_or_else(|| current.to_string()); let mut chosen = None; let mut items: Vec<(String, Rect, String)> = Vec::new(); - let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(RichText::new(&shown).size(12.5)).width(width).height(480.0).show_ui(ui, |ui| { - for (v, l) in opts { - let r = ui.selectable_label(v == current, l); - items.push((format!("settings.{key}.{v}"), r.rect, l.clone())); - if r.clicked() { - chosen = Some(v.clone()); + let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(RichText::new(crate::i18n::t(&shown)).size(12.5)).width(width).height(480.0).show_ui( + ui, + |ui| { + for (v, l) in opts { + let r = ui.selectable_label(v == current, crate::i18n::t(l)); + items.push((format!("settings.{key}.{v}"), r.rect, l.clone())); + if r.clicked() { + chosen = Some(v.clone()); + } } - } - }); + }, + ); app.auto.add(&format!("settings.{key}"), r.response.rect, &shown); for (id, rect, l) in items { app.auto.add(&id, rect, &l); @@ -221,7 +224,7 @@ fn device_opts(app: &mut FilmcraftApp, d: &mut SettingsDraft, list: DeviceList) DeviceList::Inputs => dev.inputs, DeviceList::Outputs => dev.outputs, }; - let mut v = vec![(String::new(), "System Default".to_string())]; + let mut v = vec![(String::new(), tl!("System Default").to_string())]; v.extend(names.into_iter().map(|n| (n.clone(), n))); v } @@ -246,7 +249,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi match f.kind { Kind::Bool => { let mut b = cur.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, RichText::new(f.label).size(12.5)); + let r = ui.checkbox(&mut b, RichText::new(crate::i18n::t(f.label)).size(12.5)); app.auto.add(&format!("settings.{}", f.key), r.rect, f.label); if r.changed() { put(&mut d.values, f.key, json!(b)); @@ -308,8 +311,8 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi field_label(ui, f.label); let mut s = choice_text(&cur); let hint = match f.kind { - Kind::Path => "Default location", - Kind::Font => "Font family", + Kind::Path => tl!("Default location"), + Kind::Font => tl!("Font family"), _ => "", }; let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if matches!(f.kind, Kind::Path) { 300.0 } else { 220.0 }).hint_text(hint)); @@ -318,7 +321,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi put(&mut d.values, f.key, json!(s)); } if matches!(f.kind, Kind::Path) { - let b = ui.button("Browse…"); + let b = ui.button(tl!("Browse…")); app.auto.add(&format!("settings.{}.browse", f.key), b.rect, "Browse…"); if b.clicked() && let Some(dir) = app.hooks.pick_folder.as_mut().and_then(|p| p()) @@ -354,7 +357,7 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: } fn note(ui: &mut Ui, t: &Tokens, text: &str) { - ui.add(egui::Label::new(RichText::new(text).size(11.5).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(crate::i18n::t(text)).size(11.5).color(t.text_dim)).wrap()); } fn mb(bytes: u64) -> String { @@ -366,7 +369,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str match name { "labelColors" => { let tt = t; - group(ui, &tt, "Label Colors", |ui| { + group(ui, &tt, tl!("Label Colors"), |ui| { egui::Grid::new("settings-label-colors").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { for l in Label::ALL.iter() { let id = settings::label_id(*l); @@ -391,41 +394,46 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str } "memoryInfo" => { let (used, budget) = app.frames.cache_usage(); - note(ui, &t, &format!("Decoded frames held for the monitors and thumbnails: {} of {}.", mb(used as u64), mb(budget as u64))); - note(ui, &t, "FilmCraft keeps the rest of the RAM free for other applications."); + note(ui, &t, &tlf!("Decoded frames held for the monitors and thumbnails: {used} of {budget}.", used = mb(used as u64), budget = mb(budget as u64))); + note(ui, &t, tl!("FilmCraft keeps the rest of the RAM free for other applications.")); } "mediaCacheInfo" => { if d.cache_info.is_null() { d.cache_info = app.session.execute("mediaCache.info", json!({})).unwrap_or(json!({})); } - let loc = d.cache_info["location"].as_str().unwrap_or("not available in this session").to_string(); + let loc = d.cache_info["location"].as_str().unwrap_or(tl!("not available in this session")).to_string(); let size = mb(d.cache_info["bytes"].as_u64().unwrap_or(0)); let files = d.cache_info["files"].as_u64().unwrap_or(0); - note(ui, &t, &format!("Currently: {loc} — {files} file(s), {size}")); + note(ui, &t, &tlf!("Currently: {loc} — {files} file(s), {size}", loc, files, size)); } "outputMapping" => { let ch = d.devices.as_ref().map(|x| x.output_channels).unwrap_or(0); let l = get(&d.values, "audioHardware.mapLeft").as_u64().unwrap_or(0) + 1; let r = get(&d.values, "audioHardware.mapRight").as_u64().unwrap_or(1) + 1; - let dev = if ch > 0 { format!(" ({ch} output channels)") } else { String::new() }; + let dev = if ch > 0 { tlf!(" ({ch} output channels)", ch) } else { String::new() }; note( ui, &t, - &format!("Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2."), + &tlf!( + "Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2.", + l, + r, + dev + ), ); } "autoSaveStatus" => { let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default(); if let Some(dir) = status["autoSaveDir"].as_str() { - note(ui, &t, &format!("Auto-saves go to: {dir}")); + note(ui, &t, &tlf!("Auto-saves go to: {dir}", dir)); } if let Some(dir) = status["sessionDir"].as_str() { - note(ui, &t, &format!("Recovery copy: {dir}")); + note(ui, &t, &tlf!("Recovery copy: {dir}", dir)); } let last = match (status["lastAutoSaveAt"].as_str(), status["lastJournalAt"].as_str()) { - (Some(a), Some(j)) => format!("Last auto-save {a} · last recovery copy {j}"), - (Some(a), None) => format!("Last auto-save {a}"), - (None, Some(j)) => format!("Last recovery copy {j}"), + (Some(a), Some(j)) => tlf!("Last auto-save {a} · last recovery copy {j}", a, j), + (Some(a), None) => tlf!("Last auto-save {a}", a), + (None, Some(j)) => tlf!("Last recovery copy {j}", j), (None, None) => String::new(), }; if !last.is_empty() { @@ -464,12 +472,12 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] Row::Note(text) => note(ui, &t, text), Row::Button { id, label, command } => { ui.horizontal(|ui| { - field_label(ui, "Remove Media Cache Files"); - let b = ui.button(*label); + field_label(ui, tl!("Remove Media Cache Files")); + let b = ui.button(crate::i18n::t(label)); app.auto.add(&format!("settings.{id}"), b.rect, label); if b.clicked() { d.message = match app.session.execute(command, json!({})) { - Ok(v) => format!("Deleted {} file(s), {}.", v["files"].as_u64().unwrap_or(0), mb(v["bytes"].as_u64().unwrap_or(0))), + Ok(v) => tlf!("Deleted {n} file(s), {size}.", n = v["files"].as_u64().unwrap_or(0), size = mb(v["bytes"].as_u64().unwrap_or(0))), Err(e) => e.to_string(), }; d.cache_info = Value::Null; @@ -493,7 +501,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { // title bar (Premiere keeps "Preferences" as the window title) let (bar, _) = ui.allocate_exact_size(vec2(W, 30.0), Sense::hover()); ui.painter().rect_filled(bar, CornerRadius { nw: 10, ne: 10, sw: 0, se: 0 }, t.header_bg); - ui.painter().text(bar.center(), Align2::CENTER_CENTER, "Preferences", Tokens::semibold(13.0), t.text_dim); + ui.painter().text(bar.center(), Align2::CENTER_CENTER, tl!("Preferences"), Tokens::semibold(13.0), t.text_dim); app.auto.add("settings.window", bar, "Preferences"); ui.add_space(14.0); ui.horizontal_top(|ui| { @@ -510,7 +518,13 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } else if r.hovered() { ui.painter().rect_filled(row, 0.0, t.hover.gamma_multiply(0.6)); } - ui.painter().text(row.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, c.title, Tokens::ui(12.5), if sel { t.text } else { t.text_dim }); + ui.painter().text( + row.left_center() + vec2(8.0, 0.0), + Align2::LEFT_CENTER, + crate::i18n::t(c.title), + Tokens::ui(12.5), + if sel { t.text } else { t.text_dim }, + ); app.auto.add(&format!("settings.category.{}", c.id), row, c.title); if r.clicked() { d.page = c.id.into(); @@ -536,10 +550,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.add_space(14.0); ui.horizontal(|ui| { ui.add_space(20.0); - if button(app, ui, "settings.help", "Help", false) { + if button(app, ui, "settings.help", tl!("Help"), false) { crate::links::open(ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); } - if button(app, ui, "settings.reset", "Reset…", false) { + if button(app, ui, "settings.reset", tl!("Reset…"), false) { // this category back to its defaults (OK applies it) let defaults = filmcraft_engine::autosave::Preferences::default().to_value(); let keep = get(&d.values, "general.recentProjects").clone(); @@ -548,10 +562,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.add_space(20.0); - if button(app, ui, "settings.ok", "OK", true) { + if button(app, ui, "settings.ok", tl!("OK"), true) { apply = true; } - if button(app, ui, "settings.cancel", "Cancel", false) { + if button(app, ui, "settings.cancel", tl!("Cancel"), false) { close = true; } }); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index 6c92b4ab6..0e6845a88 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -77,7 +77,7 @@ fn label_of(app: &FilmcraftApp, id: &str) -> String { .command(id) .map(|c| c.label) .or_else(|| filmcraft_engine::find_command(id).map(|c| c.label.to_string())) - .unwrap_or_else(|| id.to_string()) + .map_or_else(|| id.to_string(), |l| crate::i18n::t(&l).to_string()) } fn exec(app: &mut FilmcraftApp, cmd: &str, params: Value) -> Option { @@ -99,7 +99,7 @@ fn assign(app: &mut FilmcraftApp, command: &str, keys: &str, add: bool) { let mut msg = format!("{disp} → {}", label_of(app, command)); let moved: Vec = r["reassigned"].as_array().into_iter().flatten().filter_map(|b| b["command"].as_str().map(|c| label_of(app, c))).collect(); if !moved.is_empty() { - msg.push_str(&format!(" (removed from {})", moved.join(", "))); + msg.push_str(&tlf!(" (removed from {list})", list = moved.join(", "))); } app.shortcut_editor.message = msg; } @@ -131,12 +131,12 @@ fn record(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some((key, m)) = got else { return }; app.shortcut_editor.recording = None; if key == egui::Key::Escape && !m.any() { - app.shortcut_editor.message = "Cancelled".into(); + app.shortcut_editor.message = tl!("Cancelled").into(); return; } match chord_of(key, m) { Some(k) => assign(app, &cmd, &k, add), - None => app.shortcut_editor.message = format!("{key:?} can't be used as a shortcut"), + None => app.shortcut_editor.message = tlf!("{key} can't be used as a shortcut", key = format!("{key:?}")), } } @@ -148,7 +148,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let size = vec2((screen.width() - 40.0).clamp(760.0, 1320.0), (screen.height() - 60.0).clamp(560.0, 900.0)); let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel let mut open = true; - egui::Window::new("Keyboard Shortcuts") + egui::Window::new(tl!("Keyboard Shortcuts")) + .id(egui::Id::new("Keyboard Shortcuts")) .id(egui::Id::new("keyboard-shortcuts")) .open(&mut open) .collapsible(false) @@ -210,68 +211,68 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { let builtin: Vec = presets["builtin"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); let custom: Vec = presets["custom"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); ui.horizontal(|ui| { - ui.label(RichText::new("Preset:").size(13.0).color(t.text_dim)); - let shown = if modified { format!("{active} (modified)") } else { active.clone() }; + ui.label(RichText::new(tl!("Preset:")).size(13.0).color(t.text_dim)); + let shown = if modified { tlf!("{active} (modified)", active) } else { active.clone() }; let r = egui::ComboBox::from_id_salt("shortcuts-preset").width(280.0).selected_text(shown).show_ui(ui, |ui| { for name in builtin.iter().chain(custom.iter()) { let r = ui.selectable_label(*name == active && !modified, name); app.auto.add(&format!("shortcuts.preset.{name}"), r.rect, name); if r.clicked() { exec(app, "shortcuts.loadPreset", json!({"name": name})); - app.shortcut_editor.message = format!("Loaded “{name}”"); + app.shortcut_editor.message = tlf!("Loaded “{name}”", name); } } }); app.auto.add("shortcuts.preset", r.response.rect, "Preset"); ui.add_space(8.0); - if small_button(app, ui, "shortcuts.saveAs", "Save As…", true) { - app.shortcut_editor.save_name = Some(if builtin.contains(&active) { "My Shortcuts".into() } else { active.clone() }); + if small_button(app, ui, "shortcuts.saveAs", tl!("Save As…"), true) { + app.shortcut_editor.save_name = Some(if builtin.contains(&active) { tl!("My Shortcuts").into() } else { active.clone() }); } let can_delete = custom.contains(&active); - if small_button(app, ui, "shortcuts.delete", "Delete", can_delete) { + if small_button(app, ui, "shortcuts.delete", tl!("Delete"), can_delete) { exec(app, "shortcuts.deletePreset", json!({"name": active})); - app.shortcut_editor.message = format!("Deleted preset “{active}”"); + app.shortcut_editor.message = tlf!("Deleted preset “{active}”", active); } - if small_button(app, ui, "shortcuts.export", "Export…", true) { - let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"], &format!("{active}.json"))); + if small_button(app, ui, "shortcuts.export", tl!("Export…"), true) { + let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"], &format!("{active}.json"))); match picked { Some(path) => { if exec(app, "shortcuts.export", json!({"path": path})).is_some() { - app.shortcut_editor.message = format!("Exported to {path}"); + app.shortcut_editor.message = tlf!("Exported to {path}", path); } } - None => app.shortcut_editor.message = "Export: no file chosen (agents: shortcuts.export {path})".into(), + None => app.shortcut_editor.message = tl!("Export: no file chosen (agents: shortcuts.export {path})").into(), } } - if small_button(app, ui, "shortcuts.import", "Import…", true) { - let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"])); + if small_button(app, ui, "shortcuts.import", tl!("Import…"), true) { + let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"])); match picked { Some(path) => { if let Some(r) = exec(app, "shortcuts.import", json!({"path": path})) { - app.shortcut_editor.message = format!("Imported “{}”", r["name"].as_str().unwrap_or("")); + app.shortcut_editor.message = tlf!("Imported “{name}”", name = r["name"].as_str().unwrap_or("")); } } - None => app.shortcut_editor.message = "Import: no file chosen (agents: shortcuts.import {path})".into(), + None => app.shortcut_editor.message = tl!("Import: no file chosen (agents: shortcuts.import {path})").into(), } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(if cfg!(target_os = "macos") { "Keyboard: US (macOS)" } else { "Keyboard: US" }).size(12.0).color(t.text_dim)); + ui.label(RichText::new(if cfg!(target_os = "macos") { tl!("Keyboard: US (macOS)") } else { tl!("Keyboard: US") }).size(12.0).color(t.text_dim)); }); }); if let Some(mut name) = app.shortcut_editor.save_name.clone() { ui.horizontal(|ui| { - ui.label(RichText::new("Save preset as:").size(13.0).color(t.text_dim)); + ui.label(RichText::new(tl!("Save preset as:")).size(13.0).color(t.text_dim)); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if small_button(app, ui, "shortcuts.saveAs.ok", "Save", !name.trim().is_empty()) || enter { + if small_button(app, ui, "shortcuts.saveAs.ok", tl!("Save"), !name.trim().is_empty()) || enter { if exec(app, "shortcuts.savePreset", json!({"name": name})).is_some() { - app.shortcut_editor.message = format!("Saved preset “{}”", name.trim()); + app.shortcut_editor.message = tlf!("Saved preset “{name}”", name = name.trim()); app.shortcut_editor.save_name = None; } else { app.shortcut_editor.save_name = Some(name.clone()); } - } else if small_button(app, ui, "shortcuts.saveAs.cancel", "Cancel", true) { + } else if small_button(app, ui, "shortcuts.saveAs.cancel", tl!("Cancel"), true) { app.shortcut_editor.save_name = None; } else { app.shortcut_editor.save_name = Some(name.clone()); @@ -280,12 +281,14 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { } ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(RichText::new("Commands:").size(13.0).color(t.text_dim)); + ui.label(RichText::new(tl!("Commands:")).size(13.0).color(t.text_dim)); let cur = app.shortcut_editor.context.clone(); - let r = - egui::ComboBox::from_id_salt("shortcuts-context").width(280.0).selected_text(if cur.is_empty() { APPLICATION } else { &cur }).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt("shortcuts-context") + .width(280.0) + .selected_text(crate::i18n::t(if cur.is_empty() { APPLICATION } else { &cur })) + .show_ui(ui, |ui| { for c in std::iter::once(APPLICATION).chain(PANELS.iter().copied()) { - let label = if c == APPLICATION { c.to_string() } else { format!("{c} Panel") }; + let label = if c == APPLICATION { crate::i18n::t(c).to_string() } else { tlf!("{panel} Panel", panel = crate::i18n::t(c)) }; let r = ui.selectable_label(cur == c, label); app.auto.add(&format!("shortcuts.context.{c}"), r.rect, c); if r.clicked() { @@ -575,9 +578,9 @@ fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { fn legend(ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { for (c, text) in [ - (APP_KEY, "Application shortcuts work whatever panel has focus"), - (PANEL_KEY, "Panel shortcuts override application shortcuts while that panel has focus"), - (MOD_KEY, "Modifier held / selected"), + (APP_KEY, tl!("Application shortcuts work whatever panel has focus")), + (PANEL_KEY, tl!("Panel shortcuts override application shortcuts while that panel has focus")), + (MOD_KEY, tl!("Modifier held / selected")), ] { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); ui.painter().rect_filled(r, 2.0, c); @@ -591,21 +594,23 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let plat = Platform::current(); ui.horizontal(|ui| { let mut q = app.shortcut_editor.search.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or keys").desired_width(320.0)); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)); app.auto.add("shortcuts.search", r.rect, "Search"); app.shortcut_editor.search = q; if let Some(sel) = app.shortcut_editor.selected.clone() { - ui.label(RichText::new(format!("Selected: {}", label_of(app, &sel))).size(12.0).color(t.text_dim)); + ui.label(RichText::new(tlf!("Selected: {command}", command = label_of(app, &sel))).size(12.0).color(t.text_dim)); } }); ui.add_space(4.0); - let rows = app.session.execute("shortcuts.list", json!({"query": app.shortcut_editor.search})).unwrap_or_default(); + let rows = app.session.execute("shortcuts.list", json!({})).unwrap_or_default(); let rows = rows.as_array().cloned().unwrap_or_default(); + let query = app.shortcut_editor.search.to_lowercase(); + let rows: Vec<_> = rows.into_iter().filter(|row| matches_search(row, &query)).collect(); let w = ui.available_width(); // column header let (hr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); - ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); - ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, "Shortcut", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, tl!("Command"), Tokens::semibold(12.5), t.text_dim); + ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, tl!("Shortcut"), Tokens::semibold(12.5), t.text_dim); egui::ScrollArea::vertical().id_salt("shortcuts-list").max_height(height - 50.0).auto_shrink([false, false]).show(ui, |ui| { let mut last_cat = String::new(); for (i, row) in rows.iter().enumerate() { @@ -615,7 +620,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let (cr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); let c = cr.left_center() + vec2(9.0, 0.0); ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-4.0, -2.5), c + vec2(4.0, -2.5), c + vec2(0.0, 3.0)], t.text_dim, Stroke::NONE)); - ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, &cat, Tokens::semibold(12.0), t.text); + ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, crate::i18n::t(&cat), Tokens::semibold(12.0), t.text); last_cat = cat; } let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); @@ -628,7 +633,13 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke t.row_alt }; ui.painter().rect_filled(r, 0.0, bg); - ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, row["label"].as_str().unwrap_or(""), Tokens::ui(12.5), t.text); + ui.painter().text( + r.left_center() + vec2(22.0, 0.0), + Align2::LEFT_CENTER, + crate::i18n::t(row["label"].as_str().unwrap_or("")), + Tokens::ui(12.5), + t.text, + ); app.auto.add(&format!("shortcuts.row.{id}"), r, row["label"].as_str().unwrap_or("")); if resp.clicked() { app.shortcut_editor.selected = Some(id.clone()); @@ -637,7 +648,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let cell = Rect::from_min_max(pos2(r.min.x + w * 0.58, r.min.y + 2.0), pos2(r.max.x - 34.0, r.max.y - 2.0)); let recording = app.shortcut_editor.recording.as_ref().is_some_and(|(c, _)| *c == id); let text = if recording { - "Type a shortcut… (Esc cancels)".to_string() + tl!("Type a shortcut… (Esc cancels)").to_string() } else { row["shortcuts"] .as_array() @@ -666,7 +677,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke app.shortcut_editor.recording = Some((id.clone(), false)); } let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); - let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text("Add another shortcut"); + let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); ui.painter().text( plus.center(), Align2::CENTER_CENTER, @@ -681,24 +692,31 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke } } if rows.is_empty() { - ui.label(RichText::new("No matching commands").color(t.text_dim)); + ui.label(RichText::new(tl!("No matching commands")).color(t.text_dim)); } }); let _ = plat; } +fn matches_search(row: &serde_json::Value, query: &str) -> bool { + ["id", "label", "category"].iter().any(|key| crate::i18n::matches_query(row.get(key).and_then(serde_json::Value::as_str).unwrap_or(""), query)) + || row.get("shortcuts").and_then(serde_json::Value::as_array).into_iter().flatten().any(|binding| { + ["keys", "display", "panel"].iter().any(|key| crate::i18n::matches_query(binding.get(key).and_then(serde_json::Value::as_str).unwrap_or(""), query)) + }) +} + fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { let plat = Platform::current(); let key = app.shortcut_editor.key.clone(); ui.horizontal(|ui| { - ui.label(RichText::new("Key:").size(13.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Key:")).size(13.0).strong().color(t.text)); if let Some(k) = &key { ui.label(RichText::new(key_legend(k, plat.is_mac())).size(13.0).color(t.hot_text)); } }); let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); - ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Modifiers", Tokens::semibold(12.5), t.text_dim); - ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, tl!("Modifiers"), Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, tl!("Command"), Tokens::semibold(12.5), t.text_dim); let list = key.as_ref().and_then(|k| app.session.execute("shortcuts.forKey", json!({"key": k})).ok()).unwrap_or_default(); egui::ScrollArea::vertical().id_salt("shortcuts-key").max_height(height - 90.0).auto_shrink([false, false]).show(ui, |ui| { let w = ui.available_width(); @@ -711,7 +729,7 @@ fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens let d = k.display(plat); let legend = key_legend(c.key, plat.is_mac()); let m = d.trim_end_matches(&legend).trim_end_matches(c.key).trim_end_matches('+').to_string(); - if m.is_empty() { "None".to_string() } else { m } + if m.is_empty() { tl!("None").to_string() } else { m } }); ui.painter().text(r.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, mods.unwrap_or_default(), Tokens::ui(12.5), t.text); let cmd = b["command"].as_str().unwrap_or(""); @@ -722,17 +740,17 @@ fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens ui.painter().text(r.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, label, Tokens::ui(12.5), t.text); } if key.is_some() && list.as_array().is_none_or(|a| a.is_empty()) { - ui.label(RichText::new("Nothing is assigned to this key").color(t.text_dim)); + ui.label(RichText::new(tl!("Nothing is assigned to this key")).color(t.text_dim)); } if key.is_none() { - ui.label(RichText::new("Click a key on the keyboard to see its shortcuts").color(t.text_dim)); + ui.label(RichText::new(tl!("Click a key on the keyboard to see its shortcuts")).color(t.text_dim)); } }); // assign the selected command to the clicked key with the selected modifiers if let (Some(k), Some(cmd)) = (key, app.shortcut_editor.selected.clone()) { let keys = format!("{}{}", app.shortcut_editor.mods.prefix(), k); let disp = Chord::parse(&keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); - let label = format!("Assign {disp} to {}", label_of(app, &cmd)); + let label = tlf!("Assign {key} to {command}", key = disp, command = label_of(app, &cmd)); if small_button(app, ui, "shortcuts.assignKey", &label, true) { assign(app, &cmd, &keys, false); } @@ -750,11 +768,15 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option for (ctx, keys, cmds) in conflicts.iter().take(2) { let disp = Chord::parse(keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); let names: Vec = cmds.iter().map(|c| label_of(app, c)).collect(); - let r = ui.label(RichText::new(format!("Conflict: {disp} is assigned to {} ({ctx})", names.join(" and "))).size(12.0).color(WARN)); + let r = ui.label( + RichText::new(tlf!("Conflict: {key} is assigned to {commands} ({ctx})", key = disp, commands = names.join(tl!(" and ")), ctx)) + .size(12.0) + .color(WARN), + ); app.auto.add("shortcuts.conflict", r.rect, "conflict"); } let msg = if app.shortcut_editor.message.is_empty() { - "Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.".to_string() + tl!("Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.").to_string() } else { app.shortcut_editor.message.clone() }; @@ -762,13 +784,14 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option app.auto.add("shortcuts.message", r.rect, &msg); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = - ui.add(egui::Button::new(RichText::new("OK").size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + let ok = ui.add( + egui::Button::new(RichText::new(tl!("OK")).size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0), + ); app.auto.add("shortcuts.ok", ok.rect, "OK"); if ok.clicked() { close = Some(true); } - let cancel = ui.add(egui::Button::new(RichText::new("Cancel").size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + let cancel = ui.add(egui::Button::new(RichText::new(tl!("Cancel")).size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); app.auto.add("shortcuts.cancel", cancel.rect, "Cancel"); if cancel.clicked() { close = Some(false); @@ -776,20 +799,36 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option ui.add_space(16.0); let sel = app.shortcut_editor.selected.clone(); let panel = app.shortcut_editor.panel().map(str::to_string); - if small_button(app, ui, "shortcuts.clear", "Clear", sel.is_some()) + if small_button(app, ui, "shortcuts.clear", tl!("Clear"), sel.is_some()) && let Some(c) = sel && let Some(r) = exec(app, "shortcuts.clear", json!({"command": c, "panel": panel.clone().unwrap_or_else(|| APPLICATION.into())})) { - app.shortcut_editor.message = format!("Cleared {} shortcut(s) of {}", r["removed"], label_of(app, &c)); + app.shortcut_editor.message = tlf!("Cleared {n} shortcut(s) of {command}", n = r["removed"], command = label_of(app, &c)); } let (cu, cr) = (app.session.shortcuts.can_undo(), app.session.shortcuts.can_redo()); - if small_button(app, ui, "shortcuts.redo", "Redo", cr) { + if small_button(app, ui, "shortcuts.redo", tl!("Redo"), cr) { exec(app, "shortcuts.redo", json!({})); } - if small_button(app, ui, "shortcuts.undo", "Undo", cu) { + if small_button(app, ui, "shortcuts.undo", tl!("Undo"), cu) { exec(app, "shortcuts.undo", json!({})); } }); }); close } + +#[cfg(test)] +mod localization_tests { + use super::*; + + #[test] + fn command_search_accepts_spanish_labels_ids_and_keys() { + crate::i18n::set_current(crate::i18n::Language::Es); + let row = json!({"id": "edit.undo", "label": "Undo", "category": "Edit", "shortcuts": [{"keys": "Cmd+Z", "display": "Ctrl+Z"}]}); + for query in ["deshacer", "undo", "edit.undo", "ctrl+z", "cmd+z"] { + assert!(matches_search(&row, query), "{query}"); + } + assert!(!matches_search(&row, "rehacer")); + crate::i18n::set_current(crate::i18n::Language::En); + } +} diff --git a/crates/ui-egui/src/panels/source_drag.rs b/crates/ui-egui/src/panels/source_drag.rs new file mode 100644 index 000000000..d705e0f2b --- /dev/null +++ b/crates/ui-egui/src/panels/source_drag.rs @@ -0,0 +1,83 @@ +//! Source monitor drag controls. The payload snapshots the marked span at drag start. + +use egui::{Rect, Sense, pos2, vec2}; +use filmcraft_engine::clip_ops::source_view; +use filmcraft_project::ItemId; +use filmcraft_time::TimeRange; + +use crate::FilmcraftApp; + +#[derive(Clone, Copy, Debug)] +pub struct SourceDrag { + pub item: ItemId, + pub range: TimeRange, + pub video: bool, + pub audio: bool, +} + +pub fn begin(app: &mut FilmcraftApp, ui: &egui::Ui, video: bool, audio: bool) { + let Some(item) = app.session.state.source_item else { return }; + let Some(view) = source_view(&app.session, item) else { return }; + let Some(media) = app.session.project.item(view.media) else { return }; + let video = video && media.has_video(); + let audio = audio && media.has_audio(); + let range = view.selected_range(); + if (!video && !audio) || range.duration.0 <= 0 { + return; + } + app.stop_source(); + super::start_drag_source(ui, SourceDrag { item: view.media, range, video, audio }); +} + +pub fn controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect) { + let media = app.session.state.source_item.and_then(|i| source_view(&app.session, i)).and_then(|v| app.session.project.item(v.media)); + let (has_video, has_audio) = media.map(|i| (i.has_video(), i.has_audio())).unwrap_or_default(); + let buttons = [ + ("source.drag.video", "Drag Video Only", true, false, has_video), + ("source.drag.audio", "Drag Audio Only", false, true, has_audio), + ("source.drag.both", "Drag Video and Audio", true, true, has_video && has_audio), + ]; + let gap = 4.0_f32.min(row.width() / 12.0); + let width = ((row.width() - gap * 2.0) / 3.0).clamp(1.0, 32.0); + let left = row.center().x - (width * 3.0 + gap * 2.0) * 0.5; + for (i, (id, label, video, audio, enabled)) in buttons.into_iter().enumerate() { + let rect = Rect::from_min_size(pos2(left + i as f32 * (width + gap), row.min.y), vec2(width, row.height())); + let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::new(id), Sense::drag())).inner; + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + let t = app.tokens; + if response.hovered() || response.dragged() { + ui.painter().rect_filled(rect, 2.0, t.hover); + } + let color = if !enabled { + t.text_faint + } else if response.hovered() { + t.tab_text_active + } else { + t.icon + }; + let icon = rect.shrink(4.0); + if video && audio { + let half = icon.width() * 0.5; + crate::icons::paint(ui.painter(), Rect::from_min_size(icon.min, vec2(half, icon.height())), crate::icons::Icon::Film, color); + crate::icons::paint( + ui.painter(), + Rect::from_min_size(pos2(icon.min.x + half, icon.min.y), vec2(half, icon.height())), + crate::icons::Icon::Audio, + color, + ); + } else { + crate::icons::paint(ui.painter(), icon, if video { crate::icons::Icon::Film } else { crate::icons::Icon::Audio }, color); + } + app.auto.add(id, rect, label); + // Windows maps Grab/Grabbing to crossed arrows; Pointer maps to its hand cursor. + let hover_cursor = if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab }; + let drag_cursor = if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grabbing }; + let response = response.on_hover_cursor(hover_cursor).on_hover_text(label); + if response.dragged() { + ui.ctx().set_cursor_icon(drag_cursor); + } + if response.drag_started() { + begin(app, ui, video, audio); + } + } +} diff --git a/crates/ui-egui/src/panels/source_range.rs b/crates/ui-egui/src/panels/source_range.rs new file mode 100644 index 000000000..7b2167ec7 --- /dev/null +++ b/crates/ui-egui/src/panels/source_range.rs @@ -0,0 +1,216 @@ +//! Source and Program In/Out handles and range translation. Preview is transient; release is one engine edit. +use super::monitor::Which; +use crate::{FilmcraftApp, dock::PanelKind}; +use egui::{Rect, Sense, Stroke, pos2, vec2}; +use filmcraft_engine::{ + clip_ops::source_view, + source_monitor::{RangeEdge, adjust_range}, +}; +use filmcraft_project::ItemId; +use filmcraft_time::{FrameRate, Tick, TimeRange}; +use serde_json::json; + +#[derive(Clone, Copy, Debug)] +struct Drag { + item: ItemId, + edge: RangeEdge, + bounds: TimeRange, + original: TimeRange, + preview: TimeRange, + rate: FrameRate, + pointer_x: f32, + revision: u64, +} +fn key(which: Which) -> egui::Id { + egui::Id::new(("monitor-range-edit", which as u8)) +} +fn self_item(app: &FilmcraftApp, which: Which) -> Option { + if which == Which::Source { app.session.state.source_item } else { app.session.state.active_sequence } +} +fn drag(ctx: &egui::Context, which: Which) -> Option { + ctx.data(|d| d.get_temp(key(which))) +} +fn clear(ctx: &egui::Context, which: Which) { + ctx.data_mut(|d| d.remove::(key(which))); +} + +pub fn preview(app: &FilmcraftApp, ctx: &egui::Context) -> Option { + preview_for(app, ctx, Which::Source) +} +pub fn preview_for(app: &FilmcraftApp, ctx: &egui::Context, which: Which) -> Option { + drag(ctx, which).filter(|d| Some(d.item) == self_item(app, which) && d.revision == app.session.revision).map(|d| d.preview) +} + +#[derive(Default)] +pub struct Interaction { + pub range: Option, + pub in_hot: bool, + pub out_hot: bool, + pub handled: bool, + pub scrub: Option, +} + +pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Which) -> Interaction { + let ctx = ui.ctx().clone(); + let Some(item) = self_item(app, which) else { + clear(&ctx, which); + return Interaction::default(); + }; + let Some(view) = source_view(&app.session, item) else { + clear(&ctx, which); + return Interaction::default(); + }; + let enabled = view.subclip.is_none() && view.start.0 >= 0 && view.end > view.start; + if let Some(d) = drag(&ctx, which) + && (d.item != item || d.revision != app.session.revision || ctx.input(|i| i.key_pressed(egui::Key::Escape))) + { + clear(&ctx, which); + } + let selected = preview_for(app, &ctx, which).unwrap_or_else(|| view.selected_range()); + let span = view.end.0.saturating_sub(view.start.0).max(1) as f64; + let xof = |t: Tick| bar.min.x + (t.0.saturating_sub(view.start.0) as f64 / span).clamp(0.0, 1.0) as f32 * bar.width(); + let (a, b) = (xof(selected.start), xof(selected.end())); + let mid = (a + b) * 0.5; + let band = Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)); + let body = Rect::from_min_max(pos2((a + 6.0).min(mid), band.min.y), pos2((b - 6.0).max(mid), band.max.y)); + let inside = Rect::from_min_max(pos2(a - 6.0, bar.min.y), pos2((a + 6.0).min(mid), bar.max.y)).intersect(bar.expand2(vec2(6.0, 0.0))); + let outside = Rect::from_min_max(pos2((b - 6.0).max(mid), bar.min.y), pos2(b + 6.0, bar.max.y)).intersect(bar.expand2(vec2(6.0, 0.0))); + let mut result = Interaction::default(); + let ids = if which == Which::Source { + ["source.range.body", "source.range.in", "source.range.out"] + } else { + ["program.range.body", "program.range.in", "program.range.out"] + }; + for (id, label, edge, rect) in [ + (ids[0], "Move marked range", RangeEdge::Move, body), + (ids[1], "Drag In point", RangeEdge::In, inside), + (ids[2], "Drag Out point", RangeEdge::Out, outside), + ] { + if which == Which::Program + && match edge { + RangeEdge::In => view.mark_in.is_none(), + RangeEdge::Out => view.mark_out.is_none(), + RangeEdge::Move => view.mark_in.is_none() || view.mark_out.is_none(), + } + { + continue; + } + app.auto.add(id, rect, label); + let response = + ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label); + let cursor = if edge == RangeEdge::Move { + if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab } + } else { + egui::CursorIcon::ResizeHorizontal + }; + let response = response.on_hover_cursor(cursor); + result.handled |= response.hovered() || response.dragged() || response.clicked(); + if edge == RangeEdge::In { + result.in_hot = response.hovered(); + } + if edge == RangeEdge::Out { + result.out_hot = response.hovered(); + } + if response.drag_started() { + let Some(pointer) = ctx.input(|i| i.pointer.press_origin()) else { continue }; + let bounds = TimeRange::from_bounds(view.start, view.end); + let original = view.selected_range(); + if let Err(e) = adjust_range(bounds, original, view.rate, edge, Tick::ZERO) { + app.ui.status = e.to_string(); + continue; + } + if which == Which::Source { + app.stop_source(); + } else { + app.stop(); + } + app.ui.focused = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; + ctx.data_mut(|d| { + d.insert_temp( + key(which), + Drag { item, edge, bounds, original, preview: original, rate: view.rate, pointer_x: pointer.x, revision: app.session.revision }, + ) + }); + } + if let Some(mut d) = drag(&ctx, which).filter(|d| d.edge == edge && d.item == item) { + result.handled = true; + ctx.set_cursor_icon(cursor); + if let Some(pos) = response.interact_pointer_pos() { + let raw = (f64::from(pos.x - d.pointer_x) / f64::from(bar.width().max(1.0)) * d.bounds.duration.0 as f64) + .clamp(-(d.bounds.duration.0 as f64), d.bounds.duration.0 as f64); + if raw.is_finite() { + match adjust_range(d.bounds, d.original, d.rate, d.edge, Tick(raw as i64)) { + Ok(r) => { + d.preview = r; + ctx.data_mut(|v| v.insert_temp(key(which), d)); + } + Err(e) => { + app.ui.status = e.to_string(); + clear(&ctx, which); + } + } + } + } + if edge == RangeEdge::In { + result.in_hot = true; + } + if edge == RangeEdge::Out { + result.out_hot = true; + } + if response.drag_stopped() { + clear(&ctx, which); + if d.preview != d.original && d.revision == app.session.revision && self_item(app, which) == Some(item) { + let mut params = json!({"item":item.0}); + if edge != RangeEdge::Out { + params["in"] = json!(d.preview.start.0); + } + if edge != RangeEdge::In { + params["out"] = json!(d.preview.end().0.saturating_sub(d.rate.frame_duration().0).max(view.start.0)); + } + if let Err(e) = app.session.execute("project.setMarks", params) { + app.ui.status = e.to_string(); + } + } + } else if !ctx.input(|i| i.pointer.any_down()) { + clear(&ctx, which); + } + } + if response.clicked() + && edge == RangeEdge::Move + && let Some(pos) = response.interact_pointer_pos() + { + let time = view.start.0.saturating_add(((f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0) * span) as i64); + if let Err(e) = app.session.execute(if which == Which::Source { "source.setPlayhead" } else { "playhead.set" }, json!({"time":time})) { + app.ui.status = e.to_string(); + } + } + } + let time = if which == Which::Source { app.session.state.source_playhead } else { app.session.playhead() }; + let x = xof(time); + let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y)); + let id = if which == Which::Source { "source.playhead" } else { "program.playhead" }; + app.auto.add(id, rect, "Scrub playhead"); + let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if (playhead.dragged() || playhead.clicked()) + && let Some(pos) = playhead.interact_pointer_pos() + { + let f = (f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0); + result.scrub = Some(view.rate.snap(Tick(view.start.0.saturating_add((f * span) as i64)))); + result.handled = true; + } + result.range = preview_for(app, &ctx, which); + result +} + +/// Original generic red bracket and bidirectional arrows; no third-party artwork. +pub fn trim_cue(p: &egui::Painter, x: f32, bar: Rect, inward: f32) { + let red = crate::panels::trim_monitor::RIPPLE_RED; + let (top, bottom) = (bar.min.y + 8.0, bar.max.y); + let mid = (top + bottom) * 0.5; + let stroke = Stroke::new(1.2, red); + p.line(vec![pos2(x + inward, top), pos2(x, top), pos2(x, bottom), pos2(x + inward, bottom)], stroke); + p.line_segment([pos2(x - 9.0, mid), pos2(x + 9.0, mid)], stroke); + for direction in [-1.0_f32, 1.0] { + p.line(vec![pos2(x + direction * 6.0, mid - 2.5), pos2(x + direction * 9.0, mid), pos2(x + direction * 6.0, mid + 2.5)], stroke); + } +} diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 748354121..ba59f9a7e 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -16,25 +16,36 @@ use crate::theme::Tokens; const TABS: [&str; 3] = ["Transcript", "Captions", "Graphics"]; +/// The width a tab label needs: 7 px per character, which the strip was laid out with for Latin +/// text, or the label as drawn if that is wider (Japanese glyphs are about twice as wide, and a +/// per-character estimate alone let a label run into the next tab). Measured in the semibold the +/// active tab uses, so selecting a tab does not move its neighbours. +fn tab_text_width(painter: &egui::Painter, label: &str) -> f32 { + let drawn = painter.layout_no_wrap(label.to_string(), Tokens::semibold(12.5), Color32::WHITE).size().x; + (label.chars().count() as f32 * 7.0).max(drawn) +} + pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; ui.painter().rect_filled(rect, 0.0, t.panel_bg); // tabs let mut x = rect.min.x + 12.0; for tab in TABS { - let w = tab.len() as f32 * 7.0 + 16.0; + let shown = crate::i18n::t(tab); + let text_w = tab_text_width(ui.painter(), shown); + let w = text_w + 16.0; let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); let active = app.ui.text_tab == tab; ui.painter().text( pos2(r.min.x, r.center().y), Align2::LEFT_CENTER, - tab, + shown, if active { Tokens::semibold(12.5) } else { Tokens::ui(12.5) }, if active { t.text } else { t.text_dim }, ); if active { - ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + w - 16.0, r.max.y)], Stroke::new(2.0, t.text)); + ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.min.x + text_w, r.max.y)], Stroke::new(2.0, t.text)); } app.auto.add(&format!("text.tab.{tab}"), r, tab); if resp.clicked() { @@ -46,7 +57,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { match app.ui.text_tab.as_str() { "Captions" => captions(app, ui, body), "Transcript" => transcript(app, ui, body), - _ => crate::dock::placeholder(ui, body, &t, "Graphics text search arrives with M10.1–M10.2"), + _ => crate::dock::placeholder(ui, body, &t, tl!("Graphics text search arrives with M10.1–M10.2")), } } @@ -64,19 +75,21 @@ fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, i fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "Open a sequence to work with captions"); + crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to work with captions")); return; }; let mut actions: Vec<(String, Value)> = Vec::new(); if seq.caption_tracks.is_empty() { let c = rect.center(); icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim); - ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, "Add captions", Tokens::semibold(16.0), t.text); - ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, "Create a caption track or import a caption file.", Tokens::ui(12.0), t.text_dim); - for (i, (id, label, cmd)) in - [("text.captions.newTrack", "Create new caption track", "captions.newTrack"), ("text.captions.import", "Import captions file…", "captions.import")] - .into_iter() - .enumerate() + ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, tl!("Add captions"), Tokens::semibold(16.0), t.text); + ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, tl!("Create a caption track or import a caption file."), Tokens::ui(12.0), t.text_dim); + for (i, (id, label, cmd)) in [ + ("text.captions.newTrack", tl!("Create new caption track"), "captions.newTrack"), + ("text.captions.import", tl!("Import captions file…"), "captions.import"), + ] + .into_iter() + .enumerate() { let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); let resp = ui.interact(r, egui::Id::new(id), Sense::click()); @@ -102,7 +115,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- toolbar: search, track picker, add / split / merge / delete let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(170.0f32.min(bar.width() * 0.4), 24.0)))); - let sresp = crate::widgets::search_field(&mut child, &mut app.ui.caption_search, "Search", 170.0f32.min(bar.width() * 0.4), &t); + let sresp = crate::widgets::search_field(&mut child, &mut app.ui.caption_search, tl!("Search"), 170.0f32.min(bar.width() * 0.4), &t); app.auto.add("text.captions.search", sresp.rect, "Search captions"); let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0); let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0)); @@ -111,13 +124,13 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("text.captions.track", picker, "Caption track"); egui::Popup::menu(&presp).show(|ui| { for (i, tr) in seq.caption_tracks.iter().enumerate() { - if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, tr.format.label())).clicked() { + if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, crate::i18n::t(tr.format.label()))).clicked() { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i)); } } ui.separator(); for f in CaptionFormat::ALL { - if ui.button(format!("New {} track", f.label())).clicked() { + if ui.button(tlf!("New {format} track", format = crate::i18n::t(f.label()))).clicked() { actions.push(("captions.newTrack".into(), json!({"format": f.label()}))); } } @@ -127,17 +140,17 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let any_sel = !sel.is_empty(); let under = track.caption_at(ph).filter(|c| c.start < ph).map(|c| c.id); let tools: [(Icon, &str, &str, bool, &str, Value); 5] = [ - (Icon::Plus, "text.captions.add", "Add caption at playhead", track.caption_at(ph).is_none(), "captions.add", json!({"track": track.id.0})), + (Icon::Plus, "text.captions.add", tl!("Add caption at playhead"), track.caption_at(ph).is_none(), "captions.add", json!({"track": track.id.0})), ( Icon::Razor, "text.captions.split", - "Split caption at playhead", + tl!("Split caption at playhead"), under.is_some(), "captions.split", json!({"caption": under.map(|c| c.0), "time": ph.0}), ), - (Icon::Link, "text.captions.merge", "Merge selected captions", sel.len() > 1, "captions.merge", json!({})), - (Icon::Trash, "text.captions.delete", "Delete selected captions", any_sel, "captions.delete", json!({})), + (Icon::Link, "text.captions.merge", tl!("Merge selected captions"), sel.len() > 1, "captions.merge", json!({})), + (Icon::Trash, "text.captions.delete", tl!("Delete selected captions"), any_sel, "captions.delete", json!({})), (Icon::Export, "text.captions.export", "Export captions…", !track.captions.is_empty(), "captions.export", json!({"track": track.id.0})), ]; for (icon, id, label, enabled, cmd, params) in tools { @@ -252,8 +265,12 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if shown == 0 { ui.label( - egui::RichText::new(if q.is_empty() { "No captions on this track. Press + to add one at the playhead." } else { "No matching captions." }) - .color(t.text_faint), + egui::RichText::new(if q.is_empty() { + tl!("No captions on this track. Press + to add one at the playhead.") + } else { + tl!("No matching captions.") + }) + .color(t.text_faint), ); } }); @@ -263,7 +280,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; if app.session.active_sequence().is_none() { - crate::dock::placeholder(ui, rect, &t, "Open a sequence to see its transcript"); + crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to see its transcript")); return; } let mut actions: Vec<(String, Value)> = Vec::new(); @@ -271,17 +288,17 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if words.is_empty() { let c = rect.center(); icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim); - ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, "Transcribe sequence", Tokens::semibold(16.0), t.text); + ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, tl!("Transcribe sequence"), Tokens::semibold(16.0), t.text); let note = if filmcraft_speech_available(app) { - "Speech-to-text turns the dialogue into editable text." + tl!("Speech-to-text turns the dialogue into editable text.") } else { - "This build has no speech-to-text; import a transcript with transcript.set." + tl!("This build has no speech-to-text; import a transcript with transcript.set.") }; ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, note, Tokens::ui(12.0), t.text_dim); let r = Rect::from_center_size(c + vec2(0.0, 26.0), vec2(200.0, 26.0)); let resp = ui.interact(r, egui::Id::new("text.transcript.generate"), Sense::click()); ui.painter().rect_filled(r, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(r.center(), Align2::CENTER_CENTER, "Transcribe", Tokens::semibold(12.0), Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Transcribe"), Tokens::semibold(12.0), Color32::WHITE); app.auto.add("text.transcript.generate", r, "Transcribe"); if resp.clicked() { actions.push(("transcript.generate".into(), json!({}))); @@ -296,16 +313,16 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); let sw = 170.0f32.min(bar.width() * 0.4); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(sw, 24.0)))); - let sresp = crate::widgets::search_field(&mut child, &mut app.ui.transcript_search, "Search", sw, &t); + let sresp = crate::widgets::search_field(&mut child, &mut app.ui.transcript_search, tl!("Search"), sw, &t); app.auto.add("text.transcript.search", sresp.rect, "Search transcript"); let hits: Vec> = filmcraft_edit::transcript::search(&words, &app.ui.transcript_search); let mut x = bar.min.x + sw + 10.0; let range = sel.map(|(a, b)| json!({"from": a.min(b), "to": a.max(b)})); let tools: [(Icon, &str, &str, bool, &str, Value); 4] = [ - (Icon::Razor, "text.transcript.extract", "Extract selected text", sel.is_some(), "transcript.extract", range.clone().unwrap_or_default()), - (Icon::Trash, "text.transcript.lift", "Lift selected text", sel.is_some(), "transcript.lift", range.unwrap_or_default()), - (Icon::Link, "text.transcript.removeFillers", "Remove filler words", true, "transcript.removeFillers", json!({})), - (Icon::Captions, "text.transcript.createCaptions", "Create captions", true, "transcript.createCaptions", json!({})), + (Icon::Razor, "text.transcript.extract", tl!("Extract selected text"), sel.is_some(), "transcript.extract", range.clone().unwrap_or_default()), + (Icon::Trash, "text.transcript.lift", tl!("Lift selected text"), sel.is_some(), "transcript.lift", range.unwrap_or_default()), + (Icon::Link, "text.transcript.removeFillers", tl!("Remove filler words"), true, "transcript.removeFillers", json!({})), + (Icon::Captions, "text.transcript.createCaptions", tl!("Create captions"), true, "transcript.createCaptions", json!({})), ]; for (icon, id, label, enabled, cmd, params) in tools { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); @@ -334,7 +351,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_width(list.width() - 16.0); for (pi, pr) in paras.iter().enumerate() { let w0 = &words[pr.start]; - let head = format!("{} {}", w0.speaker.as_deref().unwrap_or("Speaker"), format_time(w0.start, rate, df, TimeDisplay::Timecode, 48_000)); + let head = format!("{} {}", w0.speaker.as_deref().unwrap_or(tl!("Speaker")), format_time(w0.start, rate, df, TimeDisplay::Timecode, 48_000)); let hr = ui.label(egui::RichText::new(head).size(11.0).color(t.text_dim).strong()); app.auto.add(&format!("text.transcript.paragraph.{pi}"), hr.rect, "Paragraph"); ui.horizontal_wrapped(|ui| { @@ -392,22 +409,30 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "color": c}))); } let mut bg = st.background; - let resp = ui.checkbox(&mut bg, "Box"); + let resp = ui.checkbox(&mut bg, tl!("Box")); app.auto.add("text.captions.style.background", resp.rect, "Background box"); if resp.changed() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "background": bg}))); } - ui.label(egui::RichText::new("Align").size(11.0).color(app.tokens.text_dim)); - for (a, icon_txt, name) in [(CaptionAlign::Left, "L", "left"), (CaptionAlign::Center, "C", "center"), (CaptionAlign::Right, "R", "right")] { - let resp = ui.selectable_label(st.align == a, icon_txt).on_hover_text(format!("Align {name}")); + ui.label(egui::RichText::new(tl!("Align")).size(11.0).color(app.tokens.text_dim)); + for (a, icon_txt, name, tip) in [ + (CaptionAlign::Left, "L", "left", tl!("Align left")), + (CaptionAlign::Center, "C", "center", tl!("Align center")), + (CaptionAlign::Right, "R", "right", tl!("Align right")), + ] { + let resp = ui.selectable_label(st.align == a, icon_txt).on_hover_text(tip); app.auto.add(&format!("text.captions.style.align.{name}"), resp.rect, name); if resp.clicked() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "align": name}))); } } - ui.label(egui::RichText::new("Position").size(11.0).color(app.tokens.text_dim)); - for (a, name) in [(CaptionAnchor::Top, "top"), (CaptionAnchor::Middle, "middle"), (CaptionAnchor::Bottom, "bottom")] { - let resp = ui.selectable_label(st.anchor == a, name[..1].to_uppercase()).on_hover_text(format!("Position: {name}")); + ui.label(egui::RichText::new(tl!("Position")).size(11.0).color(app.tokens.text_dim)); + for (a, name, tip) in [ + (CaptionAnchor::Top, "top", tl!("Position: top")), + (CaptionAnchor::Middle, "middle", tl!("Position: middle")), + (CaptionAnchor::Bottom, "bottom", tl!("Position: bottom")), + ] { + let resp = ui.selectable_label(st.anchor == a, name[..1].to_uppercase()).on_hover_text(tip); app.auto.add(&format!("text.captions.style.anchor.{name}"), resp.rect, name); if resp.clicked() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "anchor": name}))); @@ -424,3 +449,37 @@ fn run(app: &mut FilmcraftApp, ui: &egui::Ui, actions: Vec<(String, Value)>) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Latin labels keep the 7 px per character they were laid out with; Japanese labels are as + /// wide as they are drawn, so they no longer run into the next tab. + #[test] + fn tab_widths_follow_the_drawn_label() { + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &Tokens::for_kind(crate::theme::ThemeKind::default())); + // Japanese needs a craft-fonts build or an installed font; without one there is nothing wide to measure + if !crate::i18n::install_japanese_font(&ctx) { + return; + } + let labels = ["Transcript", "Captions", "Graphics", "文字起こし", "キャプション", "グラフィックス"]; + let mut widths = Vec::new(); + for _ in 0..2 { + // the added font is in place from the second pass + widths.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + for label in labels { + widths.push(tab_text_width(ui.painter(), label)); + } + }); + out.textures_delta.clear(); + } + assert_eq!(widths[..3], [70.0, 56.0, 56.0], "Latin tabs keep 7 px per character"); + // 5, 6 and 7 Japanese characters of about 12.5 px each, far over the 7 px per character estimate + for ((width, chars), label) in widths[3..].iter().zip([5.0_f32, 6.0, 7.0]).zip(&labels[3..]) { + assert!(*width > chars * 7.0 * 1.5, "{label}: {width} px"); + } + } +} diff --git a/crates/ui-egui/src/panels/timecode.rs b/crates/ui-egui/src/panels/timecode.rs index 02d0c3e40..1dcfccccd 100644 --- a/crates/ui-egui/src/panels/timecode.rs +++ b/crates/ui-egui/src/panels/timecode.rs @@ -44,7 +44,7 @@ pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) { ItemKind::Sequence(q) => (q.mark_in, q.mark_out), _ => (None, None), }; - let sr = it.as_media().and_then(|m| m.info.audio.as_ref()).map(|a| a.sample_rate as i64).unwrap_or(48000); + let sr = it.as_media().and_then(|m| m.info.audio()).map(|a| a.sample_rate as i64).unwrap_or(48000); View { name: it.name.clone(), rate: it.frame_rate(), @@ -79,7 +79,8 @@ pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) { }), }; let Some(v) = view else { - return ("--:--:--:--".into(), format!("{} · no {}", row.mode.label(), if source == TcSource::Source { "clip" } else { "sequence" })); + let mode = crate::i18n::t(row.mode.label()); + return ("--:--:--:--".into(), if source == TcSource::Source { tlf!("{mode} · no clip", mode) } else { tlf!("{mode} · no sequence", mode) }); }; let fmt = |t: Tick, rate: FrameRate, df: bool| format_time(t, rate, df, row.display, v.sample_rate); let (a, b) = (v.marks.0.unwrap_or(Tick::ZERO), v.marks.1.unwrap_or(v.dur)); @@ -94,10 +95,10 @@ pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) { TcMode::Remaining => fmt((b - v.t).max(Tick::ZERO), v.rate, v.df), }; let src = match source { - TcSource::Source => "Source", - _ => "Program", + TcSource::Source => tl!("Source"), + _ => tl!("Program"), }; - (value, format!("{src} · {} · {}", row.mode.label(), v.name)) + (value, format!("{src} · {} · {}", crate::i18n::t(row.mode.label()), v.name)) } pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -132,26 +133,26 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { r.clicked() }; for m in TcMode::ALL { - if pick(ui, format!("timecode.row.{i}.mode.{}", serde_name(&m)), m.label(), row.mode == m) { + if pick(ui, format!("timecode.row.{i}.mode.{}", serde_name(&m)), crate::i18n::t(m.label()), row.mode == m) { nr.mode = m; } } ui.separator(); for src in TcSource::ALL { - if pick(ui, format!("timecode.row.{i}.source.{}", serde_name(&src)), src.label(), row.source == src) { + if pick(ui, format!("timecode.row.{i}.source.{}", serde_name(&src)), crate::i18n::t(src.label()), row.source == src) { nr.source = src; } } ui.separator(); for (n, d) in TimeDisplay::ALL.into_iter().enumerate() { - if pick(ui, format!("timecode.row.{i}.display.{n}"), d.label(), row.display == d) { + if pick(ui, format!("timecode.row.{i}.display.{n}"), crate::i18n::t(d.label()), row.display == d) { nr.display = d; } } let mut remove = false; if rows.len() > 1 { ui.separator(); - remove = pick(ui, format!("timecode.row.{i}.remove"), "Remove Row", false); + remove = pick(ui, format!("timecode.row.{i}.remove"), tl!("Remove Row"), false); } for (id, r, l, _) in &picks { elems.push((id.clone(), *r, l.clone())); @@ -171,7 +172,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click()); ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon }); elems.push(("timecode.addRow".into(), ar, "Add Row".into())); - if aresp.on_hover_text("Add a timecode row").clicked() { + if aresp.on_hover_text(tl!("Add a timecode row")).clicked() { app.ui.panels.timecode.rows.push(TimecodeRow { mode: TcMode::Duration, ..rows.last().copied().unwrap_or_default() }); } for (id, r, l) in elems { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 0172243c0..cfc58e2bb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -22,6 +22,9 @@ use crate::icons::{self, Icon}; use crate::state::{TimelineView, Tool}; use crate::theme::Tokens; +use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at}; +pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind}; + const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right) const RULER_H: f32 = 44.0; const SCROLLBAR_H: f32 = 17.0; @@ -33,6 +36,8 @@ const SNAP_PX: f32 = 9.0; #[derive(Default)] pub struct TlState { pub drag: Option, + /// What the primary button went down on in the Timeline (#259); see [`Pressed`]. + pub pressed: Option, /// Last layout (for hit-testing from the control channel). pub layout: Option, /// Snap indicator x (screen) this frame. @@ -63,6 +68,18 @@ impl TlState { } } +/// What was under the pointer when the primary button went down in the Timeline, worked out on +/// that frame (#259). egui reports a click when the button comes up, by when it has forgotten the +/// press point, and a drag only once the pointer has moved past its 6 pt drag threshold, nearly +/// the 7 px edge zone. Both act on this instead, so a press acts on what it pressed. +#[derive(Clone, Copy, Debug)] +pub struct Pressed { + pub pos: Pos2, + /// The time under the pointer. + pub tick: Tick, + pub grab: Grab, +} + #[derive(Clone, Debug)] pub enum Drag { Scrub, @@ -78,11 +95,16 @@ pub enum Drag { edge: filmcraft_edit::Edge, mode: filmcraft_edit::TrimMode, delta: Tick, + /// The time under the pointer when the button went down: the edge moves by how far the + /// pointer has moved since (#259). + from: Tick, }, Roll { left: ClipId, right: ClipId, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, Slip { clip: ClipId, @@ -92,15 +114,21 @@ pub enum Drag { clip: ClipId, delta: Tick, }, + /// An audio clip's Volume line (see [`super::timeline_volume`]). + Volume(super::timeline_volume::LineDrag), Stretch { clip: ClipId, edge: filmcraft_edit::Edge, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, /// Remix tool: drag a music clip's Out edge; on release `clip.remix` re-plans it to the new duration. Remix { clip: ClipId, delta: Tick, + /// As in [`Drag::Trim`]. + from: Tick, }, Pan { last: Pos2, @@ -124,6 +152,8 @@ pub struct Row { pub kind: TrackKind, pub index: usize, pub rect: Rect, + /// An audio track showing a track keyframe lane, which hides its clips' Volume lines. + pub lane: bool, } #[derive(Clone, Debug)] @@ -289,14 +319,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.timeline.v_scroll = app.ui.timeline.v_scroll.clamp(0.0, max_vs); for (i, tr) in seq.video_tracks.iter().enumerate() { let top = video_area.min.y + v_off + (nv - 1 - i) as f32 * vh - (max_vs - app.ui.timeline.v_scroll); - rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh)) }); + let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + vh)); + rows.push(Row { track: tr.id, kind: TrackKind::Video, index: i, rect, lane: false }); } let na = seq.audio_tracks.len(); let max_as = (na as f32 * ah - audio_area.height()).max(0.0); app.ui.timeline.a_scroll = app.ui.timeline.a_scroll.clamp(0.0, max_as); for (i, tr) in seq.audio_tracks.iter().enumerate() { let top = audio_area.min.y + i as f32 * ah - app.ui.timeline.a_scroll; - rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect: Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)) }); + let rect = Rect::from_min_max(pos2(content.min.x, top), pos2(content.max.x, top + ah)); + rows.push(Row { track: tr.id, kind: TrackKind::Audio, index: i, rect, lane: app.ui.timeline.track_lanes.contains_key(&tr.id.0) }); } let layout = Layout { content, ruler, rows: rows.clone(), pps, scroll, split_y }; app.tl.layout = Some(layout.clone()); @@ -337,6 +369,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); + let volume_line = r.kind == TrackKind::Audio && !r.lane; for it in &tr.items { let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { @@ -349,6 +382,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let x1 = layout.x_of(start + dur); let body = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1.max(x0 + 1.0), r.rect.max.y - 1.0)); draw_clip(app, &ctx, &p, body, it, r.kind, selection.contains(&it.id), &t, rate); + if volume_line { + super::timeline_volume::paint(&p, body, it); + } app.auto.add(&format!("timeline.clip.{}", it.id.0), body.intersect(content), &it.name); // a nest that runs past the end of its sequence's contents: that part is empty if !previews.contains_key(&it.id) @@ -370,6 +406,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { let body = Rect::from_min_max(pos2(layout.x_of(*start), r.rect.min.y + 1.0), pos2(layout.x_of(*start + *dur), r.rect.max.y - 1.0)); draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate); + if volume_line { + super::timeline_volume::paint(&p, body, it); + } } } // Show Through Edits: a small bow-tie on cuts between continuous pieces of one clip @@ -411,7 +450,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // master track let master = Rect::from_min_max(pos2(rect.min.x, tracks_area.max.y - MASTER_H), pos2(content.max.x, tracks_area.max.y)); painter.rect_filled(master, 0.0, t.tl_header_bg); - painter.text(pos2(rect.min.x + 44.0, master.center().y), Align2::LEFT_CENTER, "Mix", Tokens::ui(11.5), t.text); + painter.text(pos2(rect.min.x + 44.0, master.center().y), Align2::LEFT_CENTER, tl!("Mix"), Tokens::ui(11.5), t.text); painter.text( pos2(rect.min.x + header_w - 12.0, master.center().y), Align2::RIGHT_CENTER, @@ -481,6 +520,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- interaction interact(app, ui, &seq, &layout, rect); + super::timeline_volume::interact(app, ui, &seq, &layout, aclip); // track keyframes (drawn and edited on top of the clips) super::timeline_automation::show(app, ui, &seq, &layout, aclip); if cap_n > 0 { @@ -491,16 +531,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - ui.painter().text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, "Drop media here to create sequence.", Tokens::ui(13.0), t.text_dim); + ui.painter().text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, tl!("Drop media here to create sequence."), Tokens::ui(13.0), t.text_dim); if app.session.project.items.values().any(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))) { - let hint = "Double-click a sequence in the Project panel to open it here."; + let hint = tl!("Double-click a sequence in the Project panel to open it here."); ui.painter().text(rect.center() + vec2(0.0, 50.0), Align2::CENTER_CENTER, hint, Tokens::ui(12.0), t.text_dim); } let b = Rect::from_center_size(rect.center() + vec2(0.0, 20.0), vec2(170.0, 26.0)); let resp = ui.interact(b, egui::Id::new("tl-open-demo"), Sense::click()); app.auto.add("timeline.openDemo", b, "Open Demo Project"); ui.painter().rect_filled(b, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(b.center(), Align2::CENTER_CENTER, "Open Demo Project", Tokens::semibold(12.0), Color32::WHITE); + ui.painter().text(b.center(), Align2::CENTER_CENTER, tl!("Open Demo Project"), Tokens::semibold(12.0), Color32::WHITE); if resp.clicked() { let _ = app.session.execute("file.openDemoProject", json!({})); } @@ -560,7 +600,7 @@ fn draw_clip( let base = label_color(app, it.label); let inr = in_range(app, it); let fill = if !it.enabled { - Color32::from_rgb(0x2a, 0x2a, 0x2a) + t.disabled_clip_bg } else if inr { lighten(base, 0.12) } else if selected { @@ -694,6 +734,8 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra for (a, b) in peaks.iter().skip(s0).take(s1 - s0) { m = m.max(a.abs()).max(b.abs()); } + // as in Premiere, the waveform shows the clip's Volume + m *= super::timeline_volume::gain_at(it, t0); // View ▸ Dynamic Audio Waveforms (default): logarithmic scale, −48 dB → 0, 0 dB → full; // off: linear amplitude let h = if dynamic { @@ -713,16 +755,6 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra p.rect_filled(cb, 1.0, Color32::from_black_alpha(160)); p.text(cb.center(), Align2::CENTER_CENTER, "1", Tokens::ui(7.5), Color32::from_rgb(0xd9, 0xd9, 0xd9)); } - // volume rubber band (white line with a black shadow) at mid-height of the upper zone - let level = it.effect("volume").map(|e| e.f64_at("level", it.source_in)).unwrap_or(0.0); - let upper = Rect::from_min_max(pos2(body.min.x, body.min.y + 16.0), pos2(body.max.x, area.min.y)); - if upper.height() > 6.0 { - let norm = ((level + 60.0) / 66.0).clamp(0.0, 1.0) as f32; - let y = upper.max.y - norm * upper.height(); - let cp = p.with_clip_rect(body.intersect(p.clip_rect())); - cp.line_segment([pos2(body.min.x, y + 1.0), pos2(body.max.x, y + 1.0)], Stroke::new(1.0, Color32::BLACK)); - cp.line_segment([pos2(body.min.x, y), pos2(body.max.x, y)], Stroke::new(1.0, Color32::WHITE)); - } } /// Display gain for a clip's waveform: clip gain, plus normalisation of the source to full scale @@ -955,7 +987,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: true, t, egui::Id::new(("locked", r.track.0)), - Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }), + Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock"); if lresp.clicked() { @@ -963,7 +995,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } if tr.locked { // diagonal hatch over locked lanes is drawn by the lane painter; here a subtle tint - p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, t.pressed); } // 3. target let targeted = tg.targeted.contains(&r.track); @@ -1082,25 +1114,34 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc // current timecode: Premiere's big blue timecode let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0)); - p.text(pos2(tc_rect.min.x, tc_rect.center().y), Align2::LEFT_CENTER, &tc, Tokens::timecode(), t.timecode); + let tc_id = egui::Id::new(("timeline-tc", seq_id)); + match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) { + Some(Ok(time)) => { + if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) { + app.ui.status = e.to_string(); + } + } + Some(Err(e)) => app.ui.status = e, + None => {} + } app.auto.add("timeline.timecode", tc_rect, &tc); // toolbar: 30 × 30 buttons, "on" = #4b4b4b fill let mut x = rect.min.x + 12.0; let y = rect.min.y + 26.0; let toggles: [(Icon, &str, bool, bool, &str); 6] = [ - (Icon::Nest, "nest", !app.session.state.sequences_as_clips, true, "Insert and overwrite sequences as nests or individual clips"), - (Icon::Magnet, "snap", app.session.state.snapping, true, "Snap in Timeline (S)"), - (Icon::Link, "linked", app.session.state.linked_selection, true, "Linked Selection"), - (Icon::Captions, "captions", false, false, "Caption track options"), - (Icon::Marker, "marker", false, false, "Add Marker (M)"), - (Icon::Wrench, "settings", false, false, "Timeline Display Settings"), + (Icon::Nest, "nest", !app.session.state.sequences_as_clips, true, tl!("Insert and overwrite sequences as nests or individual clips")), + (Icon::Magnet, "snap", app.session.state.snapping, true, tl!("Snap in Timeline (S)")), + (Icon::Link, "linked", app.session.state.linked_selection, true, tl!("Linked Selection")), + (Icon::Captions, "captions", false, false, tl!("Caption track options")), + (Icon::Marker, "marker", false, false, tl!("Add Marker (M)")), + (Icon::Wrench, "settings", false, false, tl!("Timeline Display Settings")), ]; for (icon, key, on, toggle, tip) in toggles { let r = Rect::from_min_size(pos2(x, y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("timeline.toggle.{key}"), r, tip); if toggle && on { - p.rect_filled(r, 4.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + p.rect_filled(r, 4.0, t.pressed); } else if resp.hovered() { p.rect_filled(r, 4.0, t.hover); } @@ -1116,21 +1157,21 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc } if key == "settings" { egui::Popup::menu(&resp).show(|ui| { - ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Show Video Thumbnails"); - ui.checkbox(&mut app.ui.timeline.show_waveforms, "Show Audio Waveform"); + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Show Audio Waveform")); ui.separator(); let mut te = app.session.state.show_through_edits; - let c = ui.checkbox(&mut te, "Show Through Edits"); + let c = ui.checkbox(&mut te, tl!("Show Through Edits")); app.auto.add("timeline.settings.showThroughEdits", c.rect, "Show Through Edits"); if c.changed() { let _ = app.session.execute("sequence.showThroughEdits", json!({"on": te})); } ui.separator(); - if ui.button("Expand All Tracks").clicked() { + if ui.button(tl!("Expand All Tracks")).clicked() { app.ui.timeline.video_track_h = 64.0; app.ui.timeline.audio_track_h = 64.0; } - if ui.button("Minimize All Tracks").clicked() { + if ui.button(tl!("Minimize All Tracks")).clicked() { app.ui.timeline.video_track_h = 26.0; app.ui.timeline.audio_track_h = 26.0; } @@ -1213,9 +1254,9 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc use filmcraft_engine::previews::BarState; let (c, what) = match span.state { BarState::None => continue, - BarState::Yellow => (t.render_yellow, "Unrendered: should play back in real time"), - BarState::Red => (t.render_red, "Unrendered: render to play back in real time"), - BarState::Green => (t.render_green, "Rendered preview"), + BarState::Yellow => (t.render_yellow, tl!("Unrendered: should play back in real time")), + BarState::Red => (t.render_red, tl!("Unrendered: render to play back in real time")), + BarState::Green => (t.render_green, tl!("Rendered preview")), }; let r = Rect::from_min_max(pos2(layout.x_of(span.start), rb.min.y), pos2(layout.x_of(span.end), rb.max.y)).intersect(ruler); if r.width() <= 0.0 { @@ -1279,10 +1320,10 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco let (left, w, per_point) = zoom_thumb(bar, seq_seconds, v.scroll, v.pps); let thumb = Rect::from_min_max(pos2(left, bar.min.y), pos2(left + w, bar.max.y)); let hover = ui.rect_contains_pointer(thumb); - p.rect_filled(thumb, thumb.height() / 2.0, if hover { Color32::from_rgb(0x6a, 0x6a, 0x6a) } else { Color32::from_rgb(0x4b, 0x4b, 0x4b) }); + p.rect_filled(thumb, thumb.height() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb }); for x in [thumb.min.x + thumb.height() / 2.0, thumb.max.x - thumb.height() / 2.0] { p.circle_filled(pos2(x, thumb.center().y), 4.5, t.panel_bg); - p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, Color32::from_rgb(0xd1, 0xd1, 0xd1))); + p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, t.icon_active)); } app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar"); app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track"); @@ -1362,44 +1403,12 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i let pos = if invert { 1.0 - *value / max } else { *value / max }; let y = bar.min.y + (bar.height() - h) * pos; let thumb = Rect::from_min_size(pos2(bar.min.x + 1.0, y), vec2(bar.width() - 2.0, h)); - p.rect_filled(thumb, 3.0, Color32::from_rgb(80, 80, 80)); + p.rect_filled(thumb, 3.0, t.vertical_scroll_thumb); let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag()); if resp.dragged() { let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max; *value = (*value + if invert { -d } else { d }).clamp(0.0, max); } - let _ = t; -} - -/// How close (px) to a cut the Selection tool rolls instead of rippling when Settings ▸ Trim ▸ -/// "Allow Selection tool to choose Roll and Ripple trims without modifier key" is on. -pub const ROLL_PX: f32 = 2.5; - -/// The trim the Selection tool starts at an edit point: Cmd+Shift = roll, Cmd = ripple. With the -/// Trim setting on (`no_modifier`), no modifier is needed: right on the cut (when a clip is on the -/// other side) rolls, elsewhere on the edge ripples. Otherwise a plain drag is a regular trim. -pub fn selection_trim_kind(no_modifier: bool, cmd: bool, shift: bool, dist_px: f32, has_neighbour: bool) -> &'static str { - if cmd && shift { - "roll" - } else if cmd { - "ripple" - } else if no_modifier { - if has_neighbour && dist_px <= ROLL_PX { "roll" } else { "ripple" } - } else { - "trim" - } -} - -/// Distance (px) from `x` to the cut at `clip`'s `edge`, and whether a clip touches that cut on -/// the other side. -fn edge_geometry(seq: &Sequence, layout: &Layout, track: filmcraft_project::TrackId, clip: ClipId, edge: filmcraft_edit::Edge, x: f32) -> (f32, bool) { - let Some(tr) = seq.track(track) else { return (f32::MAX, false) }; - let Some(it) = tr.item(clip) else { return (f32::MAX, false) }; - let (cut, neighbour) = match edge { - filmcraft_edit::Edge::Out => (it.end(), tr.items.iter().any(|x| x.start == it.end())), - filmcraft_edit::Edge::In => (it.start, tr.items.iter().any(|x| x.end() == it.start)), - }; - ((layout.x_of(cut) - x).abs(), neighbour) } /// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick. @@ -1450,59 +1459,22 @@ fn snap_to(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, t: Tick, exc } } -/// What is at a screen point. -#[derive(Clone, Debug)] -pub enum Hit { - Ruler, - Clip { track: TrackId, clip: ClipId, edge: Option }, - Transition { track: TrackId, id: filmcraft_project::TransitionId }, - Empty { track: TrackId }, - None, -} - -pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { - if layout.ruler.contains(pos) { - return Hit::Ruler; - } - if !layout.content.contains(pos) { - return Hit::None; - } - let Some(row) = layout.row_at(pos.y) else { return Hit::None }; - let Some(tr) = seq.track(row.track) else { return Hit::None }; - for trn in &tr.transitions { - let x0 = layout.x_of(trn.start); - let x1 = layout.x_of(trn.end()); - if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 { - return Hit::Transition { track: row.track, id: trn.id }; - } - } - let t = layout.tick_at(pos.x); - let edge_px = 7.0f32; - // prefer edges - for it in &tr.items { - let x0 = layout.x_of(it.start); - let x1 = layout.x_of(it.end()); - let w = x1 - x0; - let e = edge_px.min(w / 3.0); - if (pos.x - x0).abs() <= e && pos.x >= x0 - e { - return Hit::Clip { track: row.track, clip: it.id, edge: Some(filmcraft_edit::Edge::In) }; - } - if (pos.x - x1).abs() <= e && pos.x <= x1 + e { - return Hit::Clip { track: row.track, clip: it.id, edge: Some(filmcraft_edit::Edge::Out) }; - } - } - if let Some(it) = tr.item_at(t) { - return Hit::Clip { track: row.track, clip: it.id, edge: None }; - } - Hit::Empty { track: row.track } -} - -pub fn hit_json(app: &FilmcraftApp, pos: Pos2) -> Value { +/// What the Timeline shows at `pos` for the control channel's `ui.timeline.hit`, with `kind`: the +/// trim a press of the current tool with `mods` starts there (null when it would not trim). +pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { let (Some(layout), Some(seq)) = (app.tl.layout.as_ref(), app.session.active_sequence()) else { return json!({"hit": "none"}) }; let t = layout.tick_at(pos.x); - match hit(seq, layout, pos) { + let grab = grab_at(seq, layout, pos, app.ui.tool, mods, app.session.prefs.trim.selection_tool_roll_ripple); + let kind = match grab { + Grab::Edge { kind, .. } => json!(kind.name()), + Grab::Volume { .. } => json!("volume"), + Grab::Other(_) => Value::Null, + }; + match grab.hit() { Hit::Ruler => json!({"hit": "ruler", "time": t.0}), - Hit::Clip { track, clip, edge } => json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "time": t.0}), + Hit::Clip { track, clip, edge } => { + json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0}) + } Hit::Transition { track, id } => json!({"hit": "transition", "track": track.0, "transition": id.0}), Hit::Empty { track } => json!({"hit": "empty", "track": track.0, "time": t.0}), Hit::None => json!({"hit": "none"}), @@ -1535,12 +1507,12 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } } } - Drag::Remix { clip, delta } => { + Drag::Remix { clip, delta, .. } => { if let Some((_, it)) = seq.find_item(*clip) { out.insert(*clip, (it.start, it.duration + *delta, None)); } } - Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta } => { + Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => { let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]); for c in ids { if let Some((_, it)) = seq.find_item(c) { @@ -1552,7 +1524,7 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } } } - Drag::Roll { left, right, delta } => { + Drag::Roll { left, right, delta, .. } => { if let Some((_, l)) = seq.find_item(*left) { out.insert(*left, (l.start, l.duration + *delta, None)); } @@ -1584,7 +1556,7 @@ fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { /// The clip context menu: groups (separated by rules) of (label, command id). Entries marked `…` /// open their dialog through `menus::invoke`, like the same item in the Clip menu. -const CLIP_MENU: &[&[(&str, &str)]] = &[ +pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ &[ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -1620,6 +1592,87 @@ const CLIP_MENU: &[&[(&str, &str)]] = &[ &[("Reveal in Project", "clip.revealInProject"), ("Join Through Edits", "sequence.joinThroughEdits")], ]; +/// The cut right-clicked on a track (#219): the clip ending there and the clip starting there, +/// either missing at the ends of a gap. Its menu offers the edit point's trim types, Apply Default +/// Transitions and Join Through Edits, like Premiere's edit point menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EditPointTarget { + left: Option, + right: Option, +} + +impl EditPointTarget { + fn at(seq: &Sequence, track: TrackId, clip: ClipId, edge: filmcraft_edit::Edge) -> Option { + let tr = seq.track(track)?; + let it = tr.item(clip)?; + Some(match edge { + filmcraft_edit::Edge::Out => Self { left: Some(clip), right: tr.items.iter().find(|x| x.start == it.end()).map(|x| x.id) }, + filmcraft_edit::Edge::In => Self { left: tr.items.iter().find(|x| x.end() == it.start).map(|x| x.id), right: Some(clip) }, + }) + } +} + +/// The edit point menu's trim types: label, id (`timeline.editPointMenu.`), trim kind, and +/// which side of the cut it selects (the incoming clip's In or the outgoing clip's Out). +pub(crate) const EDIT_POINT_TYPES: &[(&str, &str, &str, filmcraft_edit::Edge)] = &[ + ("Ripple Trim In", "rippleIn", "ripple", filmcraft_edit::Edge::In), + ("Ripple Trim Out", "rippleOut", "ripple", filmcraft_edit::Edge::Out), + ("Roll Edit", "roll", "roll", filmcraft_edit::Edge::Out), + ("Trim In", "trimIn", "trim", filmcraft_edit::Edge::In), + ("Trim Out", "trimOut", "trim", filmcraft_edit::Edge::Out), +]; + +/// The edit point context menu (#219). +fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui, seq: &Sequence, target: EditPointTarget) { + let selected = app.session.state.edit_points.first().copied(); + for &(label, id, kind, edge) in EDIT_POINT_TYPES { + let clip = match edge { + filmcraft_edit::Edge::In => target.right, + filmcraft_edit::Edge::Out => target.left, + }; + // a roll needs a clip on both sides of the cut + let clip = clip.filter(|_| kind != "roll" || (target.left.is_some() && target.right.is_some())); + let out = edge == filmcraft_edit::Edge::Out; + let current = selected.is_some_and(|ep| Some(ep.clip) == clip && ep.out == out && serde_json::to_value(ep.kind).ok() == Some(json!(kind))); + let label = crate::i18n::t(label); + let text = if current { format!("✓ {label}") } else { label.to_string() }; + let r = ui.add_enabled(clip.is_some(), egui::Button::new(text)); + app.auto.add(&format!("timeline.editPointMenu.{id}"), r.rect, label); + if r.clicked() + && let Some(c) = clip + { + let e = if out { "out" } else { "in" }; + if let Err(e) = app.session.execute("trim.selectEditPoint", json!({"clip": c.0, "edge": e, "kind": kind})) { + app.ui.status = e.to_string(); + } + ui.close(); + } + } + ui.separator(); + let label = tl!("Apply Default Transitions"); + let r = ui.add_enabled(app.session.is_enabled("trim.applyDefaultTransition"), egui::Button::new(label)); + app.auto.add("timeline.editPointMenu.applyDefaultTransitions", r.rect, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, "trim.applyDefaultTransition", json!({})) { + app.ui.status = e; + } + ui.close(); + } + // only this cut (and its linked partners), and only when it is a through edit + let through = filmcraft_edit::through::through_edits(seq).iter().any(|e| Some(e.left) == target.left && Some(e.right) == target.right); + let label = tl!("Join Through Edits"); + let r = ui.add_enabled(through, egui::Button::new(label)); + app.auto.add("timeline.editPointMenu.joinThroughEdits", r.rect, label); + if r.clicked() + && let (Some(left), Some(right)) = (target.left, target.right) + { + if let Err(e) = app.session.execute("sequence.joinThroughEdits", json!({"cut": [left.0, right.0]})) { + app.ui.status = e.to_string(); + } + ui.close(); + } +} + /// The clip menu's Multi-Camera submenu: Enable, Flatten, and the cameras of the selected nested /// sequence clips (greyed out when none of the selected clips is a nested sequence). fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem]) { @@ -1634,8 +1687,8 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem let shown = nests.first().and_then(|it| it.multicam).filter(|m| m.enabled).map(|m| m.angle as usize); let mut run: Option<(&str, Value)> = None; ui.add_enabled_ui(!nests.is_empty(), |ui| { - let r = ui.menu_button("Multi-Camera", |ui| { - for (label, cmd) in [(if enabled { "✓ Enable" } else { "Enable" }, "clip.multicamEnable"), ("Flatten", "clip.multicamFlatten")] { + let r = ui.menu_button(tl!("Multi-Camera"), |ui| { + for (label, cmd) in [(if enabled { tl!("✓ Enable") } else { tl!("Enable") }, "clip.multicamEnable"), (tl!("Flatten"), "clip.multicamFlatten")] { let r = ui.add_enabled(app.session.is_enabled(cmd), egui::Button::new(label)); app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); if r.clicked() { @@ -1712,6 +1765,18 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let mods = ctx.input(|i| i.modifiers); let tool = app.ui.tool; let rate = seq.settings.frame_rate; + let roll_ripple = app.session.prefs.trim.selection_tool_roll_ripple; + + // ---- what the button went down on (#259): kept from the press frame until it comes up + if ctx.input(|i| !i.pointer.any_down() && !i.pointer.any_released()) { + app.tl.pressed = None; + } + if resp.is_pointer_button_down_on() + && ctx.input(|i| i.pointer.primary_pressed()) + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) }); + } // ---- wheel, as in Premiere Pro on macOS (checked in 26.5.2): // wheel the tracks under the pointer, up and down @@ -1753,26 +1818,30 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- cursor feedback + // ---- cursor feedback: what a press here would grab (#259) if app.tl.drag.is_none() && let Some(p) = pos && area.contains(p) { - let h = hit(seq, layout, p); - let cur = match (tool, &h) { - (Tool::Selection, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn, - (Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn, - (Tool::Remix, Hit::Clip { edge: Some(filmcraft_edit::Edge::Out), .. }) => CursorIcon::ResizeColumn, - (Tool::Razor, Hit::Clip { .. }) => CursorIcon::Crosshair, - (Tool::Slip | Tool::Slide, Hit::Clip { .. }) => CursorIcon::ResizeHorizontal, + let g = grab_at(seq, layout, p, tool, mods, roll_ripple); + let cur = match (tool, g) { + (_, Grab::Edge { .. }) => CursorIcon::ResizeColumn, + (_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair, + (_, Grab::Volume { .. }) => CursorIcon::ResizeVertical, + (Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair, + (Tool::Slip | Tool::Slide, Grab::Other(Hit::Clip { .. })) => CursorIcon::ResizeHorizontal, (Tool::Hand, _) => CursorIcon::Grab, (Tool::Zoom, _) => CursorIcon::ZoomIn, _ => CursorIcon::Default, }; ctx.set_cursor_icon(cur); + // the edge a press here would grab, and the trim it starts (#259) + if let Grab::Edge { track, clip, edge, kind } = g { + super::trim_monitor::paint_hover_bracket(ui.painter(), seq, layout, track, clip, edge, kind); + } // razor preview line if tool == Tool::Razor - && let Hit::Clip { track, .. } = h + && let Grab::Other(Hit::Clip { track, .. }) = g && let Some(row) = layout.rows.iter().find(|r| r.track == track) { let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x)), &[]); @@ -1781,13 +1850,25 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- press + // ---- press: act on what was under the pointer when the button went down (#259) if resp.drag_started() || (resp.clicked() && app.tl.drag.is_none()) { - let Some(p) = resp.interact_pointer_pos() else { return }; - let h = hit(seq, layout, p); - let t = layout.tick_at(p.x); - let started = match (tool, h.clone()) { - (_, Hit::Ruler) => Some(Drag::Scrub), + let pressed = match app.tl.pressed.take() { + Some(pr) => pr, + // a press and release in the same frame is never stored: it happened where egui says + None => { + let Some(p) = resp.interact_pointer_pos() else { return }; + Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) } + } + }; + let (p, t) = (pressed.pos, pressed.tick); + let adding = tool == Tool::Pen || mods.command; + // a plain click on a Volume line selects the clip, as anywhere else on it + let grab = match pressed.grab { + Grab::Volume { .. } if resp.clicked() && !adding => Grab::Other(pressed.grab.hit()), + g => g, + }; + let started = match (tool, grab) { + (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub), (Tool::Hand, _) => Some(Drag::Pan { last: p }), (Tool::Zoom, _) => { if resp.clicked() { @@ -1797,7 +1878,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::Razor, Hit::Clip { clip, .. }) => { + (Tool::Razor, Grab::Other(Hit::Clip { clip, .. })) => { if resp.clicked() || resp.drag_started() { let tt = snap(app, seq, layout, rate.snap_nearest(t), &[]); let r = if mods.shift { @@ -1811,7 +1892,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::TrackSelectForward | Tool::TrackSelectBackward, Hit::Clip { track, .. } | Hit::Empty { track }) => { + (Tool::TrackSelectForward | Tool::TrackSelectBackward, Grab::Other(Hit::Clip { track, .. } | Hit::Empty { track })) => { let fwd = tool == Tool::TrackSelectForward; let ids: Vec = seq .all_tracks() @@ -1823,14 +1904,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let _ = app.session.execute("timeline.select", json!({"clips": ids})); None } - (Tool::Selection | Tool::Ripple | Tool::Rolling, Hit::Clip { clip, edge: Some(edge), track }) if resp.clicked() => { + (Tool::Selection | Tool::Ripple | Tool::Rolling, Grab::Edge { clip, edge, track, .. }) if resp.clicked() => { // click an edge = select it as an edit point (trim mode); Shift adds let kind = match tool { Tool::Rolling => "roll", Tool::Ripple => "ripple", _ => { let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, p.x); - selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, mods.command, mods.shift, dist, neighbour) + selection_trim_kind(roll_ripple, mods.command, mods.shift, dist, neighbour) } }; let e = if edge == filmcraft_edit::Edge::In { "in" } else { "out" }; @@ -1840,36 +1921,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } None } - (Tool::Selection | Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { clip, edge: Some(edge), track }) => { - let Some(tr) = seq.track(track) else { return }; - let sel_kind = if tool == Tool::Selection { - let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, p.x); - selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, mods.command, mods.shift, dist, neighbour) - } else { - "" - }; - let mode = if tool == Tool::Ripple || sel_kind == "ripple" { filmcraft_edit::TrimMode::Ripple } else { filmcraft_edit::TrimMode::Regular }; - if tool == Tool::Rolling || sel_kind == "roll" { - // roll the cut between this and its neighbour - let Some(it) = tr.item(clip) else { return }; - let (l, r) = match edge { - filmcraft_edit::Edge::Out => (Some(clip), tr.items.iter().find(|x| x.start == it.end()).map(|x| x.id)), - filmcraft_edit::Edge::In => (tr.items.iter().find(|x| x.end() == it.start).map(|x| x.id), Some(clip)), - }; - match (l, r) { - (Some(left), Some(right)) => Some(Drag::Roll { left, right, delta: Tick::ZERO }), - _ => Some(Drag::Trim { clip, edge, mode, delta: Tick::ZERO }), - } - } else if tool == Tool::RateStretch { - Some(Drag::Stretch { clip, edge, delta: Tick::ZERO }) - } else { - Some(Drag::Trim { clip, edge, mode, delta: Tick::ZERO }) - } + (_, Grab::Edge { clip, edge, kind, .. }) => Some(match kind { + EdgeKind::Trim => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Regular, delta: Tick::ZERO, from: t }, + EdgeKind::Ripple => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Ripple, delta: Tick::ZERO, from: t }, + EdgeKind::Roll { left, right } => Drag::Roll { left, right, delta: Tick::ZERO, from: t }, + EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t }, + EdgeKind::Remix => Drag::Remix { clip, delta: Tick::ZERO, from: t }, + }), + (_, Grab::Volume { clip, .. }) if resp.drag_started() => super::timeline_volume::start(seq, layout, clip, p).map(Drag::Volume), + (_, Grab::Volume { clip, .. }) => { + super::timeline_volume::add_keyframe(app, seq, layout, clip, p.x); + None } - (Tool::Remix, Hit::Clip { clip, edge: Some(filmcraft_edit::Edge::Out), .. }) => Some(Drag::Remix { clip, delta: Tick::ZERO }), - (Tool::Slip, Hit::Clip { clip, .. }) => Some(Drag::Slip { clip, delta: Tick::ZERO }), - (Tool::Slide, Hit::Clip { clip, .. }) => Some(Drag::Slide { clip, delta: Tick::ZERO }), - (_, Hit::Clip { clip, track, .. }) => { + (Tool::Slip, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slip { clip, delta: Tick::ZERO }), + (Tool::Slide, Grab::Other(Hit::Clip { clip, .. })) => Some(Drag::Slide { clip, delta: Tick::ZERO }), + (_, Grab::Other(Hit::Clip { clip, track, .. })) => { // select (shift toggles; alt selects one side of a link) let sel = &app.session.state.selection; if mods.shift { @@ -1888,8 +1954,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & None } } - (_, Hit::Transition { .. }) => None, - (_, Hit::Empty { .. }) => { + (_, Grab::Other(Hit::Transition { .. })) => None, + (_, Grab::Other(Hit::Empty { .. })) => { if resp.drag_started() { Some(Drag::Marquee { start: p }) } else { @@ -1898,7 +1964,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & None } } - (_, Hit::None) => None, + (_, Grab::Other(Hit::None)) => None, }; if let Some(d) = started { if matches!(d, Drag::Scrub) { @@ -1955,24 +2021,23 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & }; Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta }) } - Drag::Trim { clip, edge, mode, .. } => seq.find_item(clip).map(|(_, it)| { + Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[clip]); - let delta = target - base; - Drag::Trim { clip, edge, mode, delta } + let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); + Drag::Trim { clip, edge, mode, delta: target - base, from } }), - Drag::Remix { clip, .. } => seq.find_item(clip).map(|(_, it)| { - let target = rate.snap_nearest(t_here).max(it.start + rate.frame_duration()); - Drag::Remix { clip, delta: target - it.end() } + Drag::Remix { clip, from, .. } => seq.find_item(clip).map(|(_, it)| { + let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration()); + Drag::Remix { clip, delta: target - it.end(), from } }), - Drag::Stretch { clip, edge, .. } => seq.find_item(clip).map(|(_, it)| { + Drag::Stretch { clip, edge, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[clip]); - Drag::Stretch { clip, edge, delta: target - base } + let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]); + Drag::Stretch { clip, edge, delta: target - base, from } }), - Drag::Roll { left, right, .. } => seq.find_item(left).map(|(_, l)| { - let target = snap(app, seq, layout, rate.snap_nearest(t_here), &[left, right]); - Drag::Roll { left, right, delta: target - l.end() } + Drag::Roll { left, right, from, .. } => seq.find_item(left).map(|(_, l)| { + let target = snap(app, seq, layout, rate.snap_nearest(l.end() + (t_here - from)), &[left, right]); + Drag::Roll { left, right, delta: target - l.end(), from } }), Drag::Slip { clip, .. } => { let start = resp.interact_pointer_pos().map(|_| ()).and(ctx.input(|i| i.pointer.press_origin())); @@ -1983,6 +2048,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let origin = ctx.input(|i| i.pointer.press_origin()).map(|o| layout.tick_at(o.x)).unwrap_or(t_here); Some(Drag::Slide { clip, delta: rate.snap_nearest(t_here - origin) }) } + Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))), Drag::Marquee { start } => { let r = Rect::from_two_pos(start, p); ui.painter().rect_filled(r, 0.0, Color32::from_white_alpha(18)); @@ -1993,7 +2059,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & }; app.tl.drag = new; // auto-scroll when dragging near the edges - if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. })) { + if !matches!(app.tl.drag, Some(Drag::Pan { .. }) | Some(Drag::ZoomBar { .. }) | Some(Drag::Volume(_))) { let v = &mut app.ui.timeline; if p.x > layout.content.max.x - 20.0 { v.target_scroll += 6.0 / v.pps; @@ -2017,16 +2083,16 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & .collect(); Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false}))) } - Drag::Trim { clip, edge, mode, delta } if delta != Tick::ZERO => Some(app.session.execute( + Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute( "timeline.trim", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}), )), - Drag::Stretch { clip, edge, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))), - Drag::Remix { clip, delta } if delta != Tick::ZERO => { + Drag::Stretch { clip, edge, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))), + Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => { let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO); Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0}))) } - Drag::Roll { left, right, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))), + Drag::Roll { left, right, delta, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))), Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))), Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))), Drag::Marquee { start } => { @@ -2065,16 +2131,42 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } - // ---- context menu on clips (right-clicking an unselected clip selects it first) + // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's + // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) + let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); + // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected + let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() - && let Hit::Clip { clip, .. } = hit(seq, layout, p) - && !app.session.state.selection.contains(&clip) + && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) { - let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + ctx.data_mut(|d| d.insert_temp(menu_clip_id, clip)); + let target = edge.and_then(|e| EditPointTarget::at(seq, track, clip, e).map(|t| (t, e))); + ctx.data_mut(|d| d.insert_temp(edit_point_menu_id, target.map(|(t, _)| t))); + if let Some((t, e)) = target { + let on_it = app.session.state.edit_points.iter().any(|ep| (ep.out && Some(ep.clip) == t.left) || (!ep.out && Some(ep.clip) == t.right)); + if !on_it { + let kind = match tool { + Tool::Rolling => "roll", + Tool::Ripple => "ripple", + _ => { + let (dist, neighbour) = edge_geometry(seq, layout, track, clip, e, p.x); + selection_trim_kind(app.session.prefs.trim.selection_tool_roll_ripple, false, false, dist, neighbour) + } + }; + let edge = if e == filmcraft_edit::Edge::In { "in" } else { "out" }; + let _ = app.session.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": edge, "kind": kind})); + } + } else if !app.session.state.selection.contains(&clip) { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + } } resp.context_menu(|ui| { ui.set_min_width(220.0); + if let Some(target) = ctx.data(|d| d.get_temp::>(edit_point_menu_id)).flatten() { + edit_point_menu(app, &ctx, ui, seq, target); + return; + } let sel = app.session.state.selection.clone(); let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); @@ -2086,9 +2178,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } for &(label, cmd) in *group { if cmd == "edit.label" { - ui.menu_button(label, |ui| { + ui.menu_button(crate::i18n::t(label), |ui| { for l in filmcraft_project::Label::ALL { - if ui.button(l.name()).clicked() { + if ui.button(crate::i18n::t(l.name())).clicked() { let _ = app.session.execute("edit.label", json!({"label": l.name()})); ui.close(); } @@ -2101,15 +2193,25 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & continue; } let label = match cmd { - "clip.enable" if all_enabled => "✓ Enable", - "clip.link" if linked => "Unlink", - _ => label, + "clip.enable" if all_enabled => tl!("✓ Enable"), + "clip.link" if linked => tl!("Unlink"), + _ => crate::i18n::t(label), }; let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); if r.clicked() { - if let Err(e) = crate::menus::invoke(app, &ctx, cmd, json!({})) { - app.ui.status = e; + match crate::menus::invoke(app, &ctx, cmd, json!({})) { + // Unlink (#220): the clips were selected together because they were linked; + // keep only the one right-clicked, so it can be dragged away from the others + Ok(v) if cmd == "clip.link" && v["linked"] == json!(false) => { + if let Some(clip) = ctx.data(|d| d.get_temp::(menu_clip_id)) + && app.session.state.selection.contains(&clip) + { + let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); + } + } + Ok(_) => {} + Err(e) => app.ui.status = e, } ui.close(); } @@ -2122,17 +2224,25 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) { - let row = layout.row_at(p.y).cloned(); + let source = crate::panels::dragged_source(ui); + let row = layout.row_at(p.y).cloned().filter(|r| { + source.is_none_or(|s| match r.kind { + TrackKind::Video => s.video, + TrackKind::Audio => s.audio, + }) + }); let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); let still = app.session.prefs.timeline.still_duration(rate); let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still); - let dur = app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still); + let dur = source + .map(|s| s.range.duration) + .unwrap_or_else(|| app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still)); if let Some(row) = &row { let r = Rect::from_min_max(pos2(layout.x_of(t), row.rect.min.y + 1.0), pos2(layout.x_of(t + dur), row.rect.max.y - 1.0)); ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); if mods.command { - ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, "Insert", Tokens::ui(10.0), Color32::WHITE); + ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, tl!("Insert"), Tokens::ui(10.0), Color32::WHITE); } } if ctx.input(|i| i.pointer.any_released()) @@ -2142,7 +2252,14 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), }; - let r = app.session.execute("timeline.place", json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command})); + let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); + if let Some(source) = source { + params["sourceIn"] = json!(source.range.start.0); + params["duration"] = json!(source.range.duration.0); + params["video"] = json!(source.video); + params["audio"] = json!(source.audio); + } + let r = app.session.execute("timeline.place", params); if let Err(e) = r { app.ui.status = e.to_string(); } @@ -2179,16 +2296,40 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && let Some(row) = layout.rows.iter().find(|r| r.track == tid) { { - let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y)); - ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + let transition_kind = filmcraft_project::find_effect(&effect).and_then(|d| match d.kind { + filmcraft_project::EffectKind::VideoTransition => Some(TrackKind::Video), + filmcraft_project::EffectKind::AudioTransition => Some(TrackKind::Audio), + _ => None, + }); + let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" }; + let params = json!({"effect": effect, "clip": clip.0, "edge": edge}); + if let Some(kind) = transition_kind { + if let Ok((_, tr)) = filmcraft_engine::commands::preview_transition(&app.session, ¶ms, kind) { + let r = Rect::from_min_max(pos2(layout.x_of(tr.start), row.rect.min.y + 1.0), pos2(layout.x_of(tr.end()), row.rect.max.y - 1.0)); + let painter = ui.painter().with_clip_rect(layout.content); + painter.rect_filled(r, 2.0, app.tokens.accent.gamma_multiply(0.35)); + paint_hatch(&painter, r.intersect(layout.content)); + painter.rect_stroke(r, 2.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + let cut = if edge == "in" { it.start } else { it.end() }; + let x = layout.x_of(cut); + painter.line_segment([pos2(x, r.top()), pos2(x, r.bottom())], Stroke::new(3.0, app.tokens.accent)); + // Keep the edge label readable even when the clip/transition is only a few pixels wide. + let label = if edge == "in" { tl!("In") } else { tl!("Out") }; + let label_width = painter.layout_no_wrap(label.to_string(), Tokens::ui(11.0), app.tokens.accent).size().x + 8.0; + let label_rect = Rect::from_center_size(pos2(p.x, row.rect.top() + 10.0), vec2(label_width, 15.0)); + painter.rect_filled(label_rect, 2.0, app.tokens.panel_bg); + painter.text(label_rect.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), app.tokens.accent); + app.auto.add("timeline.transitionDropPreview", r.intersect(layout.content), edge); + } + } else { + let r = Rect::from_min_max(pos2(layout.x_of(it.start), row.rect.min.y), pos2(layout.x_of(it.end()), row.rect.max.y)); + ui.painter().rect_stroke(r, 3.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside); + } if ctx.input(|i| i.pointer.any_released()) { - let is_transition = filmcraft_project::find_effect(&effect) - .is_some_and(|d| matches!(d.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition)); let r = if let Some(name) = effect.strip_prefix("preset:") { app.session.execute("presets.apply", json!({"preset": name, "clips": [clip.0]})) - } else if is_transition { - let edge = if p.x - layout.x_of(it.start) < layout.x_of(it.end()) - p.x { "in" } else { "out" }; - app.session.execute("effects.apply", json!({"effect": effect, "clip": clip.0, "edge": edge})) + } else if transition_kind.is_some() { + app.session.execute("effects.apply", params) } else { app.session.execute("effects.apply", json!({"effect": effect, "clips": [clip.0]})) }; diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs index e34e6c254..783dc5ba4 100644 --- a/crates/ui-egui/src/panels/timeline_automation.rs +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -27,16 +27,19 @@ const LANE_COL: Color32 = Color32::from_rgb(0xe8, 0xc5, 0x47); /// The lanes an audio track can show: (key, label). pub fn lane_options(tr: &Track) -> Vec<(String, String)> { - let mut v = - vec![(LANE_VOLUME.to_string(), "Volume".to_string()), (LANE_PAN.to_string(), "Panner".to_string()), (LANE_MUTE.to_string(), "Mute".to_string())]; + let mut v = vec![ + (LANE_VOLUME.to_string(), tl!("Volume").to_string()), + (LANE_PAN.to_string(), tl!("Panner").to_string()), + (LANE_MUTE.to_string(), tl!("Mute").to_string()), + ]; for (i, s) in tr.mixer.sends.iter().enumerate() { let _ = s; - v.push((send_lane(i), format!("Send {} Level", i + 1))); + v.push((send_lane(i), tlf!("Send {n} Level", n = i + 1))); } for (slot, e) in tr.effects.iter().enumerate() { let Some(def) = e.def() else { continue }; for p in def.params.iter().filter(|p| matches!(p.kind, ParamKind::Float { .. }) && p.animatable) { - v.push((filmcraft_project::mixer::fx_lane(slot, p.id), format!("{}: {}", def.name, p.label))); + v.push((filmcraft_project::mixer::fx_lane(slot, p.id), format!("{}: {}", crate::i18n::t(def.name), crate::i18n::t(p.label)))); } } v @@ -96,18 +99,18 @@ pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned(); let resp = crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL)) - .on_hover_text("Show Keyframes"); + .on_hover_text(tl!("Show Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes"); let opts = lane_options(tr); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(170.0); - let c = ui.selectable_label(showing.is_none(), "Clip Keyframes"); + let c = ui.selectable_label(showing.is_none(), tl!("Clip Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes.clip"), c.rect, "Clip Keyframes"); if c.clicked() { app.ui.timeline.track_lanes.remove(&r.track.0); } ui.separator(); - ui.label(egui::RichText::new("Track Keyframes").small()); + ui.label(egui::RichText::new(tl!("Track Keyframes")).small()); for (k, l) in &opts { let e = ui.selectable_label(showing.as_deref() == Some(k.as_str()), l); app.auto.add(&format!("timeline.track.{label}.keyframes.{k}"), e.rect, l); @@ -222,7 +225,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let time = k.time; let key2 = key.clone(); resp.context_menu(|ui| { - if ui.button("Delete").clicked() { + if ui.button(tl!("Delete")).clicked() { acts.push(("mixer.deleteKeyframe", json!({"strip": r.track.0, "lane": key2, "time": time.0}))); ui.close(); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index b154f5540..bc5b6898f 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -21,7 +21,6 @@ pub const ROW_H: f32 = 28.0; /// Caption block colours (fill, selected fill). const FILL: Color32 = Color32::from_rgb(0x6b, 0x4f, 0x8f); const FILL_SEL: Color32 = Color32::from_rgb(0x8c, 0x6c, 0xb4); -const ROW_BG: Color32 = Color32::from_rgb(0x23, 0x20, 0x2a); /// Row rects (full width incl. header) for each caption track, top first. pub fn rows(area: Rect, n: usize) -> Vec { @@ -73,7 +72,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re let mut actions: Vec<(String, Value)> = Vec::new(); for (i, (tr, row)) in seq.caption_tracks.iter().zip(rows(area, seq.caption_tracks.len())).enumerate() { let lane = Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y)); - painter.rect_filled(lane, 0.0, ROW_BG); + painter.rect_filled(lane, 0.0, t.caption_track_bg); painter.line_segment([pos2(row.min.x, row.max.y - 0.5), pos2(row.max.x, row.max.y - 0.5)], Stroke::new(1.0, t.tl_bg)); // header let label = format!("C{}", i + 1); @@ -92,7 +91,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re true, t, egui::Id::new(("cap-lock", tr.id.0)), - Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }), + Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock"); if lresp.clicked() { @@ -112,7 +111,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if eresp.clicked() { actions.push(("captions.setTrack".into(), json!({"track": tr.id.0, "enabled": !tr.enabled}))); } - let name = format!("{} · {}", tr.name, tr.format.label()); + let name = format!("{} · {}", tr.name, crate::i18n::t(tr.format.label())); painter.with_clip_rect(Rect::from_min_max(pos2(hrect.min.x + 86.0, row.min.y), pos2(hrect.max.x - 4.0, row.max.y))).text( pos2(hrect.min.x + 90.0, row.center().y), Align2::LEFT_CENTER, @@ -213,8 +212,10 @@ fn interact_block( if tr.locked { return; } + // decide on what was under the pointer when the button went down, not where egui recognised + // the drag (6 pt later, past the 5 px edge zones) (#259) if resp.drag_started() - && let Some(p) = resp.interact_pointer_pos() + && let Some(p) = ctx.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { let g = match gesture_at(p.x) { Gesture::Move => 0u8, diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs new file mode 100644 index 000000000..1716649fe --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -0,0 +1,448 @@ +//! What a press on the Timeline grabs (#259). +//! +//! Pure geometry and rules, shared by the cursor, the hover bracket, the press handler and the +//! control channel's `ui.timeline.hit`, so what the pointer shows is what a press does. + +use std::cmp::Ordering; + +use egui::{Modifiers, Pos2}; +use filmcraft_edit::Edge; +use filmcraft_project::{ClipId, Sequence, Track, TrackId, TransitionId}; + +use super::timeline::Layout; +use crate::state::Tool; + +/// How close (px) to a cut the Selection tool rolls instead of rippling when Settings ▸ Trim ▸ +/// "Allow Selection tool to choose Roll and Ripple trims without modifier key" is on. +pub const ROLL_PX: f32 = 2.5; + +/// Widest an edge zone gets (px), on each side of a clip edge. +pub const EDGE_PX: f32 = 7.0; + +/// The trim the Selection tool starts at an edit point: Cmd+Shift = roll, Cmd = ripple. With the +/// Trim setting on (`no_modifier`), no modifier is needed: right on the cut (when a clip is on the +/// other side) rolls, elsewhere on the edge ripples. Otherwise a plain drag is a regular trim. +pub fn selection_trim_kind(no_modifier: bool, cmd: bool, shift: bool, dist_px: f32, has_neighbour: bool) -> &'static str { + if cmd && shift { + "roll" + } else if cmd { + "ripple" + } else if no_modifier { + if has_neighbour && dist_px <= ROLL_PX { "roll" } else { "ripple" } + } else { + "trim" + } +} + +/// Distance (px) from `x` to the cut at `clip`'s `edge`, and whether a clip touches that cut on +/// the other side. +pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, clip: ClipId, edge: Edge, x: f32) -> (f32, bool) { + let Some(tr) = seq.track(track) else { return (f32::MAX, false) }; + let Some(it) = tr.item(clip) else { return (f32::MAX, false) }; + let (cut, neighbour) = match edge { + Edge::Out => (it.end(), tr.items.iter().any(|x| x.start == it.end())), + Edge::In => (it.start, tr.items.iter().any(|x| x.end() == it.start)), + }; + ((layout.x_of(cut) - x).abs(), neighbour) +} + +/// What is at a screen point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hit { + Ruler, + Clip { track: TrackId, clip: ClipId, edge: Option }, + Transition { track: TrackId, id: TransitionId }, + Empty { track: TrackId }, + None, +} + +pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit { + if layout.ruler.contains(pos) { + return Hit::Ruler; + } + if !layout.content.contains(pos) { + return Hit::None; + } + let Some(row) = layout.row_at(pos.y) else { return Hit::None }; + let Some(tr) = seq.track(row.track) else { return Hit::None }; + for trn in &tr.transitions { + let x0 = layout.x_of(trn.start); + let x1 = layout.x_of(trn.end()); + if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 { + return Hit::Transition { track: row.track, id: trn.id }; + } + } + if let Some((clip, edge)) = edge_at(tr, layout, pos.x) { + return Hit::Clip { track: row.track, clip, edge: Some(edge) }; + } + match tr.item_at(layout.tick_at(pos.x)) { + Some(it) => Hit::Clip { track: row.track, clip: it.id, edge: None }, + None => Hit::Empty { track: row.track }, + } +} + +/// A clip edge within reach of the pointer. +#[derive(Clone, Copy)] +struct Reach { + clip: ClipId, + edge: Edge, + /// The pointer is inside the clip (or exactly on the edge). + inside: bool, + /// How far the pointer is from the edge (px). + dist: f32, +} + +impl Reach { + /// Inside a clip beats outside one (so at a cut the side of the cut picks the clip), then the + /// nearer edge wins; an exact tie (right on a cut, or mid-way across a narrow gap) goes to the + /// later clip's In edge. + fn beats(&self, other: &Reach) -> bool { + if self.inside != other.inside { + return self.inside; + } + match self.dist.partial_cmp(&other.dist) { + Some(Ordering::Less) => true, + Some(Ordering::Greater) => false, + _ => self.edge == Edge::In && other.edge != Edge::In, + } + } +} + +/// The edge zone (px, on each side of an edge) of a clip `w` px wide on screen: a third of the +/// clip, at most [`EDGE_PX`], so the middle third of a narrow clip still moves it. `None` when the +/// clip has no usable width (zoomed far out, or a broken zoom): it has no edges then. +fn zone(w: f32) -> Option { + if w.is_finite() && w > 0.0 { Some(EDGE_PX.min(w / 3.0)) } else { None } +} + +/// The clip edge on `tr` a pointer at `x` grabs (#259), whatever the order of `tr.items`. +fn edge_at(tr: &Track, layout: &Layout, x: f32) -> Option<(ClipId, Edge)> { + let mut best: Option = None; + for it in &tr.items { + let (x0, x1) = (layout.x_of(it.start), layout.x_of(it.end())); + let Some(e) = zone(x1 - x0) else { continue }; + // the pointer's distance into the clip from each of its edges + for (edge, d) in [(Edge::In, x - x0), (Edge::Out, x1 - x)] { + if !(-e..=e).contains(&d) { + continue; + } + let r = Reach { clip: it.id, edge, inside: d >= 0.0, dist: d.abs() }; + if best.is_none_or(|b| r.beats(&b)) { + best = Some(r); + } + } + } + best.map(|r| (r.clip, r.edge)) +} + +/// The trim a press on a clip edge starts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgeKind { + /// Regular trim: the edge moves, opening or covering a gap. + Trim, + /// Ripple trim: the edge moves and later clips follow. + Ripple, + /// Roll the cut between `left` and `right`. + Roll { left: ClipId, right: ClipId }, + /// Rate Stretch: the clip's speed changes to fill its new length. + Stretch, + /// Remix: a music clip is re-planned to its new length (Out edges only). + Remix, +} + +impl EdgeKind { + /// Its name on the control channel (`ui.timeline.hit` → `kind`). + pub fn name(self) -> &'static str { + match self { + EdgeKind::Trim => "trim", + EdgeKind::Ripple => "ripple", + EdgeKind::Roll { .. } => "roll", + EdgeKind::Stretch => "rateStretch", + EdgeKind::Remix => "remix", + } + } +} + +/// What a press of a tool at a point grabs: a clip edge and the trim it starts, or whatever else +/// is there. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Grab { + Edge { + track: TrackId, + clip: ClipId, + edge: Edge, + kind: EdgeKind, + }, + /// An audio clip's Volume line (Selection and Pen tools): a drag changes the level, a Pen or + /// Cmd/Ctrl click adds a keyframe. + Volume { + track: TrackId, + clip: ClipId, + }, + /// Not an edge this tool trims: a clip body, a transition, an empty track, the ruler or + /// nothing. A clip's `edge` may still be set, for tools that do something else there (Razor, + /// Slip…). + Other(Hit), +} + +impl Grab { + /// What is under the pointer, whatever the tool does with it. + pub fn hit(self) -> Hit { + match self { + Grab::Edge { track, clip, edge, .. } => Hit::Clip { track, clip, edge: Some(edge) }, + Grab::Volume { track, clip } => Hit::Clip { track, clip, edge: None }, + Grab::Other(h) => h, + } + } +} + +/// What a press of `tool` with `mods` at `pos` grabs (#259): the one place that decides which +/// tools trim at a clip edge and which trim they start. `roll_ripple` is Settings ▸ Trim ▸ "Allow +/// Selection tool to choose Roll and Ripple trims without modifier key". +pub fn grab_at(seq: &Sequence, layout: &Layout, pos: Pos2, tool: Tool, mods: Modifiers, roll_ripple: bool) -> Grab { + let h = hit(seq, layout, pos); + if let Hit::Clip { track, clip, edge: None } = h + && matches!(tool, Tool::Selection | Tool::Pen) + && super::timeline_volume::on_line(seq, layout, clip, pos) + { + return Grab::Volume { track, clip }; + } + let Hit::Clip { track, clip, edge: Some(edge) } = h else { return Grab::Other(h) }; + let kind = match tool { + Tool::Selection => { + let (dist, neighbour) = edge_geometry(seq, layout, track, clip, edge, pos.x); + match selection_trim_kind(roll_ripple, mods.command, mods.shift, dist, neighbour) { + "roll" => roll(seq, track, clip, edge), + "ripple" => EdgeKind::Ripple, + _ => EdgeKind::Trim, + } + } + Tool::Ripple => EdgeKind::Ripple, + Tool::Rolling => roll(seq, track, clip, edge), + Tool::RateStretch => EdgeKind::Stretch, + Tool::Remix if edge == Edge::Out => EdgeKind::Remix, + _ => return Grab::Other(h), + }; + Grab::Edge { track, clip, edge, kind } +} + +/// A roll of the cut at `clip`'s `edge`, or a regular trim when no clip touches that cut on the +/// other side (there is nothing to roll against). +fn roll(seq: &Sequence, track: TrackId, clip: ClipId, edge: Edge) -> EdgeKind { + let Some(tr) = seq.track(track) else { return EdgeKind::Trim }; + let Some(it) = tr.item(clip) else { return EdgeKind::Trim }; + let pair = match edge { + Edge::Out => tr.items.iter().find(|x| x.start == it.end()).map(|r| (clip, r.id)), + Edge::In => tr.items.iter().find(|x| x.end() == it.start).map(|l| (l.id, clip)), + }; + match pair { + Some((left, right)) => EdgeKind::Roll { left, right }, + None => EdgeKind::Trim, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Rect, pos2}; + use filmcraft_project::{ItemId, Label, Project, SequenceSettings, TrackItem, TrackKind}; + use filmcraft_time::{FrameRate, Tick}; + + use crate::panels::timeline::Row; + + /// The row's top. Points are hit-tested 10 px below it, above where transitions are hit. + const TOP: f32 = 100.0; + + /// A clip on screen from `x0` to `x1` px (the layout is 100 px per second). + fn clip(id: u64, x0: f64, x1: f64) -> TrackItem { + TrackItem { + id: ClipId(id), + item: ItemId(1), + name: format!("c{id}"), + label: Label::Iris, + start: Tick::from_seconds_f64(x0 / 100.0), + duration: Tick::from_seconds_f64((x1 - x0) / 100.0), + source_in: Tick::ZERO, + speed: 1.0, + reverse: false, + enabled: true, + link: None, + group: None, + effects: Vec::new(), + markers: Vec::new(), + 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, + } + } + + /// A: 100–300, B: 300–500 (sharing a cut with A), C: 504–700 (after a 4 px gap), D: 800–809 + /// (9 px wide, so its zones are 3 px). + fn clips() -> Vec { + vec![clip(1, 100.0, 300.0), clip(2, 300.0, 500.0), clip(3, 504.0, 700.0), clip(4, 800.0, 809.0)] + } + + /// One video track holding `items`, laid out at 100 px/s from x = 0. + fn fixture(items: Vec) -> (Sequence, Layout, TrackId) { + let mut p = Project::new("t"); + let s = p.new_sequence("s", SequenceSettings { frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 0, None); + let mut seq = p.sequence(s).cloned().expect("sequence"); + let track = seq.video_tracks[0].id; + seq.video_tracks[0].items = items; + let row = Rect::from_min_max(pos2(0.0, TOP), pos2(2000.0, TOP + 60.0)); + let layout = Layout { + content: row, + ruler: Rect::from_min_max(pos2(0.0, 0.0), pos2(2000.0, 40.0)), + rows: vec![Row { track, kind: TrackKind::Video, index: 0, rect: row, lane: false }], + pps: 100.0, + scroll: 0.0, + split_y: TOP + 60.0, + }; + (seq, layout, track) + } + + fn at(x: f32) -> Pos2 { + pos2(x, TOP + 10.0) + } + + fn edge_hit(track: TrackId, clip: u64, edge: Edge) -> Hit { + Hit::Clip { track, clip: ClipId(clip), edge: Some(edge) } + } + + #[test] + fn the_side_of_a_cut_picks_the_clip() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(297.0)), edge_hit(tr, 1, Edge::Out)); + assert_eq!(hit(&seq, &l, at(303.0)), edge_hit(tr, 2, Edge::In)); + // right on the cut: the later clip + assert_eq!(hit(&seq, &l, at(300.0)), edge_hit(tr, 2, Edge::In)); + } + + #[test] + fn a_narrow_gap_splits_at_its_middle() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(501.0)), edge_hit(tr, 2, Edge::Out)); + assert_eq!(hit(&seq, &l, at(503.0)), edge_hit(tr, 3, Edge::In)); + // exactly mid-way: the later clip + assert_eq!(hit(&seq, &l, at(502.0)), edge_hit(tr, 3, Edge::In)); + } + + #[test] + fn free_edges_reach_into_the_gap_beside_them() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(95.0)), edge_hit(tr, 1, Edge::In)); + assert_eq!(hit(&seq, &l, at(705.0)), edge_hit(tr, 3, Edge::Out)); + assert_eq!(hit(&seq, &l, at(92.0)), Hit::Empty { track: tr }); + assert_eq!(hit(&seq, &l, at(200.0)), Hit::Clip { track: tr, clip: ClipId(1), edge: None }); + } + + #[test] + fn edges_do_not_depend_on_the_order_of_the_items() { + let mut reversed = clips(); + reversed.reverse(); + let (a, la, _) = fixture(clips()); + let (b, lb, _) = fixture(reversed); + // edge points only: finding the clip under a body point relies on the items being sorted + for x in [297.0, 300.0, 303.0, 501.0, 502.0, 503.0, 95.0, 705.0, 802.0, 807.0] { + assert_eq!(hit(&a, &la, at(x)), hit(&b, &lb, at(x)), "x = {x}"); + } + } + + #[test] + fn a_narrow_clip_keeps_its_middle_third() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(hit(&seq, &l, at(802.0)), edge_hit(tr, 4, Edge::In)); + assert_eq!(hit(&seq, &l, at(807.0)), edge_hit(tr, 4, Edge::Out)); + assert_eq!(hit(&seq, &l, at(804.5)), Hit::Clip { track: tr, clip: ClipId(4), edge: None }); + // 4 px outside it is beyond its 3 px zone + assert_eq!(hit(&seq, &l, at(796.0)), Hit::Empty { track: tr }); + } + + #[test] + fn hostile_points_and_zooms_never_panic_or_grab_an_edge() { + let (seq, l, tr) = fixture(vec![clip(5, 1000.0, 1000.0)]); + // a zero-length clip has no edges, even right on it + assert_eq!(hit(&seq, &l, at(1000.0)), Hit::Empty { track: tr }); + for p in [pos2(f32::NAN, TOP + 10.0), pos2(f32::INFINITY, TOP + 10.0), pos2(f32::NEG_INFINITY, TOP + 10.0), pos2(300.0, f32::NAN), pos2(300.0, 5000.0)] + { + assert_eq!(hit(&seq, &l, p), Hit::None, "{p:?}"); + } + let (seq, l, _) = fixture(clips()); + assert_eq!(hit(&seq, &l, pos2(300.0, 20.0)), Hit::Ruler); + for pps in [0.0, f64::NAN, f64::INFINITY] { + let broken = Layout { pps, ..l.clone() }; + assert!(!matches!(hit(&seq, &broken, at(300.0)), Hit::Clip { edge: Some(_), .. }), "pps {pps}"); + } + } + + use egui::Modifiers; + + use crate::state::Tool; + + const ROLL_AB: EdgeKind = EdgeKind::Roll { left: ClipId(1), right: ClipId(2) }; + + fn grab(seq: &Sequence, l: &Layout, tool: Tool, x: f32, mods: Modifiers, roll_ripple: bool) -> Grab { + grab_at(seq, l, at(x), tool, mods, roll_ripple) + } + + fn edge_grab(track: TrackId, clip: u64, edge: Edge, kind: EdgeKind) -> Grab { + Grab::Edge { track, clip: ClipId(clip), edge, kind } + } + + #[test] + fn selection_tool_trims_by_modifier_and_setting() { + let (seq, l, tr) = fixture(clips()); + let (none, cmd, cmd_shift) = (Modifiers::NONE, Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT); + let sel = |x, mods, pref| grab(&seq, &l, Tool::Selection, x, mods, pref); + assert_eq!(sel(297.0, none, false), edge_grab(tr, 1, Edge::Out, EdgeKind::Trim)); + assert_eq!(sel(303.0, cmd, false), edge_grab(tr, 2, Edge::In, EdgeKind::Ripple)); + assert_eq!(sel(303.0, cmd_shift, false), edge_grab(tr, 2, Edge::In, ROLL_AB)); + // Settings ▸ Trim: right on the cut rolls, elsewhere on the edge ripples + assert_eq!(sel(298.0, none, true), edge_grab(tr, 1, Edge::Out, ROLL_AB)); + assert_eq!(sel(302.0, none, true), edge_grab(tr, 2, Edge::In, ROLL_AB)); + assert_eq!(sel(305.0, none, true), edge_grab(tr, 2, Edge::In, EdgeKind::Ripple)); + // a roll with no clip on the other side of the cut is a regular trim + assert_eq!(sel(102.0, cmd_shift, false), edge_grab(tr, 1, Edge::In, EdgeKind::Trim)); + // mid-clip: the clip itself + assert_eq!(sel(200.0, none, false), Grab::Other(Hit::Clip { track: tr, clip: ClipId(1), edge: None })); + } + + #[test] + fn which_tools_trim_at_an_edge() { + let (seq, l, tr) = fixture(clips()); + let g = |tool, x| grab(&seq, &l, tool, x, Modifiers::NONE, false); + assert_eq!(g(Tool::Ripple, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Ripple)); + assert_eq!(g(Tool::Rolling, 303.0), edge_grab(tr, 2, Edge::In, ROLL_AB)); + assert_eq!(g(Tool::Rolling, 102.0), edge_grab(tr, 1, Edge::In, EdgeKind::Trim)); + assert_eq!(g(Tool::RateStretch, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Stretch)); + assert_eq!(g(Tool::Remix, 297.0), edge_grab(tr, 1, Edge::Out, EdgeKind::Remix)); + // Remix has Out edges only + assert_eq!(g(Tool::Remix, 303.0), Grab::Other(edge_hit(tr, 2, Edge::In))); + for tool in [Tool::Razor, Tool::Slip, Tool::Slide, Tool::Hand, Tool::Zoom, Tool::TrackSelectForward] { + assert_eq!(g(tool, 297.0), Grab::Other(edge_hit(tr, 1, Edge::Out)), "{tool:?}"); + } + } + + #[test] + fn edge_kind_names_for_the_control_channel() { + assert_eq!( + [EdgeKind::Trim, EdgeKind::Ripple, ROLL_AB, EdgeKind::Stretch, EdgeKind::Remix].map(EdgeKind::name), + ["trim", "ripple", "roll", "rateStretch", "remix"] + ); + } + + #[test] + fn a_grab_reports_what_is_under_it() { + let (seq, l, tr) = fixture(clips()); + assert_eq!(grab(&seq, &l, Tool::Selection, 303.0, Modifiers::NONE, false).hit(), edge_hit(tr, 2, Edge::In)); + assert_eq!(grab(&seq, &l, Tool::Razor, 303.0, Modifiers::NONE, false).hit(), edge_hit(tr, 2, Edge::In)); + } +} diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs new file mode 100644 index 000000000..c6a052507 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -0,0 +1,394 @@ +//! The Volume line on audio clips (#223): the clip's Volume level over time, with its keyframes, +//! on the same scale as the track faders. As in Premiere, drag the line up or down to change the +//! level (with keyframes: the stretch between the two around the pointer), Cmd/Ctrl-click it or +//! click it with the Pen tool to add a keyframe, drag keyframes, right-click one to delete it. +//! Presses on the line go through the Timeline's own hit-testing ([`super::timeline_hit`]), so a +//! right-click on it still opens the clip menu. +//! +//! Automation ids: `timeline.clip..volume` (a point on the line), +//! `timeline.clip..volume.kf.` (the keyframes) and `….kf..delete` (their menu). + +use egui::{Color32, CursorIcon, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use filmcraft_project::mixer::{FADER_MAX_DB, FADER_MIN_DB}; +use filmcraft_project::{ClipId, Param, Sequence, TrackItem, TrackKind}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use super::mixer::{db_to_pos, pos_to_db}; +use super::timeline::{Layout, Row}; +use super::timeline_hit::EDGE_PX; +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::state::Tool; +use crate::theme::Tokens; + +const KEY_COL: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); + +/// Where the line goes in a clip drawn at `body`: under the name, down to the bottom edge. +fn band(body: Rect) -> Rect { + Rect::from_min_max(pos2(body.min.x, body.min.y + 17.0), pos2(body.max.x, body.max.y - 3.0)) +} + +fn level(it: &TrackItem) -> Option<&Param> { + it.effect("volume")?.param("level") +} + +/// The level (dB) the clip plays at timeline time `t`. +fn level_at(it: &TrackItem, t: Tick) -> f64 { + level(it).map_or(0.0, |p| p.f64_at(it.source_time_at(t))) +} + +/// The clip's Volume as a gain at timeline time `t`, for its waveform (1 when Volume is off or +/// bypassed, as the mixer plays it). +pub fn gain_at(it: &TrackItem, t: Tick) -> f32 { + match it.effect("volume").filter(|e| e.enabled && !e.param("bypass").and_then(|p| p.value.as_bool()).unwrap_or(false)) { + Some(e) => filmcraft_render::audio::db_to_gain(e.f64_at("level", it.source_time_at(t))), + None => 1.0, + } +} + +/// Timeline time of media time `m`, the inverse of [`TrackItem::moving_source_time_at`]. +fn timeline_time(it: &TrackItem, m: Tick) -> Tick { + let rel = if it.reverse { it.source_out().0.saturating_sub(1).saturating_sub(m.0) } else { m.0.saturating_sub(it.source_in.0) }; + Tick(it.start.0.saturating_add((rel as f64 / it.speed.abs().max(1e-6)).round() as i64)) +} + +fn x_of(it: &TrackItem, body: Rect, t: Tick) -> f32 { + body.min.x + (t.0.saturating_sub(it.start.0) as f64 / it.duration.0.max(1) as f64) as f32 * body.width() +} + +fn t_at(it: &TrackItem, body: Rect, x: f32) -> Tick { + let f = ((x - body.min.x) / body.width().max(1.0)).clamp(0.0, 1.0) as f64; + Tick(it.start.0.saturating_add((f * it.duration.0 as f64) as i64)) +} + +fn y_of(body: Rect, db: f64) -> f32 { + let b = band(body); + b.max.y - db_to_pos(db) * b.height() +} + +/// The line from `x0` to `x1`; keyframes bend it, so it then gets a point every 2 px. +fn line(it: &TrackItem, body: Rect, x0: f32, x1: f32) -> Vec { + let n = if level(it).is_some_and(Param::is_animated) { (((x1 - x0) / 2.0).ceil() as usize).clamp(1, 4096) } else { 1 }; + (0..=n) + .map(|i| { + let x = x0 + (x1 - x0) * i as f32 / n as f32; + pos2(x, y_of(body, level_at(it, t_at(it, body, x)))) + }) + .collect() +} + +/// The keyframes: media time and where each is drawn. +fn keys(it: &TrackItem, body: Rect) -> Vec<(Tick, Pos2)> { + let Some(p) = level(it) else { return Vec::new() }; + p.keyframes.iter().map(|k| (k.time, pos2(x_of(it, body, timeline_time(it, k.time)), y_of(body, k.value.as_f64().unwrap_or(0.0))))).collect() +} + +/// Draw the line over an audio clip drawn at `body`: white with a dark shadow. +pub fn paint(p: &egui::Painter, body: Rect, it: &TrackItem) { + let vis = p.clip_rect().intersect(body); + if band(body).height() < 6.0 || vis.width() <= 0.0 { + return; + } + let p = p.with_clip_rect(vis); + let pts = line(it, body, vis.min.x, vis.max.x); + p.add(egui::Shape::line(pts.iter().map(|q| *q + vec2(0.0, 1.0)).collect(), Stroke::new(1.0, Color32::BLACK))); + p.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::WHITE))); + for (_, c) in keys(it, body).into_iter().filter(|(_, c)| vis.expand(6.0).contains(*c)) { + icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, KEY_COL); + } +} + +/// A drag of a clip's Volume line, pressed at height `y` where the line was at `from` dB. `keys` +/// are the keyframes on either side of that point, which move with it (none: the level is not +/// animated). `last` is the level sent last: the first change starts the drag's undo step. +#[derive(Clone, Debug)] +pub struct LineDrag { + clip: ClipId, + from: f64, + y: f32, + keys: [Option<(Tick, f64)>; 2], + last: Option, +} + +fn find<'a>(seq: &'a Sequence, layout: &'a Layout, clip: ClipId) -> Option<(&'a Row, &'a TrackItem)> { + let (tid, it) = seq.find_item(clip)?; + Some((layout.rows.iter().find(|r| r.track == tid)?, it)) +} + +fn body(layout: &Layout, r: &Row, it: &TrackItem) -> Rect { + Rect::from_min_max(pos2(layout.x_of(it.start), r.rect.min.y + 1.0), pos2(layout.x_of(it.end()), r.rect.max.y - 1.0)) +} + +/// Whether `pos` is on `clip`'s Volume line, within 4 px. +pub fn on_line(seq: &Sequence, layout: &Layout, clip: ClipId, pos: Pos2) -> bool { + let Some((r, it)) = find(seq, layout, clip) else { return false }; + if r.kind != TrackKind::Audio || r.lane || seq.track(r.track).is_none_or(|t| t.locked) { + return false; + } + let body = body(layout, r, it); + band(body).height() >= 6.0 && (pos.y - y_of(body, level_at(it, t_at(it, body, pos.x)))).abs() <= 4.0 +} + +/// A press on `clip`'s line at `at` that became a drag. +pub fn start(seq: &Sequence, layout: &Layout, clip: ClipId, at: Pos2) -> Option { + let (r, it) = find(seq, layout, clip)?; + let t = t_at(it, body(layout, r, it), at.x); + let m = it.source_time_at(t); + let keys = level(it).filter(|p| p.is_animated()).map_or([None, None], |p| { + let i = p.keyframes.partition_point(|k| k.time <= m); + let kv = |j: Option| j.and_then(|j| p.keyframes.get(j)).map(|k| (k.time, k.value.as_f64().unwrap_or(0.0))); + [kv(i.checked_sub(1)), kv(Some(i))] + }); + Some(LineDrag { clip, from: level_at(it, t), y: at.y, keys, last: None }) +} + +/// Follow the pointer, now at height `y`. The clip changes as the line moves, so it is heard. +pub fn drag(app: &mut FilmcraftApp, ui: &egui::Ui, seq: &Sequence, layout: &Layout, mut d: LineDrag, y: f32) -> LineDrag { + let Some((r, it)) = find(seq, layout, d.clip) else { return d }; + let db = pos_to_db(db_to_pos(d.from) + (d.y - y) / band(body(layout, r, it)).height().max(1.0)); + tip(ui, db); + if d.last == Some(db) { + return d; + } + let begin = d.last.is_none(); + let mut acts = Vec::new(); + if d.keys.iter().all(Option::is_none) { + acts.push(("effects.setParam", json!({"clip": d.clip.0, "effect": "volume", "param": "level", "value": db, "merge": true, "begin": begin}))); + } + for (n, (m, v)) in d.keys.iter().flatten().enumerate() { + let v = (v + db - d.from).clamp(FADER_MIN_DB, FADER_MAX_DB); + let p = json!({"clip": d.clip.0, "effect": "volume", "param": "level", "mediaTime": m.0, "value": v, "merge": true, "begin": begin && n == 0}); + acts.push(("effects.setKeyframe", p)); + } + run(app, acts); + d.last = Some(db); + d +} + +/// The Pen tool or a Cmd/Ctrl-click on `clip`'s line at `x`: a keyframe there. +pub fn add_keyframe(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, clip: ClipId, x: f32) { + let Some((r, it)) = find(seq, layout, clip) else { return }; + let t = seq.settings.frame_rate.snap_nearest(t_at(it, body(layout, r, it), x)); + run(app, vec![("effects.addKeyframe", json!({"clip": clip.0, "effect": "volume", "param": "level", "time": t.0}))]); +} + +fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) { + for (id, p) in acts { + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + } +} + +/// The level next to the pointer while dragging: one line, always as wide, so it does not jump. +fn tip(ui: &egui::Ui, db: f64) { + let text = if db <= FADER_MIN_DB { format!("{:>7} dB", "-∞") } else { format!("{db:>+7.2} dB") }; + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.add(egui::Label::new(egui::RichText::new(text).font(Tokens::mono(12.0))).wrap_mode(egui::TextWrapMode::Extend)); + }); +} + +/// A keyframe being dragged: `index` of `clip`, grabbed `grab` away from its centre. +#[derive(Clone, Copy)] +struct KeyDrag { + clip: ClipId, + index: usize, + grab: egui::Vec2, + begun: bool, +} + +fn key_drag_id() -> egui::Id { + egui::Id::new("tl-volume-key-drag") +} + +/// The keyframes on the lines of the audio clips in view: drag one in time and level, right-click +/// it to delete it. Runs after the timeline's own interaction, so they are above the clip. +pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, aclip: Rect) { + let held: Option = ui.data(|d| d.get_temp(key_drag_id())); + if held.is_none() && (app.tl.drag.is_some() || !matches!(app.ui.tool, Tool::Selection | Tool::Pen)) { + return; + } + let area = aclip.intersect(layout.content); + let pointer = ui.input(|i| i.pointer.interact_pos()); + let rate = seq.settings.frame_rate; + let mut next = held; + let mut acts: Vec<(&str, Value)> = Vec::new(); + for r in layout.rows.iter().filter(|r| r.kind == TrackKind::Audio && !r.lane) { + let Some(tr) = seq.track(r.track).filter(|t| !t.locked) else { continue }; + let row = r.rect.intersect(area); + if row.height() <= 0.0 { + continue; + } + for it in &tr.items { + let body = body(layout, r, it); + let vis = body.intersect(row); + if vis.width() <= 0.0 || band(body).height() < 6.0 { + continue; + } + // a point on the line for agents, away from the trim zones at the edges + let edge = EDGE_PX.min(body.width() / 3.0); + let (x0, x1) = (vis.min.x.max(body.min.x + edge), vis.max.x.min(body.max.x - edge)); + if x1 > x0 { + let xm = (x0 + x1) / 2.0; + let c = pos2(xm, y_of(body, level_at(it, t_at(it, body, xm)))); + app.auto.add(&format!("timeline.clip.{}.volume", it.id.0), Rect::from_center_size(c, vec2(12.0, 8.0)), "Volume"); + } + let ks = keys(it, body); + for (i, &(time, c)) in ks.iter().enumerate() { + if !vis.expand2(vec2(5.0, 0.0)).contains(c) { + continue; + } + let kr = Rect::from_center_size(c, vec2(11.0, 11.0)); + let resp = ui.interact(kr, egui::Id::new(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); + app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}", it.id.0), kr, "Volume keyframe"); + let mine = held.filter(|h| h.clip == it.id && h.index == i); + if resp.hovered() || mine.is_some() { + icons::paint(&ui.painter().with_clip_rect(area), Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, Color32::WHITE); + ui.ctx().set_cursor_icon(CursorIcon::Move); + } + if resp.drag_started() + && let Some(p) = pointer + { + next = Some(KeyDrag { clip: it.id, index: i, grab: p - c, begun: false }); + } + if let (Some(h), Some(p)) = (mine, pointer) + && resp.dragged() + { + let q = p - h.grab; + // on a frame, at least a frame away from the keyframes on either side + let fd = rate.frame_duration(); + let now = timeline_time(it, time); + let (mut lo, mut hi) = (it.start, Tick(it.end().0.saturating_sub(fd.0))); + for j in [i.checked_sub(1), i.checked_add(1)].into_iter().flatten() { + if let Some(&(m, _)) = ks.get(j) { + let n = timeline_time(it, m); + if n < now { + lo = lo.max(Tick(n.0.saturating_add(fd.0))); + } else { + hi = hi.min(Tick(n.0.saturating_sub(fd.0))); + } + } + } + let t = if lo <= hi { rate.snap_nearest(t_at(it, body, q.x)).max(lo).min(hi) } else { now }; + let db = pos_to_db((band(body).max.y - q.y) / band(body).height()); + if resp.drag_delta() != egui::Vec2::ZERO { + let to = it.moving_source_time_at(t); + let p = json!({"clip": h.clip.0, "effect": "volume", "param": "level", "mediaTime": time.0, "to": to.0, "value": db, "merge": true, "begin": !h.begun}); + acts.push(("effects.moveKeyframe", p)); + next = Some(KeyDrag { begun: true, ..h }); + } + tip(ui, db); + } + resp.context_menu(|ui| { + let b = ui.button(tl!("Delete")); + app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}.delete", it.id.0), b.rect, "Delete"); + if b.clicked() { + acts.push(("effects.deleteKeyframe", json!({"clip": it.id.0, "effect": "volume", "param": "level", "mediaTime": time.0}))); + ui.close(); + } + }); + } + } + } + if !ui.input(|i| i.pointer.any_down()) { + next = None; + } + match next { + Some(h) => ui.data_mut(|d| { + d.insert_temp(key_drag_id(), h); + }), + None => ui.data_mut(|d| d.remove::(key_drag_id())), + } + run(app, acts); +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::{ItemId, Label}; + + fn clip(speed: f64, reverse: bool) -> TrackItem { + TrackItem { + id: ClipId(1), + item: ItemId(1), + name: String::new(), + label: Label::Iris, + start: Tick(1_000), + duration: Tick(4_000), + source_in: Tick(500), + speed, + reverse, + enabled: true, + link: None, + group: None, + effects: filmcraft_project::effect::intrinsic_audio(), + markers: Vec::new(), + 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, + } + } + + #[test] + fn keyframes_sit_where_their_media_time_plays() { + for (speed, reverse) in [(1.0, false), (2.0, false), (0.5, false), (1.0, true), (2.0, true)] { + let it = clip(speed, reverse); + for t in [1_000, 1_001, 2_500, 4_999] { + let m = it.moving_source_time_at(Tick(t)); + assert!((timeline_time(&it, m).0 - t).abs() <= 1, "speed {speed} reverse {reverse}: {t}"); + } + } + } + + #[test] + fn damaged_keyframe_times_do_not_panic() { + for (speed, reverse) in [(1.0, false), (1e-12, true), (f64::NAN, false), (-3.0, true)] { + let mut it = clip(speed, reverse); + let p = it.effect_mut("volume").and_then(|e| e.param_mut("level")).unwrap(); + p.put_keyframe(Tick(i64::MIN), filmcraft_project::ParamValue::Float(f64::NAN)); + p.put_keyframe(Tick(i64::MAX), filmcraft_project::ParamValue::Float(1e300)); + let body = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 80.0)); + assert_eq!(keys(&it, body).len(), 2); + } + } + + #[test] + fn the_waveform_gain_is_the_volume_that_plays() { + let mut it = clip(1.0, false); + assert_eq!(gain_at(&it, Tick(2_000)), 1.0); + let e = it.effect_mut("volume").unwrap(); + e.param_mut("level").unwrap().value = filmcraft_project::ParamValue::Float(-6.0); + assert!((gain_at(&it, Tick(2_000)) - 0.501).abs() < 0.01); + let e = it.effect_mut("volume").unwrap(); + e.param_mut("bypass").unwrap().value = filmcraft_project::ParamValue::Bool(true); + assert_eq!(gain_at(&it, Tick(2_000)), 1.0, "bypassed"); + } + + #[test] + fn the_line_follows_the_level() { + let mut it = clip(1.0, false); + let body = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 80.0)); + let flat = line(&it, body, 0.0, 400.0); + assert_eq!(flat.len(), 2); + assert!(flat.iter().all(|p| (p.y - y_of(body, 0.0)).abs() < 1e-3)); + let p = it.effect_mut("volume").and_then(|e| e.param_mut("level")).unwrap(); + p.put_keyframe(Tick(500), filmcraft_project::ParamValue::Float(0.0)); + p.put_keyframe(Tick(4_500), filmcraft_project::ParamValue::Float(-20.0)); + let ramp = line(&it, body, 0.0, 400.0); + assert!(ramp.len() > 100); + assert!(ramp.windows(2).all(|w| w[1].y >= w[0].y), "goes down as the level falls"); + assert!(ramp.last().unwrap().y > y_of(body, -19.0)); + // damaged geometry draws nothing silly rather than looping or panicking + assert_eq!(line(&it, body, 0.0, f32::NAN).len(), 2); + assert!(line(&it, body, 0.0, f32::INFINITY).len() <= 4097); + } +} diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index 4347bf6ff..6634320c0 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -14,7 +14,8 @@ //! `trimMonitor.playAround`, `trimMonitor.exit`. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use filmcraft_project::{ItemKind, Sequence, Track}; +use filmcraft_edit::Edge; +use filmcraft_project::{ClipId, ItemKind, Sequence, Track, TrackId}; use filmcraft_time::{Tick, TimeDisplay, format_time}; use serde_json::{Value, json}; @@ -22,6 +23,7 @@ use crate::FilmcraftApp; use crate::frames::{FrameKey, Target}; use crate::icons::{self, Icon}; use crate::panels::timeline::{Layout, Row}; +use crate::panels::timeline_hit::EdgeKind; use crate::theme::Tokens; /// Ripple edit points are red, roll / regular trims yellow (as in Premiere's timeline). @@ -79,19 +81,40 @@ pub fn paint_edit_points(app: &FilmcraftApp, p: &egui::Painter, seq: &Sequence, let col = if ep.kind == TrimKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW }; for (clip, out) in marks { let Some(it) = tr.item(clip) else { continue }; - let x = layout.x_of(if out { it.end() } else { it.start }); - let (y0, y1) = (row.rect.min.y + 2.0, row.rect.max.y - 2.0); - let dir = if out { -1.0 } else { 1.0 }; - let xi = x + dir * 1.5; - let w = 6.0 * dir; - let s = Stroke::new(3.0, col); - p.line_segment([pos2(xi, y0), pos2(xi, y1)], s); - p.line_segment([pos2(xi, y0 + 1.0), pos2(xi + w, y0 + 1.0)], s); - p.line_segment([pos2(xi, y1 - 1.0), pos2(xi + w, y1 - 1.0)], s); + paint_bracket(p, layout.x_of(if out { it.end() } else { it.start }), row, out, col); } } } +/// A trim bracket at `x` on `row`, its arms pointing into the clip (left for an Out edge). +pub fn paint_bracket(p: &egui::Painter, x: f32, row: &Row, out: bool, col: Color32) { + let (y0, y1) = (row.rect.min.y + 2.0, row.rect.max.y - 2.0); + let dir = if out { -1.0 } else { 1.0 }; + let xi = x + dir * 1.5; + let w = 6.0 * dir; + let s = Stroke::new(3.0, col); + p.line_segment([pos2(xi, y0), pos2(xi, y1)], s); + p.line_segment([pos2(xi, y0 + 1.0), pos2(xi + w, y0 + 1.0)], s); + p.line_segment([pos2(xi, y1 - 1.0), pos2(xi + w, y1 - 1.0)], s); +} + +/// The bracket on the edge a press would grab (#259): an edit point's bracket at half strength, +/// red for a ripple and yellow otherwise; a roll marks both sides of the cut. +pub fn paint_hover_bracket(p: &egui::Painter, seq: &Sequence, layout: &Layout, track: TrackId, clip: ClipId, edge: Edge, kind: EdgeKind) { + let Some(row) = layout.rows.iter().find(|r| r.track == track) else { return }; + let Some(tr) = seq.track(track) else { return }; + let full = if kind == EdgeKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW }; + let col = full.gamma_multiply(0.5); + let marks = match kind { + EdgeKind::Roll { left, right } => vec![(left, true), (right, false)], + _ => vec![(clip, edge == Edge::Out)], + }; + for (c, out) in marks { + let Some(it) = tr.item(c) else { continue }; + paint_bracket(p, layout.x_of(if out { it.end() } else { it.start }), row, out, col); + } +} + /// Fit a w×h picture into `area`. fn fit(area: Rect, w: f32, h: f32) -> Rect { let s = (area.width() / w.max(1.0)).min(area.height() / h.max(1.0)); @@ -173,8 +196,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let col = if kind == "ripple" { RIPPLE_RED } else { ROLL_YELLOW }; // headers: clip names for (side, r, key) in [(&info["outgoing"], left, "outgoing"), (&info["incoming"], right, "incoming")] { - let name = side["name"].as_str().map(|n| format!("{} ({})", n, side["track"].as_str().unwrap_or(""))).unwrap_or_else(|| "(gap)".into()); - let label = if key == "outgoing" { format!("Out: {name}") } else { format!("In: {name}") }; + let name = side["name"].as_str().map(|n| format!("{} ({})", n, side["track"].as_str().unwrap_or(""))).unwrap_or_else(|| tl!("(gap)").into()); + let label = if key == "outgoing" { tlf!("Out: {name}", name) } else { tlf!("In: {name}", name) }; ui.painter().text(pos2(r.min.x + 2.0, rect.min.y + 4.0 + header_h / 2.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); } let mut pics = [left, right]; @@ -182,7 +205,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if side.is_null() { let p = fit(r, 16.0, 9.0); ui.painter().rect_filled(p, 0.0, Color32::BLACK); - ui.painter().text(p.center(), Align2::CENTER_CENTER, "(gap)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(p.center(), Align2::CENTER_CENTER, tl!("(gap)"), Tokens::ui(12.0), t.text_faint); pics[i] = p; } else { pics[i] = picture(app, ui, r, side, if i == 0 { "trim-outgoing" } else { "trim-incoming" }); @@ -259,12 +282,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let n = info["largeTrimOffset"].as_i64().unwrap_or(5); let bar = Rect::from_min_size(pos2(rect.min.x, row.max.y + 6.0), vec2(rect.width(), 28.0)); let buttons: [(&str, String, &str, f32); 6] = [ - ("trimMonitor.playAround", String::new(), "Play Around Edit (Space loops, Shift+K once)", 30.0), - ("trimMonitor.backwardMany", format!("−{n}"), "Trim Backward Many", 40.0), - ("trimMonitor.backward", "−1".into(), "Trim Backward", 40.0), - ("trimMonitor.applyTransition", "Apply Default Transitions".into(), "Apply Default Transitions to Selection (Shift+D)", 176.0), - ("trimMonitor.forward", "+1".into(), "Trim Forward", 40.0), - ("trimMonitor.forwardMany", format!("+{n}"), "Trim Forward Many", 40.0), + ("trimMonitor.playAround", String::new(), tl!("Play Around Edit (Space loops, Shift+K once)"), 30.0), + ("trimMonitor.backwardMany", format!("−{n}"), tl!("Trim Backward Many"), 40.0), + ("trimMonitor.backward", "−1".into(), tl!("Trim Backward"), 40.0), + ("trimMonitor.applyTransition", tl!("Apply Default Transitions").into(), tl!("Apply Default Transitions to Selection (Shift+D)"), 176.0), + ("trimMonitor.forward", "+1".into(), tl!("Trim Forward"), 40.0), + ("trimMonitor.forwardMany", format!("+{n}"), tl!("Trim Forward Many"), 40.0), ]; let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum(); let mut x = bar.center().x - total / 2.0; @@ -304,7 +327,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // exit trim mode let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text("Exit Trim Mode"); + let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); let c = if resp.hovered() { t.tab_text_active } else { t.text_dim }; ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c)); ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c)); diff --git a/crates/ui-egui/src/panels/voiceover.rs b/crates/ui-egui/src/panels/voiceover.rs index dd4544d7f..292bc398f 100644 --- a/crates/ui-egui/src/panels/voiceover.rs +++ b/crates/ui-egui/src/panels/voiceover.rs @@ -55,9 +55,9 @@ pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, track: TrackId, if resp.clicked() { act = Some(Action::Toggle(track)); } - let resp = resp.on_hover_text("Voice-over record (right-click: Voice-Over Record Settings…)"); + let resp = resp.on_hover_text(tl!("Voice-over record (right-click: Voice-Over Record Settings…)")); resp.context_menu(|ui| { - let b = ui.button("Voice-Over Record Settings…"); + let b = ui.button(tl!("Voice-Over Record Settings…")); app.auto.add(&format!("{id}.settings"), b.rect, "Voice-Over Record Settings…"); if b.clicked() { act = Some(Action::Settings); @@ -100,7 +100,7 @@ pub fn toggle(app: &mut FilmcraftApp, track: Option) -> Result Result { let t = app.session.playhead(); let r = app.session.execute("audio.voiceover.stop", json!({"time": t.0})).map_err(|e| e.to_string())?; app.ui.status = match r["path"].as_str() { - Some(p) => format!("Voice-over recorded: {p}"), - None => "Voice-over recording stopped (nothing recorded)".into(), + Some(p) => tlf!("Voice-over recorded: {p}", p), + None => tl!("Voice-over recording stopped (nothing recorded)").into(), }; Ok(r) } @@ -214,9 +214,9 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let left = (rec.record_start - now).seconds().ceil().max(1.0) as i64; format!("{left}") } else if rec.punch_out.is_some_and(|o| now >= o) { - "Post-roll".into() + tl!("Post-roll").into() } else { - "● Recording".into() + tl!("● Recording").into() }; let r = Rect::from_center_size(pos2(screen.center().x, screen.min.y + 90.0), vec2(220.0, 64.0)); egui::Area::new(egui::Id::new("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { @@ -236,17 +236,19 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut apply = false; let mut discard = false; let accent = app.tokens.accent; - egui::Window::new("Voice-Over Record Settings").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + // the title is translated; the window keeps one id whatever the interface language + let id = egui::Id::new("voiceover-settings"); + egui::Window::new(tl!("Voice-Over Record Settings")).id(id).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)); elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone())); ui.end_row(); - ui.label("Source:"); - let shown = if d.prefs.source.is_empty() { "Default Input".to_string() } else { d.prefs.source.clone() }; + ui.label(tl!("Source:")); + let shown = if d.prefs.source.is_empty() { tl!("Default Input").to_string() } else { d.prefs.source.clone() }; let previous_source = d.prefs.source.clone(); let cb = egui::ComboBox::from_id_salt("voiceover-source").selected_text(&shown).width(220.0).show_ui(ui, |ui| { - let mut opts = vec![(String::new(), "Default Input".to_string())]; + let mut opts = vec![(String::new(), tl!("Default Input").to_string())]; opts.extend(d.devices.iter().map(|x| (x.clone(), x.clone()))); for (i, (val, label)) in opts.into_iter().enumerate() { let r = ui.selectable_label(d.prefs.source == val, &label); @@ -265,13 +267,13 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { } } ui.end_row(); - ui.label("Input:"); + ui.label(tl!("Input:")); let n = u32::from(d.channels.clamp(1, 64)); d.prefs.input_channel = d.prefs.input_channel.min(63); - let cur = format!("Channel {}", d.prefs.input_channel + 1); + let cur = tlf!("Channel {n}", n = d.prefs.input_channel + 1); let cb = egui::ComboBox::from_id_salt("voiceover-input").selected_text(&cur).width(220.0).show_ui(ui, |ui| { for c in 0..n.max(d.prefs.input_channel + 1) { - let label = format!("Channel {}", c + 1); + let label = tlf!("Channel {n}", n = c + 1); let r = ui.selectable_label(d.prefs.input_channel == c, &label); elems.push((format!("voiceover.input.{c}"), r.rect, label)); if r.clicked() { @@ -282,38 +284,38 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { elems.push(("voiceover.input".into(), cb.response.rect, cur)); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.prefs.countdown_sound_cues, "Countdown Sound Cues"); + let r = ui.checkbox(&mut d.prefs.countdown_sound_cues, tl!("Countdown Sound Cues")); elems.push(("voiceover.countdown".into(), r.rect, "Countdown Sound Cues".into())); ui.end_row(); - ui.label("Pre-roll:"); - let r = ui.add(egui::DragValue::new(&mut d.prefs.preroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds")); + ui.label(tl!("Pre-roll:")); + let r = ui.add(egui::DragValue::new(&mut d.prefs.preroll_seconds).speed(0.1).range(0.0..=60.0).suffix(tl!(" seconds"))); elems.push(("voiceover.preroll".into(), r.rect, format!("{}", d.prefs.preroll_seconds))); ui.end_row(); - ui.label("Post-roll:"); - let r = ui.add(egui::DragValue::new(&mut d.prefs.postroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds")); + ui.label(tl!("Post-roll:")); + let r = ui.add(egui::DragValue::new(&mut d.prefs.postroll_seconds).speed(0.1).range(0.0..=60.0).suffix(tl!(" seconds"))); elems.push(("voiceover.postroll".into(), r.rect, format!("{}", d.prefs.postroll_seconds))); ui.end_row(); }); ui.label( - RichText::new("Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there.") + RichText::new(tl!("Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there.")) .weak() .small(), ); ui.add_space(8.0); ui.horizontal(|ui| { if app.session.voiceover.recording() { - let r = ui.button("Discard take"); + let r = ui.button(tl!("Discard take")); elems.push(("voiceover.discard".into(), r.rect, "Discard the active take".into())); if r.clicked() { discard = true; } } - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); elems.push(("voiceover.cancel".into(), c.rect, "Cancel".into())); if c.clicked() { close = true; } - let o = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent)); + let o = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(accent)); elems.push(("voiceover.ok".into(), o.rect, "OK".into())); if o.clicked() { apply = true; @@ -329,7 +331,7 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { if app.playback.playing { app.stop(); } - app.ui.status = "Voice-over take discarded".into(); + app.ui.status = tl!("Voice-over take discarded").into(); close = true; } Err(error) => app.ui.status = error.to_string(), diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 31e3ea37e..69d529441 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -33,7 +33,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option match arg("name") { Some(n) => save_new(app, &n), None => { - app.ui.workspace_dialog = Some(WorkspaceDialog::SaveAs { name: "Untitled Workspace".into() }); + app.ui.workspace_dialog = Some(WorkspaceDialog::SaveAs { name: tl!("Untitled Workspace").into() }); Ok(json!({"dialog": "saveWorkspace"})) } }, @@ -161,74 +161,77 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut act: Option<(&str, Value)> = None; let names = dock::names(&app.workspaces); let changed: Vec = app.workspaces.saved.iter().map(|s| s.name.clone()).collect(); - let title = match &d { - WorkspaceDialog::SaveAs { .. } => "New Workspace", - WorkspaceDialog::Edit { .. } => "Edit Workspaces", + let (title, shown) = match &d { + WorkspaceDialog::SaveAs { .. } => ("New Workspace", tl!("New Workspace")), + WorkspaceDialog::Edit { .. } => ("Edit Workspaces", tl!("Edit Workspaces")), }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| match &mut d { - WorkspaceDialog::SaveAs { name } => { - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(name); - push("workspaces.save.name", &r, "Name"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push("workspaces.save.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push("workspaces.save.ok", &r, "OK"); - if r.clicked() { - act = Some(("window.workspace.saveAs", json!({"name": name.clone()}))); - } - }); - } - WorkspaceDialog::Edit { selected, name } => { - for (i, n) in names.iter().enumerate() { - let r = ui.selectable_label(selected.as_ref() == Some(n), n); - push(&format!("workspaces.edit.row.{i}"), &r, n); - if r.clicked() { - *selected = Some(n.clone()); - *name = n.clone(); - } + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( + ctx, + |ui| match &mut d { + WorkspaceDialog::SaveAs { name } => { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(name); + push("workspaces.save.name", &r, "Name"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push("workspaces.save.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push("workspaces.save.ok", &r, "OK"); + if r.clicked() { + act = Some(("window.workspace.saveAs", json!({"name": name.clone()}))); + } + }); } - ui.separator(); - let sel = selected.clone().filter(|s| names.contains(s)); - let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); - push("workspaces.edit.name", &r, "Name"); - let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new("Rename")); - push("workspaces.edit.rename", &r, "Rename"); - if r.clicked() - && let Some(s) = &sel - { - act = Some(("window.workspace.rename", json!({"from": s, "to": name.clone()}))); - *selected = Some(name.trim().to_string()); - } - }); - ui.horizontal(|ui| { - // a built-in workspace can't be deleted; its saved changes can - let restore = sel.as_deref().is_some_and(|s| is_builtin(s) && changed.iter().any(|c| c == s)); - let label = if restore { "Restore Original" } else { "Delete" }; - let r = ui.add_enabled(own || restore, egui::Button::new(label)); - push("workspaces.edit.delete", &r, label); - if r.clicked() - && let Some(s) = &sel - { - act = Some(("window.workspace.delete", json!({"name": s}))); - if own { - *selected = None; - name.clear(); + WorkspaceDialog::Edit { selected, name } => { + for (i, n) in names.iter().enumerate() { + let r = ui.selectable_label(selected.as_ref() == Some(n), crate::i18n::t(n)); + push(&format!("workspaces.edit.row.{i}"), &r, n); + if r.clicked() { + *selected = Some(n.clone()); + *name = n.clone(); } } - let r = ui.button("Close"); - push("workspaces.edit.close", &r, "Close"); - close |= r.clicked(); - }); - } - }); + ui.separator(); + let sel = selected.clone().filter(|s| names.contains(s)); + let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); + push("workspaces.edit.name", &r, "Name"); + let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new(tl!("Rename"))); + push("workspaces.edit.rename", &r, "Rename"); + if r.clicked() + && let Some(s) = &sel + { + act = Some(("window.workspace.rename", json!({"from": s, "to": name.clone()}))); + *selected = Some(name.trim().to_string()); + } + }); + ui.horizontal(|ui| { + // a built-in workspace can't be deleted; its saved changes can + let restore = sel.as_deref().is_some_and(|s| is_builtin(s) && changed.iter().any(|c| c == s)); + let label = if restore { "Restore Original" } else { "Delete" }; + let r = ui.add_enabled(own || restore, egui::Button::new(if restore { tl!("Restore Original") } else { tl!("Delete") })); + push("workspaces.edit.delete", &r, label); + if r.clicked() + && let Some(s) = &sel + { + act = Some(("window.workspace.delete", json!({"name": s}))); + if own { + *selected = None; + name.clear(); + } + } + let r = ui.button(tl!("Close")); + push("workspaces.edit.close", &r, "Close"); + close |= r.clicked(); + }); + } + }, + ); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/src/perf.rs b/crates/ui-egui/src/perf.rs index 79df3c52c..ce10ce8ef 100644 --- a/crates/ui-egui/src/perf.rs +++ b/crates/ui-egui/src/perf.rs @@ -31,6 +31,21 @@ pub fn stats(app: &FilmcraftApp) -> Value { frames["renderCostMs"] = json!(app.frames.render_cost() * 1e3); frames["cache"] = json!({"images": images, "imageMB": image_bytes as f64 / 1e6, "plans": plans, "planMB": plan_bytes as f64 / 1e6}); v["frames"] = frames; + v["gpu"] = app.gpu.as_ref().map_or(Value::Null, |g| { + json!({ + "uploadedBytes": g.compositor.uploaded_bytes, + "transitions": g.compositor.gpu_transitions, + "cpuTransitions": g.compositor.cpu_transitions, + "submitMs": g.last_ms, + }) + }); + // the Program monitor's picture against what is due: how far a drag or a scrub is ahead of it + v["monitor"] = json!({ + "frame": app.program_picture().map(|k| k.frame), + "revision": app.program_picture().map(|k| k.revision), + "projectRevision": app.session.revision, + "playheadFrame": app.session.active_sequence().map(|q| q.settings.frame_rate.frame_at(app.session.playhead())), + }); v["ui"] = json!({"fps": app.fps, "frameMs": if app.fps > 0.0 { 1000.0 / app.fps as f64 } else { 0.0 }}); v["process"] = json!({"cpuS": crate::frames::process_cpu_time().map(|d| d.as_secs_f64())}); v diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs new file mode 100644 index 000000000..9f8f93fe3 --- /dev/null +++ b/crates/ui-egui/src/scrub.rs @@ -0,0 +1,264 @@ +//! Audio during scrubbing (#211): Settings ▸ Audio ▸ "Play audio while scrubbing in Source and +//! Program Monitors" (`audio.scrubAudio`, Shift+S). +//! +//! While the playhead moves with a mouse button down (the Timeline ruler, a monitor's scrub bar, +//! a dragged timecode) and playback is stopped, a short grain of the sequence's sound plays from +//! the new position. One output stream stays open for the whole scrub: each move only re-aims it, +//! so the device is not reopened every frame. The stream closes when the button is released and +//! the grain has played, when the setting is off, or when playback starts. + +use std::sync::{Arc, Mutex, PoisonError}; + +use filmcraft_time::Tick; + +use crate::FilmcraftApp; + +/// Length of the grain played after each move. +pub const GRAIN_S: f64 = 0.08; +/// Fade in / out at the grain's ends, so it doesn't click. +const RAMP_S: f64 = 0.004; +/// How long the stream stays open after the last move once the button is up. +const LINGER_S: f64 = 0.25; + +/// What the audio callback plays next: `left` of `total` frames from `cursor`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Grain { + cursor: i64, + left: i64, + total: i64, +} + +#[derive(Default)] +pub struct ScrubAudio { + grain: Arc>, + /// The scrub stream is open. + open: bool, + last_tick: Option, + last_move: f64, +} + +impl ScrubAudio { + pub fn is_open(&self) -> bool { + self.open + } +} + +impl FilmcraftApp { + /// Once a frame: play a grain when the playhead moved under a held mouse button. + pub(crate) fn scrub_audio(&mut self, ctx: &egui::Context) { + let (now, down) = ctx.input(|i| (i.time, i.pointer.any_down())); + if self.scrub_tick(now, down) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(LINGER_S)); + } + } + + /// [`FilmcraftApp::scrub_audio`] with the clock and the button state given. Returns whether + /// the scrub stream is still open. + pub fn scrub_tick(&mut self, now: f64, down: bool) -> bool { + let ph = self.session.playhead(); + let prev = self.scrub.last_tick.replace(ph); + if self.playback.playing || self.source_playback.clock.playing { + // playback (Program or Source) owns the device: it stopped the scrub stream when it + // started, and the scrub must not stop it when the button is released + self.scrub.open = false; + return false; + } + let on = self.session.prefs.audio.scrub_audio; + if on && down && prev.is_some_and(|p| p != ph) { + self.scrub_grain(ph, now); + } + if self.scrub.open && (!on || (!down && now - self.scrub.last_move >= LINGER_S)) { + if let Some(a) = self.audio.as_mut() { + a.stop(); + } + self.scrub.open = false; + } + self.scrub.open + } + + /// Aim the scrub stream at `at` (opening it if needed) and play one grain. + fn scrub_grain(&mut self, at: Tick, now: f64) { + let Some(seq_id) = self.session.state.active_sequence else { return }; + let Some(a) = self.audio.as_ref() else { return }; + let sr = a.sample_rate(); + if sr == 0 { + return; + } + let total = ((GRAIN_S * sr as f64) as i64).max(1); + { + let mut g = self.scrub.grain.lock().unwrap_or_else(PoisonError::into_inner); + *g = Grain { cursor: at.to_units_floor(sr as i64), left: total, total }; + } + self.scrub.last_move = now; + if self.scrub.open { + return; + } + let mix = crate::playback_mix(&self.session, seq_id, sr); + let fill = grain_fill(mix, self.scrub.grain.clone(), ((RAMP_S * sr as f64) as i64).max(1)); + let Some(a) = self.audio.as_mut() else { return }; + a.stop(); + match a.start(fill) { + Ok(_) => self.scrub.open = true, + Err(e) => log::warn!("audio output unavailable for scrubbing: {e}"), + } + } +} + +/// The scrub stream's callback: the rest of the current grain (faded at its ends), then silence. +fn grain_fill(mut mix: impl FnMut(i64, &mut [f32], usize) + Send + 'static, grain: Arc>, ramp: i64) -> Box { + Box::new(move |buf: &mut [f32], ch: usize| { + let ch = ch.max(1); + let n = (buf.len() / ch) as i64; + let (g, take) = { + let mut g = grain.lock().unwrap_or_else(PoisonError::into_inner); + let now = *g; + let take = g.left.clamp(0, n); + g.cursor = g.cursor.saturating_add(take); + g.left -= take; + (now, take) + }; + buf.fill(0.0); + let Some(part) = buf.get_mut(..(take as usize).saturating_mul(ch)) else { return }; + if part.is_empty() { + return; + } + mix(g.cursor, part, ch); + let done = g.total - g.left; + for (i, frame) in part.chunks_mut(ch).enumerate() { + let pos = done + i as i64; + let gain = (pos.min(g.total - 1 - pos) as f32 / ramp as f32).clamp(0.0, 1.0); + frame.iter_mut().for_each(|s| *s *= gain); + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(fill: &mut Box, frames: usize) -> Vec { + let mut buf = vec![9.0; frames * 2]; + fill(&mut buf, 2); + buf + } + + #[test] + fn a_grain_plays_once_from_its_cursor_then_silence() { + let grain = Arc::new(Mutex::new(Grain { cursor: 1000, left: 300, total: 300 })); + let starts = Arc::new(Mutex::new(Vec::new())); + let seen = starts.clone(); + let mix = move |c: i64, b: &mut [f32], _: usize| { + seen.lock().unwrap().push(c); + b.fill(1.0); + }; + let mut fill = grain_fill(mix, grain.clone(), 10); + let a = run(&mut fill, 256); + let b = run(&mut fill, 256); + let c = run(&mut fill, 256); + assert_eq!(*starts.lock().unwrap(), vec![1000, 1256], "continues where it left off"); + assert_eq!(a[0], 0.0, "fades in"); + assert_eq!(a[200], 1.0); + assert!(b[..44 * 2].iter().all(|s| *s < 1.0 + f32::EPSILON) && b[43 * 2 + 1] < 0.2, "fades out at its end"); + assert!(b[44 * 2..].iter().all(|s| *s == 0.0), "then silence"); + assert!(c.iter().all(|s| *s == 0.0)); + // a new move re-aims it + *grain.lock().unwrap() = Grain { cursor: 5000, left: 300, total: 300 }; + run(&mut fill, 256); + assert_eq!(starts.lock().unwrap().last(), Some(&5000)); + } + + type Fill = Box; + + /// An output that keeps the callback it was started with. + struct Out { + fill: Arc>>, + } + + impl crate::AudioOut for Out { + fn start(&mut self, fill: Fill) -> Result { + *self.fill.lock().unwrap() = Some(fill); + Ok(48_000) + } + fn stop(&mut self) { + *self.fill.lock().unwrap() = None; + } + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + None + } + } + + fn app(scrub_audio: bool) -> (FilmcraftApp, Arc>>) { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + s.prefs.audio.scrub_audio = scrub_audio; + let mut app = FilmcraftApp::new(s); + let fill = Arc::new(Mutex::new(None)); + app.audio = Some(Box::new(Out { fill: fill.clone() })); + (app, fill) + } + + fn loudness(fill: &Arc>>) -> f32 { + let mut g = fill.lock().unwrap(); + let Some(f) = g.as_mut() else { return 0.0 }; + let mut buf = vec![0.0; 4096 * 2]; // more than a grain + f(&mut buf, 2); + buf.iter().map(|s| s.abs()).sum() + } + + /// #211: moving the playhead with the button down plays the sequence's sound at the new spot; + /// releasing the button closes the stream. + #[test] + fn scrubbing_plays_a_grain_and_stops_after_release() { + let (mut app, fill) = app(true); + let rate = app.session.sequence_rate(); + app.session.set_playhead(rate.tick_of(48)); + assert!(!app.scrub_tick(0.0, true), "pressing alone plays nothing"); + app.session.set_playhead(rate.tick_of(60)); + assert!(app.scrub_tick(0.02, true), "a move opens the stream"); + assert!(loudness(&fill) > 0.0, "the demo's music is heard"); + assert_eq!(loudness(&fill), 0.0, "one grain, then silence"); + app.session.set_playhead(rate.tick_of(72)); + assert!(app.scrub_tick(0.04, true)); + assert!(loudness(&fill) > 0.0, "the next move plays again on the same stream"); + assert!(app.scrub_tick(0.05, false), "lingers briefly after release"); + assert!(!app.scrub_tick(0.05 + LINGER_S, false), "then closes"); + assert!(fill.lock().unwrap().is_none()); + } + + #[test] + fn nothing_plays_with_the_setting_off_or_while_playing() { + let (mut app, fill) = app(false); + let rate = app.session.sequence_rate(); + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(!app.scrub_tick(0.02, true)); + assert!(fill.lock().unwrap().is_none()); + + let (mut app, _) = self::app(true); + app.playback.playing = true; + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(!app.scrub_tick(0.02, true), "playback owns the device"); + } + + /// Source monitor playback (added on main next to this feature) owns the device too: a scrub + /// that was still lingering when Source playback started must not stop its sound on release. + #[test] + fn source_playback_owns_the_device() { + let (mut app, fill) = app(true); + let rate = app.session.sequence_rate(); + app.scrub_tick(0.0, true); + app.session.set_playhead(rate.tick_of(60)); + assert!(app.scrub_tick(0.02, true), "a grain opened the scrub stream"); + // Source playback starts (it replaces the stream with its own) + app.source_playback.clock.playing = true; + let source_fill: Fill = Box::new(|buf: &mut [f32], _| buf.fill(0.5)); + *fill.lock().unwrap() = Some(source_fill); + assert!(!app.scrub_tick(0.05 + LINGER_S, false)); + assert!(fill.lock().unwrap().is_some(), "the Source playback stream is still playing"); + assert!(!app.scrub.is_open()); + } +} diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs new file mode 100644 index 000000000..494f9d08f --- /dev/null +++ b/crates/ui-egui/src/source_playback.rs @@ -0,0 +1,258 @@ +//! Source-file auditioning. Playback is presentation state: it never edits the project. + +use std::sync::{Arc, Mutex}; + +use filmcraft_engine::clip_ops::source_view; +use filmcraft_project::ItemId; +use filmcraft_render::SourceProvider; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::{AUDIO_STALL_S, FilmcraftApp, Playback}; + +#[derive(Default)] +pub struct SourcePlayback { + pub clock: Playback, + item: Option, + shown: Tick, + range_start: Option, + failure: Arc>>, +} + +impl FilmcraftApp { + pub fn toggle_source_play(&mut self) -> Result<(), String> { + if self.source_playback.clock.playing { + self.stop_source(); + Ok(()) + } else { + self.play_source() + } + } + + pub fn play_source(&mut self) -> Result<(), String> { + self.start_source_playback(None, None) + } + + pub fn play_source_range(&mut self, from_playhead: bool, preroll: bool) -> Result<(), String> { + let item = self.session.state.source_item.ok_or("Open a Source clip first")?; + let view = source_view(&self.session, item).ok_or("Source item is unavailable")?; + let range = view.selected_range(); + if range.duration.0 <= 0 { + return Err("Source marked range is empty".into()); + } + let seconds = |v: f64| Tick::from_seconds_f64(if v.is_finite() { v.clamp(0.0, 3600.0) } else { 0.0 }); + let start = if from_playhead { + self.session.state.source_playhead + } else if preroll { + Tick(range.start.0.saturating_sub(seconds(self.session.prefs.playback.preroll_seconds).0)).max(view.start) + } else { + range.start + }; + let stop = + if preroll { Tick(range.end().0.saturating_add(seconds(self.session.prefs.playback.postroll_seconds).0)).min(view.end) } else { range.end() }; + if start >= stop { + return Err("Source playhead is at or beyond the Out point".into()); + } + self.start_source_playback(Some(start), Some(stop)) + } + + fn start_source_playback(&mut self, start: Option, stop: Option) -> Result<(), String> { + let item = self.session.state.source_item.ok_or("Open a media file in the Source monitor first")?; + let view = source_view(&self.session, item).ok_or("Source item is unavailable")?; + let media = self.session.project.item(view.media).and_then(|i| i.as_media()).ok_or("Source playback currently supports media files and subclips")?; + if media.offline { + return Err("Source media is offline: use Link Media first".into()); + } + if view.end <= view.start { + return Err("Source clip has no playable duration".into()); + } + let looping = self.source_playback.clock.looping; + let range = view.selected_range(); + if looping && range.duration.0 <= 0 { + return Err("Source marked range is empty".into()); + } + let first = if looping { range.start } else { view.start }; + let end = stop.unwrap_or(if looping { range.end() } else { view.end }); + self.stop(); + self.stop_source(); + let mut time = start.unwrap_or(self.session.state.source_playhead); + if start.is_none() && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) { + time = first; + } + self.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string())?; + let time = self.session.state.source_playhead; + self.source_playback.range_start = start; + self.source_playback.item = Some(item); + self.source_playback.shown = time; + self.source_playback.clock = Playback { playing: true, speed: 1.0, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; + self.source_playback.clock.meter.start(1.0); + self.start_source_audio(); + Ok(()) + } + + pub fn stop_source(&mut self) { + if !self.source_playback.clock.playing { + return; + } + self.source_playback.clock.playing = false; + self.source_playback.clock.audio_clock = false; + self.source_playback.clock.meter.finish(); + self.frames.stop_prefetch(); + if let Some(audio) = self.audio.as_mut() { + audio.stop(); + } + } + + fn start_source_audio(&mut self) { + self.source_playback.clock.audio_clock = false; + let Some(item) = self.source_playback.item else { return }; + let Some(view) = source_view(&self.session, item) else { return }; + let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return }; + if !media.info.has_audio() { + return; + } + let project = self.session.project.clone(); + let provider = self.session.media.provider(project, self.session.services.clone()); + let Some(source) = provider.source(view.media) else { + self.ui.status = "Source audio is unavailable: check Link Media".into(); + return; + }; + let Some(audio) = self.audio.as_mut() else { return }; + let sr = audio.sample_rate(); + if sr == 0 || audio.channels() == 0 { + self.ui.status = "Source audio output has an invalid sample rate or channel count".into(); + return; + } + let map = [self.session.prefs.audio_hardware.map_left, self.session.prefs.audio_hardware.map_right]; + let mixdown = filmcraft_audio_dsp::channels::Mixdown::from_id(&self.session.prefs.audio.mixdown_type).unwrap_or_default(); + let channels = media.interpret.audio_channels.as_ref().and_then(|m| m.clips.first()).cloned().unwrap_or_default(); + // A seek gets its own failure slot: a late callback from the old stream cannot stop it. + self.source_playback.failure = Arc::new(Mutex::new(None)); + let failure = self.source_playback.failure.clone(); + let end = self + .source_playback + .clock + .stop_at + .unwrap_or_else(|| if self.source_playback.clock.looping { view.selected_range().end() } else { view.end }) + .to_units_floor(i64::from(sr)); + let mix = move |cursor: i64, buf: &mut [f32], ch: usize| { + buf.fill(0.0); + if ch == 0 || cursor >= end { + return; + } + let frames = (buf.len() / ch).min(usize::try_from(end.saturating_sub(cursor)).unwrap_or(0)); + match source.audio(cursor, frames, sr) { + Ok(mut sound) => { + if !channels.is_empty() { + sound.channels = channels.iter().filter_map(|c| sound.channels.get(usize::from(*c)).cloned()).collect(); + } + let sound = filmcraft_render::audio::to_layout(sound, filmcraft_audio_dsp::channels::Layout::Stereo, mixdown); + let Some(left) = sound.channels.first() else { return }; + let right = sound.channels.get(1).unwrap_or(left); + filmcraft_engine::settings::map_output(left, right, buf, ch, map); + } + Err(e) => { + *failure.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(format!("Source audio decoding failed: {e}")); + } + } + }; + audio.stop(); + let cursor = self.session.state.source_playhead.to_units_floor(i64::from(sr)); + #[cfg(not(target_arch = "wasm32"))] + let fill = crate::play_ahead::spawn(mix, cursor, sr, audio.channels(), self.source_playback.clock.audio_stats.clone()); + #[cfg(target_arch = "wasm32")] + let fill = { + let (mix, mut cursor) = (mix, cursor); + Box::new(move |buf: &mut [f32], ch: usize| { + mix(cursor, buf, ch); + cursor = cursor.saturating_add(i64::try_from(buf.len() / ch.max(1)).unwrap_or(0)); + }) + }; + match audio.start(fill) { + Ok(_) => self.source_playback.clock.audio_clock = true, + Err(e) => self.ui.status = format!("Source audio output failed: {e}"), + } + } + + pub(crate) fn advance_source_playback(&mut self, ctx: &egui::Context) { + if !self.source_playback.clock.playing { + return; + } + let item = self.source_playback.item; + let Some(view) = item.filter(|i| Some(*i) == self.session.state.source_item).and_then(|i| source_view(&self.session, i)) else { + self.stop_source(); + return; + }; + let error = self.source_playback.failure.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take(); + if let Some(error) = error { + self.stop_source(); + self.ui.status = error; + return; + } + let now = ctx.input(|i| i.time); + let time = self.session.state.source_playhead; + if time != self.source_playback.shown { + self.source_playback.clock.anchor_tick = time; + self.source_playback.clock.anchor_time = now; + self.source_playback.clock.audio_seen = (0, now); + self.start_source_audio(); + } + let clock = &mut self.source_playback.clock; + if clock.anchor_time < 0.0 { + clock.anchor_time = now; + clock.audio_seen = (0, now); + } + let reading = if clock.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|n| (n, a.sample_rate()))) } else { None }; + if let Some((frames, _)) = reading + && frames != clock.audio_seen.0 + { + clock.audio_seen = (frames, now); + } + if clock.audio_clock && (reading.is_none() || now - clock.audio_seen.1 >= AUDIO_STALL_S) { + clock.anchor_tick = time; + clock.anchor_time = now; + clock.audio_clock = false; + if let Some(audio) = self.audio.as_mut() { + audio.stop(); + } + self.ui.status = "Source audio output stopped responding: playing without sound (check Settings ▸ Audio Hardware)".into(); + } + let elapsed = match reading { + Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr), + _ => (now - clock.anchor_time).max(0.0), + }; + let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed).0)); + let range = view.selected_range(); + let end = clock.stop_at.unwrap_or_else(|| if clock.looping { range.end() } else { view.end }).min(view.end); + let ended = next >= end; + if ended && clock.looping { + let start = self.source_playback.range_start.unwrap_or(range.start).max(view.start); + if let Err(e) = self.session.execute("source.setPlayhead", json!({"time":start.0})) { + self.stop_source(); + self.ui.status = e.to_string(); + return; + } + let time = self.session.state.source_playhead; + self.source_playback.shown = time; + self.source_playback.clock.anchor_tick = time; + self.source_playback.clock.anchor_time = now; + self.source_playback.clock.audio_seen = (0, now); + self.start_source_audio(); + ctx.request_repaint(); + return; + } + let next = if ended { Tick(end.0.saturating_sub(view.rate.frame_duration().0)).max(view.start) } else { next.max(view.start) }; + match self.session.execute("source.setPlayhead", json!({"time": next.0})) { + Ok(_) => self.source_playback.shown = self.session.state.source_playhead, + Err(e) => { + self.stop_source(); + self.ui.status = e.to_string(); + return; + } + } + if ended { + self.stop_source(); + } + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a115bb53f..95d207f08 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -359,6 +359,15 @@ pub enum GuideDialog { }, } +/// The colour parameter an armed eyedropper fills (`effects.setParam` arguments). +#[derive(Clone, Debug, PartialEq)] +pub struct Eyedropper { + pub clip: u64, + pub effect: usize, + pub param: String, + pub mask: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UiState { #[serde(default)] @@ -384,11 +393,21 @@ pub struct UiState { /// Collapsed effect sections in Effect Controls ("clip:index"). pub collapsed_fx: Vec, pub show_menu_bar: bool, + /// The header bar (Home, Import, Edit, Export, workspaces). An app that embeds FilmCraft can hide it. + #[serde(default = "shown")] + pub show_header: bool, + /// The status bar (hints, messages, job progress). An app that embeds FilmCraft can hide it. + #[serde(default = "shown")] + pub show_status_bar: bool, pub dark: bool, /// Lumetri scopes visible in the Program monitor area. pub show_scopes: bool, /// Transient status line shown in the footer. pub status: String, + /// The colour parameter an armed eyedropper will fill with the next pixel clicked in the Program + /// monitor (Esc or a click elsewhere disarms it). Never saved. + #[serde(skip)] + pub eyedropper: Option, /// Essential Sound sub-tab: "Edit" or "Browse". #[serde(default)] pub essential_sound_tab: String, @@ -435,6 +454,9 @@ pub struct UiState { /// Delete Tracks dialog draft. #[serde(default)] pub delete_tracks: DeleteTracksDraft, + /// Sequence Settings dialog draft. + #[serde(default)] + pub sequence_settings: SequenceSettingsDraft, /// On-monitor text editing (Type tool / double-click on a text layer). #[serde(default)] pub gfx_edit: Option, @@ -738,6 +760,60 @@ impl Default for AddTracksDraft { } } +/// The Sequence Settings dialog (Sequence ▸ Sequence Settings…), filled from the active sequence +/// when it opens (`panels::sequence_settings::open`). `tab`: `general`, `color` or `vr`; the +/// timebase is `fps_num`/`fps_den`; `mix`: `Stereo`, `Mono`, `5.1` or `Adaptive`; `working_space`: +/// a [`filmcraft_color::WorkingSpace`] id. `scale_motion` is "Scale motion effects proportionally +/// when changing frame size", on by default as in Premiere. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct SequenceSettingsDraft { + pub tab: String, + /// The sequence's name (its Project panel item); a blank name keeps the current one. + pub name: String, + /// File ▸ New ▸ Sequence…: the dialog makes a new sequence (`file.newSequence`) with these + /// settings and `video_tracks` / `audio_tracks` tracks, instead of changing the active one. + pub new_sequence: bool, + pub video_tracks: u32, + pub audio_tracks: u32, + pub fps_num: i64, + pub fps_den: i64, + pub width: u32, + pub height: u32, + pub scale_motion: bool, + pub drop_frame: bool, + pub mix: String, + pub sample_rate: u32, + pub max_render_quality: bool, + pub working_space: String, + pub wide_gamut: bool, + pub auto_tone_map: bool, +} + +impl Default for SequenceSettingsDraft { + fn default() -> Self { + Self { + tab: "general".into(), + name: String::new(), + new_sequence: false, + video_tracks: 3, + audio_tracks: 3, + fps_num: 24_000, + fps_den: 1001, + width: 1920, + height: 1080, + scale_motion: true, + drop_frame: false, + mix: "Stereo".into(), + sample_rate: 48_000, + max_render_quality: false, + working_space: "rec709".into(), + wide_gamut: false, + auto_tone_map: true, + } + } +} + /// The Delete Tracks dialog (Sequence ▸ Delete Tracks…): per kind, whether to delete and which /// track (`"empty"` = All Empty Tracks, or a track name such as `"V2"`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -779,6 +855,10 @@ pub struct GfxEdit { pub anchor: usize, } +fn shown() -> bool { + true +} + fn captions_tab() -> String { "Captions".into() } @@ -806,9 +886,12 @@ impl Default for UiState { lumetri_grid_folder: None, collapsed_fx: vec![], show_menu_bar: true, + show_header: true, + show_status_bar: true, dark: true, show_scopes: false, status: String::new(), + eyedropper: None, essential_sound_tab: "Edit".into(), tts: Default::default(), export: Default::default(), @@ -824,6 +907,7 @@ impl Default for UiState { audio_gain: AudioGainDraft::default(), add_tracks: AddTracksDraft::default(), delete_tracks: DeleteTracksDraft::default(), + sequence_settings: SequenceSettingsDraft::default(), gfx_edit: None, pen_points: vec![], link_media: None, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4de498421..ae46ddba2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -75,6 +75,55 @@ pub struct Tokens { pub row_selected: Color32, /// Scrubby (hot-text) numeric value colour. pub hot_text: Color32, + /// Selected pills and compact toggle backgrounds. + pub pill_active_bg: Color32, + /// The selected clip bar in Effect Controls. + pub clip_bar_bg: Color32, + /// Raised audio control surfaces, such as mixer channel strips. + pub control_surface: Color32, + /// Recessed audio control tracks and panner wells. + pub control_well: Color32, + pub control_border: Color32, + pub control_handle_bg: Color32, + pub control_handle: Color32, + pub control_handle_dim: Color32, + /// Data-plot surfaces (keyframe, EQ and dynamics graphs), distinct from media canvases. + pub plot_bg: Color32, + pub plot_grid: Color32, + pub plot_axis: Color32, + pub plot_handle: Color32, + pub plot_handle_dim: Color32, + /// Meter wells and the separate loudness readout surface. + pub meter_bg: Color32, + pub meter_readout_bg: Color32, + pub meter_clip_off: Color32, + pub panner_bg: Color32, + pub panner_border: Color32, + pub panner_speaker: Color32, + pub fader_knob: Color32, + pub eq_track: Color32, + pub eq_tick: Color32, + pub eq_knob: Color32, + pub eq_knob_active: Color32, + pub eq_node: Color32, + pub eq_node_off: Color32, + pub crossover_handle: Color32, + pub keyframe_plot_bg: Color32, + pub keyframe_plot_grid: Color32, + pub keyframe_plot_axis: Color32, + pub keyframe_handle: Color32, + pub switch_fill: Color32, + pub switch_knob: Color32, + pub switch_border: Color32, + pub slider_disabled_track: Color32, + pub slider_disabled_knob: Color32, + pub button_hover: Color32, + pub button_border: Color32, + pub caption_track_bg: Color32, + pub scroll_thumb: Color32, + pub scroll_thumb_hover: Color32, + pub vertical_scroll_thumb: Color32, + pub disabled_clip_bg: Color32, // timeline pub tl_bg: Color32, pub tl_track_bg: Color32, @@ -125,6 +174,49 @@ impl Tokens { row_alt: Color32::from_rgb(0x21, 0x21, 0x21), row_selected: Color32::from_rgb(0x33, 0x33, 0x33), hot_text: Color32::from_rgb(0x40, 0x96, 0xf3), + pill_active_bg: Color32::from_rgb(0x3a, 0x3a, 0x3a), + clip_bar_bg: Color32::from_rgb(58, 58, 70), + control_surface: Color32::from_rgb(0x24, 0x24, 0x24), + control_well: Color32::from_rgb(0x10, 0x10, 0x10), + control_border: Color32::from_rgb(0x50, 0x50, 0x50), + control_handle_bg: Color32::from_rgb(0x2a, 0x2a, 0x2a), + control_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0), + control_handle_dim: Color32::from_rgb(0xb0, 0xb0, 0xb0), + plot_bg: Color32::from_rgb(0x18, 0x18, 0x18), + plot_grid: Color32::from_rgb(0x2c, 0x2c, 0x2c), + plot_axis: Color32::from_rgb(0x44, 0x44, 0x44), + plot_handle: Color32::from_rgb(0xe0, 0xe0, 0xe0), + plot_handle_dim: Color32::from_rgb(0x90, 0x90, 0x90), + meter_bg: Color32::BLACK, + meter_readout_bg: Color32::from_rgb(0x14, 0x14, 0x14), + meter_clip_off: Color32::from_rgb(0x30, 0x30, 0x30), + panner_bg: Color32::from_gray(22), + panner_border: Color32::from_gray(96), + panner_speaker: Color32::from_gray(144), + fader_knob: Color32::from_gray(90), + eq_track: Color32::from_gray(48), + eq_tick: Color32::from_gray(96), + eq_knob: Color32::from_gray(192), + eq_knob_active: Color32::from_gray(240), + eq_node: Color32::from_gray(232), + eq_node_off: Color32::from_gray(112), + crossover_handle: Color32::from_gray(255), + keyframe_plot_bg: Color32::from_gray(25), + keyframe_plot_grid: Color32::from_gray(42), + keyframe_plot_axis: Color32::from_gray(51), + keyframe_handle: Color32::from_gray(208), + switch_fill: Color32::from_gray(212), + switch_knob: Color32::from_gray(29), + switch_border: Color32::from_gray(138), + slider_disabled_track: Color32::from_gray(74), + slider_disabled_knob: Color32::from_gray(106), + button_hover: Color32::from_gray(42), + button_border: Color32::from_gray(75), + caption_track_bg: Color32::from_rgb(0x23, 0x20, 0x2a), + scroll_thumb: Color32::from_gray(0x4b), + scroll_thumb_hover: Color32::from_gray(0x6a), + vertical_scroll_thumb: Color32::from_gray(80), + disabled_clip_bg: Color32::from_gray(0x2a), tl_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d), tl_track_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d), tl_track_bg_alt: Color32::from_rgb(0x1d, 0x1d, 0x1d), @@ -182,6 +274,51 @@ impl Tokens { separator: Color32::from_rgb(200, 200, 200), row_alt: Color32::from_rgb(224, 224, 224), row_selected: Color32::from_rgb(170, 200, 240), + pill_active_bg: Color32::from_rgb(196, 196, 196), + clip_bar_bg: Color32::from_rgb(190, 203, 224), + control_surface: Color32::from_rgb(218, 218, 218), + control_well: Color32::from_rgb(196, 196, 196), + control_border: Color32::from_rgb(150, 150, 150), + control_handle_bg: Color32::from_rgb(205, 205, 205), + control_handle: Color32::from_rgb(50, 50, 50), + control_handle_dim: Color32::from_rgb(85, 85, 85), + plot_bg: Color32::from_rgb(245, 245, 245), + plot_grid: Color32::from_rgb(210, 210, 210), + plot_axis: Color32::from_rgb(170, 170, 170), + plot_handle: Color32::from_rgb(50, 50, 50), + plot_handle_dim: Color32::from_rgb(100, 100, 100), + meter_bg: Color32::from_rgb(205, 205, 205), + meter_readout_bg: Color32::from_rgb(218, 218, 218), + meter_clip_off: Color32::from_rgb(155, 155, 155), + panner_bg: Color32::from_gray(196), + panner_border: Color32::from_gray(150), + panner_speaker: Color32::from_gray(100), + fader_knob: Color32::from_gray(140), + eq_track: Color32::from_gray(196), + eq_tick: Color32::from_gray(150), + eq_knob: Color32::from_gray(85), + eq_knob_active: Color32::from_gray(34), + eq_node: Color32::from_gray(50), + eq_node_off: Color32::from_gray(140), + crossover_handle: Color32::from_gray(50), + keyframe_plot_bg: Color32::from_gray(245), + keyframe_plot_grid: Color32::from_gray(210), + keyframe_plot_axis: Color32::from_gray(170), + keyframe_handle: Color32::from_gray(50), + switch_fill: Color32::from_gray(60), + switch_knob: Color32::from_gray(232), + switch_border: Color32::from_gray(110), + slider_disabled_track: Color32::from_gray(190), + slider_disabled_knob: Color32::from_gray(140), + button_hover: Color32::from_gray(210), + button_border: Color32::from_gray(170), + icon_active: Color32::from_rgb(34, 34, 34), + caption_track_bg: Color32::from_rgb(222, 215, 233), + scroll_thumb: Color32::from_gray(150), + scroll_thumb_hover: Color32::from_gray(130), + vertical_scroll_thumb: Color32::from_gray(150), + disabled_clip_bg: Color32::from_gray(190), + in_out_shade: Color32::from_gray(185), tl_bg: Color32::from_rgb(210, 210, 210), tl_track_bg: Color32::from_rgb(222, 222, 222), tl_track_bg_alt: Color32::from_rgb(228, 228, 228), @@ -257,8 +394,8 @@ pub fn font_families() -> Vec { vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())] } -/// Install fonts (Inter, Inter SemiBold, JetBrains Mono, then the Japanese craft-fonts when built with -/// `CRAFT_FONTS_DIR`) and egui visuals. +/// Install the Latin UI fonts, Chinese fallback (craft-fonts or an installed face), Japanese +/// craft-fonts and egui visuals. Document names keep their glyphs in every interface language. pub fn install(ctx: &egui::Context, t: &Tokens) { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR))); @@ -269,7 +406,10 @@ pub fn install(ctx: &egui::Context, t: &Tokens) { fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); + // Japanese before Chinese: the two share code points, and Japanese text must keep Japanese + // glyph forms; Chinese faces still cover the hanzi Japanese fonts lack add_craft_fonts(&mut fonts); + crate::cjk::install(&mut fonts); ctx.set_fonts(fonts); apply_visuals(ctx, t); } @@ -359,6 +499,88 @@ mod tests { ctx } + #[test] + fn light_theme_has_light_chrome_and_plot_surfaces() { + let dark = Tokens::for_kind(ThemeKind::Dark); + let light = Tokens::for_kind(ThemeKind::Light); + let luminance = |c: Color32| u16::from(c.r()) + u16::from(c.g()) + u16::from(c.b()); + + for (name, dark_surface, light_surface) in [ + ("active pill", dark.pill_active_bg, light.pill_active_bg), + ("clip bar", dark.clip_bar_bg, light.clip_bar_bg), + ("control surface", dark.control_surface, light.control_surface), + ("control well", dark.control_well, light.control_well), + ("plot", dark.plot_bg, light.plot_bg), + ("keyframe plot", dark.keyframe_plot_bg, light.keyframe_plot_bg), + ("panner", dark.panner_bg, light.panner_bg), + ("caption lane", dark.caption_track_bg, light.caption_track_bg), + ("active toggle", dark.pressed, light.pressed), + ("meter", dark.meter_bg, light.meter_bg), + ("meter readout", dark.meter_readout_bg, light.meter_readout_bg), + ] { + assert!(luminance(light_surface) > luminance(dark_surface), "{name} did not adapt to Light"); + } + assert!(luminance(light.control_handle) < luminance(light.control_handle_bg)); + assert!(luminance(light.plot_handle) < luminance(light.plot_bg)); + assert!(luminance(light.icon_active) < luminance(light.pressed)); + assert_eq!(Tokens::for_kind(ThemeKind::Medium).plot_bg, dark.plot_bg, "Medium keeps the established dark plot treatment"); + } + + #[test] + fn dark_and_medium_keep_existing_audio_and_effect_palettes() { + for kind in [ThemeKind::Dark, ThemeKind::Medium] { + let t = Tokens::for_kind(kind); + // Existing painted colors, recorded independently of the Light palette. + assert_eq!(t.pill_active_bg, Color32::from_gray(0x3a)); + assert_eq!(t.clip_bar_bg, Color32::from_rgb(58, 58, 70)); + assert_eq!(t.control_surface, Color32::from_gray(0x24)); + assert_eq!(t.control_well, Color32::from_gray(0x10)); + assert_eq!(t.control_handle, Color32::from_gray(0xe0)); + assert_eq!(t.meter_bg, Color32::BLACK); + assert_eq!(t.plot_bg, Color32::from_gray(0x18)); + assert_eq!(t.plot_grid, Color32::from_gray(0x2c)); + assert_eq!(t.keyframe_plot_bg, Color32::from_gray(0x19)); + assert_eq!(t.keyframe_plot_grid, Color32::from_gray(0x2a)); + assert_eq!(t.keyframe_plot_axis, Color32::from_gray(0x33)); + assert_eq!(t.keyframe_handle, Color32::from_gray(0xd0)); + } + } + + /// Chinese media and track names must render even when the interface stays in English. + /// Compare the rasterized glyphs with the missing-glyph box, including custom weight families. + #[test] + fn chinese_names_render_in_every_theme_and_font_family() { + const CHINESE: &str = "中文旁白音乐声轨测试简体汉语繁體漢語车"; + filmcraft_text::fonts::scan_system(); + let available = filmcraft_text::fonts::all_faces() + .iter() + .any(|f| matches!(f.info.origin, "system" | filmcraft_text::fonts::CRAFT_ORIGIN) && CHINESE.chars().all(|c| f.has_char(c))); + if !available { + eprintln!("SKIPPED: no craft-fonts or installed Chinese font"); + return; + } + let ctx = egui::Context::default(); + for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + install(&ctx, &Tokens::for_kind(kind)); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + ctx.fonts_mut(|fonts| { + for family in font_families() { + let font = FontId::new(13.0, family); + let missing = fonts.layout_no_wrap("\u{fffd}".into(), font.clone(), Color32::WHITE); + let missing_uv = missing.rows[0].glyphs[0].uv_rect; + let names = fonts.layout_no_wrap(CHINESE.into(), font.clone(), Color32::WHITE); + assert_eq!(names.rows.iter().map(|r| r.glyphs.len()).sum::(), CHINESE.chars().count()); + for glyph in names.rows.iter().flat_map(|r| &r.glyphs) { + assert!(!glyph.uv_rect.is_nothing(), "empty {} in {font:?}", glyph.chr); + assert_ne!(glyph.uv_rect, missing_uv, "missing {} in {kind:?} / {font:?}", glyph.chr); + } + } + }); + } + // the layouts above put glyphs in the atlas: take that update so it isn't dropped unapplied + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + } + /// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic /// first), and Japanese text gets real glyphs, not the replacement box. #[test] @@ -373,7 +595,8 @@ mod tests { for (family, stack) in &defs.families { let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len()); assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); - assert!(stack[first].starts_with("craft:BIZ UDPGothic"), "{family:?}: {stack:?}"); + let first_japanese = stack.iter().find(|n| n.starts_with("craft:BIZ") || n.starts_with("craft:Shippori")); + assert!(first_japanese.is_some_and(|n| n.starts_with("craft:BIZ UDPGothic")), "{family:?}: {stack:?}"); } for family in [FontFamily::Proportional, FontFamily::Monospace] { let font = FontId::new(13.0, family); @@ -389,12 +612,11 @@ mod tests { assert!(galley.size().x > 13.0 * 4.0, "{:?}", galley.size()); } - /// Built without craft-fonts: the theme installs only the app's own fonts and Latin text - /// works as before. + /// Latin text keeps its UI faces, with or without craft-fonts and system Chinese fonts. #[test] fn works_without_craft_fonts() { let ctx = installed(); - let n = filmcraft_text::fonts::craft_japanese().count(); + let n = filmcraft_text::fonts::craft_japanese().count() + crate::cjk::craft_fonts().count(); ctx.fonts_mut(|fonts| { let defs = fonts.definitions().clone(); assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 26b5fe677..d57b01ebe 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,6 +1,7 @@ //! Small custom widgets in Premiere's visual language. use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use filmcraft_time::{FrameRate, Tick, parse_timecode}; use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -58,11 +59,108 @@ pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64 (resp, out) } -/// A large blue timecode readout (monitors/timeline). Click to type a new time. -pub fn timecode_label(ui: &mut Ui, rect: Rect, text: &str, size: f32, color: Color32, align: Align2) -> Response { - let resp = ui.interact(rect, ui.id().with(("tc", rect.min.x as i32, rect.min.y as i32)), Sense::click_and_drag()); - ui.painter().text(align.pos_in_rect(&rect), align, text, Tokens::mono(size), color); - resp +/// A large blue timecode readout (monitors/timeline) that moves its playhead: drag horizontally to +/// scrub (1 frame per point, Shift ×10), click to type a time (Enter or clicking away commits, +/// Escape cancels). Returns the time to go to (never before `min`), or why a typed time is invalid. +#[allow(clippy::too_many_arguments)] +pub fn timecode_field( + ui: &mut Ui, + id: egui::Id, + rect: Rect, + text: &str, + time: Tick, + min: Tick, + rate: FrameRate, + drop_frame: bool, + color: Color32, +) -> Option> { + let editing_id = id.with("editing"); + let edit_id = id.with("edit"); + let select_all_id = id.with("select-all"); + if let Some(mut buf) = ui.data(|d| d.get_temp::(editing_id)) { + // Left/Right on the whole selected value start from its last digit (egui would jump to the start) + let arrow = ui.input(|i| i.modifiers.is_none() && (i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::ArrowRight))); + if arrow && let Some(mut st) = egui::text_edit::TextEditState::load(ui.ctx(), edit_id) { + let len = buf.chars().count(); + let all = [egui::text::CCursor::new(0), egui::text::CCursor::new(len)]; + if len > 0 && st.cursor.char_range().is_some_and(|c| c.sorted_cursors() == all) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(len)))); + st.store(ui.ctx(), edit_id); + } + } + let r = ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())); + if r.lost_focus() { + ui.data_mut(|d| d.remove::(editing_id)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + return None; + } + let typed = parse_timecode(&buf, rate, drop_frame, rate.frame_at(time)); + return Some(typed.map(|f| rate.tick_of(f).max(min)).map_err(|e| e.to_string())); + } + // select the whole value once the field has focus: egui collapses a selection to a caret + // in a field that does not have it yet + let selected_now = r.has_focus() && ui.data_mut(|d| d.remove_temp::(select_all_id)).is_some(); + if selected_now { + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), edit_id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())))); + st.store(ui.ctx(), edit_id); + } + // the focus and the selection show on the next frame, which nothing else would trigger + if !r.has_focus() || selected_now { + ui.ctx().request_repaint(); + } + ui.data_mut(|d| d.insert_temp(editing_id, buf)); + // only until it has focus: `request_focus` resets the field's focus-lock filter, and the + // arrow keys would then move focus out of the field (committing it) instead of the caret + if !r.has_focus() { + r.request_focus(); + } + return None; + } + let resp = ui.interact(rect, id, Sense::click_and_drag()); + ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, text, Tokens::timecode(), color); + // (frame at the press, frames dragged so far, where the pointer was pressed) + let drag_id = id.with("drag"); + let mult = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 }; + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(rect.center()); + let now = resp.interact_pointer_pos().unwrap_or(origin); + // egui reports a drag only past its click threshold: count the movement before that, + // less this frame's delta, which the `dragged` branch below adds + let early = f64::from(now.x - origin.x - resp.drag_delta().x) * mult; + ui.data_mut(|d| d.insert_temp(drag_id, (rate.frame_at(time), early, origin))); + } + let mut out = None; + if resp.dragged() { + // hidden while scrubbing, put back where it was pressed on release (below) + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + if let Some((start, acc, origin)) = ui.data(|d| d.get_temp::<(i64, f64, egui::Pos2)>(drag_id)) { + // held at `min` so dragging back moves again at once + let acc = (acc + f64::from(resp.drag_delta().x) * mult).max((rate.frame_at(min) - start) as f64); + ui.data_mut(|d| d.insert_temp(drag_id, (start, acc, origin))); + let target = rate.tick_of(start.saturating_add(acc.trunc() as i64)).max(min); + if target != time { + out = Some(Ok(target)); + } + } + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if resp.drag_stopped() + && let Some((_, _, origin)) = ui.data_mut(|d| d.remove_temp::<(i64, f64, egui::Pos2)>(drag_id)) + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(origin)); + } + if resp.clicked() { + // the field asks for focus itself next frame: asking now, before it exists, leaves the + // accessibility tree focused on a node it does not have + ui.data_mut(|d| { + d.insert_temp(editing_id, text.to_string()); + d.insert_temp(select_all_id, true); + }); + ui.ctx().request_repaint(); + } + out } /// A twirl-down section header (Effect Controls / Lumetri style). Returns open state. diff --git a/crates/ui-egui/tests/chrome_ui.rs b/crates/ui-egui/tests/chrome_ui.rs new file mode 100644 index 000000000..c5efaea0d --- /dev/null +++ b/crates/ui-egui/tests/chrome_ui.rs @@ -0,0 +1,68 @@ +//! Headless UI tests of the window chrome flags: `UiState::show_header` and +//! `UiState::show_status_bar` default to on, and an app that embeds FilmCraft can turn them off +//! to give the panels the whole window. + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::state::UiState; +use serde_json::json; + +const W: f32 = 1600.0; +const H: f32 = 980.0; + +fn harness(show_header: bool, show_status_bar: bool) -> Harness<'static, FilmcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut app = FilmcraftApp::new(s); + app.ui.show_header = show_header; + app.ui.show_status_bar = show_status_bar; + let mut harness = Harness::builder().with_size(egui::vec2(W, H)).with_max_steps(10_000).build_eframe(move |_cc| app); + for _ in 0..4 { + harness.step(); + } + harness +} + +/// Top and bottom edge of every registered element outside the header. +fn body_extent(h: &Harness<'static, FilmcraftApp>) -> (f32, f32) { + let els = h.state().auto.query(""); + let body: Vec<_> = els.iter().filter(|e| !e.id.starts_with("header.")).collect(); + assert!(!body.is_empty(), "no panel elements registered"); + let top = body.iter().map(|e| e.rect[1]).fold(f32::INFINITY, f32::min); + let bottom = body.iter().map(|e| e.rect[1] + e.rect[3]).fold(f32::NEG_INFINITY, f32::max); + (top, bottom) +} + +#[test] +fn header_and_status_bar_are_shown_by_default() { + assert!(UiState::default().show_header); + assert!(UiState::default().show_status_bar); + let h = harness(true, true); + assert!(!h.state().auto.query("header.").is_empty()); + let (top, bottom) = body_extent(&h); + assert!(top >= 38.0, "panels start under the header: {top}"); + assert!(bottom <= H - 20.0, "panels end above the status bar: {bottom}"); + assert!(h.state().ui_error.is_none(), "{:?}", h.state().ui_error); +} + +#[test] +fn hidden_header_and_status_bar_give_the_panels_the_window() { + let h = harness(false, false); + assert!(h.state().auto.query("header.").is_empty(), "header drawn while hidden"); + let (top, bottom) = body_extent(&h); + assert!(top < 38.0, "panels start at the top of the window: {top}"); + assert!(bottom > H - 20.0, "panels reach the bottom of the window: {bottom}"); + assert!(h.state().ui_error.is_none(), "{:?}", h.state().ui_error); +} + +#[test] +fn saved_ui_state_without_the_flags_shows_the_chrome() { + let mut v = serde_json::to_value(UiState::default()).unwrap(); + let obj = v.as_object_mut().unwrap(); + assert!(obj.remove("show_header").is_some()); + assert!(obj.remove("show_status_bar").is_some()); + let ui: UiState = serde_json::from_value(v).unwrap(); + assert!(ui.show_header); + assert!(ui.show_status_bar); +} diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs new file mode 100644 index 000000000..3ca87a33b --- /dev/null +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -0,0 +1,236 @@ +//! Audio of video clips (#223): a selected video clip shows the Volume, Channel Volume and Panner +//! of the audio linked to it as well, as in Premiere, and the Volume line on audio clips in the +//! timeline can be dragged and keyframed. +//! +//! With `FILMCRAFT_UI_SHOTS=` the tests also render the UI with wgpu and write PNGs there. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_project::{ClipId, TrackItem}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +/// The demo's first clip: its video on V1 and the audio linked to it on A1. +struct Pair { + video: u64, + audio: u64, +} + +impl Driver { + fn demo(panels: &[&str]) -> (Self, Pair) { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + let seq = d.exec("sequence.inspect", json!({})); + let v = &seq["video"][0]["items"][0]; + let a = seq["audio"][0]["items"].as_array().unwrap().iter().find(|a| a["link"] == v["link"]).expect("linked audio").clone(); + let pair = Pair { video: v["clip"].as_u64().unwrap(), audio: a["clip"].as_u64().unwrap() }; + for panel in panels { + d.ok("ui.panel.show", json!({"panel": panel})); + } + d.frames(3); + (d, pair) + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn find(&mut self, id: &str) -> Option<[f64; 4]> { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?; + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + self.find(id).unwrap_or_else(|| panic!("no element {id}")) + } + fn click(&mut self, id: &str) { + self.rect(id); + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + fn item(&self, clip: u64) -> TrackItem { + self.harness.state().session.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone() + } + /// The clip's Volume level: its value and its keyframes (media time, dB). + fn level(&self, clip: u64) -> (f64, Vec<(i64, f64)>) { + let it = self.item(clip); + let p = it.effect("volume").unwrap().param("level").unwrap().clone(); + (p.value.as_f64().unwrap(), p.keyframes.iter().map(|k| (k.time.0, k.value.as_f64().unwrap())).collect()) + } + fn drag(&mut self, from: (f64, f64), by: (f64, f64)) { + self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + by.0, "y": from.1 + by.1}, "steps": 6})); + self.frames(3); + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + self.frames(10); + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn centre(r: [f64; 4]) -> (f64, f64) { + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +#[test] +fn effect_controls_show_the_audio_of_a_video_clip() { + let (mut d, pair) = Driver::demo(&["EffectControls"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + let motion = d.rect("effectControls.effect.motion"); + let volume = d.rect("effectControls.effect.volume"); + assert!(volume[1] > motion[1], "Audio comes after Video: {volume:?} {motion:?}"); + d.rect("effectControls.effect.channel_volume"); + d.rect("effectControls.effect.panner"); + + // the audio rows edit the audio clip + for fx in ["motion", "opacity", "time_remap"] { + d.click(&format!("effectControls.effect.{fx}")); + } + d.click("effectControls.volume.level.stopwatch"); + assert!(d.item(pair.audio).effect("volume").unwrap().param("level").unwrap().is_animated()); + assert!(d.item(pair.video).effect("volume").is_none()); + + // only the video selected: no audio + d.exec("sequence.linkedSelection", json!({"on": false})); + d.exec("timeline.select", json!({"clips": [pair.video]})); + assert!(d.find("effectControls.effect.volume").is_none()); + d.rect("effectControls.effect.motion"); +} + +#[test] +fn properties_show_the_audio_of_a_video_clip() { + let (mut d, pair) = Driver::demo(&["Properties"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + let scale = d.rect("properties.motion.scale.addKeyframe"); + let level = d.rect("properties.volume.level.addKeyframe"); + assert!(level[1] > scale[1]); + d.click("properties.volume.level.addKeyframe"); + assert_eq!(d.item(pair.audio).effect("volume").unwrap().param("level").unwrap().keyframes.len(), 1); + + d.exec("sequence.linkedSelection", json!({"on": false})); + d.exec("timeline.select", json!({"clips": [pair.audio]})); + d.rect("properties.volume.level.addKeyframe"); + assert!(d.find("properties.motion.scale.addKeyframe").is_none()); +} + +#[test] +fn dragging_the_volume_line_changes_the_level() { + let (mut d, pair) = Driver::demo(&[]); + let start = d.item(pair.audio).start; + let (before, _) = d.level(pair.audio); + d.shot("volume-line"); + let line = d.rect(&format!("timeline.clip.{}.volume", pair.audio)); + d.drag(centre(line), (0.0, -12.0)); + let (after, keys) = d.level(pair.audio); + assert!(after > before + 1.0, "{before} dB → {after} dB"); + assert!(keys.is_empty()); + assert_eq!(d.item(pair.audio).start, start, "the clip did not move"); + let moved = d.rect(&format!("timeline.clip.{}.volume", pair.audio)); + assert!(moved[1] < line[1] - 8.0, "the line went up with the pointer: {line:?} → {moved:?}"); + d.shot("volume-line-dragged"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).0, before, "the drag was one undo step"); +} + +/// The line belongs to the clip: a click on it selects the clip and a right-click opens the clip +/// menu, only a drag (or the Pen tool) edits the level. +#[test] +fn clicks_on_the_volume_line_still_reach_the_clip() { + let (mut d, pair) = Driver::demo(&[]); + let (x, y) = centre(d.rect(&format!("timeline.clip.{}.volume", pair.audio))); + assert_eq!(d.ok("ui.timeline.hit", json!({"x": x, "y": y}))["kind"], "volume"); + d.ok("ui.click", json!({"x": x, "y": y})); + d.frames(3); + assert!(d.harness.state().session.state.selection.contains(&ClipId(pair.audio))); + d.ok("ui.click", json!({"x": x, "y": y, "button": "right"})); + d.frames(3); + d.rect("timeline.clipMenu.clip.link"); + assert!(d.level(pair.audio).1.is_empty()); +} + +#[test] +fn volume_keyframes_on_the_timeline() { + let (mut d, pair) = Driver::demo(&[]); + let id = format!("timeline.clip.{}.volume", pair.audio); + let clip = d.rect(&format!("timeline.clip.{}", pair.audio)); + let (_, y) = centre(d.rect(&id)); + // the Pen tool adds keyframes on the line + d.ok("ui.set", json!({"tool": "Pen"})); + for f in [0.3, 0.7] { + d.ok("ui.click", json!({"x": clip[0] + clip[2] * f, "y": y})); + d.frames(3); + } + let (_, keys) = d.level(pair.audio); + assert_eq!(keys.len(), 2, "{keys:?}"); + d.ok("ui.set", json!({"tool": "Selection"})); + + // the line between them takes both down + d.drag((clip[0] + clip[2] * 0.5, y), (0.0, 10.0)); + let (_, low) = d.level(pair.audio); + assert_eq!(low.iter().map(|k| k.0).collect::>(), keys.iter().map(|k| k.0).collect::>()); + assert!(low[0].1 < keys[0].1 - 1.0 && (low[0].1 - low[1].1).abs() < 1e-9, "{keys:?} → {low:?}"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).1, keys); + + // a keyframe moves in time and level + let k = d.rect(&format!("{id}.kf.1")); + d.drag(centre(k), (40.0, -10.0)); + let (_, moved) = d.level(pair.audio); + assert_eq!(moved[0], keys[0]); + assert!(moved[1].0 > keys[1].0 && moved[1].1 > keys[1].1, "{keys:?} → {moved:?}"); + d.shot("volume-keyframes"); + d.exec("edit.undo", json!({})); + assert_eq!(d.level(pair.audio).1, keys, "the keyframe drag was one undo step"); + + // right-click ▸ Delete + d.ok("ui.click", json!({"id": format!("{id}.kf.0"), "button": "right"})); + d.frames(3); + d.click(&format!("{id}.kf.0.delete")); + assert_eq!(d.level(pair.audio).1, keys[1..]); +} diff --git a/crates/ui-egui/tests/edit_point_menu_ui.rs b/crates/ui-egui/tests/edit_point_menu_ui.rs new file mode 100644 index 000000000..d4e259b50 --- /dev/null +++ b/crates/ui-egui/tests/edit_point_menu_ui.rs @@ -0,0 +1,160 @@ +//! Headless UI test of the timeline's edit point menu (#219): right-clicking a cut between two +//! clips offers its trim types, Apply Default Transitions and Join Through Edits, as in Premiere; +//! right-clicking the body of a clip still opens the clip menu. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::{ClipId, Sequence}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new() -> Self { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + // the control channel's clicks and keys enter through the input hook + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) + } + + fn seq(&mut self) -> Sequence { + self.harness.state_mut().session.active_sequence().unwrap().clone() + } + + /// Right-click `clip` at `fx` of its width (0.0 = its In edge, 0.5 = its middle). + fn right_click(&mut self, clip: ClipId, fx: f64) { + self.ok("ui.click", json!({"id": format!("timeline.clip.{}", clip.0), "fx": fx, "button": "right"})); + self.frames(3); + } + + /// Two different clips on V1 that touch, with no transition on the cut (a cut, not a through + /// edit): (outgoing, incoming). + fn cut_on_v1(&mut self) -> (ClipId, ClipId) { + let seq = self.seq(); + let tr = &seq.video_tracks[0]; + let bare = |a: ClipId, b: ClipId| !tr.transitions.iter().any(|t| t.from == Some(a) || t.to == Some(b)); + tr.items + .windows(2) + .find(|w| w[0].end() == w[1].start && w[0].item != w[1].item && bare(w[0].id, w[1].id)) + .map(|w| (w[0].id, w[1].id)) + .expect("two touching clips on V1") + } +} + +#[test] +fn right_clicking_a_cut_opens_the_edit_point_menu() { + let mut d = Driver::new(); + let (a, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + for id in ["rippleIn", "rippleOut", "roll", "trimIn", "trimOut", "applyDefaultTransitions", "joinThroughEdits"] { + assert!(d.has(&format!("timeline.editPointMenu.{id}")), "{id}"); + } + assert!(!d.has("timeline.clipMenu.edit.cut"), "not the clip menu"); + let eps = d.harness.state_mut().session.state.edit_points.clone(); + assert_eq!(eps.len(), 1, "the cut is selected as an edit point"); + assert!((eps[0].clip == a && eps[0].out) || (eps[0].clip == b && !eps[0].out), "{eps:?}"); + + // Trim In: the incoming clip's In edge, as a regular trim + d.click("timeline.editPointMenu.trimIn"); + let ep = d.harness.state_mut().session.state.edit_points.clone(); + assert_eq!(ep.len(), 1); + assert_eq!((ep[0].clip, ep[0].out, serde_json::to_value(ep[0].kind).unwrap()), (b, false, json!("trim"))); + + // Apply Default Transitions puts a transition on the cut + d.right_click(b, 0.0); + let before = d.seq().video_tracks[0].transitions.len(); + d.click("timeline.editPointMenu.applyDefaultTransitions"); + assert_eq!(d.seq().video_tracks[0].transitions.len(), before + 1, "a transition was added"); +} + +#[test] +fn the_body_of_a_clip_still_opens_the_clip_menu() { + let mut d = Driver::new(); + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(3); + d.right_click(b, 0.5); + assert!(d.has("timeline.clipMenu.edit.cut"), "the clip menu"); + assert!(!d.has("timeline.editPointMenu.rippleIn"), "not the edit point menu"); + assert!(d.harness.state_mut().session.state.selection.contains(&b), "the clip is selected"); +} + +#[test] +fn join_through_edits_joins_only_the_right_clicked_cut() { + let mut d = Driver::new(); + let first = d.seq().video_tracks[0].items[0].clone(); + // two through edits inside the first clip on V1 + let secs = |t: i64| t as f64 / filmcraft_time::TICKS_PER_SECOND as f64; + let (start, len) = (secs(first.start.0), secs(first.duration.0)); + for f in [0.3, 0.6] { + d.exec("playhead.set", json!({"seconds": start + len * f})); + assert_eq!(d.exec("timeline.razor", json!({"track": "V1"}))["cuts"], json!(1)); + } + d.frames(2); + let n = d.seq().video_tracks[0].items.len(); + let pieces: Vec = d.seq().video_tracks[0].items.iter().take(3).map(|i| i.id).collect(); + // the cut between the 2nd and 3rd pieces + d.right_click(pieces[2], 0.0); + d.click("timeline.editPointMenu.joinThroughEdits"); + let items = d.seq().video_tracks[0].items.clone(); + assert_eq!(items.len(), n - 1, "one cut joined"); + assert_eq!((items[0].id, items[1].id), (pieces[0], pieces[1]), "the other through edit is still there"); + + // a cut between two different clips is not a through edit + let (_, b) = d.cut_on_v1(); + d.right_click(b, 0.0); + assert!(d.has("timeline.editPointMenu.joinThroughEdits")); + let before = d.seq().video_tracks[0].items.len(); + d.click("timeline.editPointMenu.joinThroughEdits"); + assert_eq!(d.seq().video_tracks[0].items.len(), before, "disabled: nothing joined"); +} diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/export_menu_ui.rs new file mode 100644 index 000000000..6fe109dff --- /dev/null +++ b/crates/ui-egui/tests/export_menu_ui.rs @@ -0,0 +1,204 @@ +//! Headless UI tests of File ▸ Export from the menus (#382): a menu entry carries no parameters, so +//! the entries whose command needs a `path` ask the host's save panel for it, and Media… opens the +//! Export mode like ⌘M, instead of failing with a missing `path`. + +use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Mutex}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::state::Mode; +use serde_json::{Value, json}; + +/// What the save panel was asked: filter label, extensions, suggested name. +type Asked = Arc, String)>>>; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// `save_to: None` is a cancelled save panel. + fn new(session: Session, save_to: Option, asked: Asked) -> Self { + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + app.hooks.pick_save_as = Some(Box::new(move |filter, exts, suggested| { + asked.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string())); + save_to.as_ref().map(|d| d.join(suggested).to_string_lossy().into_owned()) + })); + let mut builder = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { + builder = builder.wgpu(); + } + let harness = builder.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } +} + +/// The demo project (sequences, clips) with one clip selected in the Project panel. +fn session() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let clip = s.project.items.iter().find(|(_, it)| it.name.ends_with(".mp4")).map(|(id, _)| id.0).unwrap(); + s.execute("project.select", json!({"items": [clip]})).unwrap(); + s +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("fc-export-menu-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// Every File ▸ Export entry whose command needs a path, and the extension it saves. +const SAVED: [(&str, &str); 6] = [ + ("file.exportEdl", "edl"), + ("file.exportFcp7Xml", "xml"), + ("file.exportFcpxml", "fcpxml"), + ("file.exportOtio", "otio"), + ("file.exportAle", "ale"), + ("file.exportSelectionProject", "fcproj"), +]; + +#[test] +fn file_export_entries_ask_for_the_destination() { + let dir = tmp("save"); + let asked = Asked::default(); + let mut d = Driver::new(session(), Some(dir.clone()), asked.clone()); + let menu = d.ok("ui.menu.list", json!({})); + for (id, ext) in SAVED { + let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == id).unwrap_or_else(|| panic!("{id} missing from the menus")); + assert_eq!(entry["path"], json!(["File", "Export"]), "{id}"); + let r = d.ok("ui.menu.invoke", json!({"id": id})); + d.frames(2); + let (_, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap_or_else(|| panic!("{id} did not ask where to save")); + assert_eq!(exts, [ext], "{id}"); + assert!(suggested.ends_with(&format!(".{ext}")) && suggested.len() > ext.len() + 1, "{id}: {suggested}"); + let written = dir.join(&suggested); + assert!(written.metadata().is_ok_and(|m| m.len() > 0), "{id} wrote nothing at {} ({r})", written.display()); + } + assert_eq!(asked.lock().unwrap().len(), SAVED.len()); +} + +#[test] +fn cancelling_the_save_panel_writes_nothing_and_reports_no_error() { + let asked = Asked::default(); + let mut d = Driver::new(session(), None, asked.clone()); + for (id, _) in SAVED { + assert_eq!(d.ok("ui.menu.invoke", json!({"id": id})), Value::Null, "{id}"); + } + assert_eq!(asked.lock().unwrap().len(), SAVED.len()); + assert!(!d.harness.state().ui.status.contains("path"), "{}", d.harness.state().ui.status); +} + +#[test] +fn media_from_the_menu_opens_the_export_mode() { + let mut d = Driver::new(session(), None, Asked::default()); + assert_eq!(d.harness.state().ui.mode, Mode::Edit); + d.ok("ui.menu.invoke", json!({"id": "file.exportMedia"})); + d.frames(2); + assert_eq!(d.harness.state().ui.mode, Mode::Export); + // with a path it still exports directly, as scripts and the CLI use it + let out = tmp("media").join("direct.wav"); + let r = d.ok("ui.menu.invoke", json!({"id": "file.exportMedia", "params": {"path": out.to_string_lossy(), "format": "wav", "wait": true}})); + assert!(out.metadata().is_ok_and(|m| m.len() > 0), "{r}"); +} + +#[test] +fn file_export_entries_need_an_open_sequence() { + let mut d = Driver::new(Session::default(), Some(tmp("noseq")), Asked::default()); + for id in ["file.exportMedia", "file.exportEdl", "file.exportFcp7Xml", "file.exportFcpxml", "file.exportOtio"] { + assert_eq!(d.call("ui.menu.invoke", json!({"id": id}))["ok"], json!(false), "{id}"); + assert_eq!(d.harness.state().ui.mode, Mode::Edit, "{id}"); + } +} + +#[test] +fn marker_csv_menu_exports_sequence_review_notes() { + let dir = tmp("markers"); + let asked = Asked::default(); + let mut s = session(); + s.execute("markers.clearAll", json!({"target":"program"})).unwrap(); + s.execute("markers.add", json!({"target":"program", "frame":24, "name":"Review, 日本語", "comment":"Change \"this\"\nNext line"})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let mut d = Driver::new(s, Some(dir.clone()), asked.clone()); + let menu = d.ok("ui.menu.list", json!({})); + let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == "markers.exportCsv").unwrap(); + assert_eq!(entry["path"], json!(["Markers"])); + assert_eq!(entry["enabled"], true); + d.ok("ui.click", json!({"id":"menu.Markers"})); + d.frames(3); + let elements = d.ok("ui.elements", json!({"prefix":"menu.markers.exportCsv"})); + assert!(!elements.as_array().unwrap().is_empty(), "the menu command has an automation id"); + if let Some(dir) = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + d.harness.render().unwrap().save(dir.join("marker-csv-menu.png")).unwrap(); + } + d.ok("ui.click", json!({"id":"menu.markers.exportCsv"})); + d.frames(3); + let (filter, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap(); + assert_eq!(filter, "Marker report (CSV)"); + assert_eq!(exts, ["csv"]); + assert!(suggested.ends_with(".csv")); + let bytes = std::fs::read(dir.join(suggested)).unwrap(); + let csv = String::from_utf8(bytes).unwrap(); + assert!(csv.contains("\"Review, 日本語\",\"Change \"\"this\"\"\nNext line\"")); + assert_eq!(csv.matches("\"Review, 日本語\"").count(), 1); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(d.harness.state().session.history.undo.len(), undo); + assert!(d.harness.state().ui.status.starts_with("Exported ")); + let direct = dir.join("direct, 字幕.csv"); + d.ok("engine.execute", json!({"command":"markers.exportCsv", "params":{"path":direct.to_string_lossy()}})); + assert_eq!(std::fs::read(direct).unwrap(), csv.as_bytes()); + assert_eq!(asked.lock().unwrap().len(), 1); +} + +#[test] +fn marker_csv_save_cancel_and_disabled_menu_leave_the_session_alone() { + let asked = Asked::default(); + let mut d = Driver::new(session(), None, asked.clone()); + let before = d.harness.state().session.project.clone(); + assert_eq!(d.ok("ui.menu.invoke", json!({"id":"markers.exportCsv"})), Value::Null); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(asked.lock().unwrap().len(), 1); + let asked = Asked::default(); + let mut d = Driver::new(Session::default(), None, asked.clone()); + assert_eq!(d.call("ui.menu.invoke", json!({"id":"markers.exportCsv"}))["ok"], false); + assert!(asked.lock().unwrap().is_empty(), "disabled exports must not open a save dialog"); +} diff --git a/crates/ui-egui/tests/frame_export_ui.rs b/crates/ui-egui/tests/frame_export_ui.rs new file mode 100644 index 000000000..a7b9df601 --- /dev/null +++ b/crates/ui-egui/tests/frame_export_ui.rs @@ -0,0 +1,295 @@ +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::{FrameRate, Tick}; +use filmcraft_ui_egui::{FilmcraftApp, dock::PanelKind, menus}; +use serde_json::json; +use std::sync::{Arc, Mutex}; + +fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); + h.step(); +} +fn harness() -> Harness<'static, FilmcraftApp> { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name":"Program clip","width":128,"height":72,"fps":24})).unwrap(); + let item = filmcraft_engine::demo::add_generator( + Arc::make_mut(&mut s.project), + &s.media, + filmcraft_media::generators::GeneratorSource::new( + filmcraft_media::Generator::Demo(filmcraft_media::DemoScene::Aurora), + 128, + 72, + FrameRate::FPS_24, + Tick::from_seconds_f64(5.0), + ), + "Source clip", + filmcraft_project::Label::Iris, + None, + ); + s.execute("source.open", json!({"item":item.0})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(s)); + for _ in 0..3 { + step(&mut h); + } + h +} +fn click(h: &mut Harness<'static, FilmcraftApp>, id: &str) { + fn center(h: &Harness<'static, FilmcraftApp>, id: &str) -> egui::Pos2 { + let e = h.state().auto.elements.iter().find(|e| e.id == id).or_else(|| h.state().auto.find(id)).unwrap_or_else(|| { + panic!( + "missing {id}; available {:?}", + h.state().auto.elements.iter().filter(|e| e.id.starts_with("exportFrame")).map(|e| (&e.id, &e.rect, &e.label)).collect::>() + ) + }); + let [x, y, w, height] = e.rect; + egui::pos2(x + w / 2.0, y + height / 2.0) + } + step(h); + let pos = center(h, id); + h.input_mut().events.push(egui::Event::PointerMoved(pos)); + step(h); + let pos = center(h, id); + h.input_mut().events.push(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + step(h); + } + step(h); +} +fn has(h: &Harness<'static, FilmcraftApp>, id: &str) -> bool { + h.state().auto.elements.iter().any(|e| e.id == id) +} + +#[test] +fn both_camera_buttons_open_the_dialog_and_cancel_writes_nothing() { + for monitor in ["source", "program"] { + let mut h = harness(); + let saves = Arc::new(Mutex::new(0)); + let saved = saves.clone(); + h.state_mut().hooks.pick_save_as = Some(Box::new(move |_, _, _| { + *saved.lock().unwrap() += 1; + None + })); + click(&mut h, &format!("{monitor}.transport.exportFrame")); + assert!(has(&h, "exportFrame.name"), "{monitor}: {}", h.state().ui.status); + let top = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.close").unwrap().rect[1]; + let bottom = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.export").unwrap().rect[1]; + assert!(top >= 0.0 && bottom - top < 350.0, "dialog must stay compact and on screen: {top}..{bottom}"); + click(&mut h, "exportFrame.cancel"); + step(&mut h); + assert!(!has(&h, "exportFrame.name")); + assert_eq!(*saves.lock().unwrap(), 0); + } +} + +#[test] +fn program_export_ignores_source_focus_and_imports_the_saved_still() { + let mut h = harness(); + h.state_mut().ui.focused = PanelKind::Source; + let folder = std::env::temp_dir().join(format!("fc-program-frame-ui-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Program clip.bmp"); + let out = folder.clone(); + h.state_mut().hooks.pick_folder = Some(Box::new(move || Some(out.to_string_lossy().into_owned()))); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "file.exportFrame", json!({"monitor":"program"})).unwrap(); + step(&mut h); + let before = h.state().session.project.items.len(); + click(&mut h, "exportFrame.format"); + click(&mut h, "exportFrame.format.bmp"); + click(&mut h, "exportFrame.browse"); + click(&mut h, "exportFrame.import"); + click(&mut h, "exportFrame.export"); + assert!(path.exists(), "{}", h.state().ui.status); + assert_eq!(h.state().session.project.items.len(), before + 1); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(i32::from_le_bytes(bytes[18..22].try_into().unwrap()), 128); + assert_eq!(i32::from_le_bytes(bytes[22..26].try_into().unwrap()).abs(), 72); + assert!(!has(&h, "exportFrame.name")); + drop(h); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(folder).unwrap(); +} + +#[test] +fn focused_source_command_captures_the_frame_and_save_cancel_keeps_the_dialog() { + let mut h = harness(); + h.state_mut().ui.focused = PanelKind::Source; + h.state_mut().session.state.source_playhead = FrameRate::FPS_24.tick_of(12); + let expected_path = std::env::temp_dir().join(format!("fc-source-frame-ui-{}.expected.png", std::process::id())); + h.state_mut().session.execute("file.exportFrame", json!({"target":"source","path":expected_path.to_string_lossy()})).unwrap(); + let expected = std::fs::read(&expected_path).unwrap(); + std::fs::remove_file(expected_path).unwrap(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "file.exportFrame", json!({})).unwrap(); + step(&mut h); + h.state_mut().session.state.source_playhead = Tick::ZERO; + h.state_mut().hooks.pick_folder = Some(Box::new(|| None)); + click(&mut h, "exportFrame.browse"); + assert!(has(&h, "exportFrame.name")); + let folder = std::env::temp_dir().join(format!("fc-source-frame-ui-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Source clip.png"); + let out = folder.clone(); + h.state_mut().hooks.pick_folder = Some(Box::new(move || Some(out.to_string_lossy().into_owned()))); + click(&mut h, "exportFrame.browse"); + click(&mut h, "exportFrame.export"); + assert!(path.exists(), "{}", h.state().ui.status); + assert_eq!(h.state().session.state.source_playhead, Tick::ZERO); + assert_eq!(std::fs::read(&path).unwrap(), expected, "the dialog must export its captured frame"); + drop(h); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(folder).unwrap(); +} + +#[test] +fn depth_dropdown_is_format_aware_and_existing_files_require_confirmation() { + let mut h = harness(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "file.exportFrame", json!({"monitor":"source"})).unwrap(); + step(&mut h); + click(&mut h, "exportFrame.depth"); + click(&mut h, "exportFrame.depth.16"); + let folder = std::env::temp_dir().join(format!("fc-frame-depth-ui-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let path = folder.join("Source clip.png"); + std::fs::write(&path, b"keep me").unwrap(); + let out = folder.clone(); + h.state_mut().hooks.pick_folder = Some(Box::new(move || Some(out.to_string_lossy().into_owned()))); + click(&mut h, "exportFrame.browse"); + click(&mut h, "exportFrame.export"); + assert!(has(&h, "exportFrame.replace")); + assert_eq!(std::fs::read(&path).unwrap(), b"keep me"); + click(&mut h, "exportFrame.replace"); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes[24], 16); + menus::invoke(h.state_mut(), &ctx, "file.exportFrame", json!({"monitor":"source"})).unwrap(); + step(&mut h); + click(&mut h, "exportFrame.format"); + click(&mut h, "exportFrame.format.jpg"); + click(&mut h, "exportFrame.depth"); + click(&mut h, "exportFrame.depth.16"); + assert!(h.state().auto.elements.iter().any(|e| e.id == "exportFrame.depth" && e.label == "8 Bit")); + drop(h); + std::fs::remove_file(path).unwrap(); + std::fs::remove_dir(folder).unwrap(); +} + +#[test] +fn white_header_moves_both_dialogs_and_controls_still_work() { + for monitor in ["source", "program"] { + let mut h = harness(); + click(&mut h, &format!("{monitor}.transport.exportFrame")); + step(&mut h); + let header = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.header").unwrap().rect; + let name = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.name").unwrap().label.clone(); + assert_eq!(name, if monitor == "source" { "Source clip" } else { "Program clip" }); + let from = egui::pos2(header[0] + header[2] / 2.0, header[1] + header[3] / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(from)); + step(&mut h); + h.input_mut().events.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + step(&mut h); + for i in 1..=8 { + let offset = egui::vec2(15.0 * i as f32, 7.5 * i as f32); + h.input_mut().events.push(egui::Event::PointerMoved(from + offset)); + step(&mut h); + } + let to = from + egui::vec2(120.0, 60.0); + h.input_mut().events.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + step(&mut h); + step(&mut h); + let moved = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.header").unwrap().rect; + assert!((moved[0] - header[0] - 120.0).abs() < 2.0 && (moved[1] - header[1] - 60.0).abs() < 2.0, "{monitor}: {header:?} -> {moved:?}"); + assert_eq!(h.state().auto.elements.iter().find(|e| e.id == "exportFrame.name").unwrap().label, name); + click(&mut h, "exportFrame.format"); + click(&mut h, "exportFrame.format.bmp"); + assert_eq!(h.state().auto.elements.iter().find(|e| e.id == "exportFrame.format").unwrap().label, "BMP"); + click(&mut h, "exportFrame.close"); + assert!(!has(&h, "exportFrame.name")); + } +} + +#[test] +fn exported_frame_timecode_matches_each_monitor_and_stays_captured() { + for (rate, drop_frame) in [ + (FrameRate::FPS_24, false), + (FrameRate::FPS_25, false), + (FrameRate::FPS_23_976, false), + (FrameRate::FPS_29_97, false), + (FrameRate::FPS_29_97, true), + (FrameRate::FPS_59_94, true), + ] { + let mut h = harness(); + let seq = h.state().session.state.active_sequence.unwrap(); + let settings = &mut Arc::make_mut(&mut h.state_mut().session.project).sequence_mut(seq).unwrap().settings; + settings.frame_rate = rate; + settings.drop_frame = drop_frame; + h.state_mut().session.set_playhead(rate.tick_of(rate.timecode_base() * 65 + 4)); + h.state_mut().session.state.source_playhead = FrameRate::FPS_24.tick_of(67); + step(&mut h); + for monitor in ["source", "program"] { + let expected = h.state().auto.elements.iter().find(|e| e.id == format!("{monitor}.timecode")).unwrap().label.clone(); + click(&mut h, &format!("{monitor}.transport.exportFrame")); + let displayed = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.timecode").unwrap().label.clone(); + assert_eq!(displayed, expected, "{monitor}, {rate:?}, drop-frame {drop_frame}"); + if monitor == "source" { + assert_eq!(displayed, "00:00:02:19"); + h.state_mut().session.state.source_playhead = Tick::ZERO; + } else { + h.state_mut().session.set_playhead(Tick::ZERO); + } + step(&mut h); + assert_eq!( + h.state().auto.elements.iter().find(|e| e.id == "exportFrame.timecode").unwrap().label, + displayed, + "captured time must remain fixed after either playhead moves" + ); + click(&mut h, "exportFrame.close"); + } + } +} + +#[test] +fn clicking_outside_dismisses_without_export_and_dropdowns_stay_open() { + for monitor in ["source", "program"] { + let mut h = harness(); + let count = h.state().session.project.items.len(); + click(&mut h, &format!("{monitor}.transport.exportFrame")); + click(&mut h, "exportFrame.format"); + assert!(has(&h, "exportFrame.name")); + click(&mut h, "exportFrame.format.tiff"); + click(&mut h, "exportFrame.depth"); + click(&mut h, "exportFrame.depth.16"); + assert!(has(&h, "exportFrame.name")); + assert_eq!(h.state().auto.elements.iter().find(|e| e.id == "exportFrame.depth").unwrap().label, "16 Bit"); + click(&mut h, "header.mode.export"); + assert!(!has(&h, "exportFrame.name")); + assert_eq!(h.state().ui.mode, filmcraft_ui_egui::state::Mode::Export); + assert_eq!(h.state().session.project.items.len(), count); + } + let mut h = harness(); + click(&mut h, "source.transport.exportFrame"); + let pos = egui::pos2(150.0, 180.0); + h.input_mut().events.push(egui::Event::PointerMoved(pos)); + step(&mut h); + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + step(&mut h); + } + step(&mut h); + assert!(!has(&h, "exportFrame.name"), "clicking the background monitor must cancel the draft"); +} diff --git a/crates/ui-egui/tests/keyboard_ui.rs b/crates/ui-egui/tests/keyboard_ui.rs index 67020b7fb..036606320 100644 --- a/crates/ui-egui/tests/keyboard_ui.rs +++ b/crates/ui-egui/tests/keyboard_ui.rs @@ -179,3 +179,62 @@ fn new_keys_work_in_the_app() { d.key("Cmd+Shift+0"); assert_eq!(d.app().ui.program.zoom, None); } + +/// Off macOS the menus show `Ctrl+…`, so that is what agents send: a `Ctrl` chord through +/// `ui.key` (and `ctrl` in `ui.click` modifiers) must act like the physical key, which egui-winit +/// reports as `ctrl` + `command`. Bindings are written `Cmd+…`, so this failed to match (#245). +#[cfg(not(target_os = "macos"))] +#[test] +fn ctrl_chords_fire_shortcuts_off_macos() { + let mut d = Driver::new(); + let seq = d.exec("sequence.inspect", json!({})); + let second = &seq["video"][0]["items"].as_array().unwrap()[1]; + let (clip, start) = (second["clip"].as_u64().unwrap(), second["start"].as_i64().unwrap()); + let start_of = |d: &mut Driver| { + d.exec("sequence.inspect", json!({}))["video"][0]["items"] + .as_array() + .unwrap() + .iter() + .find(|i| i["clip"].as_u64() == Some(clip)) + .map(|i| i["start"].as_i64().unwrap()) + .unwrap() + }; + d.exec("playhead.set", json!({"time": start + 1_000_000_000})); + d.focus("Timeline"); + d.key("D"); + assert!(d.app().session.state.selection.iter().any(|c| c.0 == clip)); + // Ctrl+Right nudges one frame right, like Cmd+Right; Ctrl+Z undoes it + let rate = d.app().session.sequence_rate(); + d.key("Ctrl+Right"); + assert_eq!(start_of(&mut d), start + rate.tick_of(1).0, "Ctrl+Right nudges"); + d.key("Ctrl+Z"); + assert_eq!(start_of(&mut d), start, "Ctrl+Z undoes"); + // the same chord spelt with the modifier flag + d.ok("ui.key", json!({"key": "Right", "ctrl": true})); + d.frames(3); + assert_eq!(start_of(&mut d), start + rate.tick_of(1).0, "`ctrl: true` nudges"); + d.ok("ui.key", json!({"key": "Z", "ctrl": true})); + d.frames(3); + assert_eq!(start_of(&mut d), start, "`ctrl: true` undoes"); + // and the bare key still does not + d.key("Z"); + assert_eq!(start_of(&mut d), start); +} + +#[test] +fn delete_key_clears_the_selected_timeline_clip() { + // #243: on Windows and Linux keyboards Delete is the forward-delete key; with the Timeline + // focused it ran Project ▸ Clear (nothing selected there), so the clip stayed. + let mut d = Driver::new(); + let items = |d: &mut Driver| d.exec("sequence.inspect", json!({}))["video"][0]["items"].as_array().unwrap().clone(); + let second = items(&mut d)[1].clone(); + let (clip, start) = (second["clip"].as_u64().unwrap(), second["start"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + 1_000_000_000})); + d.focus("Timeline"); + d.key("D"); + assert!(d.app().session.state.selection.iter().any(|c| c.0 == clip), "D selects the clip under the playhead"); + d.key("Delete"); + assert!(!items(&mut d).iter().any(|i| i["clip"].as_u64() == Some(clip)), "Delete removed the selected clip"); + d.key("Cmd+Z"); + assert!(items(&mut d).iter().any(|i| i["clip"].as_u64() == Some(clip)), "and undo brings it back"); +} diff --git a/crates/ui-egui/tests/keyframes_ui.rs b/crates/ui-egui/tests/keyframes_ui.rs new file mode 100644 index 000000000..630a8ceae --- /dev/null +++ b/crates/ui-egui/tests/keyframes_ui.rs @@ -0,0 +1,227 @@ +//! Keyframes in the Properties and Effect Controls panels, driven headless through the control +//! channel: the Properties panel's keyframe navigator (◀ ◆ ▶) adds and removes the keyframe at the +//! playhead and steps between keyframes, as Premiere's does, and the Effect Controls time ruler +//! moves the playhead (click, or drag the playhead's handle). +//! +//! With `FILMCRAFT_UI_SHOTS=` the tests also render the UI with wgpu and write PNGs there. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +impl Driver { + /// The demo project with its first video clip selected and both panels showing. + fn demo() -> (Self, Clip) { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + let seq = d.exec("sequence.inspect", json!({})); + let c = &seq["video"][0]["items"][0]; + let clip = Clip { id: c["clip"].as_u64().unwrap(), start: c["start"].as_i64().unwrap(), duration: c["duration"].as_i64().unwrap() }; + d.exec("timeline.select", json!({"clips": [clip.id]})); + for panel in ["EffectControls", "Properties"] { + d.ok("ui.panel.show", json!({"panel": panel})); + } + d.frames(3); + (d, clip) + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + /// The element's rect `[x, y, w, h]`, or `None` when it is not on screen. + fn find(&mut self, id: &str) -> Option<[f64; 4]> { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id)?; + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + self.find(id).unwrap_or_else(|| panic!("no element {id}")) + } + fn click(&mut self, id: &str) { + self.rect(id); + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + fn playhead(&mut self) -> i64 { + self.exec("sequence.inspect", json!({}))["playhead"].as_i64().unwrap() + } + fn seek(&mut self, seconds: f64) -> i64 { + self.exec("playhead.set", json!({"seconds": seconds})); + self.frames(2); + self.playhead() + } + /// The clip's Motion ▸ Scale: its keyframe times (media time) and its value at the playhead. + fn scale(&mut self, clip: &Clip) -> (Vec, f64) { + let s = &self.harness.state().session; + let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1; + let p = it.effect("motion").unwrap().param("scale").unwrap(); + (p.keyframes.iter().map(|k| k.time.0).collect(), p.f64_at(it.source_time_at(s.playhead()))) + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + for _ in 0..40 { + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +struct Clip { + id: u64, + start: i64, + duration: i64, +} + +const SCALE: &str = "properties.motion.scale"; + +/// The reported case: two Scale keyframes, the second set to 50 %. The Properties diamond removes +/// only the keyframe at the playhead, so Scale is 100 % again there (both panels read the same +/// parameter); it used to switch animation off and keep 50 %. +#[test] +fn properties_diamond_adds_and_removes_the_keyframe_at_the_playhead() { + let (mut d, clip) = Driver::demo(); + // not animated: the diamond alone + d.rect(&format!("{SCALE}.addKeyframe")); + assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none(), "no arrows before the parameter is animated"); + + let t0 = d.seek(0.5); + d.click(&format!("{SCALE}.addKeyframe")); + assert_eq!(d.scale(&clip).0.len(), 1, "the diamond turns animation on with a keyframe at the playhead"); + // animated: the arrows appear, in Properties and in Effect Controls + for panel in [SCALE, "effectControls.motion.scale"] { + d.rect(&format!("{panel}.prevKeyframe")); + d.rect(&format!("{panel}.nextKeyframe")); + } + + let t1 = d.seek(2.5); + d.click(&format!("{SCALE}.addKeyframe")); + assert_eq!(d.scale(&clip).0.len(), 2, "a second keyframe at the playhead"); + d.exec("effects.setParam", json!({"clip": clip.id, "effect": "motion", "param": "scale", "value": 50.0})); + assert_eq!(d.scale(&clip).1, 50.0); + d.shot("keyframes-two"); + + // on a keyframe: the diamond removes that keyframe only + d.click(&format!("{SCALE}.addKeyframe")); + let (keys, value) = d.scale(&clip); + assert_eq!(keys.len(), 1, "only the keyframe at the playhead went: {keys:?}"); + assert_eq!(value, 100.0, "Scale falls back to the remaining keyframe's value"); + assert_eq!(d.playhead(), t1); + d.shot("keyframes-removed"); + d.exec("edit.undo", json!({})); + let (keys, value) = d.scale(&clip); + assert_eq!((keys.len(), value), (2, 50.0), "one undo step brings the keyframe back"); + + // the arrows step between the keyframes + d.click(&format!("{SCALE}.prevKeyframe")); + assert_eq!(d.playhead(), t0, "◀ goes to the previous keyframe"); + assert_eq!(d.scale(&clip).1, 100.0); + d.click(&format!("{SCALE}.prevKeyframe")); + assert_eq!(d.playhead(), t0, "no keyframe before the first: ◀ does nothing"); + d.click(&format!("{SCALE}.nextKeyframe")); + assert_eq!(d.playhead(), t1, "▶ goes to the next keyframe"); + assert_eq!(d.scale(&clip).1, 50.0); + d.click("effectControls.motion.scale.prevKeyframe"); + assert_eq!(d.playhead(), t0, "the Effect Controls arrows do the same"); + + // removing the last keyframe ends the animation; the arrows go + d.click(&format!("{SCALE}.addKeyframe")); + d.click(&format!("{SCALE}.nextKeyframe")); + d.click(&format!("{SCALE}.addKeyframe")); + let (keys, value) = d.scale(&clip); + assert!(keys.is_empty(), "{keys:?}"); + assert_eq!(value, 50.0, "the last keyframe's value stays"); + assert!(d.find(&format!("{SCALE}.prevKeyframe")).is_none()); +} + +/// Crop is not on a clip until it is used: its diamond applies the effect, then adds the keyframe. +#[test] +fn properties_diamond_applies_crop_first() { + let (mut d, clip) = Driver::demo(); + d.seek(1.0); + d.click("properties.crop.left.addKeyframe"); + let s = &d.harness.state().session; + let it = s.active_sequence().unwrap().find_item(ClipId(clip.id)).unwrap().1; + assert_eq!(it.effect("crop").expect("crop applied").param("left").unwrap().keyframes.len(), 1); + d.rect("properties.crop.left.nextKeyframe"); +} + +/// The Effect Controls time ruler: a click moves the playhead there, the playhead's handle drags, +/// and there is no playhead while it is off the clip. +#[test] +fn effect_controls_ruler_moves_the_playhead() { + let (mut d, clip) = Driver::demo(); + let frame = Tick::from_seconds_f64(1.0 / 23.976).0; + let [x, y, w, h] = d.rect("effectControls.ruler"); + let lane = d.rect("effectControls.lane"); + assert_eq!((x, w), (lane[0], lane[2]), "the ruler spans the keyframe lane"); + let at = |f: f64| clip.start + (f * clip.duration as f64) as i64; + + d.ok("ui.click", json!({"x": x + w * 0.25, "y": y + h * 0.3})); + d.frames(2); + assert!((d.playhead() - at(0.25)).abs() <= frame, "a click on the ruler: {} vs {}", d.playhead(), at(0.25)); + let head = d.rect("effectControls.playhead"); + assert!((head[0] + head[2] / 2.0 - (x + w * 0.25)).abs() <= 2.0, "the handle sits where the playhead is: {head:?}"); + d.shot("keyframes-ruler"); + + // grab the handle and drag it right + let (hx, hy) = (head[0] + head[2] / 2.0, head[1] + head[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": hx, "y": hy}, "to": {"x": hx + w * 0.5, "y": hy}, "steps": 6})); + d.frames(2); + assert!((d.playhead() - at(0.75)).abs() <= frame, "dragging the handle: {} vs {}", d.playhead(), at(0.75)); + + // the playhead is drawn only on the clip + d.exec("playhead.set", json!({"time": clip.start + clip.duration * 2})); + assert!(d.find("effectControls.playhead").is_none(), "no handle while the playhead is past the clip"); + d.rect("effectControls.ruler"); +} diff --git a/crates/ui-egui/tests/media_ui.rs b/crates/ui-egui/tests/media_ui.rs index ea1f0849e..80246ef0d 100644 --- a/crates/ui-egui/tests/media_ui.rs +++ b/crates/ui-egui/tests/media_ui.rs @@ -89,6 +89,7 @@ fn moved_project(root: &Path) -> String { s.project = Arc::new(p); let path = root.join("p.fcproj").to_string_lossy().into_owned(); s.execute("file.save", json!({"path": path})).unwrap(); + drop(s); // Close decoder files before moving their directory on Windows. std::fs::create_dir_all(root.join("Moved")).unwrap(); std::fs::rename(&media, root.join("Moved").join("Media")).unwrap(); path @@ -178,6 +179,178 @@ impl Driver { } } +/// #301: Source transport must step by frame index when frame durations need rounding. +#[test] +fn source_transport_steps_every_frame_at_a_fractional_tick_rate() { + let rate = FrameRate::new(31, 1); + let generator = Generator::Demo(DemoScene::OceanSunset); + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41)); + let mut session = Session::default(); + let item = Arc::make_mut(&mut session.project).add_item( + "31 fps source", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + for frame in 1..=40 { + d.click("source.transport.src.stepFwd"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame); + } + d.click("source.transport.src.stepFwd"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), 40, "stop at the last source frame"); + for frame in (0..40).rev() { + d.click("source.transport.src.stepBack"); + assert_eq!(rate.frame_at(d.app().session.state.source_playhead), frame); + } + d.click("source.transport.src.stepBack"); + assert_eq!(d.app().session.state.source_playhead, Tick::ZERO); +} + +/// Source-focused menu/keyboard commands use the same frame grid as the Source toolbar. +#[test] +fn source_menu_steps_use_ordinals_for_explicit_targets_and_focus() { + let rate = FrameRate::new(31, 1); + for explicit_source in [true, false] { + let generator = Generator::Demo(DemoScene::OceanSunset); + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, rate.tick_of(41)); + let mut session = Session::default(); + session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap(); + session.execute("playhead.set", json!({"frame": 10})).unwrap(); + session.execute("prefs.set", json!({"key": "playback.stepManyFrames", "value": 12})).unwrap(); + let item = Arc::make_mut(&mut session.project).add_item( + "31 fps menu source", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }})); + let project = d.app().session.project.clone(); + let history = d.exec("history.list", json!({})); + let mut source_metadata = d.exec("source.inspect", json!({})); + source_metadata.as_object_mut().unwrap().remove("playhead"); + let source_params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) }; + for (command, frame) in [ + ("playhead.stepForward", 1), + ("playhead.stepForward", 2), + ("playhead.stepBack", 1), + ("playhead.stepBack", 0), + ("playhead.stepBack", 0), + ("playhead.stepForward5", 12), + ("playhead.stepForward5", 24), + ("playhead.stepForward5", 36), + ("playhead.stepForward5", 40), + ("playhead.stepForward5", 40), + ("playhead.stepBack5", 28), + ("playhead.stepBack5", 16), + ("playhead.stepBack5", 4), + ("playhead.stepBack5", 0), + ("playhead.stepBack5", 0), + ] { + d.ok("ui.menu.invoke", json!({"id": command, "params": source_params.clone()})); + let mut source = d.exec("source.inspect", json!({})); + assert_eq!(source["playhead"], json!(rate.tick_of(frame).0), "{command}, explicit Source target: {explicit_source}"); + source.as_object_mut().unwrap().remove("playhead"); + assert_eq!(source, source_metadata); + assert_eq!(d.app().session.playhead(), rate.tick_of(10)); + assert!(Arc::ptr_eq(&d.app().session.project, &project), "Source navigation must preserve the document"); + assert_eq!(d.exec("history.list", json!({})), history); + let state = d.ok("ui.inspect", json!({})); + assert_eq!(state["playback"]["playing"], json!(false)); + assert_eq!(state["sourcePlayback"]["playing"], json!(false)); + } + // An explicit Program target takes priority even while Source has focus. + for (command, frame) in [("playhead.stepForward", 11), ("playhead.stepBack", 10)] { + d.ok("ui.menu.invoke", json!({"id": command, "params": {"monitor": "program"}})); + assert_eq!(d.app().session.playhead(), rate.tick_of(frame)); + assert_eq!(d.app().session.state.source_playhead, Tick::ZERO); + assert!(Arc::ptr_eq(&d.app().session.project, &project)); + assert_eq!(d.exec("history.list", json!({})), history); + } + } +} + +// Requires the wide Time arithmetic: the accepted Source rate has more than one frame per tick. +fn zero_tick_frame_duration_source() -> (Driver, Tick) { + let rate = FrameRate::new(i64::MAX, 1000); + let current = Tick(254_270_016_000_000); // 1001 seconds, beyond the i64 frame-index range. + assert_eq!(rate.frame_duration(), Tick::ZERO); + assert_eq!(rate.frame_at(current), i64::MAX); + assert_eq!(rate.tick_of(i64::MAX), Tick(254_016_000_000_000)); + assert_eq!(rate.snap(current), current, "the composed Time helper must retain the exact cursor"); + let generator = Generator::ColorMatte { color: [0.2, 0.8, 0.2, 1.0] }; + let src = GeneratorSource::new(generator.clone(), 32, 18, rate, Tick(508_032_000_000_000)); // 2000 seconds. + let mut session = Session::default(); + session.execute("file.newSequence", json!({"fps": 31, "width": 32, "height": 18})).unwrap(); + let item = Arc::make_mut(&mut session.project).add_item( + "Source with less than one tick per frame", + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(generator), + info: src.info().clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + assert_eq!(filmcraft_engine::clip_ops::source_view(&session, item).unwrap().rate, rate); + session.execute("source.open", json!({"item": item.0})).unwrap(); + let mut d = Driver::new(session); + d.exec("source.setPlayhead", json!({"time": current.0})); + assert_eq!(d.app().session.state.source_playhead, current, "the public Source seek must accept the exact starting cursor"); + (d, current) +} + +#[test] +fn source_toolbar_steps_preserve_zero_tick_frame_duration_cursor() { + let (mut d, current) = zero_tick_frame_duration_source(); + for button in ["source.transport.src.stepFwd", "source.transport.src.stepBack"] { + d.click(button); + assert_eq!(d.app().session.state.source_playhead, current, "{button} must retain the existing zero-duration no-op"); + } +} + +#[test] +fn source_menu_steps_preserve_zero_tick_frame_duration_cursor() { + for explicit_source in [true, false] { + let (mut d, current) = zero_tick_frame_duration_source(); + d.ok("ui.set", json!({"focused": if explicit_source { "Program" } else { "Source" }})); + let params = if explicit_source { json!({"monitor": "source"}) } else { json!({}) }; + for command in ["playhead.stepForward", "playhead.stepBack"] { + d.ok("ui.menu.invoke", json!({"id": command, "params": params.clone()})); + assert_eq!(d.app().session.state.source_playhead, current, "{command}, explicit Source target: {explicit_source}"); + } + } +} + #[test] fn link_media_dialog_relinks_moved_media() { let root = tmp_dir("link"); diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs index 7c72924df..0d4b6f28e 100644 --- a/crates/ui-egui/tests/mixer_ui.rs +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -150,6 +150,19 @@ impl Driver { } } +#[test] +fn audio_chrome_switches_between_themes() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Audio"})); + for theme in ["light", "dark", "medium"] { + d.ok("ui.set", json!({"theme": theme})); + d.frames(3); + assert!(d.element("mixer.A1.fader").is_some()); + assert!(d.element("mixer.A1.pan").is_some()); + d.snapshot(&format!("mixer-theme-{theme}"), None); + } +} + #[test] fn track_mixer_controls_by_id_and_by_drag() { let mut d = Driver::demo(); diff --git a/crates/ui-egui/tests/perf_ui.rs b/crates/ui-egui/tests/perf_ui.rs index 55b52874c..5b9d6c845 100644 --- a/crates/ui-egui/tests/perf_ui.rs +++ b/crates/ui-egui/tests/perf_ui.rs @@ -1,6 +1,7 @@ //! `perf.stats` through the control channel (headless app under `egui_kittest`): the control //! method and the command id (what MCP `command_run` sends) both return the engine's decode -//! counters plus playback, frame-worker and UI timings. +//! counters plus playback, frame-worker and UI timings. Also how closely the Program monitor's +//! picture follows a value that is dragged and a scrub. use std::sync::mpsc::{Sender, channel}; @@ -8,6 +9,7 @@ use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::frames::{FrameKey, Target}; use serde_json::{Value, json}; struct Driver { @@ -49,6 +51,42 @@ impl Driver { } panic!("no reply to {method}"); } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + /// The frame due in the Program monitor: `shown` at the project's revision and the playhead. + fn due(&self, shown: FrameKey) -> FrameKey { + let s = &self.harness.state().session; + let frame = s.active_sequence().unwrap().settings.frame_rate.frame_at(s.playhead()); + FrameKey { frame, revision: s.revision, ..shown } + } + + /// Wait (without a UI pass) until the frame workers have `key` ready. + fn ready(&self, key: FrameKey) { + for _ in 0..2000 { + if self.harness.state().frames.is_ready(&key) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("{key:?} was never rendered"); + } + + /// The Program monitor at rest: the picture of the frame due is on screen. + fn settled(&mut self) -> FrameKey { + for _ in 0..2000 { + self.frames(1); + if let Some(k) = self.harness.state().program_picture() + && k == self.due(k) + { + return k; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the Program monitor never showed its frame"); + } } #[test] @@ -77,3 +115,78 @@ fn perf_stats_reports_playback_frames_and_decode() { let c = d.ok("engine.execute", json!({"command": "perf.stats"})); assert!(c["frames"]["workers"].as_u64().unwrap() >= 1 && c["decode"].is_object(), "{c}"); } + +/// A value dragged in Effect Controls changes on every UI pass, so the frame the monitor asks for +/// is never ready on the pass that asks. The monitor shows the frame of the pass before (ready by +/// then) and stays one pass behind the mouse; it used to keep the picture from before the drag +/// until the mouse rested. +#[test] +fn program_monitor_follows_a_value_that_changes_every_pass() { + let mut d = Driver::demo(); + let clip = d.exec("sequence.inspect", json!({}))["video"][0]["items"][0]["clip"].as_u64().unwrap(); + d.exec("playhead.set", json!({"seconds": 1.0})); + let at_rest = d.settled(); + let mut before = at_rest; + for i in 1..=12 { + // one change and one UI pass, as a mouse move while dragging the value + d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "scale", "value": 100.0 - i as f64})); + let due = d.due(at_rest); + assert_ne!(due.revision, before.revision, "each change is a new revision"); + let shown = d.harness.state().program_picture().unwrap(); + assert!(shown.revision >= before.revision, "change {i}: the picture is at most one change behind, shown {shown:?}, due {due:?}"); + d.ready(due); + before = due; + } + assert_eq!(d.settled().revision, before.revision, "at rest the picture is the frame due"); +} + +/// The same for a scrub, backwards: every pass asks for an earlier frame, and no cached frame +/// lies before it. +#[test] +fn program_monitor_follows_a_backward_scrub() { + let mut d = Driver::demo(); + d.exec("playhead.set", json!({"frame": 60})); + let at_rest = d.settled(); + let mut before = at_rest; + for i in 1..=12 { + d.exec("playhead.set", json!({"frame": 60 - i * 3})); + let due = d.due(at_rest); + assert_eq!(due.frame, 60 - i * 3); + let shown = d.harness.state().program_picture().unwrap(); + assert!(shown.frame <= before.frame, "step {i}: the picture is at most one step behind, shown {shown:?}, due {due:?}"); + d.ready(due); + before = due; + } + assert_eq!(d.settled().frame, before.frame); +} + +/// A frame being rendered is kept when the same frame is asked for at a newer revision: frames +/// that take longer than a refresh (CPU effects) were each cancelled by the next step of a drag, +/// so none was ever shown until the mouse rested. +#[test] +fn a_newer_revision_keeps_the_frame_being_rendered() { + let d = Driver::demo(); + let app = d.harness.state(); + let idle = || { + for _ in 0..200_000 { + if app.frames.queue_len() == 0 { + return; + } + std::thread::sleep(std::time::Duration::from_micros(50)); + } + panic!("the frame queue never emptied"); + }; + // the clip with Lumetri Color at full resolution: tens of milliseconds on the CPU + let seq = app.session.state.active_sequence.unwrap(); + let rate = app.session.active_sequence().unwrap().settings.frame_rate; + let frame = rate.frame_at(filmcraft_time::Tick::from_seconds_f64(15.0)); + let key = |revision| FrameKey { target: Target::Sequence(seq), frame, size: 1000, revision, draft: false }; + let project = app.session.project.clone(); + idle(); + app.frames.request(key(9001), rate.tick_of(frame), 1.0, &project, 0); + idle(); // a worker has taken it + app.frames.request(key(9002), rate.tick_of(frame), 1.0, &project, 0); + // both arrive, in either order: the one that was rendering was not cancelled for the newer + d.ready(key(9002)); + d.ready(key(9001)); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 6188afeae..bb504f32f 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -492,6 +492,34 @@ fn media_dir(tag: &str) -> std::path::PathBuf { dir } +#[test] +fn file_dialog_import_uses_shown_bin_unless_explicit() { + let dir = media_dir("import-bin"); + let path = dir.join("a-interview.wav").to_string_lossy().into_owned(); + let mut app = FilmcraftApp::new(Session::default()); + app.hooks.pick_files = Some(Box::new(move |_| vec![path.clone()])); + let parent = app.session.execute("file.newBin", json!({"name": "Footage"})).unwrap()["bin"].as_u64().unwrap(); + let child = app.session.execute("file.newBin", json!({"name": "Selects", "parent": parent})).unwrap()["bin"].as_u64().unwrap(); + let root = app.session.project.root.id.0; + app.ui.project_panel.tabs.push(filmcraft_ui_egui::panels::project::BinTab { bin: parent, ..Default::default() }); + for (shown, tab, params, expected) in [ + (None, None, json!({}), root), + (Some(child), None, json!({}), child), + (Some(child), Some(0), json!({}), parent), + (Some(child), None, json!({"bin": null}), root), + (Some(parent), None, json!({"bin": child}), child), + ] { + app.ui.project_panel.bin = shown; + app.ui.project_panel.active_tab = tab; + let result = app.file_dialog("file.import", ¶ms).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let item = ItemId(result["items"][0].as_u64().unwrap()); + let bin = app.session.project.root.find_bin(filmcraft_project::BinId(expected)).unwrap(); + assert!(bin.children.contains(&BinEntry::Item(item)), "shown={shown:?}, tab={tab:?}, params={params}: expected bin {expected}"); + } + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn media_browser_navigation_import_and_columns() { let dir = media_dir("nav"); diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index b2b534eb5..63903a485 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -186,6 +186,26 @@ fn apply_effect_appears_in_effect_controls() { assert!(!d.element_ids("effectControls.effect.").iter().any(|i| i == "effectControls.effect.gaussian_blur")); } +#[test] +fn spanish_search_finds_effects_and_presets_with_stable_automation_ids() { + let mut d = Driver::demo(); + d.ok("ui.menu.invoke", json!({"id": "app.language.spanish"})); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + d.ok("ui.set", json!({"effectsSearch": "DESENFOQUE"})); + d.frames(3); + assert!(d.element_ids("effects.item.").contains(&"effects.item.gaussian_blur".to_string())); + d.ok("ui.set", json!({"effectsSearch": "ENTRADA CON DESENFOQUE"})); + d.frames(3); + assert!(d.element_ids("effects.preset.").contains(&"effects.preset.Blur In".to_string())); + let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); + d.exec("timeline.select", json!({"clips": [clip]})); + d.exec("effects.apply", json!({"effect": "gaussian_blur"})); + d.ok("ui.panel.show", json!({"panel": "Effect Controls"})); + d.frames(3); + assert!(d.element_ids("effectControls.effect.").contains(&"effectControls.effect.gaussian_blur".to_string())); + d.ok("ui.menu.invoke", json!({"id": "app.language.english"})); +} + #[test] fn effects_panel_lists_the_premiere_transition_folders() { let mut d = Driver::demo(); diff --git a/crates/ui-egui/tests/sequence_ui.rs b/crates/ui-egui/tests/sequence_ui.rs index 807aaad56..0a16547f8 100644 --- a/crates/ui-egui/tests/sequence_ui.rs +++ b/crates/ui-egui/tests/sequence_ui.rs @@ -1,6 +1,9 @@ //! Headless UI tests of the Sequence / Markers menu additions: the Add Tracks and Delete Tracks dialogs, the //! through-edit marks on the timeline, the Markers panel colour filter and the Shift+; gap key. //! The real `FilmcraftApp` under `egui_kittest`, driven over the control channel by automation id. +//! +//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=` to also render the window offscreen with wgpu and write +//! `sequence-settings-*.png` there; without it no GPU is needed. use std::sync::mpsc::{Sender, channel}; @@ -13,6 +16,7 @@ use serde_json::{Value, json}; struct Driver { harness: Harness<'static, FilmcraftApp>, tx: Sender, + snapshots: Option, } impl Driver { @@ -21,8 +25,13 @@ impl Driver { session.execute("file.openDemoProject", json!({})).expect("demo project"); let (tx, rx) = channel(); let app = FilmcraftApp::new(session).with_control(rx); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); - let mut d = Driver { harness, tx }; + let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if snapshots.is_some() { + b = b.wgpu(); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots }; d.frames(4); d } @@ -80,6 +89,34 @@ impl Driver { e["label"].as_str().unwrap_or_default().to_string() } + /// With `FILMCRAFT_UI_SNAPSHOT_DIR` set, render the window offscreen and write `.png`, + /// cropped to the elements whose ids start with `prefix`. + fn snapshot(&mut self, name: &str, prefix: &str) { + let Some(dir) = self.snapshots.clone() else { return }; + let v = self.ok("ui.elements", json!({"prefix": prefix})); + let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for e in v.as_array().unwrap() { + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap() as f32).collect(); + bb = [bb[0].min(r[0]), bb[1].min(r[1]), bb[2].max(r[0] + r[2]), bb[3].max(r[1] + r[3])]; + } + self.frames(2); + let img = match self.harness.render() { + Ok(i) => i, + Err(e) => { + eprintln!("snapshot {name} skipped: {e}"); + return; + } + }; + let ppp = img.width() as f32 / 1600.0; + let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32; + let y0 = ((bb[1] - 40.0) * ppp).max(0.0) as u32; + let x1 = (((bb[2] + 24.0) * ppp) as u32).min(img.width()); + let y1 = (((bb[3] + 24.0) * ppp) as u32).min(img.height()); + let img = image::imageops::crop_imm(&img, x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)).to_image(); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } + fn ids(&mut self, prefix: &str) -> Vec { let v = self.ok("ui.elements", json!({"prefix": prefix})); v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() @@ -419,3 +456,194 @@ fn ctrl_x_cuts_clips() { assert!(!d.app().session.state.clipboard.is_empty()); assert!(d.app().session.active_sequence().unwrap().find_item(first.id).is_none(), "the cut clip leaves the timeline"); } + +/// Sequence ▸ Sequence Settings… opens Premiere Pro's dialog (it used to do nothing, #172): the +/// General tab shows the sequence's settings, the ones FilmCraft can't change yet are shown (greyed +/// out), and the Color Management and VR Properties tabs are there. Opening and cancelling change +/// nothing. +#[test] +fn sequence_settings_dialog_shows_the_sequence() { + let mut d = Driver::demo(); + let before = (*d.app().session.project).clone(); + let history = d.app().session.history.undo.len(); + let r = d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + assert_eq!(r["dialog"], "sequenceSettings", "{r}"); + d.frames(3); + for (id, shown) in [ + ("sequenceSettings.name", "Main Edit"), + ("sequenceSettings.editingMode", "Custom"), + ("sequenceSettings.timebase", "23.976 frames/second"), + ("sequenceSettings.width", "1920"), + ("sequenceSettings.height", "1080"), + ("sequenceSettings.aspect", "16:9"), + ("sequenceSettings.scaleMotion", "true"), + ("sequenceSettings.par", "Square Pixels (1.0)"), + ("sequenceSettings.fields", "No Fields (Progressive Scan)"), + ("sequenceSettings.videoDisplay", "23.976 fps Timecode"), + ("sequenceSettings.channelFormat", "Stereo"), + ("sequenceSettings.channels", "2"), + ("sequenceSettings.sampleRate", "48000 Hz"), + ("sequenceSettings.maxRenderQuality", "false"), + ("sequenceSettings.linearColor", "true"), + ] { + assert_eq!(d.label(id), shown, "{id}"); + } + for id in ["sequenceSettings.tab.general", "sequenceSettings.tab.color", "sequenceSettings.tab.vr", "sequenceSettings.ok", "sequenceSettings.cancel"] { + assert!(d.ids(id).iter().any(|i| i == id), "{id} missing"); + } + d.snapshot("sequence-settings-general", "sequenceSettings."); + // the timebase list offers the common rates + d.click("sequenceSettings.timebase"); + let rates = d.ids("sequenceSettings.timebase.option."); + for r in ["24000/1001", "24/1", "25/1", "30000/1001", "60/1"] { + assert!(rates.iter().any(|i| i.ends_with(r)), "{r} missing: {rates:?}"); + } + d.click("sequenceSettings.timebase.option.24000/1001"); + d.click("sequenceSettings.tab.color"); + assert_eq!(d.label("sequenceSettings.workingSpace"), "Rec. 709"); + assert_eq!(d.label("sequenceSettings.autoToneMap"), "true"); + d.snapshot("sequence-settings-color", "sequenceSettings."); + d.click("sequenceSettings.tab.vr"); + assert_eq!(d.label("sequenceSettings.vr.projection"), "None"); + d.snapshot("sequence-settings-vr", "sequenceSettings."); + d.click("sequenceSettings.cancel"); + assert!(d.ids("sequenceSettings.").is_empty(), "dialog closed"); + assert_eq!(*d.app().session.project, before); + assert_eq!(d.app().session.history.undo.len(), history, "no undo step"); + // OK without a change closes it and adds no undo step either + d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + d.frames(3); + d.click("sequenceSettings.ok"); + assert!(d.ids("sequenceSettings.").is_empty()); + assert_eq!(d.app().session.history.undo.len(), history); +} + +/// OK applies the dialog in one undo step: a new timebase (29.97 starts on drop-frame timecode), a +/// smaller frame with the clips' Motion scaled to match, and Maximum Render Quality. +#[test] +fn sequence_settings_dialog_applies_in_one_undo_step() { + let mut d = Driver::demo(); + let before = (*d.app().session.project).clone(); + let motion = |d: &mut Driver| { + let q = d.app().session.active_sequence().unwrap(); + let m = q.video_tracks.iter().flat_map(|t| &t.items).find_map(|i| i.effect("motion")).unwrap().clone(); + match (&m.param("position").unwrap().value, &m.param("scale").unwrap().value) { + (filmcraft_engine::project::ParamValue::Vec2(p), filmcraft_engine::project::ParamValue::Float(s)) => (p.x, p.y, *s), + _ => panic!("Motion has a point and a number"), + } + }; + let (x0, y0, s0) = motion(&mut d); + d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + // the window sizes itself over its first frames; click once it has settled + d.frames(10); + d.click("sequenceSettings.timebase"); + d.snapshot("sequence-settings-timebase-list", "sequenceSettings."); + d.click("sequenceSettings.timebase.option.30000/1001"); + assert_eq!(d.label("sequenceSettings.timebase"), "29.97 frames/second"); + assert_eq!(d.label("sequenceSettings.videoDisplay"), "29.97 fps Drop-Frame Timecode"); + // the frame-size fields are drag values; set the draft as typing would + d.app().ui.sequence_settings.width = 1280; + d.app().ui.sequence_settings.height = 720; + d.frames(2); + assert_eq!(d.label("sequenceSettings.aspect"), "16:9"); + d.click("sequenceSettings.maxRenderQuality"); + d.click("sequenceSettings.ok"); + assert!(d.ids("sequenceSettings.").is_empty(), "dialog closed"); + let st = d.app().session.active_sequence().unwrap().settings.clone(); + assert_eq!((st.width, st.height, st.frame_rate), (1280, 720, filmcraft_engine::time::FrameRate::FPS_29_97)); + assert!(st.drop_frame && st.max_render_quality); + let (x1, y1, s1) = motion(&mut d); + let k = 2.0 / 3.0; + assert!((x1 - x0 * k).abs() < 1e-9 && (y1 - y0 * k).abs() < 1e-9 && (s1 - s0 * k).abs() < 1e-9, "{x0},{y0},{s0} -> {x1},{y1},{s1}"); + d.exec("edit.undo", json!({})); + assert_eq!(*d.app().session.project, before, "one undo step for the whole dialog"); +} + +/// The General tab's Sequence Name renames the sequence (its Project panel item) together with +/// the other settings, in the same undo step; a blank name keeps the old one. +#[test] +fn sequence_settings_dialog_renames_the_sequence() { + let mut d = Driver::demo(); + let id = d.app().session.state.active_sequence.unwrap(); + let name = |d: &mut Driver| d.app().session.project.item(id).unwrap().name.clone(); + assert_eq!(name(&mut d), "Main Edit"); + d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + d.frames(10); + assert_eq!(d.label("sequenceSettings.name"), "Main Edit"); + // the name field is a text edit; set the draft as typing would + d.app().ui.sequence_settings.name = "Rough Cut".into(); + d.app().ui.sequence_settings.max_render_quality = true; + d.frames(2); + d.click("sequenceSettings.ok"); + assert!(d.ids("sequenceSettings.").is_empty(), "dialog closed"); + assert_eq!(name(&mut d), "Rough Cut"); + assert!(d.app().session.active_sequence().unwrap().settings.max_render_quality); + d.exec("edit.undo", json!({})); + assert_eq!(name(&mut d), "Main Edit", "one undo step for the name and the settings"); + assert!(!d.app().session.active_sequence().unwrap().settings.max_render_quality); + // a blank name changes nothing + let history = d.app().session.history.undo.len(); + d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + d.frames(10); + d.app().ui.sequence_settings.name = " ".into(); + d.frames(2); + d.click("sequenceSettings.ok"); + assert_eq!(name(&mut d), "Main Edit"); + assert_eq!(d.app().session.history.undo.len(), history); +} + +/// File ▸ New ▸ Sequence… (Cmd+N) made a default sequence at once; it opens New Sequence: the +/// Sequence Settings dialog from the default settings, named like the command would name it, with +/// a Tracks tab. OK makes the sequence with what was chosen (one undo step); Cancel makes none. +#[test] +fn new_sequence_opens_its_dialog() { + let mut d = Driver::demo(); + let sequences = |d: &mut Driver| d.app().session.project.sequences().count(); + let n0 = sequences(&mut d); + let r = d.ok("ui.menu.invoke", json!({"id": "file.newSequence"})); + assert_eq!(r["dialog"], "newSequence", "{r}"); + d.frames(10); + assert_eq!(sequences(&mut d), n0, "nothing is made before OK"); + assert_eq!(d.label("sequenceSettings.name"), format!("Sequence {:02}", n0 + 1)); + assert_eq!(d.label("sequenceSettings.timebase"), "23.976 frames/second"); + assert!(d.ids("sequenceSettings.tab.").iter().any(|i| i == "sequenceSettings.tab.tracks")); + assert!(d.ids("sequenceSettings.tab.color").is_empty(), "New Sequence has General and Tracks"); + d.click("sequenceSettings.tab.tracks"); + assert_eq!(d.label("sequenceSettings.tracks.video"), "3"); + assert_eq!(d.label("sequenceSettings.tracks.audio"), "3"); + d.snapshot("new-sequence-tracks", "sequenceSettings."); + // set the draft as typing would: a vertical 30 fps sequence with 2 video and 4 audio tracks + { + let s = &mut d.app().ui.sequence_settings; + s.name = "Shorts".into(); + (s.width, s.height) = (1080, 1920); + (s.fps_num, s.fps_den) = (30, 1); + (s.video_tracks, s.audio_tracks) = (2, 4); + } + d.frames(2); + d.click("sequenceSettings.ok"); + assert!(d.ids("sequenceSettings.").is_empty(), "dialog closed"); + assert_eq!(sequences(&mut d), n0 + 1); + let id = d.app().session.state.active_sequence.unwrap(); + assert_eq!(d.app().session.project.item(id).unwrap().name, "Shorts", "the new sequence is the open one"); + let q = d.app().session.active_sequence().unwrap().clone(); + assert_eq!((q.settings.width, q.settings.height, q.settings.frame_rate), (1080, 1920, filmcraft_engine::time::FrameRate::FPS_30)); + assert_eq!((q.video_tracks.len(), q.audio_tracks.len()), (2, 4)); + d.exec("edit.undo", json!({})); + assert_eq!(sequences(&mut d), n0, "one undo step"); + // Cancel makes nothing + d.ok("ui.menu.invoke", json!({"id": "file.newSequence"})); + d.frames(10); + d.click("sequenceSettings.cancel"); + assert_eq!(sequences(&mut d), n0); + // with params (a clip, an agent) the command still makes the sequence directly + d.ok("ui.menu.invoke", json!({"id": "file.newSequence", "params": {"name": "Direct"}})); + d.frames(2); + assert_eq!(sequences(&mut d), n0 + 1); + // and Sequence Settings… is still the active sequence's dialog, with its three tabs + d.ok("ui.menu.invoke", json!({"id": "sequence.settings"})); + d.frames(10); + assert_eq!(d.label("sequenceSettings.name"), "Direct"); + assert!(d.ids("sequenceSettings.tab.color").iter().any(|i| i == "sequenceSettings.tab.color")); + d.click("sequenceSettings.cancel"); +} diff --git a/crates/ui-egui/tests/settings_ui.rs b/crates/ui-egui/tests/settings_ui.rs index c85136187..d5f0d0e80 100644 --- a/crates/ui-egui/tests/settings_ui.rs +++ b/crates/ui-egui/tests/settings_ui.rs @@ -245,6 +245,21 @@ fn dropdowns_numbers_and_reset() { assert_eq!(d.draft()["page"], "memory"); } +#[test] +fn view_menu_and_settings_name_each_color_theme_alike() { + let mut d = Driver::demo(); + let menu = d.ok("ui.menu.list", json!({})); + let menu_label = |id: &str| menu.as_array().unwrap().iter().find(|m| m["id"] == id).unwrap()["label"].as_str().unwrap().to_string(); + d.menu("app.settings.appearance"); + d.click("settings.appearance.colorTheme"); + for (menu_id, pref) in [("view.theme.dark", "darkest"), ("view.theme.medium", "dark"), ("view.theme.light", "light")] { + let id = format!("settings.appearance.colorTheme.{pref}"); + let found = d.ok("ui.elements", json!({"prefix": id})); + let choice = found.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap(); + assert_eq!(menu_label(menu_id), choice["label"].as_str().unwrap(), "{menu_id} and {id} name the same theme"); + } +} + #[test] fn appearance_labels_and_tooltips_take_effect() { let mut d = Driver::demo(); diff --git a/crates/ui-egui/tests/source_drag_ui.rs b/crates/ui-egui/tests/source_drag_ui.rs new file mode 100644 index 000000000..92f7fc9de --- /dev/null +++ b/crates/ui-egui/tests/source_drag_ui.rs @@ -0,0 +1,393 @@ +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::Tick; +use filmcraft_ui_egui::FilmcraftApp; +use serde_json::json; + +fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); // Direct engine edits in a harness do not wake the native event loop. + h.step(); +} +fn harness(item: u64) -> Harness<'static, FilmcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name":"Drop test","width":1280,"height":720,"fps":24})).unwrap(); + s.execute("source.open", json!({"item":item})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(s)); + for _ in 0..4 { + step(&mut h); + } + h +} +fn pointer(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, pressed: Option) { + h.input_mut().events.push(egui::Event::PointerMoved(pos)); + if pressed == Some(true) { + step(h); // Establish hover before pressing, as the live control-channel drag does. + } + if let Some(pressed) = pressed { + h.input_mut().events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + step(h); +} + +fn control_rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> egui::Rect { + let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).or_else(|| h.state().auto.find(id)).unwrap().rect; + egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, height)) +} + +fn drag(h: &mut Harness<'static, FilmcraftApp>, control: &str, audio: bool, change_marks: bool) { + let from = control_rect(h, control).center(); + let panel = control_rect(h, "panel.Timeline"); + let row = control_rect(h, if audio { "timeline.track.A1.target" } else { "timeline.track.V1.target" }); + let to = egui::pos2(panel.min.x + h.state().ui.timeline.header_w + 60.0, row.center().y); + pointer(h, from, Some(true)); + pointer(h, from + egui::vec2(0.0, 9.0), None); + if change_marks { + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":Tick::from_seconds_f64(8.0).0})).unwrap(); + } + pointer(h, to, None); + pointer(h, to, Some(false)); + for _ in 0..2 { + step(h); + } +} + +#[test] +fn source_drag_controls_place_each_stream_choice_and_preserve_range() { + for (control, audio, video_count, audio_count) in [("source.drag.video", false, 1, 0), ("source.drag.audio", true, 0, 1), ("source.drag.both", false, 1, 1)] + { + let mut h = harness(5); + let start = Tick::from_seconds_f64(1.0); + let out = Tick::from_seconds_f64(3.0); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap(); + step(&mut h); + drag(&mut h, control, audio, false); + let s = &h.state().session; + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::(), video_count, "{control}: {}", h.state().ui.status); + assert_eq!(q.audio_tracks.iter().map(|t| t.items.len()).sum::(), audio_count, "{control}: {}", h.state().ui.status); + for c in q.all_tracks().flat_map(|t| t.items.iter()) { + assert_eq!(c.source_in, start); + } + if video_count == 1 && audio_count == 1 { + assert!(q.video_tracks[0].items[0].link.is_some()); + assert_eq!(q.video_tracks[0].items[0].link, q.audio_tracks[0].items[0].link); + } + } +} + +#[test] +fn drag_captures_marked_range_before_later_marker_changes() { + let mut h = harness(5); + let start = Tick::from_seconds_f64(1.0); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":Tick::from_seconds_f64(3.0).0})).unwrap(); + step(&mut h); + drag(&mut h, "source.drag.video", false, true); + assert_eq!(h.state().session.active_sequence().unwrap().video_tracks[0].items[0].source_in, start); +} + +#[test] +fn unmarked_clip_drags_full_length_without_creating_marks() { + let mut h = harness(5); + drag(&mut h, "source.picture", false, false); + let s = &h.state().session; + let c = &s.active_sequence().unwrap().video_tracks[0].items[0]; + assert_eq!(c.source_in, Tick::ZERO); + assert_eq!(c.duration, s.active_sequence().unwrap().settings.frame_rate.snap_nearest(Tick::from_seconds_f64(18.0))); + let m = s.project.item(filmcraft_engine::project::ItemId(5)).unwrap().as_media().unwrap(); + assert!(m.mark_in.is_none() && m.mark_out.is_none()); +} + +#[test] +fn audio_file_drag_places_only_audio() { + let mut h = harness(11); + drag(&mut h, "source.drag.audio", true, false); + let q = h.state().session.active_sequence().unwrap(); + assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::(), 0); + assert_eq!(q.audio_tracks[0].items.len(), 1); +} + +#[test] +fn clear_in_out_shortcut_addresses_the_focused_monitor_and_is_undoable() { + use filmcraft_engine::clip_ops::source_view; + use filmcraft_engine::project::ItemId; + use filmcraft_ui_egui::dock::PanelKind; + let mut h = harness(5); + let start = Tick::from_seconds_f64(1.0); + let out = Tick::from_seconds_f64(3.0); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap(); + h.state_mut().session.execute("markers.markIn", json!({"time":start.0})).unwrap(); + h.state_mut().session.execute("markers.markOut", json!({"time":out.0})).unwrap(); + let before = h.state().session.project.to_json(); + let modifiers = egui::Modifiers { shift: true, command: true, ctrl: !cfg!(target_os = "macos"), mac_cmd: cfg!(target_os = "macos"), ..Default::default() }; + let press = |h: &mut Harness<'static, FilmcraftApp>| { + h.input_mut().events.push(egui::Event::Key { key: egui::Key::X, physical_key: Some(egui::Key::X), pressed: true, repeat: false, modifiers }); + step(h); + h.input_mut().events.push(egui::Event::Key { key: egui::Key::X, physical_key: Some(egui::Key::X), pressed: false, repeat: false, modifiers }); + step(h); + }; + h.state_mut().ui.focused = PanelKind::Source; + press(&mut h); + let view = source_view(&h.state().session, ItemId(5)).unwrap(); + assert!(view.mark_in.is_none() && view.mark_out.is_none()); + assert_eq!(view.selected_range().duration, view.end - view.start); + assert_eq!(h.state().session.active_sequence().unwrap().mark_in, Some(start)); + assert_eq!(h.state().session.active_sequence().unwrap().mark_out, Some(out)); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.to_json(), before); + h.state_mut().ui.focused = PanelKind::Program; + press(&mut h); + assert!(h.state().session.active_sequence().unwrap().mark_in.is_none()); + assert!(h.state().session.active_sequence().unwrap().mark_out.is_none()); + assert_eq!(source_view(&h.state().session, ItemId(5)).unwrap().mark_in, Some(start)); + h.state_mut().session.state.active_sequence = None; + h.state_mut().ui.focused = PanelKind::Source; + press(&mut h); + assert!(source_view(&h.state().session, ItemId(5)).unwrap().mark_in.is_none()); + let cleared = h.state().session.project.to_json(); + for target in [json!("other"), json!(23), json!(null)] { + assert!(h.state_mut().session.execute("markers.clearInOut", json!({"target":target})).is_err()); + assert_eq!(h.state().session.project.to_json(), cleared); + } +} + +fn marked_harness() -> Harness<'static, FilmcraftApp> { + let mut h = harness(5); + let view = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap(); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":view.rate.tick_of(48).0,"out":view.rate.tick_of(95).0})).unwrap(); + step(&mut h); + h +} +fn marked_range(h: &Harness<'static, FilmcraftApp>) -> filmcraft_time::TimeRange { + filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().selected_range() +} +#[test] +fn range_handles_preview_then_commit_one_undo_step() { + for id in ["source.range.in", "source.range.out", "source.range.body"] { + let mut h = marked_harness(); + let original = marked_range(&h); + let before = h.state().session.project.to_json(); + let from = control_rect(&h, id).center(); + let to = from + egui::vec2(35.0, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + assert_eq!(h.state().session.project.to_json(), before, "{id} must preview without editing"); + assert!( + filmcraft_ui_egui::panels::source_range::preview(h.state(), &h.ctx).is_some(), + "{id}: from={from:?}, source_time={:?}, playhead={:?}", + h.state().session.state.source_playhead, + h.state().auto.find("source.playhead") + ); + pointer(&mut h, to, Some(false)); + let changed = marked_range(&h); + assert_ne!(changed, original, "{id}: {}", h.state().ui.status); + if id.ends_with("body") { + assert_eq!(changed.duration, original.duration); + } + if id.ends_with("in") { + assert_eq!(changed.end(), original.end()); + } + if id.ends_with("out") { + assert_eq!(changed.start, original.start); + } + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.to_json(), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(marked_range(&h), changed); + } +} +#[test] +fn range_drag_escape_and_concurrent_edits_cancel_without_overwriting_marks() { + for concurrent in [false, true] { + let mut h = marked_harness(); + let before = h.state().session.project.to_json(); + let from = control_rect(&h, "source.range.in").center(); + let to = from + egui::vec2(35.0, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + if concurrent { + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":null,"out":null})).unwrap(); + } else { + h.input_mut().events.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: Some(egui::Key::Escape), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + step(&mut h); + let current = h.state().session.project.to_json(); + pointer(&mut h, to, Some(false)); + assert_eq!(h.state().session.project.to_json(), current); + if !concurrent { + assert_eq!(current, before); + } + } +} +#[test] +fn range_move_clamps_to_media_bounds_and_keeps_duration() { + for delta in [-3000.0, 3000.0] { + let mut h = marked_harness(); + let before = marked_range(&h); + let from = control_rect(&h, "source.range.body").center(); + let to = from + egui::vec2(delta, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + pointer(&mut h, to, Some(false)); + let after = marked_range(&h); + let view = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap(); + assert_eq!(after.duration, before.duration); + assert!(after.start >= view.start && after.end() <= view.end); + } +} +fn chord(h: &mut Harness<'static, FilmcraftApp>, key: egui::Key, shift: bool, primary: bool) { + let modifiers = egui::Modifiers { + shift, + command: primary, + ctrl: primary && !cfg!(target_os = "macos"), + mac_cmd: primary && cfg!(target_os = "macos"), + ..Default::default() + }; + for pressed in [true, false] { + h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }); + step(h); + } +} +#[test] +fn source_marker_navigation_and_clear_shortcuts_leave_program_alone() { + use filmcraft_ui_egui::dock::PanelKind; + let mut h = marked_harness(); + h.state_mut().ui.focused = PanelKind::Source; + let before = h.state().session.active_sequence().unwrap().clone(); + let program_time = h.state().session.playhead(); + chord(&mut h, egui::Key::I, true, false); + assert_eq!(h.state().session.state.source_playhead, marked_range(&h).start); + assert_eq!(h.state().session.playhead(), program_time); + chord(&mut h, egui::Key::M, false, false); + assert_eq!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().markers.len(), 1); + assert_eq!(h.state().session.active_sequence().unwrap(), &before); + chord(&mut h, egui::Key::I, true, true); + assert!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().mark_in.is_none()); + chord(&mut h, egui::Key::O, true, true); + assert!(filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().mark_out.is_none()); + assert_eq!(h.state().session.active_sequence().unwrap(), &before); + assert_eq!(h.state().session.playhead(), program_time); +} + +#[test] +fn program_range_handles_share_behavior_and_leave_source_marks_alone() { + use filmcraft_engine::{clip_ops::source_view, project::ItemId}; + use filmcraft_ui_egui::dock::PanelKind; + for id in ["program.range.in", "program.range.out", "program.range.body"] { + let mut h = marked_harness(); + h.state_mut() + .session + .execute("timeline.place", json!({"item":5,"sourceIn":0,"duration":Tick::from_seconds_f64(12.0).0,"video":true,"audio":true})) + .unwrap(); + let seq = h.state().session.state.active_sequence.unwrap(); + let rate = h.state().session.active_sequence().unwrap().settings.frame_rate; + h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":rate.tick_of(48).0,"out":rate.tick_of(95).0})).unwrap(); + h.state_mut().ui.focused = PanelKind::Program; + step(&mut h); + let original = source_view(&h.state().session, seq).unwrap().selected_range(); + let source = source_view(&h.state().session, ItemId(5)).unwrap().selected_range(); + let before = h.state().session.project.to_json(); + let source_time = h.state().session.state.source_playhead; + let program_time = h.state().session.playhead(); + let from = control_rect(&h, id).center(); + let to = from + egui::vec2(35.0, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + assert_eq!(h.state().session.project.to_json(), before); + pointer(&mut h, to, Some(false)); + let changed = source_view(&h.state().session, seq).unwrap().selected_range(); + assert_ne!(changed, original, "{id}: {}", h.state().ui.status); + if id.ends_with("body") { + assert_eq!(changed.duration, original.duration); + } + if id.ends_with("in") { + assert_eq!(changed.end(), original.end()); + } + if id.ends_with("out") { + assert_eq!(changed.start, original.start); + } + assert_eq!(source_view(&h.state().session, ItemId(5)).unwrap().selected_range(), source); + assert_eq!(h.state().session.state.source_playhead, source_time); + assert_eq!(h.state().session.playhead(), program_time); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.to_json(), before); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(source_view(&h.state().session, seq).unwrap().selected_range(), changed); + } +} +#[test] +fn program_range_draft_cancels_when_active_sequence_changes() { + let mut h = marked_harness(); + h.state_mut().session.execute("timeline.place", json!({"item":5,"sourceIn":0,"duration":Tick::from_seconds_f64(12.0).0})).unwrap(); + let seq = h.state().session.state.active_sequence.unwrap(); + h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":Tick::from_seconds_f64(2.0).0,"out":Tick::from_seconds_f64(5.0).0})).unwrap(); + step(&mut h); + let before = h.state().session.project.to_json(); + let from = control_rect(&h, "program.range.in").center(); + let to = from + egui::vec2(35.0, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + h.state_mut().session.state.active_sequence = None; + step(&mut h); + pointer(&mut h, to, Some(false)); + assert_eq!(h.state().session.project.to_json(), before); +} + +#[test] +fn playhead_scrubs_above_full_range_and_trim_handles_in_both_monitors() { + use filmcraft_ui_egui::dock::PanelKind; + for source in [true, false] { + let mut h = harness(5); + if !source { + h.state_mut().session.execute("timeline.place", json!({"item":5})).unwrap(); + } + h.state_mut().ui.focused = if source { PanelKind::Source } else { PanelKind::Program }; + if source { + h.state_mut().session.execute("source.setPlayhead", json!({"time":0})).unwrap(); + } else { + h.state_mut().session.execute("playhead.set", json!({"time":0})).unwrap(); + } + step(&mut h); + let before = h.state().session.project.to_json(); + let from = control_rect(&h, if source { "source.playhead" } else { "program.playhead" }).center(); + let to = from + egui::vec2(110.0, 0.0); + pointer(&mut h, from, Some(true)); + pointer(&mut h, to, None); + pointer(&mut h, to, Some(false)); + assert_eq!(h.state().session.project.to_json(), before, "scrubbing must not edit range marks"); + let time = if source { h.state().session.state.source_playhead } else { h.state().session.playhead() }; + assert!(time > Tick::ZERO, "scrubber must win over the In handle"); + } +} + +#[test] +fn program_handles_exist_only_for_explicit_marks_and_clear_hides_them() { + let mut h = harness(5); + h.state_mut().session.execute("timeline.place", json!({"item":5})).unwrap(); + let seq = h.state().session.state.active_sequence.unwrap(); + step(&mut h); + let has = |h: &Harness<'static, FilmcraftApp>, id: &str| h.state().auto.elements.iter().any(|e| e.id == id); + assert!(!has(&h, "program.range.in") && !has(&h, "program.range.out") && !has(&h, "program.range.body")); + assert!(has(&h, "program.playhead")); + h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"in":Tick::from_seconds_f64(2.0).0})).unwrap(); + step(&mut h); + assert!(has(&h, "program.range.in")); + assert!(!has(&h, "program.range.out") && !has(&h, "program.range.body")); + h.state_mut().session.execute("project.setMarks", json!({"item":seq.0,"out":Tick::from_seconds_f64(5.0).0})).unwrap(); + step(&mut h); + assert!(has(&h, "program.range.in") && has(&h, "program.range.out") && has(&h, "program.range.body")); + h.state_mut().session.execute("markers.clearInOut", json!({"target":"program"})).unwrap(); + step(&mut h); + assert!(!has(&h, "program.range.in") && !has(&h, "program.range.out") && !has(&h, "program.range.body")); + assert!(has(&h, "source.range.in") && has(&h, "source.range.out")); +} diff --git a/crates/ui-egui/tests/source_playback_ui.rs b/crates/ui-egui/tests/source_playback_ui.rs new file mode 100644 index 000000000..7ae955461 --- /dev/null +++ b/crates/ui-egui/tests/source_playback_ui.rs @@ -0,0 +1,198 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::Tick; +use filmcraft_ui_egui::{AudioOut, FilmcraftApp, dock::PanelKind, menus}; +use serde_json::json; + +fn app(item: u64) -> FilmcraftApp { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("source.open", json!({"item":item})).unwrap(); + FilmcraftApp::new(session) +} + +#[test] +fn source_runs_pauses_seeks_and_does_not_edit_the_sequence() { + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + let before = h.state().session.project.to_json(); + let program = h.state().session.playhead(); + h.state_mut().play_source().unwrap(); + for _ in 0..8 { + h.step(); + } + assert!(h.state().session.state.source_playhead > Tick::from_seconds_f64(1.0)); + assert_eq!(h.state().session.playhead(), program); + assert_eq!(h.state().session.project.to_json(), before); + h.state_mut().session.execute("source.setPlayhead", json!({"seconds":8})).unwrap(); + h.step(); + h.step(); + assert!(h.state().session.state.source_playhead.seconds() >= 8.0); + assert!(h.state().session.state.source_playhead.seconds() < 9.0); + h.state_mut().stop_source(); + let stopped = h.state().session.state.source_playhead; + h.step(); + h.step(); + assert_eq!(h.state().session.state.source_playhead, stopped); +} + +#[test] +fn source_stops_at_end_restarts_and_stops_when_item_changes() { + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + h.state_mut().session.execute("source.setPlayhead", json!({"seconds":17.5})).unwrap(); + h.state_mut().play_source().unwrap(); + for _ in 0..8 { + h.step(); + } + assert!(!h.state().source_playback.clock.playing); + assert!(h.state().session.state.source_playhead.seconds() < 18.0); + h.state_mut().play_source().unwrap(); + assert_eq!(h.state().session.state.source_playhead, Tick::ZERO); + h.state_mut().session.execute("source.open", json!({"item":6})).unwrap(); + h.step(); + assert!(!h.state().source_playback.clock.playing); +} + +#[test] +fn space_targets_the_focused_monitor_and_output_is_exclusive() { + let mut app = app(5); + let ctx = egui::Context::default(); + app.ui.focused = PanelKind::Source; + menus::invoke(&mut app, &ctx, "playback.toggle", json!({})).unwrap(); + assert!(app.source_playback.clock.playing); + assert!(!app.playback.playing); + menus::invoke(&mut app, &ctx, "playback.toggle", json!({"monitor":"program"})).unwrap(); + assert!(app.playback.playing); + assert!(!app.source_playback.clock.playing); + app.play_source().unwrap(); + assert!(!app.playback.playing); + menus::invoke(&mut app, &ctx, "playback.stop", json!({})).unwrap(); + assert!(!app.source_playback.clock.playing); +} + +#[test] +fn empty_source_returns_a_useful_error() { + let mut app = FilmcraftApp::new(Session::default()); + assert!(app.play_source().unwrap_err().contains("Source monitor")); + assert!(!app.source_playback.clock.playing); +} + +type Fill = Box; +struct Out { + frames: Arc, + fill: Arc>>, + stalled: bool, +} +impl AudioOut for Out { + fn start(&mut self, fill: Fill) -> Result { + self.frames.store(0, Ordering::SeqCst); + *self.fill.lock().unwrap() = Some(fill); + Ok(48_000) + } + fn stop(&mut self) { + *self.fill.lock().unwrap() = None; + } + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + Some(if self.stalled { 0 } else { self.frames.fetch_add(4_800, Ordering::SeqCst) + 4_800 }) + } +} + +#[test] +fn audio_source_outputs_sound_and_uses_the_device_clock() { + let frames = Arc::new(AtomicU64::new(0)); + let fill = Arc::new(Mutex::new(None)); + let mut app = app(11); + app.audio = Some(Box::new(Out { frames, fill: fill.clone(), stalled: false })); + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app); + h.step(); + h.state_mut().play_source().unwrap(); + for _ in 0..8 { + h.step(); + } + assert!(h.state().source_playback.clock.audio_clock); + assert!(h.state().session.state.source_playhead.seconds() > 0.2); + let mut heard = false; + for _ in 0..100 { + std::thread::sleep(std::time::Duration::from_millis(5)); + let mut buffer = vec![0.0; 960]; + if let Some(f) = fill.lock().unwrap().as_mut() { + f(&mut buffer, 2); + } + heard |= buffer.iter().any(|x| x.abs() > 0.001); + if heard { + break; + } + } + assert!(heard, "the audio callback must produce non-silent source samples"); + h.state_mut().stop_source(); +} + +#[test] +fn stalled_source_audio_falls_back_to_wall_clock() { + let mut app = app(11); + app.audio = Some(Box::new(Out { frames: Arc::new(AtomicU64::new(0)), fill: Arc::new(Mutex::new(None)), stalled: true })); + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app); + h.step(); + h.state_mut().play_source().unwrap(); + for _ in 0..16 { + h.step(); + } + assert!(!h.state().source_playback.clock.audio_clock); + assert!(h.state().session.state.source_playhead.seconds() > 1.5); + h.state_mut().stop_source(); +} + +#[test] +fn source_range_playback_stops_at_out_and_loop_restarts_without_program_playback() { + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + let rate = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap().rate; + let (start, out) = (rate.tick_of(24), rate.tick_of(47)); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":start.0,"out":out.0})).unwrap(); + h.state_mut().ui.focused = PanelKind::Source; + let before = h.state().session.project.to_json(); + let program = h.state().session.playhead(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "playback.inToOut", json!({})).unwrap(); + assert_eq!(h.state().session.state.source_playhead, start); + for _ in 0..10 { + h.step(); + } + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.state.source_playhead, out); + assert!(!h.state().playback.playing); + assert_eq!(h.state().session.playhead(), program); + menus::invoke(h.state_mut(), &ctx, "playback.loop", json!({})).unwrap(); + menus::invoke(h.state_mut(), &ctx, "playback.inToOut", json!({})).unwrap(); + for _ in 0..20 { + h.step(); + assert!(h.state().source_playback.clock.playing); + let t = h.state().session.state.source_playhead; + assert!(t >= start && t <= out); + } + assert_eq!(h.state().session.project.to_json(), before); + assert!(!h.state().playback.playing); + h.state_mut().stop_source(); +} +#[test] +fn a_single_frame_source_range_starts_at_the_marked_last_frame() { + let mut h = Harness::builder().with_step_dt(0.1).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + let v = filmcraft_engine::clip_ops::source_view(&h.state().session, filmcraft_engine::project::ItemId(5)).unwrap(); + let last = v.rate.snap(v.end - v.rate.frame_duration()); + h.state_mut().session.execute("project.setMarks", json!({"item":5,"in":last.0,"out":last.0})).unwrap(); + h.state_mut().play_source_range(false, false).unwrap(); + assert_eq!(h.state().session.state.source_playhead, last); + for _ in 0..4 { + h.step(); + } + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.state.source_playhead, last); +} diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs new file mode 100644 index 000000000..badc35365 --- /dev/null +++ b/crates/ui-egui/tests/timecode_ui.rs @@ -0,0 +1,176 @@ +//! The Timeline's and monitors' current-time timecodes, driven headless through the control +//! channel: dragging scrubs the playhead (1 frame per point, never before the start), a click +//! turns the timecode into a field where Enter goes to the typed time and Escape cancels. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_time::{FrameRate, parse_timecode}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("source.open", json!({"item": 5})).expect("source clip"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(r) = reply.try_recv() { + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + self.frames(2); + return r["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + /// Centre of an element, from `ui.elements`. + fn centre(&mut self, id: &str) -> (f64, f64) { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn label(&mut self, id: &str) -> String { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().find(|e| e["id"] == id).and_then(|e| e["label"].as_str()).unwrap_or_default().to_string() + } + fn drag_by(&mut self, id: &str, dx: f64) { + let (x, y) = self.centre(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y}})); + } + fn type_into(&mut self, id: &str, text: &str, key: &str) { + self.ok("ui.click", json!({"id": id})); + self.ok("ui.type", json!({"text": text})); + self.ok("ui.key", json!({"key": key})); + } + fn frame(&self) -> i64 { + let s = &self.harness.state().session; + s.sequence_rate().frame_at(s.playhead()) + } + fn source_rate(&self) -> FrameRate { + filmcraft_engine::clip_ops::source_view(&self.harness.state().session, filmcraft_project::ItemId(5)).unwrap().rate + } + fn source_frame(&self) -> i64 { + self.source_rate().frame_at(self.harness.state().session.state.source_playhead) + } + fn set_frame(&mut self, f: i64) { + self.harness.state_mut().session.execute("playhead.set", json!({"frame": f})).unwrap(); + self.frames(2); + } +} + +#[test] +fn dragging_the_timeline_timecode_scrubs_and_stops_at_the_start() { + let mut d = Driver::demo(); + let before = d.harness.state().session.project.to_json(); + d.set_frame(30); + d.drag_by("timeline.timecode", 20.0); + assert_eq!(d.frame(), 50, "1 frame per point"); + d.drag_by("timeline.timecode", -12.0); + assert_eq!(d.frame(), 38); + d.drag_by("timeline.timecode", -300.0); + assert_eq!(d.frame(), 0, "never before the start"); + assert_eq!(d.harness.state().session.project.to_json(), before, "scrubbing does not edit the project"); +} + +#[test] +fn typing_in_the_timeline_timecode_goes_to_that_time() { + let mut d = Driver::demo(); + d.set_frame(30); + d.type_into("timeline.timecode", "1000", "Enter"); + let (rate, df) = { + let s = &d.harness.state().session; + (s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame) + }; + assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap(), "the whole value was selected and replaced"); + d.type_into("timeline.timecode", "+15", "Enter"); + assert_eq!(d.frame(), parse_timecode("1000", rate, df, 0).unwrap() + 15, "relative to the current time"); +} + +#[test] +fn arrow_keys_move_the_caret_instead_of_committing() { + let mut d = Driver::demo(); + let (rate, df) = { + let s = &d.harness.state().session; + (s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame) + }; + // Left on the selected value lands before its last digit: Delete removes that digit + d.set_frame(30); + let tc = d.label("timeline.timecode"); + d.ok("ui.click", json!({"id": "timeline.timecode"})); + d.ok("ui.key", json!({"key": "Left"})); + assert_eq!(d.frame(), 30, "Left neither commits nor steps the playhead"); + d.ok("ui.key", json!({"key": "Delete"})); + d.ok("ui.key", json!({"key": "Enter"})); + let mut expect = tc.clone(); + expect.pop(); + assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} without its last digit"); + // Right goes to the end: Backspace removes the last digit, then a new one is typed + d.set_frame(30); + d.ok("ui.click", json!({"id": "timeline.timecode"})); + d.ok("ui.key", json!({"key": "Right"})); + d.ok("ui.key", json!({"key": "Backspace"})); + d.ok("ui.type", json!({"text": "9"})); + d.ok("ui.key", json!({"key": "Enter"})); + let mut expect = tc.clone(); + expect.pop(); + expect.push('9'); + assert_eq!(d.frame(), parse_timecode(&expect, rate, df, 0).unwrap(), "{tc} with its last digit replaced"); +} + +#[test] +fn escape_cancels_and_a_bad_time_is_reported_without_moving() { + let mut d = Driver::demo(); + d.set_frame(30); + d.type_into("timeline.timecode", "1000", "Escape"); + assert_eq!(d.frame(), 30, "Escape cancels"); + d.type_into("timeline.timecode", "abc", "Enter"); + assert_eq!(d.frame(), 30); + assert!(d.harness.state().ui.status.contains("timecode"), "status: {}", d.harness.state().ui.status); + // after a cancel or an error the readout scrubs again + d.drag_by("timeline.timecode", 10.0); + assert_eq!(d.frame(), 40); +} + +#[test] +fn the_program_and_source_timecodes_move_their_own_playheads() { + let mut d = Driver::demo(); + d.set_frame(30); + d.drag_by("program.timecode", 20.0); + assert_eq!(d.frame(), 50); + let program = d.harness.state().session.playhead(); + let source = d.source_frame(); + d.drag_by("source.timecode", 20.0); + assert_eq!(d.source_frame(), source + 20); + assert_eq!(d.harness.state().session.playhead(), program, "the Source timecode leaves the sequence alone"); + d.type_into("source.timecode", "100", "Enter"); + assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate"); + assert_eq!(d.harness.state().session.playhead(), program); +} diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs new file mode 100644 index 000000000..8ea07cd2b --- /dev/null +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -0,0 +1,300 @@ +//! Headless UI tests of trimming in the Timeline (#259): a press acts on what was under the +//! pointer when the button went down, the side of a cut picks the clip, and the cursor, +//! `ui.timeline.hit` and the press agree. The real `FilmcraftApp` under `egui_kittest` with the +//! demo project, the pointer moving a few pixels per frame as a mouse does. + +use std::sync::mpsc::{Sender, channel}; + +use egui::{CursorIcon, Pos2, pos2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_project::ClipId; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +/// Two clips of the demo project that share a cut on V1. +const CITY: &str = "City_Night_Drive.mp4"; +const DUNES: &str = "Desert_Dunes.mp4"; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The demo project, snapping off so drags land exactly where the pointer goes. Frames are + /// 1/60 s apart, as on a display: at the harness's default 1/4 s, a press held for four frames + /// is already a drag by time (egui's 0.8 s click limit), wherever the pointer is. + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + session.execute("sequence.snap", json!({"on": false})).expect("snapping off"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + // `ui.elements` answers once the timeline has finished fitting the sequence + d.ok("ui.elements", json!({"prefix": "timeline.clip."})); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn app(&self) -> &FilmcraftApp { + self.harness.state() + } + + /// Press at `from`, move to `to` over `steps` frames (as a mouse does), release. + fn drag(&mut self, from: Pos2, to: Pos2, steps: usize) { + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::PointerMoved(from)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + self.frames(1); + for i in 1..=steps { + push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + self.frames(2); + } + + /// Move the pointer to `at` with no button down, and let the Timeline react. + fn hover(&mut self, at: Pos2) { + self.harness.input_mut().events.push(egui::Event::PointerMoved(at)); + self.frames(2); + } + + /// The cursor the last frame asked for. + fn cursor(&self) -> CursorIcon { + self.harness.output().platform_output.cursor_icon + } + + /// Render the window to `/ui-screenshots/.png` (needs a GPU). + fn screenshot(&mut self, name: &str) -> std::path::PathBuf { + self.frames(2); + let img = self.harness.render().expect("render"); + let png = filmcraft_ui_egui::control::encode_png(img.as_raw(), img.width(), img.height()).expect("png"); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ui-screenshots"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.png")); + std::fs::write(&path, png).unwrap(); + eprintln!("screenshot: {}", path.display()); + path + } + + /// The clip named `name` on V1. + fn v1_clip(&self, name: &str) -> u64 { + let seq = self.app().session.active_sequence().expect("sequence"); + seq.video_tracks[0].items.iter().find(|i| i.name == name).unwrap_or_else(|| panic!("no {name} on V1")).id.0 + } + + /// A clip's (start, end) in ticks. + fn span(&self, clip: u64) -> (i64, i64) { + let seq = self.app().session.active_sequence().expect("sequence"); + let (_, it) = seq.find_item(ClipId(clip)).expect("clip"); + (it.start.0, it.end().0) + } + + /// Screen x of a clip's In and Out edges, and a y 8 px below the top of its track: the band + /// where edges are grabbed, above the lower part of a track where a transition on a cut takes + /// the press (the demo has one on the City_Night_Drive / Desert_Dunes cut). + fn edges(&mut self, clip: u64) -> (f32, f32, f32) { + // `ui.timeline.locate` answers with points 2 px inside the edges + let i = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "in"})); + let o = self.ok("ui.timeline.locate", json!({"clip": clip, "edge": "out"})); + let f = |v: &Value, k: &str| v[k].as_f64().expect("number") as f32; + let id = format!("timeline.clip.{clip}"); + let els = self.ok("ui.elements", json!({"prefix": id})); + let el = els.as_array().expect("elements").iter().find(|e| e["id"] == id.as_str()).unwrap_or_else(|| panic!("no {id}: {els}")).clone(); + let top = el["rect"][1].as_f64().expect("rect") as f32; + (f(&i, "x") - 2.0, f(&o, "x") + 2.0, top + 8.0) + } + + /// Ticks per screen pixel, from a clip's length on screen. + fn ticks_per_px(&mut self, clip: u64) -> f64 { + let (s, e) = self.span(clip); + let (x0, x1, _) = self.edges(clip); + (e - s) as f64 / f64::from(x1 - x0) + } + + /// One frame of the sequence, in ticks. + fn frame(&self) -> i64 { + self.app().session.active_sequence().expect("sequence").settings.frame_rate.frame_duration().0 + } + + fn undo_steps(&self) -> usize { + self.app().session.history.undo.len() + } +} + +/// #259: grabbing a clip's Out edge 3 px inside and dragging inward trims it, by as far as the +/// pointer moved. The drag is recognised only once the pointer has moved past egui's 6 pt drag +/// threshold, about 10 px inside the clip and so outside the 7 px edge zone, where the clip used to +/// be selected and moved instead. +#[test] +fn dragging_an_out_edge_inward_trims_the_clip() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let (x0, x1, y) = d.edges(city); + let (s0, e0) = d.span(city); + let (tpp, frame, undo0) = (d.ticks_per_px(city), d.frame(), d.undo_steps()); + let by = ((x1 - x0) / 4.0).round(); + d.drag(pos2(x1 - 3.0, y), pos2(x1 - 3.0 - by, y), by as usize); + let (s1, e1) = d.span(city); + assert_eq!(s1, s0, "the In edge stayed: trimmed, not moved"); + assert!(e1 < e0, "the Out edge moved in: {e0} → {e1}"); + let want = f64::from(by) * tpp; + assert!(((e0 - e1) as f64 - want).abs() <= frame as f64, "the edge moved {} ticks for {by} px ({want} ticks) ± a frame ({frame})", e0 - e1); + assert_eq!(d.undo_steps(), undo0 + 1, "one undo step"); +} + +/// Just right of a cut the clip on the right is grabbed: its In edge trims, and the clip on the +/// left stays as it was. The left clip's Out edge used to claim both sides of the cut. +#[test] +fn right_of_a_cut_trims_the_right_clip() { + let mut d = Driver::demo(); + let (city, dunes) = (d.v1_clip(CITY), d.v1_clip(DUNES)); + assert_eq!(d.span(city).1, d.span(dunes).0, "the demo's City_Night_Drive and Desert_Dunes share a cut"); + let (cut, _, y) = d.edges(dunes); + let (city0, (s0, e0)) = (d.span(city), d.span(dunes)); + d.drag(pos2(cut + 3.0, y), pos2(cut + 43.0, y), 40); + let (s1, e1) = d.span(dunes); + assert!(s1 > s0, "Desert_Dunes' In edge moved right: {s0} → {s1}"); + assert_eq!(e1, e0, "Desert_Dunes' Out edge stayed: trimmed, not moved"); + assert_eq!(d.span(city), city0, "City_Night_Drive is untouched"); +} + +/// A click that wobbles 5.5 px (still a click to egui, under its 6 pt limit) selects the edit point +/// that was pressed. It used to be hit-tested where the button came up, 8.5 px inside the clip and +/// outside the edge zone, and selected the clip. +#[test] +fn a_wobbly_click_on_an_edge_selects_the_edit_point() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let st = &mut d.harness.state_mut().session.state; + st.selection.clear(); + st.edit_points.clear(); + let (_, x1, y) = d.edges(city); + d.drag(pos2(x1 - 3.0, y), pos2(x1 - 8.5, y), 3); + let st = &d.app().session.state; + assert!(st.edit_points.iter().any(|e| e.clip == ClipId(city) && e.out), "City_Night_Drive's Out edge is the edit point: {:?}", st.edit_points); + assert!(!st.selection.contains(&ClipId(city)), "the clip is not selected"); +} + +/// A moved clip stays under the point it was grabbed by. It used to lag behind the pointer by the +/// distance egui waits before it calls a press a drag. +#[test] +fn a_moved_clip_follows_the_pointer_from_where_it_was_grabbed() { + let mut d = Driver::demo(); + let city = d.v1_clip(CITY); + let (x0, x1, y) = d.edges(city); + let (s0, _) = d.span(city); + let (tpp, frame) = (d.ticks_per_px(city), d.frame()); + let mid = (x0 + x1) / 2.0; + d.drag(pos2(mid, y), pos2(mid + 120.0, y), 30); + let (s1, _) = d.span(city); + let want = 120.0 * tpp; + assert!(((s1 - s0) as f64 - want).abs() <= frame as f64, "moved {} ticks for 120 px ({want} ticks) ± a frame ({frame})", s1 - s0); +} + +/// The cursor, `ui.timeline.hit` and the press agree on what is under the pointer: just left of +/// a cut the left clip's Out edge, just right of it the right clip's In edge, mid-clip no edge. +/// (That a drag from those points trims those edges is tested above, from the same points.) +#[test] +fn cursor_hit_test_and_press_agree_at_a_cut() { + let mut d = Driver::demo(); + let (city, dunes) = (d.v1_clip(CITY), d.v1_clip(DUNES)); + let (x0, _, _) = d.edges(city); + let (cut, _, y) = d.edges(dunes); + let hit = |d: &mut Driver, x: f32, mods: Value| d.ok("ui.timeline.hit", json!({"x": x, "y": y, "modifiers": mods})); + for (x, clip, edge) in [(cut - 3.0, city, "Out"), (cut + 3.0, dunes, "In")] { + d.hover(pos2(x, y)); + assert_eq!(d.cursor(), CursorIcon::ResizeColumn, "trim cursor at x {x}"); + let h = hit(&mut d, x, json!({})); + assert_eq!((h["clip"].as_u64(), h["edge"].as_str(), h["kind"].as_str()), (Some(clip), Some(edge), Some("trim")), "at x {x}: {h}"); + let h = hit(&mut d, x, json!({"command": true})); + assert_eq!(h["kind"], "ripple", "Cmd at x {x}: {h}"); + } + let mid = (x0 + cut) / 2.0; + d.hover(pos2(mid, y)); + assert_eq!(d.cursor(), CursorIcon::Default, "no trim cursor mid-clip"); + let h = hit(&mut d, mid, json!({})); + assert_eq!((h["clip"].as_u64(), h["kind"].clone()), (Some(city), Value::Null), "mid-clip: {h}"); +} + +/// Renders the hover bracket at the City_Night_Drive / Desert_Dunes cut to PNGs for visual +/// review (needs a GPU): `cargo test -p filmcraft-ui-egui --test timeline_trim_ui -- --ignored +/// hover_bracket_screenshots`. Yellow on City_Night_Drive's Out edge just left of the cut and on +/// Desert_Dunes' In edge just right of it, red for the Ripple tool, both sides for the Rolling tool. +#[test] +#[ignore] +fn hover_bracket_screenshots() { + let mut d = Driver::demo(); + let dunes = d.v1_clip(DUNES); + let (cut, _, y) = d.edges(dunes); + for (tool, name, x) in + [("selection", "trim-out", cut - 3.0), ("selection", "trim-in", cut + 3.0), ("ripple", "ripple-in", cut + 3.0), ("rolling", "roll", cut + 3.0)] + { + d.ok("ui.set", json!({"tool": tool})); + d.hover(pos2(x, y)); + d.screenshot(&format!("hover-{name}")); + } +} + +/// A caption's Out edge, grabbed 2 px inside and dragged inward, trims the caption. The gesture +/// used to be decided where the drag was recognised, about 9 px inside (2 px plus egui's 6 pt drag +/// threshold), past the 5 px edge zone, so the caption moved. +#[test] +fn dragging_a_caption_edge_inward_trims_it() { + let mut d = Driver::demo(); + let id = d.exec("captions.add", json!({"seconds": 2.0, "durationSeconds": 2.0}))["caption"].as_u64().expect("caption id"); + d.frames(4); + let name = format!("timeline.caption.{id}"); + let els = d.ok("ui.elements", json!({"prefix": name})); + let el = els.as_array().expect("elements").iter().find(|e| e["id"] == name.as_str()).unwrap_or_else(|| panic!("no {name}: {els}")).clone(); + let r: Vec = el["rect"].as_array().expect("rect").iter().map(|v| v.as_f64().expect("number") as f32).collect(); + let (x1, y) = (r[0] + r[2], r[1] + r[3] / 2.0); + let span = |d: &Driver| { + let seq = d.app().session.active_sequence().expect("sequence"); + let (_, c) = seq.find_caption(ClipId(id)).expect("caption"); + (c.start.0, c.end().0) + }; + let (s0, e0) = span(&d); + d.drag(pos2(x1 - 2.0, y), pos2(x1 - 32.0, y), 30); + let (s1, e1) = span(&d); + assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); + assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); +} diff --git a/crates/ui-egui/tests/transition_drop_ui.rs b/crates/ui-egui/tests/transition_drop_ui.rs new file mode 100644 index 000000000..e26133472 --- /dev/null +++ b/crates/ui-egui/tests/transition_drop_ui.rs @@ -0,0 +1,141 @@ +//! Transition drag feedback: actual span and edge, no edit until release, one undo step. +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::{Session, commands::preview_transition}; +use filmcraft_project::TrackKind; +use filmcraft_ui_egui::FilmcraftApp; +use serde_json::json; + +fn step(h: &mut Harness<'static, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + ctx.request_repaint(); + h.step(); +} + +fn pointer(h: &mut Harness<'static, FilmcraftApp>, pos: Pos2, pressed: Option) { + h.input_mut().events.push(Event::PointerMoved(pos)); + if pressed == Some(true) { + step(h); + } + if let Some(pressed) = pressed { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + step(h); +} + +fn rect(h: &Harness<'static, FilmcraftApp>, id: &str) -> Rect { + let [x, y, w, height] = h.state().auto.elements.iter().find(|e| e.id == id).unwrap_or_else(|| panic!("missing {id}")).rect; + Rect::from_min_size(pos2(x, y), vec2(w, height)) +} + +fn harness(effect: &str) -> Harness<'static, FilmcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name":"Transition drops","fps":24})).unwrap(); + let rate = s.sequence_rate(); + // A short clip touching a longer clip: either edge must be distinguishable on screen. + for (at, frames) in [(0, 48), (48, 6)] { + s.execute("timeline.place", json!({"item":5,"time":rate.tick_of(at).0,"duration":rate.tick_of(frames).0})).unwrap(); + } + let mut app = FilmcraftApp::new(s); + app.set_workspace("Effects"); + app.ui.effects_search = effect.to_string(); + let mut builder = Harness::builder().with_size(vec2(1600.0, 980.0)).with_max_steps(10_000); + if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_cc| app); + for _ in 0..4 { + step(&mut h); + } + // Opening the new sequence restores its own view; set the test zoom after that initial fit. + h.state_mut().ui.timeline.pps = 60.0; + h.state_mut().ui.timeline.target_pps = 60.0; + h.state_mut().ui.timeline.fit_pending = false; + step(&mut h); + h +} + +#[test] +fn transition_drag_previews_both_edges_of_short_clips_and_commits_that_span() { + for (kind, effect, name) in [(TrackKind::Video, "cross_dissolve", "Cross Dissolve"), (TrackKind::Audio, "constant_power", "Constant Power")] { + let mut h = harness(name); + let clip = h.state().session.active_sequence().unwrap().tracks(kind)[0].items[1].id; + let clip_rect = rect(&h, &format!("timeline.clip.{}", clip.0)); + let from = rect(&h, &format!("effects.item.{effect}")).center(); + let before = h.state().session.project.to_json(); + pointer(&mut h, from, Some(true)); + pointer(&mut h, from + vec2(0.0, 10.0), None); + for (edge, fraction) in [("in", 0.2), ("out", 0.8)] { + let to = pos2(clip_rect.left() + clip_rect.width() * fraction, clip_rect.center().y); + pointer(&mut h, to, None); + let preview = h.state().auto.elements.iter().find(|e| e.id == "timeline.transitionDropPreview").expect("transition preview while dragging"); + assert_eq!(preview.label, edge); + assert_eq!(h.state().session.project.to_json(), before, "hover must not edit"); + let (_, planned) = preview_transition(&h.state().session, &json!({"effect":effect,"clip":clip.0,"edge":edge}), kind).unwrap(); + let rate = h.state().session.sequence_rate(); + let short = &h.state().session.active_sequence().unwrap().tracks(kind)[0].items[1]; + let px_per_tick = clip_rect.width() / short.duration.0 as f32; + let expected_left = clip_rect.left() + (planned.start - short.start).0 as f32 * px_per_tick; + let content = rect(&h, "timeline.tracks"); + let expected_right = (expected_left + planned.duration.0 as f32 * px_per_tick).min(content.right()); + let expected_left = expected_left.max(content.left()); + assert!((preview.rect[0] - expected_left).abs() < 1.0); + assert!( + (preview.rect[2] - (expected_right - expected_left)).abs() < 1.0, + "{kind:?} {edge}: preview {:?}, clip {clip_rect:?}, planned {planned:?}, pps {}", + preview.rect, + h.state().ui.timeline.pps + ); + assert_eq!( + planned.duration, + if kind == TrackKind::Video { + h.state().session.prefs.timeline.video_transition_duration(rate) + } else { + h.state().session.prefs.timeline.audio_transition_duration(rate) + } + ); + if let Some(dir) = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("transition-{effect}-{edge}.png"))).unwrap(); + } + } + let to = pos2(clip_rect.left() + clip_rect.width() * 0.8, clip_rect.center().y); + let (_, planned) = preview_transition(&h.state().session, &json!({"effect":effect,"clip":clip.0,"edge":"out"}), kind).unwrap(); + pointer(&mut h, to, Some(false)); + let applied = h.state().session.active_sequence().unwrap().tracks(kind)[0].transitions.last().unwrap(); + assert_eq!((applied.start, applied.duration, applied.from, applied.to), (planned.start, planned.duration, planned.from, planned.to)); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project.to_json(), before); + step(&mut h); + assert!(!h.state().auto.elements.iter().any(|e| e.id == "timeline.transitionDropPreview")); + } +} + +#[test] +fn incompatible_and_locked_tracks_do_not_offer_a_transition_preview() { + for locked in [false, true] { + let mut h = harness("Cross Dissolve"); + let kind = if locked { TrackKind::Video } else { TrackKind::Audio }; + let q = h.state().session.active_sequence().unwrap(); + let (track, clip) = (q.tracks(kind)[0].id, q.tracks(kind)[0].items[1].id); + if locked { + let s = &mut h.state_mut().session; + std::sync::Arc::make_mut(&mut s.project).sequence_mut(s.state.active_sequence.unwrap()).unwrap().track_mut(track).unwrap().locked = true; + step(&mut h); + } + let to = rect(&h, &format!("timeline.clip.{}", clip.0)).center(); + let from = rect(&h, "effects.item.cross_dissolve").center(); + let before = h.state().session.project.to_json(); + pointer(&mut h, from, Some(true)); + pointer(&mut h, from + vec2(0.0, 10.0), None); + pointer(&mut h, to, None); + assert!(!h.state().auto.elements.iter().any(|e| e.id == "timeline.transitionDropPreview")); + pointer(&mut h, to, Some(false)); + assert_eq!(h.state().session.project.to_json(), before); + } +} diff --git a/crates/ui-egui/tests/unlink_ui.rs b/crates/ui-egui/tests/unlink_ui.rs new file mode 100644 index 000000000..6366cee60 --- /dev/null +++ b/crates/ui-egui/tests/unlink_ui.rs @@ -0,0 +1,114 @@ +//! Headless UI test of Unlink from the timeline clip menu (#220): after unlinking, the clip that +//! was right-clicked is the only one selected, so a drag moves it away from its former partner. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::{ClipId, TrackItem}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new() -> Self { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + // the control channel's clicks and keys enter through the input hook + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + + fn item(&mut self, c: ClipId) -> TrackItem { + self.app().session.active_sequence().unwrap().find_item(c).unwrap().1.clone() + } + + fn selection(&mut self) -> Vec { + let mut v = self.app().session.state.selection.clone(); + v.sort_by_key(|c| c.0); + v + } + + /// Right-click `clip` on the timeline and choose Link / Unlink from its menu. + fn menu_link(&mut self, clip: ClipId) { + self.ok("ui.click", json!({"id": format!("timeline.clip.{}", clip.0), "button": "right"})); + self.frames(3); + self.ok("ui.click", json!({"id": "timeline.clipMenu.clip.link"})); + self.frames(3); + } + + /// A linked picture on V1 and its sound. + fn linked_pair(&mut self) -> (ClipId, ClipId) { + let seq = self.app().session.active_sequence().unwrap().clone(); + let v = seq.video_tracks[0].items.iter().find(|i| i.link.is_some()).expect("a linked clip"); + let a = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.link == v.link).expect("its sound"); + (v.id, a.id) + } +} + +#[test] +fn unlinked_picture_drags_away_from_its_sound() { + let mut d = Driver::new(); + let (v, a) = d.linked_pair(); + let (v0, a0) = (d.item(v).start, d.item(a).start); + d.menu_link(v); + assert_eq!((d.item(v).link, d.item(a).link), (None, None), "unlinked"); + assert_eq!(d.selection(), [v], "only the right-clicked clip stays selected"); + // dragging the picture straight away leaves the sound where it was + let id = format!("timeline.clip.{}", v.0); + d.ok("ui.drag", json!({"from": {"id": id, "fx": 0.3}, "to": {"id": id, "fx": 0.6}, "steps": 12})); + d.frames(4); + assert_ne!(d.item(v).start, v0, "the picture moved"); + assert_eq!(d.item(a).start, a0, "its sound stayed"); +} + +#[test] +fn unlinking_from_the_sound_keeps_the_sound_selected_and_link_keeps_both() { + let mut d = Driver::new(); + let (v, a) = d.linked_pair(); + d.menu_link(a); + assert_eq!(d.selection(), [a]); + // Link again: select both, link from the menu; the selection is left alone + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [v.0, a.0]}})); + d.menu_link(v); + assert!(d.item(v).link.is_some() && d.item(v).link == d.item(a).link, "linked again"); + let mut both = vec![v, a]; + both.sort_by_key(|c| c.0); + assert_eq!(d.selection(), both); +} diff --git a/docs/adr/0001-platform-ffi.md b/docs/adr/0001-platform-ffi.md index d067c4b78..3dd438368 100644 --- a/docs/adr/0001-platform-ffi.md +++ b/docs/adr/0001-platform-ffi.md @@ -25,10 +25,12 @@ Containment rules: 1. The crate does not use `lints.workspace = true`. Its own `[lints]` table copies the workspace lints except `unsafe_code = "deny"` (not `forbid`), and adds - `clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`, and - `nvenc::ffi` / `nvenc::session` on Windows) carry `#[allow(unsafe_code)]`; the rest of the crate + `clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`, + `nvenc::ffi` / `nvenc::session` / `nvenc::device` on Windows and 64-bit Linux, and `vaapi::va` on Linux) carry `#[allow(unsafe_code)]`; the rest of the crate (the fallback logic in `hybrid`, the decoder logic in `media_foundation`, `annexb`, `biplanar`, - the encoder logic in `nvenc`) has no `unsafe`. + the encoder logic in `nvenc`, H.264 and H.265 alike) has no `unsafe`. NVENC's H.265 support + added no `unsafe` module: it reuses `nvenc::ffi` and `nvenc::session` (new data declarations and + a second codec-configuration member of an existing union). 2. Every `unsafe` block has a `// SAFETY:` comment saying why it is sound. 3. The public API is safe: no `pub unsafe fn`, no raw pointers or FFI types in public signatures; failures are `Result`s. The crate keeps the never-crash `deny(clippy::unwrap_used, …)` @@ -93,6 +95,14 @@ Everything else in this record applies unchanged: `unsafe` stays in `crates/plat has a `// SAFETY:` comment, no panic crosses the FFI boundary, the public API is safe, and the crate compiles everywhere (`register()` is a no-op off macOS). +## Addendum (2026-10-08): NVENC HEVC Main 10 (HDR) + +HDR H.265 added no `unsafe` module either. `nvenc::ffi` gained data declarations (the HEVC picture +parameters, `NV_ENC_SEI_PAYLOAD`, the 10-bit buffer format) checked by the generated layout tests, and +`nvenc::session` gained the P010 pitch check and the SEI pointers it hands to the driver, which are +heap blocks owned by the session for its whole life (`// SAFETY:` comments say so). The conversion from +float pictures to 10-bit planes is safe code in the export crate. + ## Addendum (2026-10-07): hardware H.265 (HEVC) encoding The same VideoToolbox session wrapper also creates HEVC sessions (`VtProfile::HevcMain`). The rules @@ -101,3 +111,38 @@ there will not be one (pure Rust, clean-room: x265 is GPL), so `Format::Hevc` ex OS has a hardware encoder. Choosing the format is the opt-in, `filmcraft_export::available` asks a probe the platform crate registers, and what the hardware path does not take (two-pass, odd sizes, a machine without the encoder) is an error naming the reason instead of a different encoder. + +## Addendum (2026-10-08): VA-API hardware decoding on Linux + +The Linux backend this decision named: H.264 and HEVC decoding through VA-API (`vaapi/`). The rules above +hold; VA-API differs from the other two backends in three ways. + +- **libva is loaded at run time** (`libva.so.2`, `libva-drm.so.2`, through `libloading`: ISC, already + in the lockfile through wgpu). Building needs no libva headers, and a system without libva, a DRM + render node or a working driver starts as before and decodes in software (`register()` reports + `Unavailable`). The declarations (`vaapi/ffi.rs`) are transcribed from libva's MIT-licensed + `va.h`, and `vaapi/abi_tests.rs` checks sizes, offsets, constants and bit-field positions against a + C compiler's view of it, as for NVENC. Only `vaapi::va` carries `#[allow(unsafe_code)]`; the + declarations, the H.264 front end (`vaapi::h264`) and the decoder (`VaDecoder`) are safe code. +- **Decoding is stateless:** the host parses the stream and keeps the decoded picture buffer; the GPU + only decodes slice data. FilmCraft does that host side with the software decoders' own parsers and + DPBs (`filmcraft_h264::dpb` and `filmcraft_hevc::dpb` are generic over what a picture is), so the two decoders decide alike by + construction, and the front end is tested on every OS against a stand-in for the hardware. The + one place they cannot agree is concealment: pictures predicted from frames that were never decoded + (the leading pictures of an open H.264 GOP after a seek) are concealed by both but can differ; + HEVC pictures with references that were never decoded go to the software decoder instead. +- **One callback into Rust:** libva's error messages go to our log through a callback that runs under + `catch_unwind`; its info messages are turned off. + +The fallback guarantee is unchanged: what the hardware path does not take is declined up front or, +mid-stream, handed to the software decoder by `HybridDecoder`. + +## Addendum: Linux NVENC H.264 encoding + +The existing NVENC session is shared with 64-bit Linux. Only device acquisition and driver loading +are platform-specific: Windows retains the Direct3D device; Linux retains the first CUDA device's +primary context through `libcuda.so.1`. `cuDevicePrimaryCtxRetain` does not push a context onto the +calling thread's stack. The retained reference is released after the NVENC session and its buffers; +other users' references are not reset. `libnvidia-encode.so.1` stays loaded with its function table. +Both libraries come from the installed NVIDIA driver, not this project. Export registration on +Linux does not claim that a hardware decoder is available. The same opt-in and fallback rules apply. diff --git a/docs/agents.md b/docs/agents.md index 2f02bc91f..39d53f770 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -33,6 +33,23 @@ claude mcp add filmcraft-headless -- /abs/path/filmcraft/target/release/filmcraf { "mcpServers": { "filmcraft": { "command": "/abs/path/filmcraft-cli", "args": ["mcp", "--bridge", "127.0.0.1:9876"] } } } ``` +### From an installed release + +The release packages ship `filmcraft-cli` alongside the desktop app, so no build is needed: + +| Install | CLI | +|---|---| +| Windows (MSI) | `C:\Program Files\FilmCraft\filmcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Linux (deb, rpm) | `/usr/bin/filmcraft-cli` | +| macOS | the separate `filmcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | + +```sh +# Windows, default install folder +claude mcp add filmcraft -- "C:\Program Files\FilmCraft\filmcraft-cli.exe" mcp +# Linux, or macOS with the CLI unzipped onto PATH +claude mcp add filmcraft -- filmcraft-cli mcp +``` + ### Tools | Tool | Modes | Purpose | @@ -166,6 +183,9 @@ Notes: Every command is also one shell call away. Options go anywhere; output is JSON; exit status is 0 on success, 1 when a command fails and 2 on a usage error. `filmcraft-cli help` prints the reference. +If the reader of stdout closes the pipe early (`filmcraft-cli commands | head`), the CLI drops the rest +of its output but still finishes the work, saves included, and the exit status still reports +failures; any other stdout write error is reported and makes the status 1. ```sh filmcraft-cli commands razor # find ids (add --json for machine output) @@ -173,6 +193,7 @@ filmcraft-cli describe timeline.razor # one command: menu, shortcut, filmcraft-cli --demo inspect sequence # project / sequence as JSON filmcraft-cli --project p.fcproj --save exec timeline.razor seconds=3.5 filmcraft-cli --project p.fcproj exec effects.apply '{"effect":"Gaussian Blur"}' +filmcraft-cli --project p.fcproj exec markers.exportCsv path=review.csv filmcraft-cli --project p.fcproj --save import a.mov b.wav filmcraft-cli --project p.fcproj export out.mp4 # format from the extension; waits for the job filmcraft-cli --project p.fcproj export out --preset "YouTube 1080p Full HD" --start 0 --end 10 @@ -187,6 +208,22 @@ strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command (`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`. `--save` writes back to `--project`; `--save-as path` writes elsewhere. +`markers.exportCsv {path}` exports all markers in the active sequence, including colours hidden +in the UI and markers outside In/Out, sorted by start then id. The UTF-8 CSV has a header and CRLF +records; quotes, commas and embedded newlines are escaped. Columns are Sequence, Marker ID, Name, +Comment, Kind, Color, Start Timecode, End Timecode, Duration Frames, Start Ticks, Duration Ticks and +Frame Rate (exact numerator/denominator). Color uses the user's display label from Settings ▸ +Labels. Timecodes include the sequence's start timecode and +drop-frame setting; End is exclusive, and a point marker's End equals Start. Duration Frames is +rounded down; tick columns preserve sub-frame timing (254016000000 ticks per second). +An empty sequence writes just the header. User text starting with a spreadsheet formula character +(`=`, `+`, `-`, `@`, including after whitespace) gets a leading apostrophe in the exported cell. +The export leaves the project, dirty flag and undo history untouched, works with locked tracks, +and uses the host's file service (atomic +on desktop, a download on web). It exports sequence markers only, not clip/source markers; +reports are limited to 100000 markers and a conservative 16 MiB output budget. Invalid timing or a +failed write reports an error. The menu opens the normal save dialog; cancelling writes nothing. + ## 3. Verifying UI work For any UI change, look at the result: diff --git a/docs/architecture.md b/docs/architecture.md index 9d1a29b51..f54585d7f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ and `filmcraft-cli`. | `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue | | `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers | | `automation` | L5 | MCP server (`rmcp`, stdio), headless or bridged to the running app | -| `platform` | L5 | OS media FFI only: hardware video decoding (VideoToolbox H.264 / HEVC on macOS; Media Foundation / Direct3D 11 H.264 / HEVC on Windows; a no-op elsewhere) behind `codecs::VideoDecoder`, with transparent fallback to our decoders, and hardware H.264 encoding (VideoToolbox, opt-in) and H.265 encoding (VideoToolbox, the only H.265 encoder; the format exists only where a hardware encoder does) and NVIDIA NVENC H.264 encoding (Windows, opt-in) behind `export::VideoEncoder`; H.264 declines to the built-in encoder for what the hardware does not take. The one crate allowed `unsafe` ([ADR 0001](adr/0001-platform-ffi.md), [README](../crates/platform/README.md)) | +| `platform` | L5 | OS media FFI only: hardware video decoding (VideoToolbox H.264 / HEVC on macOS; Media Foundation / Direct3D 11 H.264 / HEVC / VP9 / AV1 on Windows; VA-API H.264 / HEVC on Linux, with our own parsers and DPBs driving the stateless hardware; a no-op elsewhere) behind `codecs::VideoDecoder`, with transparent fallback to our decoders, and hardware H.264 encoding (VideoToolbox, opt-in) and H.265 encoding (VideoToolbox, the only H.265 encoder; the format exists only where a hardware encoder does) and NVIDIA NVENC H.264 (Windows and 64-bit Linux, opt-in) and H.265 (Windows, FilmCraft's only H.265 encoder there) encoding behind `export::VideoEncoder`; H.264 declines to the built-in encoder for what the hardware does not take. The one crate allowed `unsafe` ([ADR 0001](adr/0001-platform-ffi.md), [README](../crates/platform/README.md)) | | `filmcraft` | L6 | desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server | | `filmcraft-cli` | L6 | headless CLI: `exec`, `run`, `inspect`, `describe`, `commands`, `import`, `export`, `render`, `probe`, `mcp`; `--bridge` targets the running app | | `filmcraft-web` | L6 | the browser app (wasm32): eframe web runner on WebGPU/WebGL2, Blob-backed services, OPFS recovery, WebAudio, WebCodecs, `window.filmcraft` API ([web.md](web.md)) | @@ -265,17 +265,25 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see Decoders run slices on rayon, so an export worker waiting inside a decode can pick up another frame of the same source. A request that finds the shared decoder busy decodes with a private decoder. -- **Hardware encoding.** On Windows, `platform::nvenc::export::factory` is registered with +- **Hardware encoding.** On Windows and 64-bit Linux, `platform::nvenc::export::factory` is registered with `filmcraft_export::register_encoder` (`register()` does this once), in front of the software H.264 encoder. It takes an export only when Export ▸ Hardware encoding (`ExportSettings.hardwareEncoding`, `HardwareEncoding::Auto`) is Auto, which is off by default: hardware streams differ from ours, and exports are otherwise byte-identical from run to run. NVENC (`platform::nvenc`, the driver's - `nvEncodeAPI64.dll` loaded at run time) then takes RGBA frames, converted to 4:2:0 by the software + `nvEncodeAPI64.dll` / `libnvidia-encode.so.1` loaded at run time) then takes RGBA frames, converted to 4:2:0 by the software encoder's own conversion, and produces the H.264 samples and `avcC` of an MP4 / MOV. It declines (the software encoder runs, counted in `export.hardware.declined`) two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits, and systems without an NVIDIA GPU or driver. A failure during an export ends it with an error: the software encoder cannot take over a hardware - stream. + stream. The same NVENC session also encodes H.265 (HEVC Main 8-bit 4:2:0 SDR, or Main 10 HDR: PQ / HLG, + BT.2020, limited range, from the float R'G'B' pictures through `rgbf_to_yuv420_10` into P010 input + buffers, with the HDR10 static metadata as SEI on every IDR for PQ; the `hvcC` is built from the SPS + the encoder wrote). An HDR sequence exports as HDR H.265 only where a registered probe says so + (`filmcraft_export::register_hdr_probe`, `hdr_available`; NVENC with 10-bit support), otherwise it is + tone-mapped to SDR as on macOS: `register()` hands `platform::nvenc::hevc_available` to + `filmcraft_export::register_format_probe`, so the H.265 format exists where an HEVC encoder does, + and choosing it is the opt-in (the Hardware encoding toggle governs H.264 only). With no software + H.265 encoder, what NVENC declines is counted and ends the export with an error that says why. - **Compositor.** `render` is the reference for monitors, thumbnails and export. `render::plan` turns a frame into GPU layers, each with its opacity and blend mode. All 27 blend modes run on the GPU (`filmcraft-gpu`): Normal and Dissolve with fixed-function "over" blending, the others by @@ -308,9 +316,11 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see not zero (`VideoFrame::alpha_region`, `to_linear_f32_region`, `blend::composite_at`), the rest being transparent anyway; and working images are recycled through `filmcraft_frame::pool` (`take_f32_overwritten` / `recycle_f32`: the buffer comes back with its old contents, so there is - no zero-fill) by the export pipeline once a frame is converted to 8 bits. A clip with effects, - opacity masks, frame blending, colour management, or a picture that is not placed one to one on - the output takes the general path. + no zero-fill) by the export pipeline once a frame is converted to 8 bits; a standalone export's + pipeline frees the idle float images (`pool::trim_f32`) when it goes away, so a finished export + leaves none behind (parts of a batch, such as render-preview segments, keep them for the next part). A clip + with effects, opacity masks, frame blending, colour management, or a picture that is not placed + one to one on the output takes the general path. - **Frame scheduling.** `crates/ui-egui/src/frames.rs` runs a small pool of worker threads with prioritised jobs: the frame on screen first, then playback prefetch, then thumbnails. The UI never decodes. It shows the exact frame when it is ready and holds the nearest cached frame meanwhile. @@ -439,7 +449,7 @@ stereo / mono track ──5.1 panner───┼─► 5.1 submix / 5.1 Mix ─ ### 5.1.2a Text to Speech (narrations) -**Narrations** (`engine::narration`, `tts.*`; voices in `filmcraft-tts`). The Text to Speech panel (Window ▸ Text to Speech) writes a script with pause markers (`[pause 1s]`), a voice, a vocal pitch and a pace; `tts.create` synthesizes it, writes `Narration .wav` (mono 32-bit float, 24 kHz, where voice-over recordings go), imports it and places it at the playhead on the first free targeted audio track, else the first free audio track, else a new one, as one undo step. The settings are kept per generated item in `Project::narrations` (schema v13). Selecting a narration clip loads it into the panel; `tts.edit` writes a new file (beside the previous one) and swaps the clip to it, keeping the clip's start, duration and speed: longer speech is kept whole in the file but plays only to the clip's end; shorter speech is padded with silence to the clip's length. The old item and narration stay, so undo is exact. `tts.preview` synthesizes without changing the project and the UI plays `Session::tts_preview` on the audio output. Built-in voices are an original formant synthesizer (robotic, no download). The natural voices (feature `neural-voices`, on in the desktop app) are Kokoro-82M on candle (`filmcraft-tts`) fed by `filmcraft-tts-text` (normalizer, CMUdict + letter-to-sound); their 336 MB package is downloaded once after the user confirms (`tts.downloadVoices`, a job). The UI synthesizes with `tts.render` (a job filling `Session::tts_cache`) and then runs create / edit / preview, which use the cache. +**Narrations** (`engine::narration`, `tts.*`; voices in `filmcraft-tts`). The Text to Speech panel (Window ▸ Text to Speech) writes a script with pause markers (`[pause 1s]`), a voice, a vocal pitch and a pace; `tts.create` synthesizes it, writes `Narration .wav` (mono 32-bit float, 24 kHz, where voice-over recordings go), imports it and places it at the playhead on the first free targeted audio track, else the first free audio track, else a new one, as one undo step. The settings are kept per generated item in `Project::narrations` (schema v14). Selecting a narration clip loads it into the panel; `tts.edit` writes a new file (beside the previous one) and swaps the clip to it, keeping the clip's start, duration and speed: longer speech is kept whole in the file but plays only to the clip's end; shorter speech is padded with silence to the clip's length. The old item and narration stay, so undo is exact. `tts.preview` synthesizes without changing the project and the UI plays `Session::tts_preview` on the audio output. Built-in voices are an original formant synthesizer (robotic, no download). The natural voices (feature `neural-voices` of the desktop app, off by default like `whisper`) are Kokoro-82M on candle (`filmcraft-tts`) fed by `filmcraft-tts-text` (normalizer, CMUdict + letter-to-sound); their 336 MB package is downloaded once after the user confirms (`tts.downloadVoices`, a job). The UI synthesizes with `tts.render` (a job filling `Session::tts_cache`) and then runs create / edit / preview, which use the cache. ### 5.1.3 Remix @@ -456,7 +466,7 @@ Clip ▸ Remix ▸ Enable Remix / Remix Properties… / Revert Remix (`clip.remi ```text clip.essential (type + settings) ──apply()──► clip effects marked `essential`, clip gain, Volume, Panner -essentialSound.autoMatch BS.1770 integrated loudness of render::audio::clip_signal → match gain (clip gain) +essentialSound.autoMatch BS.1770 integrated loudness of render::audio::clip_signal (mono clips as one channel) → match gain (clip gain) essentialSound.generateDucking trigger clips' summed level (10 ms hops) → activity regions → Volume keyframes ``` diff --git a/docs/captions.md b/docs/captions.md index 12564ddd8..fd76483e2 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -52,7 +52,7 @@ Details of each format and the SCC encoder are in [`crates/captions/README.md`]( | `captions.setStyle` | Text panel style strip | `track`, `size`, `color`, `background`, `backgroundColor`, `outline`, `outlineColor`, `align`, `anchor`, `margin`, `lineSpacing`, `reset` | | `captions.deleteTrack` | | `track` | | `captions.import` / `captions.export` | File ▸ Export ▸ Captions… | `path`, `format`, `track`, `dropFrame` | -| `captions.list` | (query) | `track` — tracks, styles and captions with timecodes | +| `captions.list` | (query) | `track` — tracks, styles and captions with timecodes (a `track` that names no caption track is an error) | Tracks are addressed by id or `"C1"`, `"C2"`… (top first). Edits are limited by neighbouring captions and a one-frame minimum. Sync-locked caption tracks follow insert and extract edits on diff --git a/docs/contributing.md b/docs/contributing.md index 5bfaf1cf1..570259e19 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -36,8 +36,13 @@ cargo run --release -p filmcraft-cli -- mcp --demo # headless MCP server on s cargo xtask web --serve 8765 # the web app on http://127.0.0.1:8765/ (docs/web.md) ``` -`FILMCRAFT_CONTROL_PORT=9876` works like `--control 9876`, and `FILMCRAFT_CPU_COMPOSITE=1` -disables the GPU compositor. +| Variable | Effect | +|---|---| +| `FILMCRAFT_CONTROL_PORT` | Same as `--control ` | +| `FILMCRAFT_CPU_COMPOSITE=1` | Disables the GPU compositor | +| `FILMCRAFT_DATA_DIR` | Same as `--data-dir `: auto-save, crash recovery, settings and logs | +| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | + `filmcraft --help` lists the app's options. An option it does not know, or a control port that is not a number (from `--control` or from `FILMCRAFT_CONTROL_PORT`), is an error on stderr with exit code 2 instead of a window. @@ -45,6 +50,25 @@ code 2 instead of a window. Dev builds compile dependencies at `opt-level = 2` and workspace crates at `opt-level = 1`. For playback and codec speed, use `--release`. +### Logs + +The desktop app writes its `log` records to standard error and to `Logs/filmcraft.log` in the data +directory (`~/.local/share/filmcraft/Logs/` or `$XDG_DATA_HOME/filmcraft/Logs/` on Linux, +`~/Library/Application Support/FilmCraft/Logs/` on macOS, `%APPDATA%\FilmCraft\Logs\` on Windows, +or under `--data-dir` / `FILMCRAFT_DATA_DIR`), next to the crash logs; Help ▸ Reveal Log Files opens +the folder. A start from a desktop menu or the Dock has no terminal, so this file is what to attach +to a bug report: GPU compositor fallbacks, audio output problems, panicked frame jobs and container +warnings all land there. Each launch moves the previous log to `filmcraft.1.log` (and that one to +`filmcraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing +at 16 MiB. `--help`, `--version` and command-line errors write no file. + +By default FilmCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces +that with env_logger-style directives, for example `RUST_LOG=debug`, +`RUST_LOG=warn,filmcraft_codecs=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` +covers every target starting with it (`filmcraft*=debug`). The logger is +`apps/filmcraft/src/logging.rs`; the realtime audio callbacks do not log. The web app logs to the +browser console instead. + ### Building with craft-fonts Font assets live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this @@ -66,6 +90,15 @@ FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a c warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts' glyphs skip when it is empty; when you touch fonts, run the gates both with and without it. +Chinese media names and track labels use the `Hans` / `Hant` craft-fonts faces after the Japanese +fallbacks in every UI font family, regardless of interface language (Japanese and Chinese share code +points, so Japanese text keeps Japanese glyph forms; the Chinese faces cover hanzi the Japanese ones lack). Without those build inputs, +native builds look up an installed Chinese face (for example Microsoft YaHei or Noto Sans CJK SC) +once and reuse it across theme changes. No system font files are bundled or copied into the app. +Web builds depend on the font faces selected by `crates/text/build.rs`; they cannot scan system fonts. +`theme::tests::chinese_names_render_in_every_theme_and_font_family` verifies Chinese glyphs in the +timeline's proportional, monospace, medium and semibold families under all three themes. + ## 3. Quality gates Every commit must pass all of these: diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6419e154a..e17bf4649 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,12 +34,12 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input | | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | -| `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…) / `{text}` | keyboard | -| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y}` / `{clip, edge?}` | timeline hit-testing | +| `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard | +| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | -| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle plane buffers of evicted frames, and planes decoded into a recycled one), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | +| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when nothing is selected. When `params` name the targets under a key the command documents (`clips` / diff --git a/docs/graphics.md b/docs/graphics.md index 6d935e82c..4e1888fb3 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -84,9 +84,9 @@ Timecode and Clip Name effects also draw with the text engine. |---|---|---| | `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `box` (`[w, h]`: paragraph text in a box, `position` is its top-left corner), `clip` (add to this graphic), `vertical`, `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` | | `graphics.newVerticalText` | New Layer ▸ Vertical Text | as `graphics.newText`; characters stack top to bottom, paragraphs are columns right to left | -| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position`, `size`, `clip`; polygon `sides` (6) | +| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position` (the shape's centre), `size`, `clip`, `seconds` (5), `track`, `time`; polygon `sides` (6) | | `graphics.newFromFile` | New Layer ▸ From file… | `path` — imports the image or video and places it above the clips at the playhead (a separate clip; graphics have no media layers yet) | -| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` | +| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position` (the shape's centre), `size`, `points`, `clip` (add to this graphic), `seconds` (5), `track`, `time` | | `graphics.setTextType` | Text Properties ▸ Text Layer Type | `clip`, `layer`, `type` (`point` / `paragraph`); the text stays where it is | | `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) | | `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` | @@ -248,7 +248,17 @@ panel state and the Type-tool selection. | `file.replaceFonts` | Graphics and Titles ▸ Replace Fonts in Projects… | `from` (family or {family, style}), `to`, `toStyle`: graphic layers, character styles, source graphics and caption tracks | | `graphics.fonts.used` | (query) | fonts in use with counts and whether they are missing | +## Gradient fills + +Appearance › Fill Type › Linear Gradient paints the shape or the text with a ramp from Gradient +Start to Gradient End. Stops are blended in linear light. The angle is degrees clockwise on +screen; 0° runs left to right across the layer's own bounds, and the ramp turns with the layer. +`graphics.set` accepts `fill_kind` (`solid` or `linear gradient`), `gradient_start`, +`gradient_end` and `gradient_angle`. A per-character fill colour still paints those characters +as a solid; the rest of the text keeps the ramp. A singular layer transform falls back to the +solid fill colour. + ## Not yet -Mask-with-text, gradient fills, per-layer blend modes, Bézier curves in the pen tool (paths are -polygons), media layers inside graphics, and template controls grouped into folders. +Mask-with-text, per-layer blend modes, Bézier curves in the pen tool (paths are polygons), media +layers inside graphics, and template controls grouped into folders. diff --git a/docs/monitors.md b/docs/monitors.md index 4f79e6d7a..d60ff85bb 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -30,3 +30,123 @@ Automation ids: `.zoom`, `.zoom.`, `.settings. `.guide.`, `program.compare.reference|prev|next|set`, `source.waveform`, and in the dialogs `guides.add.vertical|horizontal|position|ok|cancel`, `guides.save.name|ok|cancel`, `guides.manage.row.|apply|delete|close`. + +## Source playback + +Video files, audio files, generated media and media subclips play in the Source monitor with its +Play/Pause button or Space while Source has focus. The Source clock and playhead are independent +of the active sequence; auditioning does not create timeline clips or change the project. Starting +Source stops Program playback, and starting Program stops Source, so only one monitor owns audio. +Scrubbing while playing seeks the Source clock and restarts its audio at that position. Replacing +the open source stops playback. Playback stops at the last frame, and Play there starts again. +Play auditions the full source span by default. Play In to Out and Play to Out +address the marked Source range when Source has focus. The Source wrench menu can loop the +marked range; Program keeps its separate loop setting. + +Normal forward playback has sound. L starts normal forward Source playback, K stops, and the +left/right frame-step keys address Source when it has focus. Reverse and shuttle-speed Source +playback and source sequences are not yet supported and report an explanatory error. A failed +audio device leaves the picture advancing with a status message; a decoding error stops Source. + +Commands: `source.playback.play`, `source.playback.stop`, `source.playback.toggle` (no parameters), +available through the UI command registry, CLI bridge and MCP. `playback.toggle` also accepts +`monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, +audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test source_playback_ui`. + +## Drag a marked Source range to the timeline + +An unmarked Source clip shows In at its beginning and Out on its last frame. These are implicit +limits: opening a clip does not write marks or dirty the project. Set In/Out with I/O or the +transport buttons; the saved marks are reused when reopening that clip. Out includes its frame. + +Drag the **Video only**, **Audio only**, or **Video + audio** icons between Fit and playback +resolution to an open +sequence. Drop video on a video track and audio on an audio track. Both uses the dropped track +and its corresponding track of the other kind, creating linked clips in one undoable edit. +Unavailable stream choices are disabled. Dragging the video picture also brings its available +streams; a vertical drag from an audio waveform brings sound, while a horizontal drag scrubs. +The drag captures the selected range at its start. Source playback pauses when lifting a clip. +A locked or unavailable destination reports an error without partially placing the pair. + +Automation ids: `source.drag.video`, `source.drag.audio`, `source.drag.both`, `source.picture`, +and `source.waveform`. Placement dispatches `timeline.place` with `sourceIn`, `duration`, `video` +and `audio`; the stream flags default to true for existing callers. Engine tests cover range +bounds, stream choices, linked placement, invalid parameters and undo/redo. UI tests exercise +actual drags: `cargo test --release -p filmcraft-ui-egui --test source_drag_ui`. + +Ctrl+Shift+X (Cmd+Shift+X on macOS) clears In/Out on the focused Source clip, including split +points, and restores the implicit full span. It is undoable and leaves Program marks unchanged. +The command `markers.clearInOut` accepts `target: "source"|"program"`; omitting it addresses Program. +Source drag icons use the Windows hand cursor because its native Grab mapping is crossed arrows. + +Source range braces use the playhead's theme color and the gray selection band is translucent, +so the ruler ticks remain visible. This shared egui implementation applies to Windows, macOS +and Linux. The clear shortcut uses the primary modifier: Ctrl on Windows/Linux, Cmd on macOS; +I/O, Space and the three drag gestures are common to all three platforms. Runtime verification +for this contribution was performed on Windows; macOS/Linux have not been run locally. + +## Adjust Source marks on the ruler + +Drag either In/Out handle to trim the selected span, or drag the interior gray band to move +both marks while keeping the duration. Handles show an original red bracket and arrows on +hover. Edits snap to source frames, stay within the clip and retain at least one frame. +A gesture previews without editing the project and commits one undoable `project.setMarks` +command on release. Escape cancels; opening another source or a concurrent project edit +also cancels the draft. Subclip handles are currently disabled. + +Automation ids are `source.range.in`, `source.range.out`, `source.range.body` and +`source.settings.loop`. Clicking the band's interior still scrubs. + +Source-focused Go to In/Out, clear individual/both marks, clip-marker commands and +previous/next marker address the open Source clip. Shift+Left/Right step multiple Source +frames using the preference. UI callers can override focus with `monitor: "source"|"program"`; +engine marker/navigation commands accept `target: "source"|"program"` and default to Program. +Source marker edits leave sequence markers and the Program playhead unchanged. + +Coverage: engine `source_monitor::tests` validates frame bounds and hostile inputs; UI +`source_drag_ui` covers release, cancellation, range movement and command routing; +`source_playback_ui` covers marked-range stopping, looping and the last-frame boundary. + +## Program range handles + +Program uses the same In/Out hover cues, frame-snapped trimming, duration-preserving range +movement and one-edit undo behavior. It targets the active sequence through `project.setMarks` +and leaves an independently opened Source clip's marks and playhead unchanged. Program shows only explicitly set marks; clearing them hides the corresponding handles. +Range movement requires both Program marks. Empty sequences disable the handles. +Changing the active sequence cancels an unfinished draft. Automation ids: `program.range.in`, +`program.range.out` and `program.range.body`. Both monitors share the original brace artwork, +playhead-blue color and translucent gray band. Runtime review remains Windows-only. + +The playhead has drag priority above the range band and trim handles, including a full-span +selection. Drag it to scrub; the unoccupied upper ruler can also scrub. Drag the gray band away +from the playhead to translate the marked range. + +## Export Frame + +The camera button in either monitor opens Export Frame. Drag its white header to move the dialog. +The suggested name is the source filename without its extension, or the sequence name, without an added `.Still` suffix. Choose a name, format and depth, +use Browse to choose the destination folder (or edit Path), and click OK. Existing files require +an explicit Replace confirmation. Cancel, the header close button, Escape, and clicking outside +the dialog (including the top menus or mode tabs) dismiss it without writing anything. +The dialog's own dropdowns and Browse button keep it open. The white +header and dark body use original generic controls. Source details and frame time appear below +the settings. Frame time uses the same hours/minutes/seconds/frames timecode as its monitor, +including the Program sequence's drop-frame setting, and stays captured while the dialog is open. +The last successful folder is reused during the current app session. + +PNG and TIFF support 8 or 16 bits per channel. The 16-bit path quantizes directly from the +float image, preserving precision beyond 8 bits. JPEG (quality 95) and BMP support 8-bit output; +choosing either resets Depth to 8 Bit and disables 16 Bit. The command rejects unsupported +format/depth combinations. In the browser the still is offered as a download. Import into project +adds the saved still as one undoable edit. Failed writes retain the settings and report an error. + +The monitor, item and frame time are captured when the settings open. Export uses the original +full-resolution media, even with proxies or reduced preview resolution enabled. Source exports +the source picture at its media playhead; Program exports the sequence composite, effects and +captions at its timeline playhead. Audio-only Source clips have no picture to export. + +The shared `file.exportFrame` command accepts `target: "source" | "program"` (Program by default), +`format: "png" | "jpeg" | "tiff" | "bmp"`, `depth: 8 | 16` (default 8), +optional `item` for Source or `sequence` for Program, and an exact integer `time` in ticks. +Supplying `path` executes directly; invoking it through the UI without a path opens settings, +routed to the focused monitor unless an explicit monitor/target is provided. diff --git a/docs/performance.md b/docs/performance.md index 5fd62d739..2aec7db9c 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -95,6 +95,18 @@ compositor's savings are a larger part of the export. What is left in a frame without overlays is the Y'CbCr → linear float conversion of the camera picture (4.5 ms in the synthetic case) and the conversion to 8 bits (2 ms). +### Memory after an export + +The recycled images stay on the shelves of `filmcraft_frame::pool` (up to 320 MiB of them, and +192 MiB of each plane type), and nothing else asks for images of an export's size, so a finished +export left them idle. A standalone export's pipeline now frees the idle float images when it goes +away, however the export ended (done, failed, cancelled, dropped half way). The plane shelves are +kept (playback recycles decoded frames through them), and so are the float images after one part +of a batch (a render-preview segment, a proxy), which the next part reuses. A process that exported a synthetic 3 s 1080p24 matte as PNG frames three times +(Apple M1, `footprint`, 10 s after the last export) rested at **575 MB** with the pool holding +332 MB idle and at **258 MB** with the trim. The memory does not come back at once: macOS returns +freed pages over a few seconds (892 MB right after the export in both cases, 726 MB after 2 s). + ## Results (GPU2: standard effects on the GPU compositor, #30, before → after) Before = this change with clips that carry standard effects sent back to the CPU layer path in @@ -214,9 +226,80 @@ Where the built-in export's time goes (`sample` on the process during a 20 s exp time in the call tree; the figures overlap because work-stealing mixes the tasks): about two thirds of the busy CPU is in the encoder, about a third in compositing and layer decoding. Moving the encoder to the media engine removes most of the first and lets the next batch render while the previous one -encodes (the exporter renders a batch of frames in parallel, then encodes it). What is left is the +encodes (the exporter now does that: see "Export: rendering the next batch while the current one is +encoded" below). What is left is the CPU compositor, the next target of #30 ("GPU export"). +## Export: rendering the next batch while the current one is encoded + +Until now a step of the stepped exporter (`filmcraft_export::Exporter`) rendered a batch of frames in +parallel and only then encoded, muxed and mixed the audio of that batch on one thread, so the wall +time was the sum of the stages. Where the sum was large (1080p30, 300 frames, `perf.stats` +`export.stages`) the stages were: software H.264 render 4257 ms, encode 17845 (RGB to YUV 766 of it), +audio 462, mux 17, finish 142 = 22.96 s; NVENC H.264 setup 465, render 3316, encode 1113 (convert 794), +audio 471, mux 20, finish 24 = 5.69 s. + +Now a step renders batch k+1 on the rayon pool while the calling thread encodes, muxes and mixes the +audio of batch k (`rayon::in_place_scope`), and the next step starts from the frames already rendered. +Wall time is about `max(render, encode + audio + mux)` instead of the sum. What did not change: + +- **The file.** Batches are cut and encoded in the same order and the audio / mux interleave still + follows the 16-frame groups, so the output is byte-identical with or without overlap, on any core count + and with any step size, including one frame per step (tests in `determinism_tests.rs`: H.264, ProRes, + Motion-JPEG, two-pass). +- **Pending media.** A prefetched batch whose sources were not ready is dropped and rendered again by a + later step; nothing is encoded from an incomplete batch. The web app (no threads) never overlaps: it runs + the sequential render-then-encode as before. +- **Cancel and errors.** Cancelling drops the prefetched frames; a panic while rendering ahead becomes an + export error. The encoder stays on the thread that called `step`. +- **Memory.** Two batches are in flight (the one being encoded and the one being rendered), so up to + `2 * batch * frame_bytes` of finished frames are held, and the batch is lowered until that fits in + `IN_FLIGHT_BUDGET` (512 MiB; at least one frame): 16 frames of 1080p (8 MB), 8 + 8 frames of 4K SDR + (33 MB), 2 + 2 frames of 4K HDR (100 MB, 3840 x 2160 x 3 f32), 1 of 8K HDR. A batch never grows, and + frames are freed as soon as they are encoded. A first try with a 1 GiB budget (16 + 16 frames of 4K SDR, + 5 + 5 of 4K HDR) raised the peak RSS of the 4K SDR exports by 80-700 MB over the old code; 512 MiB + brings it under the old peak (table below). `Exporter::set_overlap(false)` gives the old behaviour. +- **Known web issue, not changed here.** `AudioOut::pull` moves its position before the pending check, so + when a web export retries a batch after a pending audio source, that group of audio is lost. It is + independent of the overlap (the web never overlaps); TODO: fix separately. + +`perf.stats` `export.stages`: `renderMs` and `encodeMs` (and audio / mux) now run at the same time, so +their sum can exceed the export's wall time. `waitMs` is the time the encoding side spent waiting for the +next batch to finish rendering after it was done encoding: large means render-bound, near zero means +encode-bound. + +Measured (Xeon E5-2680 v4 14C/28T, RTX 5060, driver 617.42, Windows 11, release CLI, 300 frames of +30 fps, mean of 2 alternated rounds, export wall time, peak RSS of the CLI process; "before" is the same +harness on the commit without the overlap, "now" the 512 MiB budget; the 1080p rows were measured with a +1 GiB budget, which gives the same batches at that size): + +| Case | wall before | wall now | speedup | `waitMs` now | peak RSS before / now | +|---|---|---|---|---|---| +| 1080p SW H.264 + AAC | 22.60 s | 20.66 s | 1.09x | 0 | 1279 / 1427 MB (+148) | +| 1080p NVENC H.264 | 5.56 s | 4.99 s | 1.11x | 49 ms | 1318 / 1441 MB (+123) | +| 1080p ProRes 422 HQ | 16.97 s | 15.32 s | 1.11x | 0 | 1234 / 1392 MB (+158) | +| 2160p SW H.264 | 60.79 s | 54.12 s | 1.12x | 0 | 4030 / 3113 MB (-917) | +| 2160p NVENC H.264 | 18.08 s | 15.64 s | 1.16x | 678 ms | 4177 / 3250 MB (-927) | +| 2160p ProRes 422 HQ | 60.92 s | 55.71 s | 1.09x | 0 | 3675 / 3140 MB (-535) | +| 2160p HDR PQ SW H.264 | 82.79 s | 77.62 s | 1.07x | 6758 ms | 5284 / 2560 MB (-2724) | +| 2160p HDR PQ ProRes 422 HQ | 89.14 s | 83.95 s | 1.06x | 215 ms | 5183 / 2565 MB (-2618) | +| 1080p effects NVENC H.264 | 28.70 s | 26.82 s | 1.07x | 13116 ms | 2044 / 2130 MB (+86) | +| 1080p effects SW H.264 | 39.60 s | 34.35 s | 1.15x | 0 | 2017 / 2084 MB (+67) | + +With the first 1 GiB budget the 2160p rows were 55.42 / 16.00 / 56.75 s at 4404 / 4254 / 4376 MB peak and +the HDR rows 76.69 / 84.39 s at 3504 / 3532 MB: the same speed within 2 % (the HDR SW row 1.2 % slower +at 512 MiB), but 4K SDR used up to 700 MB more than the old code. Halving the budget costs no time and +makes every 4K export smaller than before. + +The gain is 6-16 %, well under the `max(render, encode + audio + mux)` bound (1.06-1.86x). The reason is +that the encode side is not one core: the software H.264 and ProRes encoders run their slices on the same +rayon pool, and the RGB to YUV conversion is parallel too, so render and encode compete for the same +cores and overlapping them only fills idle gaps (the serial parts: entropy coding tails, muxing, audio, +the encoder's hand-off). Only NVENC, whose encode is light on the CPU, comes close to the bound in the +1080p case (4.99 s against 4.00 s). The effects case on NVENC is render-bound (`waitMs` 13 s of 27 s: the +encoder waits for the renderer), as expected. 1080p exports use 70-160 MB more (the second batch of 16 +frames); 4K exports use less than before because the batch is smaller (8 for SDR, 2 for HDR instead of 16). + ## Results (HW3: VideoToolbox hardware H.265 (HEVC) encoding, against hardware H.264) Apple M1 (8 cores, 16 GB), 2026-10-07, single runs on an otherwise idle machine, release build. Both @@ -349,6 +432,48 @@ Software AV1 decodes 4K at 5.5 fps here, so it never plays in real time; with th plays without a drop. As on the other codecs, the hardware ignores draft mode and what is left on the CPU is the readback and plane conversion. +## Results (Linux VA-API H.264 and HEVC hardware decoding, Off → Auto) + +Same commit, Settings ▸ Playback ▸ Hardware decoding switched with the bench flag +(`cargo xtask bench --sections decode --only dec_h --repeat 3 --hw off|auto`), 2026-10-08, Intel +Core i5-13500H with Iris Xe graphics (Raptor Lake-P), 7.4 GB RAM, Intel iHD driver 26.1.2 through +libva 2.22, load average 5–15. Only H.264 goes through VA-API so far; the HEVC rows decode in +software either way and show the run-to-run spread. The decoded pictures are identical +(bit-exact parity tests, `crates/platform/tests/vaapi.rs`). + +| codec | size | CPU ms/frame Off → **Auto** | fps Off → **Auto** (best of 3) | hw frames | +|---|---|---|---|---| +| H.264 | 1080p | 103.7 → **2.3** | 133 → **263** | 360 | +| H.264 | 2160p | 416.5 → **11.4** | 32 → **56** | 216 | +| HEVC | 1080p | 63.2 → 46.5 | 99 → 131 | 0 | +| HEVC | 2160p | 349.5 → 353.7 | 30 → 35 | 0 | +| HEVC Main 10 | 2160p | 348.9 → 352.6 | 29 → 29 | 0 | + +What is left on the CPU per H.264 frame is the host side of stateless decoding (parsing, DPB, +filling the VA buffers) and the read-back: `vaGetImage` into an NV12 image and the copy into +planar Y'CbCr. One picture is decoded at a time and read back as soon as the DPB outputs it, so +4K throughput (56 fps) is bound by that round trip, not by the video engine; overlapping decode +and read-back, or zero-copy into wgpu, would raise it. + +### HEVC through VA-API (HW5 follow-up) + +Same machine and commands on 2026-10-09 with HEVC added, at a lower load (the software decoders ran +faster than in the table above; compare within a row). H.264 is unchanged; every HEVC stream now +goes through VA-API too (216–360 hardware frames, no fallbacks), bit-exact with the software decoder. + +| codec | size | CPU ms/frame Off → **Auto** | fps Off → **Auto** (best of 3) | +|---|---|---|---| +| H.264 | 1080p | 57.3 → **2.6** | 227 → **241** | +| H.264 | 2160p | 231.8 → **12.6** | 58 → **52** | +| HEVC | 1080p | 45.6 → **2.3** | 141 → **341** | +| HEVC | 2160p | 202.5 → **10.1** | 47 → **76** | +| HEVC Main 10 | 2160p | 211.6 → **18.4** | 42 → **46** | + +At this lower load the multi-threaded software decoders keep up with the one-picture-at-a-time +hardware path on throughput for H.264 and 10-bit HEVC, at 5–20 % of their CPU time; under load +(the run above) the hardware path is ahead on both. 10-bit pictures cost more to read back (P010 is +twice the bytes of NV12 and is shifted down into 16-bit planes). + ## Results (GPU1: blend modes on the GPU compositor, #30, before → after) Before = this change with the old whole-frame CPU fallback for non-Normal blend modes put back diff --git a/docs/project-files.md b/docs/project-files.md index 68980cea8..c8827150b 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -8,7 +8,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm ```json { "format": "filmcraft.project", - "schema_version": 12, + "schema_version": 13, "generator": "FilmCraft 0.1.0", "project": { "name": "…", "settings": { … }, "root": { … }, "items": { … }, "next_id": 48 }, "view": { "open_sequences": [21, 50], "active_sequence": 50, "sequences": { "21": { "pps": 40.0, "scroll": 0.0, … } } } @@ -21,12 +21,12 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm clips, effects, keyframes, markers). Media is referenced by path, never embedded. - Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB. - `view` (optional) is what was open when the project was saved: the Timeline's sequence tabs in - order, the active one, and each sequence's zoom, scroll and track heights - (`filmcraft_project::ProjectView`). It is beside the project, not in it: it is not part of the + order, the active one, each sequence's zoom, scroll and track heights, and each sequence's + playhead (`filmcraft_project::ProjectView`). It is beside the project, not in it: it is not part of the edit, never an undo step, and changing it does not mark the project as changed. Opening a project restores it when the Timeline preference "Restore open sequences when opening projects" is on. It is outside the schema version: older builds ignore it, ids that are not sequences and - numbers out of range are dropped or clamped, and a `view` that cannot be read is ignored (the + numbers out of range are dropped or clamped (a playhead also snaps to a frame of its sequence), and a `view` that cannot be read is ignored (the project opens on its first sequence, as it does without one or with no open sequence in it). - A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto" (the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back @@ -52,6 +52,7 @@ writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` comm | 10 | M3.10 | clip time interpolation, Hold Filters, Field Options, audio source channels, Modify ▸ Audio Channels map, subclip Restrict Trims; no-op step | | 11 | M3.11 | search bins (`project.search_bins`), Flash Cue markers, Project Settings safe areas, capture format and scratch disks; no-op step | | 12 | M10.7 | graphics design data: `TrackItem::graphic` (roll / crawl, responsive time, template link), `EffectInstance::layer` (layer uid, per-character styles, responsive pins), `project.source_graphics`; no-op step | +| 13 | Multi-stream audio | `MediaInfo::audio_streams` and `TrackItem::audio_stream`; older `audio` objects load as stream zero, missing clip indices default to zero; no-op step | ### Migrations @@ -159,7 +160,8 @@ Desktop flags: `--recover` recovers the newest candidate without asking. `--no-r without asking; the changes stay available through File ▸ Recover Unsaved Changes…. `--data-dir ` (or `FILMCRAFT_DATA_DIR`) moves the data directory. The default is `~/Library/Application Support/FilmCraft` on macOS, `%APPDATA%\FilmCraft` on Windows, and `$XDG_DATA_HOME/filmcraft` or -`~/.local/share/filmcraft` elsewhere. +`~/.local/share/filmcraft` elsewhere. A `portable.txt` file next to the executable (the Windows +portable zip ships one) makes it `data` next to the executable instead. ### Cost diff --git a/docs/releasing.md b/docs/releasing.md index 04e59e73c..631e2ee96 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -77,6 +77,9 @@ deliberately, in every job at once. - **Notarization:** the app is sent with `xcrun notarytool submit`, then the ticket is stapled and checked with `stapler validate`. The app goes on a DMG (`hdiutil makehybrid`), which is signed and notarized too. The universal `filmcraft-cli` is signed the same way, zipped, and the zip is notarized. + Its Finder window (background, icon size and positions) comes from + [`packaging/macos/dmg/`](../packaging/macos/dmg/README.md), and its volume is named `FilmCraft` + without the version, which the window's background needs; the DMG file name keeps the version. Locally, without certificates, the script signs ad hoc and skips notarization, which is enough to check the bundle and the DMG on your own Mac (`packaging/macos/package.sh`). @@ -104,6 +107,15 @@ nor the portable zip needs the Visual C++ redistributable. `.tar.gz` tree. The packages install both programs, the desktop entry, the AppStream metainfo, the icons and the licence files. +The Linux tarball includes `install.sh`. Extract it, open a terminal in the extracted +directory and run `./install.sh` to install both programs and their desktop integration +under `~/.local` (no administrator permissions needed). The application-menu entry uses +the installed binary's absolute path; add `~/.local/bin` to `PATH` for terminal commands. +Run the installer again to update. For a system-wide installation, use +`sudo ./install.sh --prefix /usr/local`. A custom absolute `--prefix` is also supported; +its `share` directory must be in the desktop's data search path to appear in the menu. +Prefixes containing `=`, `%` or control characters are rejected for desktop-launcher compatibility. + The jobs run on `ubuntu-22.04`, the oldest GitHub-hosted image, so the binaries only need glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. The deb (`libc6 (>= 2.35)`) and the rpm (`glibc >= 2.35`) both declare that floor, so older systems refuse the install. Moving @@ -126,7 +138,9 @@ runs `filmcraft-cli --version` in the sandbox. Users install it with `flatpak install --user `; the freedesktop runtime comes from Flathub. `packaging/linux/flatpak/ai.storyteller.filmcraft.yml` is the from-source manifest for a Flathub submission (its header says how to build it). Both manifests must keep the same runtime and -`finish-args` (packaging-lint checks this). +`finish-args`, and install the same programs: the app and `filmcraft-cli`, which runs the +headless commands and the MCP server in the sandbox +(`flatpak run --command=filmcraft-cli ai.storyteller.filmcraft mcp`). packaging-lint checks this. ### FreeBSD diff --git a/docs/testing.md b/docs/testing.md index fdd569601..856dad73f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,6 +20,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails | Monitor view / Graphics menu | `crates/ui-egui/tests/view_ui.rs`, `crates/engine/src/graphics_tests.rs` | View menu items and checkmarks, playback/paused resolution, channel / comparison / multi-camera / waveform display modes, magnification and Hand-tool panning, guides dragged from the rulers (move, lock, remove, Add Guide…, templates in the preferences), snapping a graphic to the frame centre and a guide; align to frame / as group / to selection, distribute (centres and gaps), arrange, select next/previous graphic and layer, reset parameters / duration, vertical text, New Layer from file (`FILMCRAFT_UI_SHOTS=` writes `view-*.png`) | | Settings | `crates/engine/src/settings_tests.rs`, `crates/ui-egui/tests/settings_ui.rs` | schema keys and defaults, persistence in the data directory, v1 → v2 migration and repair of bad values, validation, per-category reset; each wired setting (still / transition durations, step many, label defaults and colours, media scaling, timebase, recent projects, media cache policy, output mapping, smart quotes, auto-transcribe); the dialog: every category from its command and Cmd+,, edits by automation id, OK / Cancel / Escape / Reset…, theme, label names in Edit ▸ Label, tooltips, frame cache (`FILMCRAFT_UI_SHOTS=` writes `settings-*.png`) | | Scopes and panels (M8.9 / M12.6) | `crates/scopes/src/tests.rs`, `crates/engine/src/{scopes,panels}_tests.rs`, `crates/ui-egui/tests/panels_ui.rs` | scope maths on generated frames (flat colours → exact histogram bins, ramps → waveform row = code value, 75 % bars → parade levels and vectorscope target cells for BT.601/709/2020, HLS hue angles, YC chroma, Clamp Signal, decimation, NaN); `scopes.read` on a colour matte (exact bins, 78.43 %) and on Bars and Tone (the six targets); metadata edits (one undo step, read-only fields refused, saved in the project), the event log (failed / disabled commands, repeats, jobs started / finished / failed / cancelled); every panel from Window ▸, the scopes' wrench menu, presets, five-scope grid, typing into a Metadata field, Timecode rows and modes, Events filter / Clear All, Progress cancel, Reference Monitor park / gang / scopes (`FILMCRAFT_UI_SNAPSHOT_DIR=` writes `panels-*.png`); `perf_scopes_at_1080p` (ignored) times each scope | +| Keyframe navigator and Effect Controls ruler | `crates/engine/src/tests.rs` (`add_keyframe_toggles_the_keyframe_at_the_playhead`), `crates/ui-egui/tests/keyframes_ui.rs`, `lane_tests` in `crates/ui-egui/src/panels/effect_controls.rs` | `effects.addKeyframe` adds a keyframe at the playhead or removes the one there (the value falls back to the remaining keyframes; undo, redo, parameters that name nothing); headless: the Properties diamond turns animation on, adds and removes the keyframe at the playhead (Scale 50 % → 100 % again), its ◀ ▶ arrows and Effect Controls' step between keyframes, Crop is applied before its first keyframe; a click on the Effect Controls time ruler and a drag of the playhead's handle move the playhead, and no playhead is drawn off the clip; ruler labels are sequence timecode, and clip times, frame rates and panel widths at their limits don't panic (`FILMCRAFT_UI_SHOTS=` writes `keyframes-*.png`) | | Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) | | Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases | | Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity | @@ -34,6 +35,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails | Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id | | Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | | Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic | +| Program monitor while dragging | `crates/ui-egui/tests/perf_ui.rs` | headless: a parameter changed on every UI pass (a value dragged in Effect Controls or Properties) and a backward scrub leave the Program monitor's picture at most one step behind (it shows the frame asked for the pass before while the frame due renders; it used to freeze until the mouse rested); a frame still rendering is kept when the same frame is asked for at a newer revision, so frames slower than a refresh (CPU effects) are shown too; `perf.stats` reports the picture's frame and revision against the playhead and the project | | Performance | `*/tests/perf.rs` (`#[ignore]`) | §5 | Commit `*.proptest-regressions` files so failing cases are re-run. @@ -72,11 +74,21 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). without a Direct3D 11 video device or the HEVC / VP9 / AV1 codec extensions of the Microsoft Store. The VP9 and AV1 fixtures (libvpx-vp9, libaom-av1; 360p, 1080p, 2160p, hidden alt-ref frames, two GOPs) need an ffmpeg with those encoders. -The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs`, Windows only) skip without an -NVIDIA GPU with NVENC. The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated -from a C program built with MSVC against NVIDIA's MIT-licensed `nvEncodeAPI.h` (12.1); to regenerate -them, print the sizes, alignments, offsets, constants and GUIDs of `src/nvenc/ffi.rs` from that -program and update the asserts. +The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for +the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, +`nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and +`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU +with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says +there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in +`crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC +(`cl` after `vcvars64.bat`) against NVIDIA's MIT-licensed `nvEncodeAPI.h` (12.1); the header and the +program are not in the repository. To regenerate them, put the header in a scratch directory and +write a program that prints, for every type, field, constant and GUID of `src/nvenc/ffi.rs` that the +asserts name (H.264, HEVC and the HEVC picture parameters with their SEI payload array), `sizeof` / `alignof` / `offsetof`, the enum and macro values cast to +64-bit integers and the GUIDs as 128-bit hex; for the bit-fields of the `flags` words, which +`offsetof` cannot take, zero a structure, set that one field to its maximum and print the 32-bit word. +Compare the numbers with the asserts (a difference is drift, which must be understood before +anything is "fixed" to match) and add asserts for anything new. ### Pass criteria per codec @@ -184,13 +196,19 @@ window (for example on a locked screen, where `ui.screenshot` cannot capture). `crates/ui-egui/tests/essential_sound_ui.rs` does the same for the Essential Sound panel (type buttons, switches, a slider drag as one undo step, section bypass, Auto-Match, ducking, Browse presets; `essential-sound-*.png`). +`crates/ui-egui/tests/clip_audio_ui.rs` covers the audio of video clips (#223): the linked audio's +Volume, Channel Volume and Panner in Effect Controls and Properties, and the Volume line on audio +clips in the Timeline (a drag is one undo step, Pen-tool keyframes, keyframe drags, a click or +right-click on the line still reaches the clip; `FILMCRAFT_UI_SHOTS=` writes `volume-*.png`). Essential Sound engine tests (`crates/engine/src/essential_sound_tests.rs`) build projects from generated speech-like and tonal WAVs: Auto-Match lands within ±0.5 LU of the target (measured: 0.000 LU, -with and without a repair/clarity chain); ducking on a dialogue + music project gives keyframes within -60 ms of the expected times (measured 20 ms) and −15.00 dB in the mix; each repair stage improves its -metric through the render path (hum −39 dB, rumble −22 dB, noise floor −13 dB, sibilance −19 dB, -reverb tail −10 dB); the mix is bit-identical however requests are cut and the WAV export equals it. +with and without a repair/clarity chain) and measures a mono clip as one channel (3.01 LU below the +same signal as dual mono, within 0.1 LU of a one-channel meter over the file); ducking on a dialogue + +music project gives keyframes within 60 ms of the expected times (measured 20 ms) and −15.00 dB in the +mix; each repair stage improves its metric through the render path (hum −39 dB, rumble −22 dB, noise +floor −13 dB, sibilance −19 dB, reverb tail −10 dB); the mix is bit-identical however requests are cut +and the WAV export equals it. `perf_full_dialogue_chain_realtime_factor` (ignored; run with `--release`) prints the realtime factor of all nine Dialogue effects on one clip (22× on one core). @@ -326,3 +344,13 @@ At load ~25–60 the same final build plays h264-1080, stack3 and h264-2160 at F in 3 of 3 runs (192/0) and render previews 192/0. The 4K fixture needs ~4 cores of decode per real-time second (≈160 ms CPU per frame at 170 Mbit/s); the demo project's procedural footage ~190 ms per Full-resolution frame. + +### Audio mixer throughput + +`perf_24_tracks_3_effects_realtime_factor` (24 tracks, 72 inserts and a compressed submix on one +core) must run at least 4x realtime in release builds (1x in debug). On Unix it times the test +thread's CPU time, so it runs with the rest of the suite. Hosts without a per-thread CPU clock +(Windows) fall back to wall time, which depends on load, so there it is ignored by default; run it +explicitly with +`cargo test --release -p filmcraft-render --lib perf_24_tracks_3_effects_realtime_factor -- --ignored --nocapture` +and record the machine with the result. diff --git a/packaging/linux/flatpak/ai.storyteller.filmcraft.yml b/packaging/linux/flatpak/ai.storyteller.filmcraft.yml index 3bbc1ecd9..ef8addc75 100644 --- a/packaging/linux/flatpak/ai.storyteller.filmcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.filmcraft.yml @@ -7,6 +7,11 @@ # flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.filmcraft.yml # flatpak run ai.storyteller.filmcraft # +# The command-line tool and its MCP server run in the same sandbox, without opening a window: +# +# flatpak run --command=filmcraft-cli ai.storyteller.filmcraft --version +# flatpak run --command=filmcraft-cli ai.storyteller.filmcraft mcp +# # For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. id: ai.storyteller.filmcraft runtime: org.freedesktop.Platform @@ -49,8 +54,10 @@ modules: CARGO_HOME: /run/build/filmcraft/cargo CARGO_NET_OFFLINE: 'true' build-commands: - - cargo --offline build --release --locked -p filmcraft + - cargo --offline build --release --locked -p filmcraft -p filmcraft-cli - install -Dm755 target/release/filmcraft /app/bin/filmcraft + # The headless CLI and its stdio MCP server, as in the release bundle (#346). + - install -Dm755 target/release/filmcraft-cli /app/bin/filmcraft-cli - install -Dm644 packaging/linux/ai.storyteller.filmcraft.desktop /app/share/applications/ai.storyteller.filmcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.filmcraft.mime.xml /app/share/mime/packages/ai.storyteller.filmcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 000000000..033fe9b7e --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Install the extracted Linux tarball. No sudo is needed for the default prefix. +set -euo pipefail + +usage() { + echo 'Usage: ./install.sh [--prefix /absolute/path]' + echo 'Installs FilmCraft and its desktop integration into ~/.local by default.' + echo 'For all users: sudo ./install.sh --prefix /usr/local' +} +fail() { echo "error: $*" >&2; exit 1; } +prefix="${HOME:?HOME is not set}/.local" +while [ "$#" -gt 0 ]; do + case "$1" in + --prefix) + [ "$#" -ge 2 ] || fail '--prefix needs an absolute path' + prefix="$2"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) usage >&2; fail "unknown argument: $1" ;; + esac +done +case "$prefix" in /*) ;; *) fail '--prefix must be an absolute path' ;; esac +prefix="$(realpath -m -- "$prefix")" +# Desktop Exec paths cannot contain '='; launchers also misinterpret '%' in executable names. +[[ "$prefix" != *'='* && "$prefix" != *'%'* && ! "$prefix" =~ [[:cntrl:]] ]] || fail 'prefix cannot contain =, % or control characters' +[ "$prefix" != / ] || fail 'use /usr/local or a user directory, not /' +source_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)" +case "$prefix/" in "$source_dir/"*) fail 'choose an installation prefix outside the extracted tarball' ;; esac +app_id=ai.storyteller.filmcraft +for binary in filmcraft filmcraft-cli; do + [ -f "$source_dir/bin/$binary" ] || fail "missing bin/$binary; extract the complete tarball first" +done +[ -f "$source_dir/share/applications/$app_id.desktop" ] || fail 'missing desktop entry; extract the complete tarball first' + +mkdir -p -- "$prefix/bin" "$prefix/share" +install -m755 -- "$source_dir/bin/filmcraft" "$prefix/bin/filmcraft" +install -m755 -- "$source_dir/bin/filmcraft-cli" "$prefix/bin/filmcraft-cli" +cp -R -- "$source_dir/share/." "$prefix/share/" + +# Use an absolute path so launching from the application menu does not depend on PATH. +# Escape the Exec argument, then the desktop entry's string value (two separate layers). +# https://specifications.freedesktop.org/desktop-entry/latest/exec-variables.html +exec_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/[\\"`$]/\\&/g; s/\\/\\\\/g')" +try_path="$(printf '%s' "$prefix/bin/filmcraft" | sed 's/\\/\\\\/g')" +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + Exec=*) printf 'Exec="%s" %%F\n' "$exec_path" ;; + TryExec=*) printf 'TryExec=%s\n' "$try_path" ;; + *) printf '%s\n' "$line" ;; + esac +done <"$source_dir/share/applications/$app_id.desktop" >"$prefix/share/applications/$app_id.desktop" + +# Cache tools are optional; installing binaries must also work on minimal systems. +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q "$prefix/share/applications" || echo 'warning: desktop cache refresh failed' >&2 +fi +if command -v update-mime-database >/dev/null 2>&1; then + update-mime-database "$prefix/share/mime" || echo 'warning: MIME cache refresh failed' >&2 +fi +if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f "$prefix/share/icons/hicolor" || echo 'warning: icon cache refresh failed' >&2 +fi +printf 'Installed FilmCraft in %s\nLaunch it from the application menu or run "%s/bin/filmcraft".\n' "$prefix" "$prefix" +case ":${PATH:-}:" in + *":$prefix/bin:"*) ;; + *) printf 'For terminal commands, add %s/bin to your PATH.\n' "$prefix" ;; +esac diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 1ee72e194..b2d00bc39 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -76,6 +76,7 @@ has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } if has tar; then mkdir -p "$WORK/tar" cp -R "$STAGE/usr" "$WORK/tar/$BASENAME" + install -m755 "$HERE/install.sh" "$WORK/tar/$BASENAME/install.sh" tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" echo "wrote $DIST/$BASENAME.tar.gz" fi diff --git a/packaging/linux/test_install.py b/packaging/linux/test_install.py new file mode 100644 index 000000000..ecaf22529 --- /dev/null +++ b/packaging/linux/test_install.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Exercise the tarball installer with disposable binaries and installation directories.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import time +import unittest + + +HERE = Path(__file__).resolve().parent +APP_ID = "ai.storyteller.filmcraft" + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="filmcraft-install-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bundle = self.root / "extracted bundle" + (self.bundle / "bin").mkdir(parents=True) + self.share = self.bundle / "share" + (self.share / "applications").mkdir(parents=True) + shutil.copyfile(HERE / "install.sh", self.bundle / "install.sh") + shutil.copyfile( + HERE / f"{APP_ID}.desktop", self.share / "applications" / f"{APP_ID}.desktop" + ) + for name in ("filmcraft", "filmcraft-cli"): + (self.bundle / "bin" / name).write_text( + '#!/bin/sh\nprintf \'%s\\n\' "$0" "$@" > "$(dirname "$0")/launched"\n' + ) + for path in ("doc/filmcraft/LICENSE-MIT", "icons/hicolor/test", "mime/packages/test.xml"): + target = self.share / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("fixture\n") + + def install(self, *args): + # Run outside the extracted directory to check that the script finds its own files. + return subprocess.run( + ["bash", str(self.bundle / "install.sh"), *map(str, args)], + cwd=self.root, capture_output=True, text=True, timeout=30, + ) + + def test_install_and_update_preserve_other_applications(self): + prefix = self.root / "user programs" + other = prefix / "share/applications/other.desktop" + other.parent.mkdir(parents=True) + other.write_text("unrelated\n") + for version in ("first", "updated"): + (self.bundle / "share/doc/filmcraft/LICENSE-MIT").write_text(version) + result = self.install("--prefix", prefix) + self.assertEqual(result.returncode, 0, result.stderr) + for name in ("filmcraft", "filmcraft-cli"): + self.assertTrue(os.access(prefix / "bin" / name, os.X_OK)) + self.assertEqual( + (prefix / "bin" / name).read_bytes(), (self.bundle / "bin" / name).read_bytes() + ) + self.assertEqual((prefix / "share/doc/filmcraft/LICENSE-MIT").read_text(), version) + self.assertTrue((prefix / "share/icons/hicolor/test").is_file()) + self.assertTrue((prefix / "share/mime/packages/test.xml").is_file()) + self.assertEqual(other.read_text(), "unrelated\n") + desktop = prefix / "share/applications" / f"{APP_ID}.desktop" + self.assertIn(f'Exec="{prefix}/bin/filmcraft" %F\n', desktop.read_text()) + if shutil.which("desktop-file-validate"): + subprocess.run(["desktop-file-validate", str(desktop)], check=True) + + @unittest.skipUnless(shutil.which("gio"), "gio is needed to exercise the desktop launcher") + def test_desktop_launch_escapes_paths_and_preserves_file_arguments(self): + prefix = self.root / 'programs with "quotes" $dollar `tick` \\slash & more' + result = self.install("--prefix", prefix) + self.assertEqual(result.returncode, 0, result.stderr) + document = self.root / "my film.filmcraft" + document.touch() + desktop = prefix / "share/applications" / f"{APP_ID}.desktop" + subprocess.run(["gio", "launch", str(desktop), str(document)], check=True, timeout=10) + launched = prefix / "bin/launched" + for _ in range(100): + if launched.exists() and launched.read_text().splitlines() == [ + str(prefix / "bin/filmcraft"), str(document) + ]: + break + time.sleep(0.01) + self.assertEqual(launched.read_text().splitlines(), [str(prefix / "bin/filmcraft"), str(document)]) + + def test_invalid_arguments_fail_before_writing(self): + for args in (("--prefix",), ("--prefix", "relative"), ("--prefix", "/"), ("--unknown",), + ("--prefix", "///"), ("--prefix", "/tmp/../"), ("--prefix", str(self.bundle)), + ("--prefix", str(self.bundle / "nested")), + ("--prefix", str(self.root / "bad=path")), + ("--prefix", str(self.root / "bad%path")), + ("--prefix", str(self.root / "bad\npath"))): + with self.subTest(args=args): + self.assertNotEqual(self.install(*args).returncode, 0) + + def test_incomplete_bundle_does_not_install(self): + (self.bundle / "bin/filmcraft-cli").unlink() + prefix = self.root / "destination" + result = self.install("--prefix", prefix) + self.assertNotEqual(result.returncode, 0) + self.assertIn("extract the complete tarball", result.stderr) + self.assertFalse(prefix.exists()) + + def test_help(self): + self.assertEqual(self.install("--help").returncode, 0) + + def test_release_tarball_includes_executable_installer(self): + target = self.root / "target" + binaries = target / "release" + binaries.mkdir(parents=True) + for name in ("filmcraft", "filmcraft-cli"): + binary = binaries / name + binary.write_text("#!/bin/sh\necho 'FilmCraft fixture'\n") + binary.chmod(0o755) + dist = self.root / "dist" + result = subprocess.run( + ["bash", str(HERE / "package.sh"), "--skip-build", "--formats", "tar"], + env={**os.environ, "CARGO_TARGET_DIR": str(target), "DIST": str(dist)}, + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + archives = list(dist.glob("*.tar.gz")) + self.assertEqual(len(archives), 1) + with tarfile.open(archives[0]) as archive: + installer = next(m for m in archive.getmembers() if m.name.endswith("/install.sh")) + self.assertEqual(installer.mode & 0o777, 0o755) + self.assertEqual(archive.extractfile(installer).read(), (HERE / "install.sh").read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md new file mode 100644 index 000000000..941880a2b --- /dev/null +++ b/packaging/macos/dmg/README.md @@ -0,0 +1,44 @@ +# macOS DMG window + +What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the +`Applications` link side by side. `package.sh` copies these files into the image; nothing here is +generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no +Finder scripting on CI, nothing extra installed in the signing job). + +| File | What | +|---|---| +| `background.svg` | Source of the background: the app icon (`assets/app-icon/filmcraft.svg`, linked, not copied) cropped as a cover on the FilmCraft colour field (`#8b5cf6`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. | +| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. | +| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, FilmCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | +| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | + +## The volume name has no version + +The mounted volume is called `FilmCraft`, not `FilmCraft `. The layout points at the background +through an alias that includes the volume name, and Finder resolves it by that name: with a +versioned name the window keeps its size and icon positions but shows no background (tested). +The DMG file name (`filmcraft--macos-.dmg`) still carries the version. + +## Rules + +- **Finder draws the icon labels in black in light and dark mode** when a window has a background, + so the area under both icons stays light (Paper). +- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of + the label strip under it. + +## Regenerate + +Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS: + +```sh +pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 +python3 packaging/macos/dmg/generate.py # needs resvg on PATH +``` + +Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the +SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background +alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG, +outline it (`usvg` from resvg converts text to paths). The window is 660 × 432 with Finder's 32 pt +title bar; the content area is 660 × 400. + +This follows VectorCraft's `packaging/macos/dmg/` (storytold/vectorcraft#493). diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg new file mode 100644 index 000000000..abe090858 --- /dev/null +++ b/packaging/macos/dmg/background.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/macos/dmg/background.svg.attribution b/packaging/macos/dmg/background.svg.attribution new file mode 100644 index 000000000..0b3f939a5 --- /dev/null +++ b/packaging/macos/dmg/background.svg.attribution @@ -0,0 +1,8 @@ +asset: background.svg +title: FilmCraft macOS DMG window background +author: @XusBadia +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT, LICENSE-APACHE +added: 2026-10-08 by @XusBadia +notes: Original work for this repository, derived from the FilmCraft app icon (assets/app-icon/filmcraft.svg, referenced via , not copied). Text is outlined Inter / JetBrains Mono glyph paths. Used for the Finder window of the macOS DMG; packaging/macos/dmg/generate.py renders it to background.tiff. See packaging/macos/dmg/README.md. diff --git a/packaging/macos/dmg/background.tiff b/packaging/macos/dmg/background.tiff new file mode 100644 index 000000000..75d261ad2 Binary files /dev/null and b/packaging/macos/dmg/background.tiff differ diff --git a/packaging/macos/dmg/background.tiff.attribution b/packaging/macos/dmg/background.tiff.attribution new file mode 100644 index 000000000..37ed92793 --- /dev/null +++ b/packaging/macos/dmg/background.tiff.attribution @@ -0,0 +1,8 @@ +asset: background.tiff +title: FilmCraft macOS DMG window background (rendered) +author: @XusBadia +source: rendered from background.svg by generate.py +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT, LICENSE-APACHE +added: 2026-10-08 by @XusBadia +notes: 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely"). Regenerate with packaging/macos/dmg/generate.py. diff --git a/packaging/macos/dmg/dmg-layout.DS_Store b/packaging/macos/dmg/dmg-layout.DS_Store new file mode 100644 index 000000000..d6327a60a Binary files /dev/null and b/packaging/macos/dmg/dmg-layout.DS_Store differ diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py new file mode 100644 index 000000000..1dde56392 --- /dev/null +++ b/packaging/macos/dmg/generate.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here). + +Needs resvg on PATH and: + pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3 + +Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing +from the machine that made them: no local paths, user or disk names, dates or volume UUIDs. + + python3 packaging/macos/dmg/generate.py +""" + +import datetime +import os +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias, TargetInfo, VolumeInfo +from PIL import Image, ImageCms + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# Keep in sync with package.sh (volume name) and README.md (layout). +VOLUME = "FilmCraft" +WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size +TITLE_BAR = 32 +ICON_SIZE = 128 +PALETTE = 16 # colours in background.tiff +ICONS = {"FilmCraft.app": (326, 205), "Applications": (574, 205)} +# A fixed date for the alias and the colour profile. Finder never matches it against the image +# (each build is a new volume): it finds the background by volume name and path. +FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) + + +def render(svg, width, out): + # The SVG's text is already outlined and no fonts are loaded, so every machine draws the + # same pixels. + subprocess.run( + ["resvg", "--skip-system-fonts", "-w", str(width), svg, out], + check=True, + ) + with Image.open(out) as im: + return im.convert("RGB") + + +def quantize(im): + # No dithering: dither noise compresses badly and the artwork has flat fills. + return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) + + +def srgb_profile(): + # LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed. + icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()) + d = FIXED_DATE + icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0)) + if icc[84:100] != bytes(16): + sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date") + return bytes(icc) + + +def write_tiff(svg, out): + """1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them.""" + with tempfile.TemporaryDirectory() as tmp: + one, two = ( + quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2) + ) + two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page + # Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette + # keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's. + one.save( + out, + "TIFF", + save_all=True, + append_images=[two], + compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate) + dpi=(72, 72), + icc_profile=srgb_profile(), + ) + with Image.open(out) as im: + im.seek(1) + if im.info.get("dpi") != (144, 144): + sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)") + strips = [] + for page in (0, 1): + im.seek(page) + strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts + # TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff + # leaves the padding byte after it uninitialised, so the file changed from run to run with + # identical pixels. Zero it: nothing refers to that byte. + starts = {o for o, _ in strips} + with open(out, "r+b") as f: + for o, n in strips: + if (o + n) % 2 and o + n not in starts: + f.seek(o + n) + f.write(bytes(1)) + + +def background_alias(): + # What Finder stores for /.background/background.tiff on the volume, minus anything about the + # machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable), + # the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids + # are only hints, as the volume's creation date. + volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0") + volume.posix_path = f"/Volumes/{VOLUME}" + target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0") + target.folder_name = ".background" + target.cnid_path = [25] + target.carbon_path = f"{VOLUME}:.background:\0background.tiff" + target.posix_path = "/.background/background.tiff" + return Alias(volume=volume, target=target).to_bytes() + + +def write_ds_store(out): + if os.path.exists(out): + os.remove(out) + with DSStore.open(out, "w+") as d: + d["."]["bwsp"] = { + "ShowStatusBar": False, + "ShowToolbar": False, + "ShowTabView": False, + "ContainerShowSidebar": False, + "ShowSidebar": False, + "WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}", + } + d["."]["icvp"] = { + "viewOptionsVersion": 1, + "backgroundType": 2, + "backgroundImageAlias": background_alias(), + "backgroundColorRed": 1.0, + "backgroundColorGreen": 1.0, + "backgroundColorBlue": 1.0, + "gridOffsetX": 0.0, + "gridOffsetY": 0.0, + "gridSpacing": 100.0, + "arrangeBy": "none", + "showIconPreview": True, + "showItemInfo": False, + "labelOnBottom": True, + "textSize": 16.0, + "iconSize": float(ICON_SIZE), + } + d["."]["vSrn"] = ("long", 1) + for name, pos in ICONS.items(): + d[name]["Iloc"] = pos + + +def main(): + if not shutil.which("resvg"): + sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)") + write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff")) + write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store")) + + +if __name__ == "__main__": + main() diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index e9d036be0..85902b91b 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -131,10 +131,15 @@ STAGE="$WORK/dmg" mkdir -p "$STAGE" ditto "$APP" "$STAGE/FilmCraft.app" ln -s /Applications "$STAGE/Applications" +# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md). +mkdir -p "$STAGE/.background" +cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" +cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -hdiutil makehybrid -hfs -hfs-volume-name "FilmCraft $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +# The volume name has no version: .DS_Store finds the background through an alias that includes it. +hdiutil makehybrid -hfs -hfs-volume-name "FilmCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 04b31efba..faa4b8638 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): filmcraft--windows-.msi per-machine installer (WiX v5) - filmcraft--windows--portable.zip filmcraft.exe + filmcraft-cli.exe + filmcraft--windows--portable.zip filmcraft.exe + filmcraft-cli.exe + portable.txt The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a @@ -106,6 +106,11 @@ $Portable = Join-Path $TargetDir "windows-package\filmcraft-$Version-windows-$Ar Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable +# The marker that makes FilmCraft keep its data in .\data next to the exe instead of %APPDATA% (#139). +Set-Content -Encoding ascii -Path (Join-Path $Portable 'portable.txt') -Value @( + 'FilmCraft portable: preferences, presets, logs and crash recovery are kept in the data folder', + 'next to filmcraft.exe. Delete this file to use %APPDATA%\FilmCraft instead.' +) foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable }