diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index a7446cac..a3fb4af4 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 bf7c41d3..960f1ee3 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 @@ -64,6 +64,11 @@ jobs: assert m["id"] == "ai.storyteller.filmcraft" and m["command"] == "filmcraft", m["id"] for key in ("runtime", "runtime-version", "sdk", "finish-args"): assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" + # Both install the app and the CLI/MCP server (#346). + import re + def programs(m): + return {p for c in m["modules"][0]["build-commands"] for p in re.findall(r"/app/bin/([\w.-]+)", c)} + assert programs(src) == programs(bundle) == {"filmcraft", "filmcraft-cli"}, f"Flatpak manifests install {programs(src)} vs {programs(bundle)}" print("Flatpak manifests ok") EOF - name: FreeBSD package layout (stub binaries) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a65db7bf..08eba22b 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 22a3a54b..ccb475e5 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/Cargo.lock b/Cargo.lock index 2512f9b2..4803e4b1 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", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1706,7 +1706,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1716,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", @@ -1739,7 +1739,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1750,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", @@ -1770,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", @@ -1791,7 +1791,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1825,7 +1825,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1843,7 +1843,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1887,7 +1887,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1917,7 +1917,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1930,7 +1930,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1965,7 +1965,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1982,7 +1982,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1992,7 +1992,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2002,7 +2002,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2012,7 +2012,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2048,7 +2048,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2070,7 +2070,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2078,28 +2078,28 @@ 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", @@ -2125,7 +2125,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2139,7 +2139,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2149,7 +2149,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2169,7 +2169,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-color", "serde", @@ -2178,7 +2178,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.4.0" +version = "0.5.0" dependencies = [ "candle-core", "candle-nn", @@ -2196,14 +2196,14 @@ 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", @@ -2215,7 +2215,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -2223,7 +2223,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "cpu-time", "eframe", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "filmcraft-vp9" -version = "0.4.0" +version = "0.5.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2267,7 +2267,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -7257,7 +7257,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 b18a7355..58a21642 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 8c498e3d..a4381a8d 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. diff --git a/ROADMAP.md b/ROADMAP.md index 0f0708be..50b95eea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.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 covers the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 03ff62f0..be104b30 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -56,7 +56,7 @@ fn stdout_failed(e: std::io::Error) { if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe { return; } - eprintln!("filmcraft-cli: can't write to stdout: {e}"); + diag(format_args!("filmcraft-cli: can't write to stdout: {e}")); STDOUT_ERROR.store(true, Ordering::Relaxed); } @@ -115,13 +115,23 @@ EXIT STATUS 0 success · 1 a command failed · 2 usage error "; +/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's +/// reader is gone, which would turn a usage error, a failure or a finished export into a crash. +fn write_diag(w: &mut dyn std::io::Write, msg: std::fmt::Arguments) { + let _ = writeln!(w, "{msg}"); +} + +fn diag(msg: std::fmt::Arguments) { + write_diag(&mut std::io::stderr(), msg); +} + fn usage(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage."); + diag(format_args!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.")); std::process::exit(2) } fn fail(msg: impl std::fmt::Display) -> ! { - eprintln!("filmcraft-cli: {msg}"); + diag(format_args!("filmcraft-cli: {msg}")); std::process::exit(1) } @@ -373,11 +383,7 @@ async fn cli() { if let Some(r) = a.opt("--range") { p["range"] = json!(r); } - if let (Some(s0), Some(s1)) = (a.opt("--start"), a.opt("--end")) { - p["range"] = json!("custom"); - p["startSeconds"] = parse_value(s0); - p["endSeconds"] = parse_value(s1); - } + set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end")); if let Some(js) = a.opt("--settings") { p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}"))); } @@ -399,7 +405,7 @@ async fn cli() { match r { Ok(v) => { print(&a, &v); - eprintln!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()); + diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64())); } Err(e) => fail(format!("export: {e}")), } @@ -414,11 +420,11 @@ async fn cli() { let out = a.opt("--out").unwrap_or("frame.png"); s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs)); let t0 = std::time::Instant::now(); - let Some(img) = s.render_program(scale) else { fail("no sequence") }; + let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); let dt = t0.elapsed(); let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e)); std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}"))); - eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0); + diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { let server = match a.opt("--bridge") { @@ -454,8 +460,40 @@ fn written_paths(result: &Value, requested: &str) -> Vec { 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] @@ -467,6 +505,23 @@ mod format_tests { 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; @@ -478,6 +533,23 @@ mod format_tests { assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]); } + #[test] + fn export_lone_bound_is_forwarded_as_custom_range() { + use serde_json::json; + let mut p = json!({}); + super::set_custom_bounds(&mut p, Some("0.25"), None); + assert_eq!(p["range"], "custom"); + assert_eq!(p["startSeconds"], 0.25); + assert!(p.get("endSeconds").is_none()); + let mut p = json!({}); + super::set_custom_bounds(&mut p, None, Some("0.25")); + assert_eq!(p["range"], "custom"); + assert_eq!(p["endSeconds"], 0.25); + let mut p = json!({}); + super::set_custom_bounds(&mut p, None, None); + assert!(p.get("range").is_none()); + } + #[test] fn export_format_from_extension() { assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); diff --git a/contributors/contributors.json b/contributors/contributors.json index 798be44a..29b65c4c 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 58b06996..9762e2ac 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,6 @@ display_name = "Bloxy" [people.ReReddo] display_name = "ReReddo" sync_github_name = false + +[people.liangforstudy] +sync_github_name = true diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 4a43cd2c..4c705c3c 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 d72a220e..e140b976 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 7998dbd1..0892fd69 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 d309bac6..033f590e 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 b5da4e29..3004872b 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/src/hw_frame.rs b/crates/codecs/src/hw_frame.rs index 59537d80..b37fc4d0 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 fb405cd7..a31da3a7 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}; @@ -178,4 +179,6 @@ mod audio_timing_tests; #[cfg(test)] mod rounded_pts_tests; #[cfg(test)] +mod stream_color_tests; +#[cfg(test)] mod tests; diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 9f96df44..cef204d9 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; +use filmcraft_color::{ColorInfo, Matrix, Transfer}; use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig}; use filmcraft_matroska::{Codec, MkvFile, TrackKind}; @@ -24,6 +24,7 @@ use filmcraft_time::{FrameRate, Tick}; use crate::audio::{PacketDecoder, decode_pcm}; use crate::gop::{GopCache, VideoSamples}; +use crate::stream_color::ColorCodes; use crate::video::VideoDecoder; use crate::{CodecError, make_video_decoder}; @@ -218,32 +219,24 @@ fn hdr_metadata(c: &filmcraft_matroska::Colour) -> Option (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 { @@ -312,11 +305,11 @@ impl MkvSource { FrameRate::from_f64(1.0 / step) } }; - let (color, explicit) = color_of(&v, w, h); + ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16); + let (color, explicit) = color_of(&v, w, h, ventry.as_ref().and_then(|e| crate::stream_color::from_codec_config(&e.codec))); if explicit { explicit_color = Some(color); } - ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16); let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9; let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::() 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()) }; diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index e48670df..9ef2792b 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}; @@ -104,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 { diff --git a/crates/codecs/src/stream_color.rs b/crates/codecs/src/stream_color.rs new file mode 100644 index 00000000..d01d6a64 --- /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 00000000..1c6526fe --- /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 4753785a..3f1f5047 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -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/edit/src/lib.rs b/crates/edit/src/lib.rs index ed66f951..dc228522 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -535,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); } @@ -751,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); } @@ -762,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) } @@ -799,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 { @@ -807,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()); } @@ -819,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 558e1ebc..ae258cb0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -234,6 +234,27 @@ fn close_gap_works() { assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10)]); } +#[test] +fn reversed_trim_uses_the_handle_its_playback_direction_consumes() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + // source frames [5, 15) played backwards; the media is 1000 frames long + let a = fx.put(v1, 30, 10, 5); + fx.seq.find_item_mut(a).unwrap().1.reverse = true; + let mut n = fx.next; + // out extends into the 5 frames before source_in, not into the 985 after source_out + let d = trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(20), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, f(5)); + let it = fx.seq.find_item(a).unwrap().1; + assert_eq!((it.source_in, it.source_out()), (f(0), f(15))); + // in extends into the media after source_out + let d = trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, -f(10), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, -f(10)); + let it = fx.seq.find_item(a).unwrap().1; + assert_eq!((it.source_in, it.source_out()), (f(0), f(25))); + fx.seq.check().unwrap(); +} + #[test] fn regular_trim_respects_neighbours_and_handles() { let mut fx = Fx::new(); @@ -253,6 +274,24 @@ fn regular_trim_respects_neighbours_and_handles() { fx.seq.check().unwrap(); } +#[test] +fn held_regular_in_trim_stops_at_previous_clip() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + fx.put(v1, 0, 10, 0); + let b = fx.put(v1, 15, 10, 5); + fx.seq.find_item_mut(b).unwrap().1.frame_hold = Some(f(5)); + let mut n = fx.next; + // 5 frames of gap: a 20 frame extension is clamped to 5 instead of overlapping the previous clip + let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, -f(5)); + assert_eq!(fx.spans(v1), vec![(0, 10), (10, 15)]); + // no space left: nothing to do, and no error + let d = trim(&mut fx.seq, b, Edge::In, TrimMode::Regular, -f(3), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(d, Tick::ZERO); + fx.seq.check().unwrap(); +} + #[test] fn ripple_trim_shifts_following() { let mut fx = Fx::new(); @@ -359,6 +398,25 @@ fn roll_slip_slide() { fx.seq.check().unwrap(); } +#[test] +fn slide_keeps_reversed_neighbours_content() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 30, 20); + let b = fx.put(v1, 30, 30, 50); + let c = fx.put(v1, 60, 30, 70); + for id in [a, c] { + fx.seq.find_item_mut(id).unwrap().1.reverse = true; + } + let mut n = fx.next; + slide(&mut fx.seq, b, f(10), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(fx.spans(v1), vec![(0, 40), (40, 30), (70, 20)]); + assert_eq!(fx.seq.find_item(a).unwrap().1.source_in, f(10)); + assert_eq!(fx.seq.find_item(b).unwrap().1.source_in, f(50)); + assert_eq!(fx.seq.find_item(c).unwrap().1.source_in, f(70)); + fx.seq.check().unwrap(); +} + #[test] fn transitions_stay_on_their_cuts() { let mut fx = Fx::new(); @@ -673,3 +731,21 @@ fn shorter_head_closes_the_stretch_after_the_cut_on_sync_locked_tracks() { assert_eq!(fx.spans(v1), vec![(0, 10), (10, 10), (20, 10)], "unchanged on failure"); } } + +#[test] +fn roll_reversed_clips_keeps_media_mapping() { + let mut fx = Fx::new(); + let v = fx.v(0); + let mut a = fx.item(0, 30, 20); + a.reverse = true; + let mut b = fx.item(30, 30, 50); + b.reverse = true; + let (ia, ib) = (a.id, b.id); + fx.seq.track_mut(v).unwrap().items.extend([a, b]); + let mut n = 0; + roll(&mut fx.seq, ia, ib, f(10), &mut Fx::ctx(&mut n)).unwrap(); + let (_, l) = fx.seq.find_item(ia).unwrap(); + assert_eq!((l.start, l.duration, l.source_in), (f(0), f(40), f(10))); + let (_, r) = fx.seq.find_item(ib).unwrap(); + assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); +} diff --git a/crates/edit/src/through.rs b/crates/edit/src/through.rs index c8267535..9b662abf 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; } diff --git a/crates/engine/src/aaf_omf.rs b/crates/engine/src/aaf_omf.rs index fd0d0196..823b895d 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 4d93d6ea..83bdd05d 100644 --- a/crates/engine/src/aaf_omf_tests.rs +++ b/crates/engine/src/aaf_omf_tests.rs @@ -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 index 541324ae..f765e055 100644 --- a/crates/engine/src/audio_placement_tests.rs +++ b/crates/engine/src/audio_placement_tests.rs @@ -94,6 +94,50 @@ fn placing_media_with_two_streams_expands_tracks_and_keeps_stream_indices() { assert_eq!(a.start, b.start); } +#[test] +fn clip_peaks_measure_the_stream_the_clip_plays() { + let mut s = Session::default(); + let mut p = (*s.project).clone(); + let rate = FrameRate::FPS_24; + let seq = p.new_sequence("Two streams", Default::default(), 1, 1, None); + let source = filmcraft_media::generators::GeneratorSource::new(filmcraft_media::Generator::BarsAndTone, 32, 32, rate, rate.tick_of(24)); + let mut info = source.info().clone(); + info.audio_streams.push(info.audio_streams[0].clone()); + let item = p.add_item( + "two streams", + filmcraft_project::Label::Iris, + filmcraft_project::ItemKind::Media(filmcraft_project::MediaClip { + media: filmcraft_project::MediaRef::Generator(filmcraft_media::Generator::BarsAndTone), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }), + None, + ); + let (vdest, adest) = { + let q = p.sequence(seq).unwrap(); + (q.video_tracks[0].id, q.audio_tracks[0].id) + }; + s.project = std::sync::Arc::new(p); + s.state.active_sequence = Some(seq); + let ids = + crate::commands::place_item(&mut s, item, TimeRange::new(Tick::ZERO, rate.tick_of(24)), Tick::ZERO, Some(vdest), Some(adest), false, "test", None) + .unwrap(); + let q = s.project.sequence(seq).unwrap(); + let (a, b) = (q.audio_tracks[0].items[0].id, q.audio_tracks[1].items[0].id); + assert!(ids.contains(&a) && ids.contains(&b)); + let peaks = crate::mixer::clip_peaks(&s, &[a, b]); + let db = |c: filmcraft_project::ClipId| peaks.iter().find(|(id, _)| *id == c).map(|(_, d)| *d).unwrap(); + // The generator only has stream 0, so the clip playing stream 1 is silent, not a copy of stream 0. + assert!(db(a) > -30.0, "{peaks:?}"); + assert!(db(b) < -100.0, "{peaks:?}"); +} + #[test] fn missing_audio_tracks_are_cloned_from_the_destination() { let s = demo(); diff --git a/crates/engine/src/captions.rs b/crates/engine/src/captions.rs index 64428276..1566a596 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 2167d4b7..feb65c17 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_tests.rs b/crates/engine/src/clip_ops_tests.rs index 9fc46de9..b6fcfe86 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -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 b5e4c847..70cdeb65 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1325,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(()) @@ -1837,6 +1841,15 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!( + "markers.exportCsv", + "Export Markers as CSV…", + ["Markers"], + None, + r#"{"path":str} (all markers in the active sequence)"#, + has_seq, + crate::marker_export::export + ), cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| { if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? { return Ok(v); @@ -1889,8 +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}))), @@ -2203,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(()) })?; @@ -2497,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") ), @@ -2515,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") ), @@ -2704,7 +2728,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 { @@ -2730,6 +2759,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); @@ -3183,6 +3215,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() }); @@ -3233,6 +3275,9 @@ fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result Result 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(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 7d3073f2..a2ef4ab4 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(), @@ -769,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/graphic_templates.rs b/crates/engine/src/graphic_templates.rs index db00d6ee..0b9941e2 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 fea4922a..a222ce2b 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -96,23 +96,25 @@ pub(crate) fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, /// Place a new graphic clip holding `layer` at the playhead: on the first video track above the /// topmost clip at the playhead that is free for the duration (a track is added if needed). -fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result { +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); // Here `seconds` is duration, not the generic time parser's placement alias. let mut placement = p.clone(); if let Some(params) = placement.as_object_mut() { params.remove("seconds"); } - place_video_clip(s, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| { + place_video_clip(s, cmd, name, &placement, "New Graphic", vec![layer], move |pr, (w, h, rate)| { let src = graphic_source(pr, w, h, rate); (src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration())) }) } /// Place a video clip of the item `source` returns (with its duration) at the playhead (or -/// `time`), above the clips there, as one undo step; selects it. +/// `time`), above the clips there, as one undo step; selects it. `cmd` is the command that asked, +/// for its errors. pub(crate) fn place_video_clip( s: &mut Session, + cmd: &str, name: &str, p: &Value, label: &str, @@ -133,7 +135,7 @@ pub(crate) fn place_video_clip( let src_size = pr.source_size(src).unwrap_or((w, h)); let t = rate.snap(t); let dur = dur.max(rate.frame_duration()); - let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?; + let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?; ti.name = name; ti.effects.extend(extra); for e in &mut ti.effects { @@ -146,7 +148,7 @@ pub(crate) fn place_video_clip( let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range)); let idx = match want_track { Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i, - Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))), + Some(i) if i < q.video_tracks.len() => return Err(bad(cmd, format!("V{} is not free here", i + 1))), _ => { let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1); match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) { @@ -190,6 +192,10 @@ fn param_id(k: &str) -> &str { "fontSize" => "size", "fontStyle" | "style" => "font_style", "fillColor" | "color" => "fill_color", + "fillKind" => "fill_kind", + "gradientStart" => "gradient_start", + "gradientEnd" => "gradient_end", + "gradientAngle" => "gradient_angle", "strokeColor" => "stroke_color", "strokeWidth" => "stroke_width", "backgroundColor" => "background_color", @@ -212,6 +218,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option graphic::ALIGN_OPTS, "caps" => graphic::CAPS_OPTS, "stroke_type" | "stroke2_type" => graphic::STROKE_OPTS, + "fill_kind" => graphic::FILL_KIND_OPTS, "shape" => SHAPE_OPTS, _ => &[], }; @@ -703,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"), ), @@ -712,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"), ), @@ -721,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!({}) }; @@ -766,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})) }, @@ -902,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})) @@ -913,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(); @@ -936,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 0c7cc4cb..70ede0ce 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -65,6 +65,36 @@ fn graphic_duration_keeps_explicit_time_frame_and_timecode_placement() { } } +/// A new graphic goes on the track and at the time it is given, for a shape as for text; a track +/// that is not free there is an error named after the command that asked. +#[test] +fn graphic_placement_takes_a_track_and_a_time() { + let commands = + [("graphics.newText", json!({})), ("graphics.newShape", json!({})), ("graphics.newRectangle", json!({})), ("graphics.newPolygon", json!({"sides": 5}))]; + for (command, mut params) in commands { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name": "Placement", "fps": 24, "width": 128, "height": 128, "video": 3, "audio": 1})).unwrap(); + s.execute("playhead.set", json!({"frame": 72})).unwrap(); + params["track"] = json!(2); + params["frame"] = json!(24); + params["seconds"] = json!(1); + let r = s.execute(command, params.clone()).unwrap(); + let clip = ClipId(r["clip"].as_u64().unwrap()); + let q = s.active_sequence().unwrap(); + let (track, it) = q.find_item(clip).unwrap(); + assert_eq!(track, q.video_tracks[2].id, "{command}"); + assert_eq!((it.start, it.duration), (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(1.0)), "{command}"); + // the same place again is taken + let e = s.execute(command, params).unwrap_err().to_string(); + assert!(e.contains("V3 is not free here"), "{command}: {e}"); + let named = if command == "graphics.newText" { "graphics.newText" } else { "graphics.newShape" }; + assert!(e.contains(named), "{command}: {e}"); + assert_eq!(s.active_sequence().unwrap().video_tracks[2].items.len(), 1, "{command}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_sequence().unwrap().find_item(clip).is_none(), "{command}"); + } +} + #[test] fn new_text_makes_a_graphic_clip_above_the_footage() { let mut s = demo(); @@ -149,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/lib.rs b/crates/engine/src/lib.rs index 2578420b..f7d82362 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; @@ -707,14 +708,15 @@ impl Session { open_sequences: self.state.open_sequences.iter().copied().filter(is_seq).collect(), active_sequence: self.state.active_sequence.filter(is_seq), sequences: self.state.timeline_views.iter().filter(|(id, _)| is_seq(id)).map(|(id, v)| (*id, *v)).collect(), + playheads: self.state.playheads.iter().filter(|(id, _)| is_seq(id)).map(|(id, t)| (*id, *t)).collect(), } } /// Open what was open when the project was saved. Nothing in `view` is trusted: ids that are /// not sequences of this project are dropped (also a second mention of the same sequence), - /// and numbers are brought into range. A view without any open sequence leaves the project - /// on its first sequence: a project saved by a session that never showed one (a script, the - /// CLI) should not open on an empty Timeline. + /// and numbers are brought into range (a playhead also lands on a frame of its sequence). A + /// view without any open sequence leaves the project on its first sequence: a project saved + /// by a session that never showed one (a script, the CLI) should not open on an empty Timeline. pub fn restore_project_view(&mut self, view: filmcraft_project::ProjectView) { let mut open: Vec = Vec::new(); for id in view.open_sequences { @@ -728,6 +730,12 @@ impl Session { } self.state.timeline_views = view.sequences.into_iter().filter(|(id, _)| self.project.sequence(*id).is_some()).filter_map(|(id, v)| Some((id, v.checked()?))).collect(); + for (id, t) in view.playheads { + if let Some(seq) = self.project.sequence(id) { + let t = t.clamp(Tick::ZERO, filmcraft_project::ProjectView::MAX_PLAYHEAD); + self.state.playheads.insert(id, seq.settings.frame_rate.snap(t)); + } + } } /// Every edit passes through here: one that would put a sequence inside itself (directly or diff --git a/crates/engine/src/marker_export.rs b/crates/engine/src/marker_export.rs new file mode 100644 index 00000000..44012543 --- /dev/null +++ b/crates/engine/src/marker_export.rs @@ -0,0 +1,311 @@ +//! 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::default(); + s.services = files; + 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 cc268ab2..2a53cfdf 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(()) diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index cce70f7f..549a64fc 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/mixer.rs b/crates/engine/src/mixer.rs index 75c8042a..51400751 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; diff --git a/crates/engine/src/project_manager.rs b/crates/engine/src/project_manager.rs index aa426cc3..3a78fff1 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, diff --git a/crates/engine/src/project_manager_tests.rs b/crates/engine/src/project_manager_tests.rs index 3039cec1..3f7ce4fe 100644 --- a/crates/engine/src/project_manager_tests.rs +++ b/crates/engine/src/project_manager_tests.rs @@ -101,3 +101,24 @@ fn consolidate_trims_to_used_ranges_with_handles() { } let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn nested_subclip_keeps_its_whole_parent_chain_used() { + use filmcraft_project::{ItemKind, Label}; + use filmcraft_time::{Tick, TimeRange}; + let root = tmp_dir("pm-nested-subclip"); + let (s, items, _) = project(&root); + let seq = s.state.active_sequence.unwrap(); + let mut p = (*s.project).clone(); + let range = TimeRange::new(Tick::ZERO, Tick(1000)); + let inner = p.add_item("Inner", Label::Iris, ItemKind::Subclip { parent: items[0], range, restrict_trims: false }, None); + let outer = p.add_item("Outer", Label::Iris, ItemKind::Subclip { parent: inner, range, restrict_trims: false }, None); + for t in p.sequence_mut(seq).unwrap().all_tracks_mut() { + for x in t.items.iter_mut().filter(|i| i.item == items[0]) { + x.item = outer; + } + } + let (_, used) = super::project_manager::used_ranges(&p, &[seq]); + assert!(used.contains(&outer) && used.contains(&inner) && used.contains(&items[0]), "{used:?}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 0dce9fdc..c0390d9d 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) => { @@ -754,6 +770,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(); diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index 11b7bd49..e00af732 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/relink.rs b/crates/engine/src/relink.rs index d8f0a9aa..7037404a 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -407,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 64efe4ba..b6475c0d 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -128,6 +128,22 @@ fn fingerprint_mismatch_is_refused_unless_forced() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn forced_relink_takes_the_new_files_properties() { + let root = tmp_dir("relink-forced-info"); + let (path, items) = saved_project(&root); + std::fs::remove_dir_all(root.join("Media")).unwrap(); + // same name, different pictures and a different size + let other = root.join("Other"); + std::fs::create_dir_all(&other).unwrap(); + make_movie(&other.join("a.mov"), DemoScene::Dunes, W * 2, H * 2, 12); + let mut s = open(&path); + s.execute("media.relink", json!({"item": items[0].0, "path": other.join("a.mov").to_string_lossy(), "force": true, "relinkOthers": false})).unwrap(); + let width = s.project.item(items[0]).and_then(|i| i.as_media()).and_then(|m| m.info.video.as_ref().map(|v| v.width)); + assert_eq!(width, Some(W * 2), "the item describes the replacement file"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn make_offline_shows_the_slate_until_relinked() { let root = tmp_dir("relink-offline"); diff --git a/crates/engine/src/sequence_tabs_tests.rs b/crates/engine/src/sequence_tabs_tests.rs index d8d6cd48..f3cb5f36 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 de1e0218..50409099 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 diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index aa81758c..01b55b43 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(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 01d5b813..15af2927 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(); @@ -282,6 +326,51 @@ fn add_keyframe_toggles_the_keyframe_at_the_playhead() { 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(); @@ -641,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 5b73786e..dda7a4d1 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 bbeb147f..25735712 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 f01ced7f..cb2df958 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/format/src/lib.rs b/crates/format/src/lib.rs index 2c97f20a..1abdc666 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -418,6 +418,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/interchange/src/aaf/ids.rs b/crates/interchange/src/aaf/ids.rs index c010f5a7..262d1d0b 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 c89b143d..69b17e88 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 c050b55c..e8a7e6a5 100644 --- a/crates/interchange/src/aaf/tests.rs +++ b/crates/interchange/src/aaf/tests.rs @@ -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 f0876cfe..ea0ea8e8 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/common.rs b/crates/interchange/src/common.rs index 5527e06e..45dfa500 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)) @@ -675,6 +684,8 @@ mod tests { assert_eq!(file_url_to_path("/plain/path.mov"), "/plain/path.mov"); assert_eq!(path_to_file_url("/Users/me/My Clip.mov", true), "file://localhost/Users/me/My%20Clip.mov"); assert_eq!(path_to_file_url("C:\\Media\\a b.mxf", false), "file:///C:/Media/a%20b.mxf"); + assert_eq!(path_to_file_url("\\\\?\\C:\\Media\\a.mp4", true), "file://localhost/C:/Media/a.mp4"); + assert_eq!(path_to_file_url("\\\\?\\UNC\\srv\\share\\a.mp4", false), "file://srv/share/a.mp4"); for p in ["/a/b c/ü.mov", "C:/x/y.mp4", "/r/%/#?.mov"] { assert_eq!(file_url_to_path(&path_to_file_url(p, true)), p); assert_eq!(file_url_to_path(&path_to_file_url(p, false)), p); diff --git a/crates/interchange/src/comp.rs b/crates/interchange/src/comp.rs index bf9b2fba..2ade96bc 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(); + 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 45a56878..f1a9cb93 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() 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/platform/README.md b/crates/platform/README.md index 58df2f6e..8170e629 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -65,14 +65,18 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. - No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12 - input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC + No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. 8-bit pictures go + in as the export's RGBA (ABGR input buffers, a ring of eight) and NVENC converts them to BT.709 + limited 4:2:0 on the GPU, with the same codes as the software encoder's own conversion; a driver + that refuses RGB input gets that CPU conversion into NV12 instead. The conversion used to run on + the encode thread, where it competed with the render of the next frames for the thread pool and + held NVENC back. The encoder runs preset P5 with high-quality tuning, CABAC (CAVLC for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it. It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). - **Linux: 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` @@ -281,6 +285,7 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | | `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | | `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | | `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | | `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | | `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | diff --git a/crates/platform/src/hybrid.rs b/crates/platform/src/hybrid.rs index 07354918..c010e79e 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/nvenc/abi_tests.rs b/crates/platform/src/nvenc/abi_tests.rs index 1d6980fb..270108a5 100644 --- a/crates/platform/src/nvenc/abi_tests.rs +++ b/crates/platform/src/nvenc/abi_tests.rs @@ -444,6 +444,7 @@ fn constants_match_the_header() { 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"); diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 2acd3ab2..a63b6a0e 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -120,6 +120,8 @@ impl VideoEncoder for NvencEncoder { } (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) @@ -209,7 +211,21 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin // 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, Nvenc::new) + 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. @@ -254,7 +270,7 @@ fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option c, Err(why) => return declined(&why), }; - match Nvenc::with_signal(&cfg, &nvenc_signal(&s.signal)) { + 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)))) diff --git a/crates/platform/src/nvenc/ffi.rs b/crates/platform/src/nvenc/ffi.rs index c3e479f4..dd348cff 100644 --- a/crates/platform/src/nvenc/ffi.rs +++ b/crates/platform/src/nvenc/ffi.rs @@ -94,6 +94,8 @@ 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; diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index e03ed0cf..f97adc5f 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -11,8 +11,11 @@ //! 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 / hvcC +//! 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`) @@ -120,6 +123,8 @@ pub struct Nvenc { 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). @@ -231,6 +236,19 @@ impl Nvenc { /// 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")); @@ -278,6 +296,7 @@ impl Nvenc { Profile::High => 2, }, ten_bit, + rgba_input, signal: signal.clone(), level, sar: cfg.sar, @@ -312,6 +331,7 @@ impl Nvenc { emitted: 0, size: (w, h), ten_bit, + rgba_input, }) } @@ -345,6 +365,25 @@ impl Nvenc { 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 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. @@ -352,6 +391,9 @@ impl Nvenc { 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 { @@ -455,6 +497,20 @@ 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. diff --git a/crates/platform/src/nvenc/session.rs b/crates/platform/src/nvenc/session.rs index 1791c017..b19691f7 100644 --- a/crates/platform/src/nvenc/session.rs +++ b/crates/platform/src/nvenc/session.rs @@ -150,6 +150,8 @@ pub struct Params { 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. @@ -160,7 +162,7 @@ pub struct Params { } /// 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. +/// 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, @@ -315,8 +317,14 @@ impl Session { if !p.signal.sei.is_empty() && p.codec != Codec::Hevc { return Err("SEI messages are written for HEVC only".into()); } - self.fmt = if p.ten_bit { NV_ENC_BUFFER_FORMAT_YUV420_10BIT } else { NV_ENC_BUFFER_FORMAT_NV12 }; - self.bytes_per_sample = if p.ten_bit { 2 } else { 1 }; + 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) @@ -516,7 +524,8 @@ 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 or P010: luma rows then chroma rows, the pitch - // in bytes) that stays valid until `UnlockInputBuffer`; the slice borrows it only inside `fill`. + // 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; @@ -531,7 +540,8 @@ impl Session { let _ = unlock(self.enc, input); 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 { diff --git a/crates/platform/src/vaapi/va.rs b/crates/platform/src/vaapi/va.rs index 5ee5e06e..aa07512b 100644 --- a/crates/platform/src/vaapi/va.rs +++ b/crates/platform/src/vaapi/va.rs @@ -10,7 +10,8 @@ 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::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use filmcraft_frame::VideoFrame; use libloading::Library; @@ -125,9 +126,49 @@ pub struct Display { _device: File, } -// SAFETY: a VA display is not tied to the thread that opened it, and a `Display` is only used -// through its single owner (`&mut` or `&` of one `Session` at a time), never concurrently. +// 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. @@ -164,6 +205,7 @@ impl Display { // 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) }; @@ -206,7 +248,8 @@ impl Drop for Display { /// 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 { - display: Display, + /// The shared display (see [`SHARED`]), or the one that replaced it. + display: Arc, config: ffi::VAConfigID, context: ffi::VAContextID, surfaces: Vec, @@ -231,8 +274,23 @@ impl Session { /// 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 display = Display::open()?; let api = display.api; let dpy = display.dpy; let mut config = None; @@ -497,8 +555,9 @@ impl Accel for Session { 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; the display - // itself is terminated afterwards (when the `display` field is dropped). + // 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); @@ -521,5 +580,6 @@ impl Drop for Session { /// driver's description, or why not. pub fn probe() -> &'static Result { static PROBE: OnceLock> = OnceLock::new(); - PROBE.get_or_init(|| Display::open().map(|d| d.vendor().to_string())) + // 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 f6005c14..95d8e430 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/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs new file mode 100644 index 00000000..4009f3e6 --- /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 c177e55d..cae45b19 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_displays.rs b/crates/platform/tests/vaapi_displays.rs new file mode 100644 index 00000000..8ec45334 --- /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/graphic.rs b/crates/project/src/graphic.rs index 5908d423..978917e3 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, #[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:`. diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index b88a5915..db84a1f6 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 70df605b..eaf1f6b9 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 3664aa01..dd98fe74 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/time/src/lib.rs b/crates/time/src/lib.rs index 4da7e27a..23f328bf 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,49 +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` (ceiling division). /// /// Uses ceiling (not floor) so that `frame_at(tick_of(f)) == f` holds for every - /// frame index, even when the frame duration is not an integer number of ticks + /// 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. - /// Ceiling is correct because the frame duration is always > 1 tick, so - /// `floor(ceil(f·D) / D) == f` for every integer `f`. + /// 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. @@ -286,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); } @@ -329,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; @@ -345,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). @@ -377,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 => { @@ -511,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 { @@ -660,6 +808,18 @@ mod tests { } 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]; diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 21ff94e6..699a95d4 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -3,6 +3,18 @@ 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 @@ -19,10 +31,10 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish covers menus, panels, dialogs, settings and registry labels. Searches accept both the +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; Japanese currently covers core menus and falls back to English elsewhere. +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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82e..aa156831 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -973,6 +973,8 @@ 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 @@ -1031,6 +1033,7 @@ Corner Radius Radio de esquina Polygon Sides Lados del polígono Fill Relleno + Gradient Degradado Stroke Trazo Stroke 2 Trazo 2 Background Fondo @@ -1891,6 +1894,11 @@ 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 @@ -3480,3 +3488,5 @@ New Sequence Nueva secuencia Tracks Pistas tracks pistas + In Entrada + Out Salida diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 12a3ea2a..14a5c02d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1,95 +1,3484 @@ -# Japanese (ja) catalog. Same format as `es.tsv`: context source translation. +# 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 label using ordinary -# Japanese computing vocabulary. No proprietary localisation resource, string table or product UI -# was consulted or copied. This catalog covers the menus only; anything else 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". - Clip クリップ - Sequence シーケンス - Markers マーカー + 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 グラフィックスとタイトル - Settings 環境設定 - Object オブジェクト - Effect 効果 - Settings… 環境設定… - Image 画像 - Layer レイヤー - Type 書式 - Select 選択 - Filter フィルター - Window ウィンドウ - Language 表示言語 - Save As… 別名で保存… - Exit 終了 - New… 新規… - New 新規 - Horizontal 横書き - Vertical 縦書き - Orientation 組み方向 - Layers レイヤー + 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 プロパティ - Color カラー - Brush Settings ブラシ設定 + 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 ツール - Options オプション + 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 ズームアウト - 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 サイドパネル + 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 トラック diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c04416c6..07a10488 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -368,7 +368,8 @@ mod tests { if p.is_dir() { dirs.push(p); } else if p.extension().is_some_and(|e| e == "rs") { - let text = std::fs::read_to_string(&p).unwrap_or_default(); + // 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())); } @@ -474,6 +475,7 @@ mod tests { } } 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 = [ diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 32cfa3e4..d302d2f4 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> { @@ -725,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)]); + } } } @@ -853,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 83d18261..c03c4ecc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -46,6 +46,7 @@ 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; @@ -204,9 +205,10 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0; /// timeline zoom animation finished) before capturing what is there. const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; -/// File ▸ Export entries that run from the menus through the save panel: command, filter label, +/// Export entries that run from the menus through the save panel: command, filter label, /// extension. -pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [ +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"), @@ -223,6 +225,8 @@ pub struct FilmcraftApp { 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, @@ -426,6 +430,7 @@ impl FilmcraftApp { 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), @@ -1351,6 +1356,7 @@ impl FilmcraftApp { self.handle_shortcuts(&ctx); self.advance_playback(&ctx); self.advance_source_playback(&ctx); + self.scrub_audio(&ctx); let t = self.tokens; let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.app_bg); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b35c2771..bbf47a09 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -311,8 +311,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: 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 delta = view.rate.frame_duration().0.saturating_mul(count).saturating_mul(direction); - let time = filmcraft_time::Tick(app.session.state.source_playhead.0.saturating_add(delta)); + 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" => { @@ -696,13 +700,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(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 { @@ -712,7 +717,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) { @@ -724,16 +729,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); @@ -741,6 +746,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(); diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index fbbde839..bccdd0ad 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -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); @@ -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); @@ -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| { @@ -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")]; @@ -461,7 +461,8 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { 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() diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index e3ae049c..b4142ca4 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}; @@ -28,24 +28,37 @@ 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, 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)); - } +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 { + let clips = selected_clips(app); + let Some((clip, it, _)) = clips.first().cloned() else { crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; @@ -57,7 +70,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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); } @@ -78,7 +91,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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, Color32::from_rgb(58, 58, 70)); + 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); @@ -97,90 +110,93 @@ 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 { tl!("Video") } else { tl!("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, 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); + 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(tl!("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(tl!("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); } } }); @@ -466,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); @@ -659,7 +683,8 @@ 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 { + 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; }; @@ -709,7 +734,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; 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)); - 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 [ (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), @@ -780,7 +805,13 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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) { + } + 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() @@ -792,7 +823,7 @@ 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}))); } - properties_nav(app, &mut bui, r, clip, &it, mt, "volume", "level", &mut actions); + 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() @@ -892,10 +923,10 @@ 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 }; @@ -909,9 +940,9 @@ 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, tl!("Velocity"), Tokens::ui(11.0), t.text_dim); @@ -944,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; @@ -958,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)); diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index ef99ad1c..76f4f8c2 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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; } diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 934a638b..958594db 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -360,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 { @@ -387,7 +387,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 { @@ -467,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() @@ -521,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); @@ -533,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 }, ), ); @@ -587,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 { @@ -605,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/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 46feeb4c..1c7c6330 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -1536,8 +1536,22 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- appearance if section(ui, app, "Appearance", &t) { let (_, mut vui) = cx.row(ui, tl!("Fill")); - cx.check(&mut vui, "fill", ""); + let fill_on = cx.check(&mut vui, "fill", ""); cx.color(&mut vui, "fill_color"); + 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")), diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 61332853..674769d6 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -478,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) }); @@ -513,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/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index e4cd2e93..de3370d2 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -161,6 +161,7 @@ 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)); + 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) @@ -170,24 +171,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { .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::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; + 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.label(fname); - ui.label(RichText::new(path).small()); - ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); 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| { @@ -234,24 +237,26 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { if d.candidates.is_empty() { ui.label(RichText::new(tl!("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)) => 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); + 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)); diff --git a/crates/ui-egui/src/panels/meters.rs b/crates/ui-egui/src/panels/meters.rs index b542e469..03555f2d 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/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index c5bfe64f..db2c92d7 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 }); } } @@ -305,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); } @@ -319,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); } @@ -369,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(); @@ -581,7 +581,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s 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(tl!("Pan / balance (drag; double-click: centre)")); - let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) }; + 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)); @@ -690,16 +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(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))); @@ -769,14 +769,14 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr 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 }); @@ -900,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) }; @@ -930,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)); @@ -989,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()); @@ -1023,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 e19bbce3..91613447 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -46,6 +46,7 @@ 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 voiceover; diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 39034bfa..0ed5aea9 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -284,6 +284,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) 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); } @@ -305,8 +306,19 @@ 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 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 @@ -455,6 +467,99 @@ fn cpu_texture( } } +/// 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; + }; + 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 { // Buckets keep the cache effective while resizing the panel. let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0]; @@ -674,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/text.rs b/crates/ui-egui/src/panels/text.rs index 4408779d..ba59f9a7 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -16,6 +16,15 @@ 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); @@ -23,7 +32,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + 12.0; for tab in TABS { let shown = crate::i18n::t(tab); - let w = shown.chars().count() as f32 * 7.0 + 16.0; + 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; @@ -35,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { 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() { @@ -439,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/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 811a75ee..cfc58e2b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -114,6 +114,8 @@ 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, @@ -150,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)] @@ -315,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()); @@ -363,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) { @@ -375,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) @@ -396,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 @@ -507,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 { @@ -586,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 { @@ -720,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 { @@ -739,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 @@ -981,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() { @@ -989,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); @@ -1108,7 +1114,16 @@ 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; @@ -1126,7 +1141,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc 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); } @@ -1305,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"); @@ -1388,13 +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; } /// Snap `t` to nearby candidates (edits, playhead, markers, in/out). Returns snapped tick. @@ -1453,6 +1467,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value { 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() { @@ -1577,6 +1592,87 @@ pub(crate) 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]) { @@ -1730,6 +1826,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & 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, @@ -1763,7 +1861,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } }; let (p, t) = (pressed.pos, pressed.tick); - let started = match (tool, pressed.grab) { + 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, _) => { @@ -1824,6 +1928,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & 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::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, .. })) => { @@ -1939,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)); @@ -1949,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; @@ -2021,20 +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) + && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) { ctx.data_mut(|d| d.insert_temp(menu_clip_id, clip)); - if !app.session.state.selection.contains(&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); @@ -2164,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_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index a9a770f6..bc5b6898 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() { diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs index fa536c77..1716649f 100644 --- a/crates/ui-egui/src/panels/timeline_hit.rs +++ b/crates/ui-egui/src/panels/timeline_hit.rs @@ -173,6 +173,12 @@ pub enum Grab { 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…). @@ -184,6 +190,7 @@ impl Grab { 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, } } @@ -194,6 +201,12 @@ impl Grab { /// 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 => { @@ -288,7 +301,7 @@ mod tests { 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 }], + rows: vec![Row { track, kind: TrackKind::Video, index: 0, rect: row, lane: false }], pps: 100.0, scroll: 0.0, split_y: TOP + 60.0, 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 00000000..c6a05250 --- /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/scrub.rs b/crates/ui-egui/src/scrub.rs new file mode 100644 index 00000000..9f8f93fe --- /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/state.rs b/crates/ui-egui/src/state.rs index 140d60cc..a8f12b8a 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)] @@ -395,6 +404,10 @@ pub struct UiState { 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, @@ -875,6 +888,7 @@ impl Default for UiState { dark: true, show_scopes: false, status: String::new(), + eyedropper: None, essential_sound_tab: "Edit".into(), export: Default::default(), text_tab: captions_tab(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3a81ae12..ae46ddba 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), @@ -362,6 +499,53 @@ 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] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 26b5fe67..d57b01eb 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/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs new file mode 100644 index 00000000..3ca87a33 --- /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 00000000..d4e259b5 --- /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 index 4c1ef8cc..6fe109df 100644 --- a/crates/ui-egui/tests/export_menu_ui.rs +++ b/crates/ui-egui/tests/export_menu_ui.rs @@ -29,7 +29,11 @@ impl Driver { 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 harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + 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 @@ -142,3 +146,59 @@ fn file_export_entries_need_an_open_sequence() { 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/media_ui.rs b/crates/ui-egui/tests/media_ui.rs index d8bda6c8..80246ef0 100644 --- a/crates/ui-egui/tests/media_ui.rs +++ b/crates/ui-egui/tests/media_ui.rs @@ -179,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 7c72924d..0d4b6f28 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/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs new file mode 100644 index 00000000..badc3536 --- /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/transition_drop_ui.rs b/crates/ui-egui/tests/transition_drop_ui.rs new file mode 100644 index 00000000..e2613347 --- /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/docs/agents.md b/docs/agents.md index 9ad0b2bc..39d53f77 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 | @@ -176,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 @@ -190,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/captions.md b/docs/captions.md index 12564ddd..fd76483e 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/control-protocol.md b/docs/control-protocol.md index 7bca6de0..e17bf464 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `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`…; 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`), or null | +| `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 | diff --git a/docs/graphics.md b/docs/graphics.md index 6d935e82..4e1888fb 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/project-files.md b/docs/project-files.md index c736ec31..c8827150 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index 14e699c5..631e2ee9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -138,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 1b64781c..a453bed5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -73,7 +73,8 @@ 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` for H.264, `nvenc_hevc.rs`, +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 @@ -194,6 +195,10 @@ 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, diff --git a/packaging/linux/flatpak/ai.storyteller.filmcraft.yml b/packaging/linux/flatpak/ai.storyteller.filmcraft.yml index 3bbc1ecd..ef8addc7 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/