diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 960f1ee38..f54b0538b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,57 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: Linux tarball installer - run: python3 packaging/linux/test_install.py - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/filmcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.filmcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.filmcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.filmcraft.bundle.yml")) - for m in (src, bundle): - 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) - run: bash packaging/freebsd/package.sh --dry-run + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ebde2f4a..60a30d2b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "FilmCraft v". See docs/releasing.md. -# -# The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release` -# environment (restricted to the `release` branch), which holds the signing secrets. Every secret -# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment -# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are -# refused, so the draft-release job (which needs them) is skipped and nothing is published. name: Release on: @@ -20,469 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - FILMCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### FilmCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libasound2-dev \ - desktop-file-utils appstream python3-yaml zsync - 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@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/filmcraft$|metainfo|applications' - ldd target/release/filmcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.filmcraft.yml')); assert m['id'] == 'ai.storyteller.filmcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the - # hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they - # need no riscv64 libraries and fall back to the pure-Rust codecs when absent. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the tarball - run: | - tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \ - | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. - runs-on: ubuntu-latest - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - 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 # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "FILMCRAFT_VERSION FILMCRAFT_BUILD_DATE FILMCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages (.github/workflows/freebsd.yml), plus bash for the script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$FILMCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} - FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - WASM_BINDGEN_VERSION: 0.2.129 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install wasm-bindgen CLI - run: | - # Must match the wasm-bindgen crate version exactly (WASM_BINDGEN in xtask/src/main.rs). - if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then - cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked - fi - wasm-bindgen --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$FILMCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="FilmCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: filmcraft + app-title: FilmCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index ccb475e58..7666423a1 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,70 +26,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - 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.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: filmcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: filmcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'FilmCraft' - foreach ($exe in 'filmcraft.exe', 'filmcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'filmcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "filmcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok filmcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'filmcraft.exe')) { throw 'uninstall left filmcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: filmcraft + app-dir: FilmCraft diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0c1793ed1..35622be49 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,7 +9,9 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 | +| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/Cargo.lock b/Cargo.lock index 1db021333..7ec966a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1687,6 +1696,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "pollster 0.4.0", "rfd", "serde_json", "sys-locale", @@ -1757,6 +1767,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.6.0" +dependencies = [ + "filmcraft-testkit", +] + [[package]] name = "filmcraft-bitstream" version = "0.6.0" @@ -1797,6 +1814,7 @@ dependencies = [ "filmcraft-platform", "filmcraft-project", "filmcraft-render", + "filmcraft-testkit", "filmcraft-time", "serde_json", "tokio", @@ -1810,6 +1828,7 @@ dependencies = [ "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -1819,6 +1838,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -1827,9 +1847,11 @@ dependencies = [ "filmcraft-prores", "filmcraft-testkit", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "serde_json", "symphonia", "thiserror 2.0.21", @@ -1844,6 +1866,15 @@ dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.6.0" +dependencies = [ + "filmcraft-dng", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" version = "0.6.0" @@ -1875,12 +1906,14 @@ dependencies = [ "filmcraft-captions", "filmcraft-codecs", "filmcraft-color", + "filmcraft-dng", "filmcraft-edit", "filmcraft-export", "filmcraft-format", "filmcraft-frame", "filmcraft-geom", "filmcraft-interchange", + "filmcraft-isobmff", "filmcraft-media", "filmcraft-mxf", "filmcraft-project", @@ -1909,6 +1942,7 @@ dependencies = [ "filmcraft-codecs", "filmcraft-color", "filmcraft-dnx", + "filmcraft-flac", "filmcraft-frame", "filmcraft-geom", "filmcraft-h264enc", @@ -1929,6 +1963,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "filmcraft-flac" +version = "0.6.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-format" version = "0.6.0" @@ -2067,14 +2110,25 @@ name = "filmcraft-media" version = "0.6.0" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", + "png", "rayon", "serde", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.6.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" version = "0.6.0" @@ -2264,6 +2318,7 @@ name = "filmcraft-ui-egui" version = "0.6.0" dependencies = [ "cpu-time", + "craft-ui", "eframe", "egui", "egui_kittest", @@ -2293,9 +2348,17 @@ dependencies = [ "serde", "serde_json", "toml 1.1.6+spec-1.1.0", + "unicode-bidi", "web-time", ] +[[package]] +name = "filmcraft-vp8" +version = "0.6.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index a8990aa7b..d660c6578 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,21 +17,26 @@ filmcraft-geom = { path = "crates/geom" } filmcraft-color = { path = "crates/color" } filmcraft-bitstream = { path = "crates/bitstream" } filmcraft-isobmff = { path = "crates/isobmff" } +filmcraft-avi = { path = "crates/avi" } filmcraft-matroska = { path = "crates/matroska" } filmcraft-mxf = { path = "crates/mxf" } filmcraft-cfb = { path = "crates/cfb" } filmcraft-mpegts = { path = "crates/mpegts" } filmcraft-mpeg2v = { path = "crates/mpeg2v" } filmcraft-ac3 = { path = "crates/ac3" } +filmcraft-mpa = { path = "crates/mpa" } filmcraft-ogg = { path = "crates/ogg" } filmcraft-h264 = { path = "crates/h264" } filmcraft-hevc = { path = "crates/hevc" } +filmcraft-vp8 = { path = "crates/vp8" } filmcraft-vp9 = { path = "crates/vp9" } filmcraft-prores = { path = "crates/prores" } filmcraft-dnx = { path = "crates/dnx" } filmcraft-apv = { path = "crates/apv" } +filmcraft-dng = { path = "crates/dng" } filmcraft-av1 = { path = "crates/av1" } filmcraft-aac = { path = "crates/aac" } +filmcraft-flac = { path = "crates/flac" } filmcraft-opus = { path = "crates/opus" } filmcraft-h264enc = { path = "crates/h264enc" } filmcraft-audio-dsp = { path = "crates/audio-dsp" } @@ -67,6 +72,8 @@ proptest = "1" roxmltree = "0.21" # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" @@ -107,12 +114,18 @@ opt-level = 3 [profile.dev.package.filmcraft-apv] opt-level = 3 +[profile.dev.package.filmcraft-dng] +opt-level = 3 + [profile.dev.package.filmcraft-av1] opt-level = 3 [profile.dev.package.filmcraft-aac] opt-level = 3 +[profile.dev.package.filmcraft-flac] +opt-level = 3 + # Audio effects: realtime-factor tests and long convolution / spectral runs. [profile.dev.package.filmcraft-audio-dsp] opt-level = 3 @@ -123,6 +136,9 @@ opt-level = 3 [profile.dev.package.filmcraft-opus] opt-level = 3 +[profile.dev.package.filmcraft-vp8] +opt-level = 3 + [profile.dev.package.filmcraft-vp9] opt-level = 3 diff --git a/README.md b/README.md index 0ce67cb3e..6fac25cdf 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **HEVC / H.265** | ✓ | | Main and Main 10, bit-exact on 41 streams (tiles, WPP, PCM, long-term references), about 225 fps at 1080p | | **Apple ProRes** | ✓ | ✓ | Decodes 422 Proxy to 4444 XQ; export writes 422 HQ | | **AAC-LC** | ✓ | ✓ | | +| **VP8** | ✓ | | Every coding tool, bitstream versions 0–3, hidden altref frames, alpha layer of transparent WebM; bit-exact with libvpx on its published VP8 test vectors; in WebM/Matroska and MP4 | | **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 | | **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 | | **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska | @@ -183,7 +184,8 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | -| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | +| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet | +| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). @@ -304,7 +306,7 @@ A layered Cargo workspace: | Layer | Crates | |---|---| -| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` | +| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` | | Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` | | Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` | | Rendering and output | `render`, `gpu`, `export` | diff --git a/ROADMAP.md b/ROADMAP.md index 9566bc599..7f3962fd6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,11 @@ # FilmCraft Roadmap +- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. +- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. + **Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in [docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration), @@ -79,10 +82,10 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Hindi | none (no Devanagari shaping in the interface) | 0% | | Arabic | none (no RTL interface) | 0% | | French | partial (whole interface; native review welcome) | ~100% | -| Portuguese (Brazil) | menus only | 9.9% | -| Indonesian | none | 0% | +| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% | +| Indonesian | partial (whole interface; native review welcome) | ~100% | | Japanese | partial (whole interface) | 99.7% | -| German | none | 0% | +| German | menus only (native-reviewed) | 10.6% | | Korean | none | 0% | | Vietnamese | none | 0% | @@ -161,6 +164,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points | | 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) | diff --git a/apps/filmcraft-cli/Cargo.toml b/apps/filmcraft-cli/Cargo.toml index ae3a6f317..218b7f7d7 100644 --- a/apps/filmcraft-cli/Cargo.toml +++ b/apps/filmcraft-cli/Cargo.toml @@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true } filmcraft-automation = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444f..d49652b36 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -76,7 +76,7 @@ SUBCOMMANDS import ... import media into the project export [--preset name] [--format f] [--range r] [--start s --end s] [--settings json] [--gpu-rendering auto|off] - [--scale f] [--quality 0-100] [--no-audio] [--queue] + [--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed @@ -85,9 +85,12 @@ SUBCOMMANDS preset; --scale renders at a fraction of the frame size (0.5 = half); --quality 0-100 for the formats that take one; --no-audio leaves the sound out; --queue adds to the export queue and runs it - instead + instead. Offline media (missing, made offline, unreadable) renders + as placeholder frames: the export then lists it in `offlineMedia`, + warns on stderr and exits 1, unless --allow-offline accepts it export --list-presets [query] list export presets (name, category, format) as JSON - render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG + render --seconds S --out f.png [--scale 0.5] [--allow-offline] + render one Program frame to PNG (offline media: as for export) probe [--image-sequence] media info as JSON, with MXF / Ogg / BWF details; with --image-sequence is the first numbered still of a sequence @@ -113,7 +116,8 @@ VALUES Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=. EXIT STATUS - 0 success · 1 a command failed · 2 usage error + 0 success · 1 a command failed, or export/render drew offline media without --allow-offline + · 2 usage error "; /// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's @@ -244,6 +248,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability { filmcraft_platform::register() } +/// `path` made absolute against the working directory, as the project stores it. Unlike +/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\` +/// prefix (#850). A path it cannot resolve is kept as given. +fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + #[tokio::main] async fn main() { cli().await; @@ -253,7 +264,8 @@ async fn main() { } async fn cli() { - let a = Args::parse(std::env::args().skip(1)); + // `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1) + let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned())); // `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference: // the parser takes it as a flag option, so it never reaches the subcommand match. if a.flag("--help") { @@ -373,8 +385,7 @@ async fn cli() { print(&a, &v); } "import" => { - let paths: Vec = - a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect(); + let paths: Vec = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect(); if paths.is_empty() { usage("import ..."); } @@ -442,7 +453,9 @@ async fn cli() { match r { Ok(v) => { print(&a, &v); - diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64())); + let written = written_paths(&v, out).join(", "); + check_offline(&a, &offline_media(&v), &format!("export: {written}")); + diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64())); } Err(e) => fail(format!("export: {e}")), } @@ -457,10 +470,12 @@ 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 img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); + let (img, offline) = 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}"))); + let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); + check_offline(&a, &offline, &format!("render: {out}")); diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); } "mcp" => { @@ -468,7 +483,7 @@ async fn cli() { usage(format!("unknown mcp option `{opt}`")); } let server = match a.opt("--bridge") { - Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)), + Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)), None => match Backend::open(&a) { Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s), Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"), @@ -500,9 +515,46 @@ 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()) +/// The offline items an export result reports (`offlineMedia` of a direct export, or of each +/// queued item), once each. +fn offline_media(result: &Value) -> Vec { + let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten()); + let mut out: Vec = Vec::new(); + for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() { + if !out.iter().any(|x| x.get("item") == o.get("item")) { + out.push(o.clone()); + } + } + out +} + +/// One stderr line per offline item that rendered as placeholder frames (#1054). +fn offline_warning(offline: &[Value]) -> String { + let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len()); + for o in offline { + let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default(); + msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path"))); + } + msg +} + +/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after +/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture. +fn check_offline(a: &Args, offline: &[Value], what: &str) { + if offline.is_empty() { + return; + } + diag(format_args!("{}", offline_warning(offline))); + if !a.flag("--allow-offline") { + fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline")); + } +} + +/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be +/// rendered (no sequence, bad scale). +fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec), String> { + s.try_render_program_with_offline_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 @@ -531,7 +583,7 @@ mod format_tests { 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); + assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8); } /// The CLI (and MCP / headless runs, which share its entry point) registers the hardware @@ -573,6 +625,19 @@ mod format_tests { assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]); } + #[test] + fn offline_media_from_direct_and_queued_results() { + use serde_json::json; + let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""}); + let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""}); + assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a)); + let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]}); + assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]); + assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty()); + let w = super::offline_warning(&[a, b]); + assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}"); + } + #[test] fn export_lone_bound_is_forwarded_as_custom_range() { use serde_json::json; @@ -590,6 +655,27 @@ mod format_tests { assert!(p.get("range").is_none()); } + /// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…` + /// verbatim paths in the project (#850). + #[test] + fn imported_paths_are_absolute_without_resolving_links() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy()); + assert!(!super::absolute("clip.mov").starts_with(r"\\?\")); + assert_eq!(super::absolute(""), ""); + #[cfg(unix)] + { + let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("real.mov"), b"x").unwrap(); + let link = dir.join("link.mov"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap(); + assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy()); + std::fs::remove_dir_all(&dir).unwrap(); + } + } + #[test] fn export_format_from_extension() { assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); diff --git a/apps/filmcraft-cli/tests/cli.rs b/apps/filmcraft-cli/tests/cli.rs index 8552cc640..5d9e3753d 100644 --- a/apps/filmcraft-cli/tests/cli.rs +++ b/apps/filmcraft-cli/tests/cli.rs @@ -22,6 +22,9 @@ fn help_and_usage_errors() { assert_eq!(cli(&[]).status.code(), Some(2)); assert_eq!(cli(&["frobnicate"]).status.code(), Some(2)); assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2)); + // Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921). + assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2)); + assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2)); } #[test] @@ -291,3 +294,55 @@ fn closed_stdout_still_runs_the_script_and_saves() { assert_eq!(out.status.code(), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + +/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the +/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the +/// placeholder frames; then it still warns. +#[test] +fn export_with_missing_media_fails_unless_allowed() { + let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = |n: &str| dir.join(n).to_str().unwrap().to_string(); + let (still, proj) = (f("still.png"), f("p.fcproj")); + let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj])); + let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\")); + let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap(); + json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"])); + let export = |out: &str, extra: &[&str]| { + let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"]; + args.extend_from_slice(extra); + cli(&args) + }; + + let on = export(&f("on.png"), &[]); + let v = json_out(&on); + assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}"); + assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr)); + + std::fs::remove_file(&still).unwrap(); + let off = export(&f("off.png"), &[]); + let err = String::from_utf8_lossy(&off.stderr); + assert_eq!(off.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}"); + let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed"); + assert_eq!(v["offlineMedia"][0]["item"], item, "{v}"); + assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}"); + + let allowed = export(&f("allowed.png"), &["--allow-offline"]); + let err = String::from_utf8_lossy(&allowed.stderr); + assert!(allowed.status.success(), "{err}"); + assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}"); + assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing"); + + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]); + let err = String::from_utf8_lossy(&render.stderr); + assert_eq!(render.status.code(), Some(1), "{err}"); + assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}"); + let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]); + assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr)); + assert!(String::from_utf8_lossy(&render.stderr).contains("still.png")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/workflows.rs new file mode 100644 index 000000000..b0f7a57a8 --- /dev/null +++ b/apps/filmcraft-cli/tests/workflows.rs @@ -0,0 +1,488 @@ +//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent +//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`, +//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot: +//! +//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames); +//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3). +//! +//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross +//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes) +//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which +//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels. +//! +//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never +//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure). +//! ffmpeg and ffprobe are external test oracles only. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +// --------------------------------------------------------------------------------------------- +// Fixtures + +/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped +/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The +/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0 +/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers. +fn phone_vfr_mp4(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"]) + .args([ + "-filter_complex", + "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\ + select='not(eq(mod(n,7),3))'[v];\ + [1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]", + ]) + .args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"]) + .args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe, +/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3. +fn camera_long_gop_mkv(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"]) + .args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]) + .args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"]) + .args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A scratch directory under the workspace `target/`, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Scratch(d) + } + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +// --------------------------------------------------------------------------------------------- +// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio) + +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Mcp { + /// Start the headless server (an empty project) and initialise the session. `data_dir` keeps + /// user presets away from the real data directory. + fn start(data_dir: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")) + .args(["--data-dir", data_dir, "mcp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn filmcraft-cli mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut m = Mcp { child, stdin, stdout, next_id: 1 }; + let init = + m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}})); + assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}"); + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + m + } + + fn send(&mut self, msg: &Value) { + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// Send a request and return its response (notifications in between are skipped). + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let mut line = String::new(); + assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending"); + let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}")); + if v["id"] == id { + return v; + } + } + } + + /// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error. + fn tool(&mut self, name: &str, arguments: Value) -> Value { + let r = self.request("tools/call", json!({"name": name, "arguments": arguments})); + let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) + } + + /// `command_run`: one engine command by id. + fn run(&mut self, id: &str, params: Value) -> Value { + self.tool("command_run", json!({"id": id, "params": params})) + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +fn probe(ffprobe: &Path, path: &str) -> Value { + let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> { + j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect() +} + +fn num(v: &Value) -> f64 { + v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN) +} + +/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout. +fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec { + let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap(); + assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout +} + +/// Size of the frames [`frames`] returns. +const FW: usize = 80; +const FH: usize = 60; + +/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes +/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it). +fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec> { + let chain = match vf { + Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"), + None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"), + }; + let bpp = if pix_fmt == "gray" { 1 } else { 3 }; + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]); + raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect() +} + +/// Mean absolute difference of two equally sized frames. +fn diff(a: &[u8], b: &[u8]) -> f64 { + a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::() / a.len().max(1) as f64 +} + +/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close. +fn assert_shows(got: &[u8], src: &[Vec], want: usize, what: &str) { + let d: Vec = src.iter().map(|s| diff(got, s)).collect(); + let best = d.iter().copied().fold(f64::INFINITY, f64::min); + let at = d.iter().position(|x| *x == best).unwrap_or(0); + assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]); +} + +/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz. +fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec { + let map = format!("0:a:{stream}"); + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]); + raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart. +fn onsets(x: &[f32]) -> Vec { + let peak = x.iter().fold(0f32, |m, v| m.max(v.abs())); + let mut out = Vec::new(); + let mut last = f64::NEG_INFINITY; + for (i, block) in x.chunks(48).enumerate() { + let t = i as f64 * 48.0 / 48_000.0; + if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 { + out.push(t); + } + if block.iter().any(|v| v.abs() > peak / 4.0) { + last = t; + } + } + out +} + +fn rms(x: &[f32], from: f64, to: f64) -> f64 { + let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default(); + (s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::() / s.len().max(1) as f64).sqrt() +} + +/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel). +fn tone(x: &[f32], at: f64, freq: f64) -> f64 { + let start = (at * 48_000.0) as usize; + let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default(); + let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos(); + let (mut s1, mut s2) = (0.0, 0.0); + for v in s { + let s0 = f64::from(*v) + c * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64 +} + +fn db(ratio: f64) -> f64 { + 20.0 * ratio.log10() +} + +fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec { + seq[kind][track]["items"].as_array().unwrap() +} + +// --------------------------------------------------------------------------------------------- +// Workflows + +/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the +/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and +/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame +/// holds the one before), the cut lands on the right frames, picture and sound stay in sync +/// across the cut, the title is in the picture only while it is on the timeline. +#[test] +fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = phone_vfr_mp4(&ffmpeg) else { + return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("phone"); + let mut m = Mcp::start(&dir.path("data")); + + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + assert!(made["sequence"].is_u64(), "{made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate"); + assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps"); + + // Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain. + m.run("markers.markIn", json!({"seconds": 1.5})); + m.run("markers.markOut", json!({"seconds": 2.5})); + m.run("sequence.extract", json!({})); + m.run("edit.selectAll", json!({})); + let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0})); + assert_eq!(gain["clips"], 2, "both audio pieces: {gain}"); + m.run("edit.deselectAll", json!({})); + m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5})); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["durationFrames"], 89, "{seq}"); + assert_eq!(clips_on(&seq, "video", 0).len(), 2); + assert_eq!(clips_on(&seq, "audio", 0).len(), 2); + + let out = dir.path("phone_edit.mp4"); + let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true})); + assert_eq!(r["result"]["frames"], 89, "{r}"); + + // container and streams + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "30/1"); + assert_eq!(v[0]["nb_read_frames"], "89"); + assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}"); + assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]); + + // picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source + // as an editor plays it (CFR 30, a dropped frame holding the previous one) + let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray"); + let got = frames(&ffmpeg, &out, None, "gray"); + assert!(src_frames.len() >= 120, "{} source frames", src_frames.len()); + assert_eq!(got.len(), 89); + for k in [20, 32, 40, 44, 45, 50, 70, 88] { + let want = if k < 45 { k } else { k + 31 }; + assert_shows(&got[k], &src_frames, want, &format!("export frame {k}")); + } + // the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31 + let mean = |f: &Vec| f.iter().map(|v| f64::from(*v)).sum::() / f.len() as f64; + let flashes: Vec = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect(); + assert_eq!(flashes, [30, 31, 59], "white frames"); + + // the title: over frames 0..15 only (its pixels differ from the source there, nowhere after) + let blocks_changed = |k: usize| { + let mut n = 0; + for by in (0..FH).step_by(10) { + for bx in (0..FW).step_by(10) { + let mut d = 0.0; + for y in by..by + 10 { + for x in bx..bx + 10 { + d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x])); + } + } + n += usize::from(d / 100.0 > 15.0); + } + } + n + }; + for k in [2, 8, 14] { + assert!(blocks_changed(k) >= 2, "title missing on frame {k}"); + } + for k in [16, 25] { + assert_eq!(blocks_changed(k), 0, "title still on frame {k}"); + } + + // sound: beeps where the flashes are (A/V sync across the extract), 6 dB down + let src_pcm = pcm(&ffmpeg, &src, 0); + let got_pcm = pcm(&ffmpeg, &out, 0); + let beeps = onsets(&got_pcm); + assert_eq!(beeps.len(), 2, "beeps at {beeps:?}"); + for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) { + assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s"); + } + let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09)); + assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB"); + let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09)); + assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB"); +} + +/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make +/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at +/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross +/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes. +/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio +/// tracks are in the mix at their own level and stop where the clip does. +#[test] +fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = camera_long_gop_mkv(&ffmpeg) else { + return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("camera"); + let data = dir.path("data"); + let project = dir.path("camera.fcproj"); + { + let mut m = Mcp::start(&data); + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + let clips: Vec = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1})); + assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks"); + let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap(); + + // 65 frames off the head and 15 off the tail, the linked audio following + m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65})); + m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15})); + let seq = m.tool("sequence_inspect", json!({})); + let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap(); + for t in 0..2 { + let a = &clips_on(&seq, "audio", t)[0]; + assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1); + } + + let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap(); + let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false})); + let matte_clip = placed["clips"][0].as_u64().unwrap(); + m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"})); + let seq = m.tool("sequence_inspect", json!({})); + let tr = &seq["video"][0]["transitions"][0]; + assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}"); + m.run("file.saveAs", json!({"path": project})); + } + assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit"); + + // a fresh process opens the saved project and renders it + let out = dir.path("camera_edit.mov"); + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap(); + assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr)); + let r: Value = serde_json::from_slice(&o.stdout).unwrap(); + let total = 71 + 50; + assert_eq!(r["result"]["frames"], total, "{r}"); + + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "25/1"); + assert_eq!(v[0]["nb_read_frames"], total.to_string()); + assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}"); + + // picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it + let src_frames = frames(&ffmpeg, &src, None, "rgb24"); + let got = frames(&ffmpeg, &out, None, "rgb24"); + assert_eq!((src_frames.len(), got.len()), (150, total as usize)); + for k in [0, 1, 2, 30, 64] { + assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}")); + } + let red = &got[100]; + assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red"); + // the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut + let mix = |k: usize| { + let (cam, e) = (&src_frames[65 + k], &got[k]); + let (mut num, mut den) = (0.0, 0.0); + for ((c, r), e) in cam.iter().zip(red).zip(e) { + let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e)); + num += (e - c) * (r - c); + den += (r - c) * (r - c); + } + num / den + }; + let alphas: Vec = (64..=78).map(mix).collect(); + assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}"); + assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}"); + assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}"); + + // sound: both tracks in the mix at their own level while the clip plays, silence on the matte + let got_pcm = pcm(&ffmpeg, &out, 0); + let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1)); + for at in [0.2, 1.5, 2.6] { + let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0)); + let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0)); + assert!( + db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0, + "at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})" + ); + } + assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte"); +} + +/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through +/// `filmcraft-cli --project … inspect sequence`. +fn end_frame_of(project: &str) -> Option { + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?; + let v: Value = serde_json::from_slice(&o.stdout).ok()?; + v["video"][0]["items"][0]["endFrame"].as_u64() +} diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs index 6b41a2cdd..a075f9a93 100644 --- a/apps/filmcraft-web/src/import.rs +++ b/apps/filmcraft-web/src/import.rs @@ -263,7 +263,10 @@ pub fn install_hooks(app: &mut FilmcraftApp) { })); // Saving writes the file in memory and offers it as a download (`fs::WebServices`). app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}")))); - app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}")))); + app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| { + let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy()); + Some(format!("/exports/{file}")) + })); // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the // hint's command runs once the user has chosen (#111). app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option| { diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 85175489d..3ae35e6c6 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -41,6 +41,8 @@ open = "5" [dev-dependencies] glob = "0.3" +# graphics.rs: enumerate the real adapters in a test. +pollster = "0.4" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..c64e3509f 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -1,5 +1,8 @@ //! The desktop app's command line (`filmcraft --help`). +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + /// `filmcraft --help`. pub const USAGE: &str = "\ Usage: filmcraft [options] [project.fcproj | media files...] @@ -7,12 +10,17 @@ Usage: filmcraft [options] [project.fcproj | media files...] Options: --control start the localhost JSON-lines control server (also: FILMCRAFT_CONTROL_PORT) + --control-input-only accept widget input only from the control server + (requires --control or FILMCRAFT_CONTROL_PORT) --demo open the demo project --empty start with an empty project --recover recover the newest unsaved changes without asking --no-recover start without asking about unsaved changes --data-dir folder for auto-save, crash recovery and logs (also: FILMCRAFT_DATA_DIR) + --graphics start with this graphics mode and keep it: default, + dx12 (Windows), software (Windows, Linux); + `--graphics default` undoes an automatic fallback --version print the version and exit -h, --help print this help and exit "; @@ -26,9 +34,10 @@ pub enum Cli { } /// Options of a normal start. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Launch { pub control_port: Option, + pub control_input_only: bool, /// A project and / or media files to open. pub files: Vec, pub demo: bool, @@ -36,6 +45,8 @@ pub struct Launch { pub startup_flag: bool, pub recover: Option, pub data_dir: Option, + /// `--graphics `: start with this graphics mode and keep it (see `graphics.rs`). + pub graphics: Option, } /// Parse the arguments after the program name. `env_control_port` is the value of @@ -45,29 +56,49 @@ pub struct Launch { /// mistyped flag helps nobody, and starting without the control server that was asked for leaves /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. -pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { - let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; +/// +/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name +/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily +/// (its import then reports it missing); `--data-dir` keeps the folder exactly. +pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { + let mut l = Launch { + control_port: None, + control_input_only: false, + files: Vec::new(), + demo: true, + startup_flag: false, + recover: None, + data_dir: None, + graphics: None, + }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); - while let Some(a) = args.next() { - match a.as_str() { + while let Some(raw) = args.next() { + let a = raw.to_string_lossy(); + match a.as_ref() { "-h" | "--help" => help = true, "--version" => version = true, "--control" => { let port = args.next().ok_or("--control needs a port number")?; + let port = port.to_string_lossy(); l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?); } "--demo" => (l.demo, l.startup_flag) = (true, true), + "--control-input-only" => l.control_input_only = true, "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), - "--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?), + "--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?), + "--graphics" => { + let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?; + l.graphics = Some(graphics_mode(&name.to_string_lossy())?); + } // everything after `--` is a file, whatever it looks like - "--" => l.files.extend(args.by_ref()), + "--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())), // macOS adds a process serial number when it launches an app bundle _ if a.starts_with("-psn_") => {} _ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")), - _ => l.files.push(a), + _ => l.files.push(a.into_owned()), } } if help { @@ -81,15 +112,51 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator bool { + Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj")) +} + +/// `path` made absolute against the working directory, so a project or media file named relative +/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the +/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks +/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given. +pub fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + +/// The launch files as the project to open (the first one, see [`is_project`]) and the media files +/// to import (every file that is not a project), all made [`absolute`]. +pub fn project_and_media(files: &[String]) -> (Option, Vec) { + let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f)); + let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect(); + (project, media) +} + +/// A `--graphics` value: one of this platform's modes. +fn graphics_mode(name: &str) -> Result { + use crate::graphics::{LADDER, Mode}; + let here = || LADDER.iter().map(|m| m.name()).collect::>().join(", "); + let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?; + if !LADDER.contains(&mode) { + return Err(format!("--graphics {name} is not available on this platform (use one of {})", here())); + } + Ok(mode) +} + #[cfg(test)] mod tests { use super::*; fn parse_env(env: Option<&str>, args: &[&str]) -> Result { - parse(env.map(str::to_string), args.iter().map(|a| a.to_string())) + parse(env.map(str::to_string), args.iter().map(OsString::from)) } fn parse_args(args: &[&str]) -> Result { @@ -157,7 +224,19 @@ mod tests { #[test] fn known_options_and_files_parse_as_before() { let none = launch(&[]); - assert_eq!(none, Launch { control_port: None, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }); + assert_eq!( + none, + Launch { + control_port: None, + control_input_only: false, + files: vec![], + demo: true, + startup_flag: false, + recover: None, + data_dir: None, + graphics: None + } + ); let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]); assert_eq!(l.control_port, Some(9876)); assert_eq!((l.demo, l.startup_flag, l.recover), (false, true, Some(false))); @@ -168,4 +247,79 @@ mod tests { assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]); assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]); } + + /// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on + /// Linux) stopped the app before its window opened. + #[cfg(unix)] + #[test] + fn arguments_that_are_not_unicode_still_start_the_app() { + use std::os::unix::ffi::OsStringExt; + let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec()); + let file = OsString::from_vec(b"caf\xe9.mov".to_vec()); + let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else { + panic!("should start the app"); + }; + assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly"); + assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]); + } + + /// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be + /// imported as a media file instead of opened (#850). + #[test] + fn a_project_is_recognised_whatever_the_case_of_its_extension() { + for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] { + assert!(is_project(p), "{p}"); + } + for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] { + assert!(!is_project(p), "{p}"); + } + } + + /// Relative launch paths were opened and imported as given, so the project's media paths and + /// Recent Projects pointed nowhere once the app ran from another folder (#850). + #[test] + fn launch_files_are_made_absolute_and_split_case_insensitively() { + let cwd = std::env::current_dir().unwrap(); + let nested = Path::new("clips").join("a.mp4"); + let already = cwd.join("b.wav").to_string_lossy().into_owned(); + let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()]; + let (project, media) = project_and_media(&files); + assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned())); + assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]); + for p in media.iter().chain(&project) { + assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}"); + } + assert_eq!(project_and_media(&[]), (None, vec![])); + // nothing to resolve against: kept as given rather than dropped + assert_eq!(absolute(""), ""); + } + + #[test] + fn isolated_input_is_explicit_and_requires_a_control_server() { + assert!(!launch(&["--control", "9876"]).control_input_only); + assert!(parse_args(&["--control-input-only"]).is_err()); + assert!(launch(&["--control-input-only", "--control", "9876"]).control_input_only); + assert!(matches!(parse_env(Some("9876"), &["--control-input-only"]), Ok(Cli::Run(Launch { control_input_only: true, control_port: Some(9876), .. })))); + assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help)); + assert!(USAGE.contains("--control-input-only")); + } + + #[test] + fn graphics_picks_one_of_this_platforms_modes() { + use crate::graphics::{LADDER, Mode}; + assert_eq!(launch(&[]).graphics, None); + assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default)); + for mode in LADDER { + assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode)); + } + // a mode this platform has not is refused, not ignored + for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) { + assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available")); + } + assert!(parse_args(&["--graphics"]).is_err(), "missing mode"); + assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan")); + assert!(parse_args(&["--graphics", "DEFAULT"]).is_err()); + assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err()); + assert!(USAGE.contains("--graphics")); + } } diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index f0e9bc489..cb56e059e 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -1,18 +1,33 @@ //! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host //! ("Device Class"), the output device, the I/O buffer size and the sample rate. +//! +//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which +//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by +//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound +//! resumes where it stopped instead of playback going silent (#840). +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError, TryLockError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use filmcraft_engine::settings::AudioHardwarePrefs; use filmcraft_ui_egui::{AudioDevices, AudioOut}; +/// The playback mixer callback handed to [`AudioOut::start`]. +type Fill = Box; + pub struct CpalOut { stream: Option, + /// The running playback's mixer, shared with the stream so a restarted stream continues it. + fill: Option>>, played: Arc, - failed: Arc, + health: Arc, + watchdog: Watchdog, + /// Time base for the watchdog. + epoch: Instant, rate: u32, channels: u16, hw: AudioHardwarePrefs, @@ -128,8 +143,11 @@ impl CpalOut { }; Self { stream: None, + fill: None, played: Arc::new(AtomicU64::new(0)), - failed: Arc::new(AtomicBool::new(false)), + health: Arc::new(Health::default()), + watchdog: Watchdog::default(), + epoch: Instant::now(), rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0), channels: cfg.as_ref().map_or(2, |c| c.channels()), hw: AudioHardwarePrefs::default(), @@ -153,19 +171,14 @@ impl CpalOut { Ok(exact.unwrap_or(default)) } - fn converted_stream( - &self, - dev: &cpal::Device, - config: &cpal::StreamConfig, - mut fill: Box, - ) -> Result + fn converted_stream(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc>) -> Result where T: cpal::SizedSample + cpal::FromSample, { let channels = usize::from(config.channels); let played = self.played.clone(); - let failed = self.failed.clone(); - let error_flag = failed.clone(); + let health = self.health.clone(); + let error_health = health.clone(); let mut scratch = Vec::new(); dev.build_output_stream( config, @@ -173,39 +186,23 @@ impl CpalOut { // Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread. if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() { buf.fill(T::EQUILIBRIUM); - if !failed.swap(true, Ordering::SeqCst) { + if !health.failed.swap(true, Ordering::SeqCst) { eprintln!("filmcraft: unable to allocate the audio conversion buffer"); } return; } scratch.resize(buf.len(), 0.0); - fill(&mut scratch, channels); + render(&fill, &mut scratch, channels, &played, &health); convert_samples(&scratch, buf); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); }, - move |e| stream_error(&error_flag, e), + move |e| stream_error(&error_health, e), None, ) } -} -fn stream_error(failed: &AtomicBool, e: cpal::StreamError) { - if !failed.swap(true, Ordering::SeqCst) { - eprintln!("filmcraft: audio stream error: {e}"); - } -} - -fn convert_samples>(input: &[f32], output: &mut [T]) { - // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. - let upper = f32::from_bits(1.0_f32.to_bits() - 1); - for (sample, converted) in input.iter().zip(output) { - *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); - } -} - -impl AudioOut for CpalOut { - fn start(&mut self, mut fill: Box) -> Result { - self.stop(); + /// Open the configured output device and play `fill` on it. `expect` is the (sample rate, + /// channels) a restarted stream must keep: the mixer was set up for them. + fn open(&mut self, fill: Arc>, expect: Option<(u32, u16)>) -> Result<(), String> { let host = host(&self.hw.device_class); let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?; self.note = fallback; @@ -214,6 +211,11 @@ impl AudioOut for CpalOut { if channels == 0 || cfg.sample_rate().0 == 0 { return Err("the output device returned an invalid channel count or sample rate".into()); } + if let Some((rate, ch)) = expect + && (rate, ch) != (cfg.sample_rate().0, cfg.channels()) + { + return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels())); + } let mut config: cpal::StreamConfig = cfg.clone().into(); if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size() && (*min..=*max).contains(&self.hw.buffer_size) @@ -222,20 +224,18 @@ impl AudioOut for CpalOut { } self.rate = cfg.sample_rate().0; self.channels = cfg.channels(); - self.played.store(0, Ordering::SeqCst); - self.failed.store(false, Ordering::SeqCst); - let played = self.played.clone(); - let failed = self.failed.clone(); let stream = match cfg.sample_format() { - cpal::SampleFormat::F32 => dev.build_output_stream( - &config, - move |buf: &mut [f32], _| { - fill(buf, channels); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); - }, - move |e| stream_error(&failed, e), - None, - ), + cpal::SampleFormat::F32 => { + let played = self.played.clone(); + let health = self.health.clone(); + let error_health = health.clone(); + dev.build_output_stream( + &config, + move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health), + move |e| stream_error(&error_health, e), + None, + ) + } cpal::SampleFormat::I8 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I16 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I24 => self.converted_stream::(&dev, &config, fill), @@ -251,10 +251,172 @@ impl AudioOut for CpalOut { .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; self.stream = Some(stream); + Ok(()) + } + + /// Replace a dropped stream with a new one on the same mixer, clock and format. + fn restart(&mut self) -> Result<(), String> { + let fill = self.fill.clone().ok_or("nothing is playing")?; + let expect = (self.rate, self.channels); + // The old stream (and its audio thread) goes first: only one stream ever runs the mixer. + self.stream = None; + self.health.clear_errors(); + self.open(fill, Some(expect)) + } +} + +/// What the audio thread reports to the UI thread. +#[derive(Default)] +struct Health { + /// The device went away (unplugged): restart the stream at once. + lost: AtomicBool, + /// A backend error since the last check (cpal keeps the stream alive after these): restart + /// only if the stream also stopped consuming samples. + errored: AtomicBool, + /// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out. + /// [`AudioOut::played_frames`] reports `None` and playback goes on without sound. + failed: AtomicBool, + /// The first error of a stream has been printed. + reported: AtomicBool, +} + +impl Health { + fn clear_errors(&self) { + self.lost.store(false, Ordering::SeqCst); + self.errored.store(false, Ordering::SeqCst); + self.reported.store(false, Ordering::SeqCst); + } +} + +/// How long a stream that reported an error may go without consuming samples before it is +/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock. +const STALL_RESTART: Duration = Duration::from_millis(300); +/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up. +const MAX_RESTARTS: usize = 3; +const RESTART_WINDOW: Duration = Duration::from_secs(10); + +/// What [`Watchdog::check`] decides about the running stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Verdict { + Healthy, + Restart, + GiveUp, +} + +/// Decides when a stream that reported errors is restarted (UI thread, every frame). +#[derive(Default)] +struct Watchdog { + /// Played-frame count last seen, and when it last changed. + seen: (u64, Duration), + /// An error was reported and the stream has not consumed samples since. + pending_error: bool, + /// When recent restarts happened (at most [`MAX_RESTARTS`] kept). + restarts: Vec, +} + +impl Watchdog { + fn reset(&mut self, now: Duration) { + *self = Self { seen: (0, now), ..Self::default() }; + } + + fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict { + if played != self.seen.0 { + self.seen = (played, now); + self.pending_error = false; + } + // An error that arrives with the stream still consuming samples is only a hiccup. + self.pending_error |= errored; + let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART; + if !lost && !(self.pending_error && stalled) { + return Verdict::Healthy; + } + self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW); + if self.restarts.len() >= MAX_RESTARTS { + return Verdict::GiveUp; + } + self.restarts.push(now); + self.seen = (played, now); + self.pending_error = false; + Verdict::Restart + } +} + +/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a +/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never +/// expected: a restart drops the old stream first) plays silence without advancing the clock. +fn render(fill: &Mutex, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) { + let mut guard = match fill.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => { + buf.fill(0.0); + return; + } + }; + if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() { + buf.fill(0.0); + if !health.failed.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: the audio mixer failed; playing without sound"); + } + return; + } + played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst); +} + +fn stream_error(health: &Health, e: cpal::StreamError) { + match e { + cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst), + cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst), + } + if !health.reported.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: audio stream error: {e}"); + } +} + +fn convert_samples>(input: &[f32], output: &mut [T]) { + // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. + let upper = f32::from_bits(1.0_f32.to_bits() - 1); + for (sample, converted) in input.iter().zip(output) { + *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); + } +} + +impl AudioOut for CpalOut { + fn start(&mut self, fill: Box) -> Result { + self.stop(); + self.played.store(0, Ordering::SeqCst); + self.health = Arc::new(Health::default()); + self.watchdog.reset(self.epoch.elapsed()); + let fill = Arc::new(Mutex::new(fill)); + self.open(fill.clone(), None)?; + self.fill = Some(fill); Ok(self.rate) } fn stop(&mut self) { self.stream = None; + self.fill = None; + } + fn recover(&mut self) { + if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) { + return; + } + let lost = self.health.lost.swap(false, Ordering::SeqCst); + let errored = self.health.errored.swap(false, Ordering::SeqCst); + match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) { + Verdict::Healthy => {} + Verdict::Restart => { + log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" }); + if let Err(e) = self.restart() { + log::warn!("the audio output stream could not be restarted: {e}"); + self.stream = None; + self.health.failed.store(true, Ordering::SeqCst); + } + } + Verdict::GiveUp => { + log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs()); + self.health.failed.store(true, Ordering::SeqCst); + } + } } fn sample_rate(&self) -> u32 { self.rate @@ -263,7 +425,7 @@ impl AudioOut for CpalOut { self.channels as usize } fn played_frames(&self) -> Option { - self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) + self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) } fn devices(&self) -> AudioDevices { let h = host(&self.hw.device_class); @@ -327,11 +489,92 @@ mod tests { } #[test] - fn failed_stream_relinquishes_the_playback_clock() { - let failed = AtomicBool::new(false); - stream_error(&failed, cpal::StreamError::DeviceNotAvailable); - assert!(failed.load(Ordering::SeqCst)); + fn stream_errors_ask_for_a_restart_instead_of_failing_playback() { + let health = Health::default(); + stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } }); + assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst)); + stream_error(&health, cpal::StreamError::DeviceNotAvailable); + assert!(health.lost.load(Ordering::SeqCst)); + assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback"); let output = CpalOut::new(); - assert_eq!(output.played_frames(), None); + assert_eq!(output.played_frames(), None, "no stream, no clock"); + } + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn an_error_while_samples_keep_flowing_is_only_a_hiccup() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy); + // the stream keeps consuming samples after the error: never restarted + for (i, t) in (20..2_000).step_by(20).enumerate() { + assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy); + } + } + + #[test] + fn a_stream_that_errors_and_stops_consuming_is_restarted() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy); + // POLLERR: the backend reports errors and no longer calls back + assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart); + // the restarted stream gets a fresh grace period + assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy); + // a stall without any reported error is left to the UI's own watchdog + assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy); + } + + #[test] + fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp); + // sporadic failures far apart keep being recovered + assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart); + } + + #[test] + fn the_device_callback_never_panics_and_counts_played_frames() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25))); + let mut buf = [1.0_f32; 8]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4)); + // a mixer still held elsewhere plays silence without moving the clock + { + let _held = fill.lock().unwrap(); + render(&fill, &mut buf, 2, &played, &health); + } + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4)); + // zero channels from a broken driver must not divide by zero + render(&fill, &mut buf, 0, &played, &health); + assert_eq!(played.load(Ordering::SeqCst), 12); + } + + #[test] + fn a_panicking_mixer_plays_silence_and_fails_the_stream() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| { + buf.fill(0.5); + panic!("mixer bug"); + })); + let mut buf = [1.0_f32; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); + assert_eq!(played.load(Ordering::SeqCst), 0); + assert!(health.failed.load(Ordering::SeqCst)); + // later callbacks stay silent instead of re-entering the broken mixer + buf = [1.0; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); } } diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cbf..e79ace08e 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,33 +1,487 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback +//! when a start fails (docs/gaps.md G4, #820). +//! +//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver +//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error +//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. +//! +//! # When a start counts as failed +//! The data folder keeps the mode to start with and the failed starts in a row in it +//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/-.json`, +//! so concurrent launches never write the same file) that is removed once the window has painted +//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened +//! by then. An attempt file found by a later launch therefore means a process that died within +//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs, +//! or is force-quit or crashes later, is not counted; neither is another instance that is still +//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After +//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next +//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A +//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once, +//! within the same process. +//! +//! # Getting back +//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`, +//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command +//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves +//! the saved state alone: the user is in charge. +//! +//! All files are written beside and renamed into place, so a reader never sees half a file; two +//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted +//! once less, a confirmation repeated by the next start), never corrupt it. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave -/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) -/// names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends - // shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and - // leaves machines without DX12 with no backend at all. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{self, Backends}, +}; + +/// The saved graphics state in the data folder: `{"mode": "", "failures": }`. +pub const STATE_FILE: &str = "graphics-start.json"; +/// The folder in the data folder with one attempt file per start that has not painted yet. +pub const ATTEMPTS_DIR: &str = "graphics-start"; +/// How long after launch a start that has not painted may die and still count as failed. A +/// driver crash in instance or device creation happens within a second or two. +pub const DEATH_WINDOW: Duration = Duration::from_secs(15); +/// Failed starts in a row, in the same mode, that move on to the next mode. +pub const FAILURES_TO_MOVE_ON: u32 = 2; +/// Attempt files looked at per launch; anything beyond is left for a later launch. +const MAX_ATTEMPTS: usize = 64; +/// The state and attempt files are a few bytes; anything longer is not ours. +const MAX_FILE: u64 = 4096; + +/// How the window's graphics device is created. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on + /// macOS, Vulkan or OpenGL elsewhere. + Default, + /// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up. + Dx12, + /// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then + /// composites on the CPU (`FilmcraftApp::set_wgpu`). + Software, +} + +impl Mode { + /// The name in [`STATE_FILE`] and on the command line (`--graphics `). + pub fn name(self) -> &'static str { + match self { + Mode::Default => "default", + Mode::Dx12 => "dx12", + Mode::Software => "software", + } } + + /// The English name shown in the interface (translated there). + pub fn label(self) -> &'static str { + match self { + Mode::Default => "default graphics", + Mode::Dx12 => "DirectX 12 only", + Mode::Software => "software rendering", + } + } + + pub fn parse(name: &str) -> Option { + [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) + } +} + +/// The modes tried in order on this platform. macOS has only Metal, and no software adapter. +pub const LADDER: &[Mode] = if cfg!(target_os = "windows") { + &[Mode::Default, Mode::Dx12, Mode::Software] +} else if cfg!(target_os = "macos") { + &[Mode::Default] +} else { + &[Mode::Default, Mode::Software] +}; + +/// The mode after `mode` in `ladder`, if any. +fn next_in(ladder: &[Mode], mode: Mode) -> Option { + let i = ladder.iter().position(|m| *m == mode)?; + ladder.get(i.checked_add(1)?).copied() +} + +/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Saved { + mode: Mode, + failures: u32, +} + +/// An attempt file left by a start: its process id (from the file name), mode and launch time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Attempt { + pid: u32, + mode: Mode, + /// Seconds since the Unix epoch. + started: u64, +} + +/// What a launch makes of the saved state and the attempt files it found. +#[derive(Debug, PartialEq, Eq)] +struct Decision { + /// The state to start with and save. + saved: Saved, + /// Moved on to the next mode because the last starts failed. + moved_on: bool, + /// Per attempt: done with (counted, or not ours to count), delete it. + delete: Vec, +} + +/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix +/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was +/// reused), `window` is [`DEATH_WINDOW`] in seconds. +fn decide(saved: Option, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision { + let first = ladder.first().copied().unwrap_or(Mode::Default); + // Nothing saved, or a mode this platform does not use (a data folder copied from another OS). + let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 }); + let mut failures = saved.failures; + let mut delete = Vec::with_capacity(attempts.len()); + for a in attempts { + if a.started > now.saturating_add(window) { + // From the future (a clock that jumped back): unknown, and not worth keeping. + delete.push(true); + continue; + } + let dead = a.pid == pid || now.saturating_sub(a.started) >= window; + // A younger attempt is another instance starting right now: leave it to that instance. + delete.push(dead); + // Only failures of the mode we would start in count; older ones are history. + if dead && a.mode == saved.mode { + failures = failures.saturating_add(1); + } + } + if failures >= FAILURES_TO_MOVE_ON { + return match next_in(ladder, saved.mode) { + Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete }, + // Nothing left to try: the last mode is still the best chance. + None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete }, + }; + } + Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete } +} + +fn read_small(path: &Path) -> Option { + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?; + Some(text) +} + +/// [`Saved`] from its text; `None` for anything else. A missing failure count is none. +fn parse_state(text: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + let mode = Mode::parse(v.get("mode")?.as_str()?)?; + let failures = match v.get("failures") { + None => 0, + Some(n) => u32::try_from(n.as_u64()?).ok()?, + }; + Some(Saved { mode, failures }) +} + +fn state_json(s: Saved) -> String { + serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string() +} + +/// An attempt from its file name (`-.json`) and text; `None` for anything else. +fn parse_attempt(file_name: &str, text: &str) -> Option { + let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?; + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? }) +} + +fn attempt_json(mode: Mode, started: u64) -> String { + serde_json::json!({ "mode": mode.name(), "started": started }).to_string() +} + +/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a +/// start that dies mid-write, or another instance reading, never sees half a file. +fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(format!(".{}.tmp", std::process::id())); + let tmp = PathBuf::from(tmp); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +fn remove_quietly(path: &Path) { + match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()), + _ => {} + } +} + +/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable. +fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option)> { + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + entries + .filter_map(Result::ok) + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + name.ends_with(".json").then(|| { + let path = e.path(); + let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text)); + (path, attempt) + }) + }) + .take(MAX_ATTEMPTS) + .collect() +} + +fn unix_now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// Attempt numbers within this process, so each attempt file has its own name. +static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0); + +/// This launch's graphics mode and its files in the data folder. +#[derive(Clone, Debug)] +pub struct GraphicsStart { + /// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set. + state: Option, + /// This start's attempt file (with `state`). + attempt: Option, + mode: Mode, + /// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it. + reset: Arc, +} + +impl GraphicsStart { + /// Pick this launch's mode from the data folder and record the attempt. `forced` + /// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set) + /// leaves the data folder alone and starts in `forced`, else [`Mode::Default`]. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option) -> Self { + let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id()); + if let Some(path) = start.attempt.clone() { + expire_later(path); + } + start + } + + /// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer. + fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option, now: u64, pid: u32) -> Self { + let first = LADDER.first().copied().unwrap_or(Mode::Default); + let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first }); + let reset = Arc::new(AtomicBool::new(false)); + let Some(dir) = data_dir.filter(|_| !explicit_backend) else { + return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset }; + }; + let state = dir.join(STATE_FILE); + let attempts_dir = dir.join(ATTEMPTS_DIR); + let saved = read_small(&state).and_then(|t| parse_state(&t)); + let found = list_attempts(&attempts_dir); + let known: Vec = found.iter().filter_map(|(_, a)| *a).collect(); + let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER); + let mut to_delete = decision.delete.iter(); + for (path, attempt) in &found { + // unreadable files in our folder are debris (a different version, a damaged disk) + if attempt.is_none() || to_delete.next().copied().unwrap_or(false) { + remove_quietly(path); + } + } + let file = state.display(); + let next = match forced { + Some(mode) => { + log::info!("graphics mode {} chosen with --graphics", mode.name()); + Saved { mode, failures: 0 } + } + None => { + if decision.moved_on { + let failed = saved.map_or(first, |s| s.mode).name(); + log::warn!( + "the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)", + decision.saved.mode.name() + ); + } else if decision.saved.failures > 0 { + log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name()); + } else if decision.saved.mode != Mode::Default { + log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name()); + } + decision.saved + } + }; + if saved != Some(next) + && let Err(e) = write_atomic(&state, &state_json(next)) + { + log::warn!("can't write {file}: {e}"); + } + let start = Self { state: Some(state), attempt: None, mode: next.mode, reset }; + start.with_new_attempt(&attempts_dir, now, pid) + } + + /// Record a new attempt file for the current mode. + fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self { + let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed); + let path = dir.join(format!("{pid}-{n}.json")); + match write_atomic(&path, &attempt_json(self.mode, now)) { + Ok(()) => self.attempt = Some(path), + Err(e) => log::warn!("can't write {}: {e}", path.display()), + } + self + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Running in a fallback mode, i.e. not the platform default. + pub fn degraded(&self) -> bool { + self.mode != Mode::Default + } + + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + /// The error is certain, so the next mode is saved at once. + pub fn step_down(&mut self) -> bool { + let Some(next) = next_in(LADDER, self.mode) else { + return false; + }; + log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); + self.mode = next; + if let Some(state) = self.state.clone() { + self.save(&state, Saved { mode: next, failures: 0 }); + if let Some(old) = self.attempt.take() { + remove_quietly(&old); + if let Some(dir) = old.parent() { + let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt; + if let Some(path) = &path { + expire_later(path.clone()); + } + self.attempt = path; + } + } + } + true + } + + /// The window is up: keep this mode, and the failed starts in a row are over. + pub fn confirm(&self) { + self.finish(); + let Some(state) = &self.state else { return }; + if self.reset.load(Ordering::Relaxed) { + return; + } + let ok = Saved { mode: self.mode, failures: 0 }; + if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) { + self.save(state, ok); + } + } + + /// This start is over, whether it painted or not (the window closed, or every mode failed + /// with an error that was reported): its attempt no longer means a crash. + pub fn finish(&self) { + if let Some(path) = &self.attempt { + remove_quietly(path); + } + } + + /// Start with the default mode next time (the notice's reset button). This process keeps + /// its mode: the graphics device can't be swapped under a running window. + pub fn reset_to_default(&self) -> Result<(), String> { + self.reset.store(true, Ordering::Relaxed); + let Some(state) = &self.state else { return Ok(()) }; + let first = LADDER.first().copied().unwrap_or(Mode::Default); + write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?; + log::info!("graphics mode reset to {} for the next start", first.name()); + Ok(()) + } + + fn save(&self, path: &Path, s: Saved) { + if let Err(e) = write_atomic(path, &state_json(s)) { + log::warn!("can't write {}: {e}", path.display()); + } + } +} + +/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not +/// counted as a failed one, painted or not. +fn expire_later(path: PathBuf) { + let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || { + let _ = std::panic::catch_unwind(move || { + std::thread::sleep(DEATH_WINDOW); + remove_quietly(&path); + }); + }); + if let Err(e) = spawned { + log::warn!("can't start the graphics start timer: {e}"); + } +} + +/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` +/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. +pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { + apply_for(options, mode, backend_override, cfg!(target_os = "windows")); +} + +fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option, windows: bool) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else { + return; + }; + match mode { + // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it + // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter + // selection or any Rust error handling runs, so the window never appears. Choosing an + // adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep + // Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects + // fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no + // backend at all. + Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default => {} + // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. + Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, + Mode::Software => { + create.instance_descriptor.backends = software_backends(windows); + create.native_adapter_selector = Some(Arc::new(pick_software)); + } + } +} + +/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on +/// Windows for the reason in [`apply_for`]. +fn software_backends(windows: bool) -> Backends { + if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL } +} + +/// The first software adapter that can draw to the window's surface. +fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result { + adapters + .iter() + .find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s))) + .cloned() + .ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len())) +} + +/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may +/// avoid (as opposed to a display, event-loop or app error). +pub fn is_graphics_failure(err: &eframe::Error) -> bool { + matches!(err, eframe::Error::Wgpu(_)) } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software]; + const LINUX: &[Mode] = &[Mode::Default, Mode::Software]; + const MACOS: &[Mode] = &[Mode::Default]; + + fn configured(mode: Mode, backend_override: Option, windows: bool) -> (Backends, bool) { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + apply_for(&mut options, mode, backend_override, windows); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } #[test] @@ -37,16 +491,349 @@ mod tests { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - let configured = configured_backends(None); - assert_eq!(configured, Backends::PRIMARY); - assert!(!configured.contains(Backends::GL)); - assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); + let (backends, selector) = configured(Mode::Default, None, true); + assert_eq!(backends, Backends::PRIMARY); + assert!(!backends.contains(Backends::GL)); + assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); + assert!(!selector); } #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + assert_eq!(configured(Mode::Default, Some(backends), true).0, backends); } } + + #[test] + fn fallback_modes_leave_out_the_crashing_backends() { + // DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413). + assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false)); + // Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477). + assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true)); + assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true)); + // Off Windows the default is eframe's own. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); + } + + const W: u64 = 15; + const NOW: u64 = 1_000_000; + const ME: u32 = 7; + + fn saved(mode: Mode, failures: u32) -> Option { + Some(Saved { mode, failures }) + } + + fn dead(mode: Mode) -> Attempt { + Attempt { pid: 1, mode, started: NOW - 100 } + } + + fn mode_after(s: Option, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) { + let d = decide(s, attempts, NOW, ME, W, ladder); + (d.saved.mode, d.saved.failures, d.moved_on) + } + + #[test] + fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() { + // first launch + assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false)); + // one start died: counted, but the same mode is tried again + assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false)); + // the second in a row moves on: DX12, then software + assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true)); + assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true)); + // two dead instances found at once count as two + assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + // a saved mode is kept + assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false)); + assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false)); + // nothing left to try: stay + assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false)); + assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false)); + // a mode this platform has not: start over + assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false)); + assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false)); + assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false)); + // a hostile count doesn't overflow + assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true)); + } + + #[test] + fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() { + let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 }; + let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW }; + let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 }; + let older_mode = dead(Mode::Dx12); + let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 }; + let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS); + // another instance still starting is left alone; one from the future is dropped; a + // failure in another mode is history; our own process id can only be a dead process + assert_eq!(d.delete, [false, false, true, true, true]); + assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false)); + // the clock a little behind another instance's: still starting + let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 }; + assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]); + } + + #[test] + fn the_files_round_trip_and_reject_anything_else() { + for mode in WINDOWS { + for failures in [0, 1, 2, u32::MAX] { + assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures)); + } + assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW })); + } + // without a count (an earlier build of this change wrote `pending`): none + assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0)); + for bad in [ + "", + "{", + "null", + "[]", + r#"{"mode":"vulkan","failures":1}"#, + r#"{"failures":1}"#, + r#"{"mode":1,"failures":1}"#, + r#"{"mode":"dx12","failures":"one"}"#, + r#"{"mode":"dx12","failures":-1}"#, + r#"{"mode":"dx12","failures":4294967296}"#, + ] { + assert_eq!(parse_state(bad), None, "{bad}"); + } + let good = attempt_json(Mode::Default, NOW); + for (name, text) in [ + ("12-0.json", "{"), + ("12-0.json", r#"{"mode":"default"}"#), + ("12-0.json", r#"{"mode":"default","started":-5}"#), + ("x-0.json", good.as_str()), + ("12.json", good.as_str()), + ("-0.json", good.as_str()), + ("99999999999-0.json", good.as_str()), + ("12-0.txt", good.as_str()), + ] { + assert_eq!(parse_attempt(name, text), None, "{name} {text}"); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn state_of(dir: &Path) -> Option { + read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t)) + } + + fn attempt_files(dir: &Path) -> usize { + std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count()) + } + + /// A launch as another process (`pid`) at `now`. + fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart { + GraphicsStart::begin_at(Some(dir), false, None, now, pid) + } + + #[test] + fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() { + let dir = temp_dir("paint"); + let start = launch(&dir, 10, NOW); + assert_eq!(start.mode(), Mode::Default); + assert!(!start.degraded()); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(attempt_files(&dir), 1); + start.confirm(); + assert_eq!(attempt_files(&dir), 0); + // a window closed before it painted is no failure either + let start = launch(&dir, 11, NOW + 60); + start.finish(); + assert_eq!(attempt_files(&dir), 0); + assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() { + let dir = temp_dir("dead"); + let second = LADDER.get(1).copied().unwrap_or(Mode::Default); + // dies without painting + let _ = launch(&dir, 20, NOW); + // the next launch counts it, tries the same mode again and dies too + let again = launch(&dir, 21, NOW + 60); + assert_eq!(again.mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 1)); + // two in a row: the next mode, kept + let moved = launch(&dir, 22, NOW + 120); + assert_eq!(moved.mode(), second); + assert_eq!(moved.degraded(), second != Mode::Default); + moved.confirm(); + assert_eq!(launch(&dir, 23, NOW + 180).mode(), second); + // one failure, then a start that paints: the count starts over + let _ = std::fs::remove_dir_all(&dir); + let _ = launch(&dir, 30, NOW); + let painted = launch(&dir, 31, NOW + 60); + painted.confirm(); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + let _ = launch(&dir, 32, NOW + 120); + assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn concurrent_launches_neither_count_nor_disturb_each_other() { + let dir = temp_dir("race"); + // two instances start within the same seconds; neither has painted yet + let a = launch(&dir, 40, NOW); + let b = launch(&dir, 41, NOW + 1); + let c = launch(&dir, 42, NOW + 2); + assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default)); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file"); + b.confirm(); + a.confirm(); + c.confirm(); + assert_eq!(attempt_files(&dir), 0); + assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_huge_or_foreign_files_start_with_the_default() { + let dir = temp_dir("corrupt"); + let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR)); + let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000)); + for text in ["garbage", huge.as_str()] { + let _ = std::fs::write(dir.join(STATE_FILE), text); + let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text); + let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text); + let start = launch(&dir, 51, NOW); + assert_eq!(start.mode(), Mode::Default); + // replaced with a well-formed state; the unreadable attempt is gone, other files stay + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists()); + assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists()); + start.finish(); + } + // a data folder that is a file: nothing can be written, the default still starts + let file = dir.join("not-a-folder"); + let _ = std::fs::write(&file, "x"); + let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert!(start.reset_to_default().is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn many_attempt_files_are_bounded_per_launch() { + let dir = temp_dir("many"); + let attempts = dir.join(ATTEMPTS_DIR); + let _ = std::fs::create_dir_all(&attempts); + let n = MAX_ATTEMPTS + 10; + for pid in 0..n { + let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1)); + } + // all of them belong to instances still starting: none is counted or removed + let start = launch(&dir, 60, NOW); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(attempt_files(&dir), n + 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() { + let dir = temp_dir("forced"); + let last = LADDER.last().copied().unwrap_or(Mode::Default); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 })); + launch(&dir, 70, NOW).confirm(); + // `--graphics default` + let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + start.confirm(); + assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default); + // `--graphics ` is kept for the next launches + GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm(); + assert_eq!(launch(&dir, 74, NOW + 240).mode(), last); + // a mode this platform has not starts with the first one + if !LADDER.contains(&Mode::Dx12) { + assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reset_to_default_wins_over_a_later_confirm() { + let dir = temp_dir("reset"); + let last = LADDER.last().copied().unwrap_or(Mode::Default); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 })); + let start = launch(&dir, 80, NOW); + assert_eq!(start.mode(), last); + // the notice's button, from a clone of the start (the app holds one) + assert_eq!(start.clone().reset_to_default(), Ok(())); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + start.confirm(); + assert_eq!(state_of(&dir), saved(Mode::Default, 0)); + assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_data_folder_alone() { + let dir = temp_dir("explicit"); + let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 })); + let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(start.reset_to_default(), Ok(())); + assert_eq!(state_of(&dir), saved(Mode::Software, 1)); + assert_eq!(attempt_files(&dir), 0); + // with --graphics too, that mode starts, still without touching the folder + let last = LADDER.last().copied().unwrap_or(Mode::Default); + assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last); + assert_eq!(state_of(&dir), saved(Mode::Software, 1)); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_saves_each_mode_at_once() { + let dir = temp_dir("step"); + let mut start = launch(&dir, ME, NOW); + for expected in LADDER.iter().skip(1) { + assert!(start.step_down()); + assert_eq!(start.mode(), *expected); + assert_eq!(state_of(&dir), saved(*expected, 0)); + assert_eq!(attempt_files(&dir), 1, "one attempt file per process"); + } + assert!(!start.step_down(), "nothing after the last mode"); + start.finish(); + assert_eq!(attempt_files(&dir), 0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_software_selector_only_picks_a_software_adapter() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // only the backends software mode uses: no driver this test loads is one the app avoids + let backends = software_backends(cfg!(target_os = "windows")); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + match pick_software(&adapters, None) { + Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()), + Err(why) => { + assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}"); + } + } + assert!(pick_software(&[], None).is_err()); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00fb..3bb009ea5 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -31,7 +31,6 @@ mod control_server; #[cfg(target_os = "linux")] mod dev_icon; mod file_filters; -#[cfg(any(target_os = "windows", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -44,6 +43,8 @@ use filmcraft_engine::Session; use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir}; use filmcraft_ui_egui::FilmcraftApp; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id. const APP_ID: &str = "ai.storyteller.filmcraft"; @@ -80,7 +81,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let launch = match args::parse(env_port, std::env::args_os().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -95,7 +96,8 @@ fn main() -> eframe::Result { std::process::exit(2); } }; - let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR)); + let data_root = launch.data_dir.clone().or_else(default_data_dir); + let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR)); log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); // The log file lives next to the crash logs; opened after the arguments, so `--help`, // `--version` and usage errors leave no file behind. Records logged until now go in first. @@ -118,165 +120,38 @@ fn main() -> eframe::Result { // OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸ // Hardware decoding switches them off. Unsupported streams and failures use our decoders. register_hardware_decoders(); - register_gpu_frame_renderer(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("FilmCraft") - .with_inner_size([1600.0, 980.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()) - .with_icon(app_icon()) - // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. - .with_app_id(APP_ID), - event_loop_builder: agent_event_loop(control_port.is_some()), - ..Default::default() + // Which graphics backends and adapter the window starts with: `--graphics`, else the saved + // mode, which moves on after starts that died before their window painted (a driver crash), + // see graphics.rs. + let backend_override = eframe::wgpu::Backends::from_env(); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics); + // The GPU export renderer creates its own wgpu instance with every backend; after a start + // fell back, that would load the driver that failed. Exports then composite on the CPU. + if graphics.mode() == graphics::Mode::Default { + register_gpu_frame_renderer(); + } + let started = loop { + let mut options = native_options(launch.control_port.is_some()); + // Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs). + graphics::apply(&mut options, graphics.mode(), backend_override); + let app_created = Arc::new(AtomicBool::new(false)); + let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created))); + let app_created = app_created.load(Ordering::Relaxed); + match &result { + // wgpu reported the failure (no adapter, device creation failed) before the app + // existed: try the next mode in this process. + Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => { + log::warn!("graphics start failed: {e}"); + continue; + } + // Closed before the second frame: the window still came up. + Ok(()) if app_created => graphics.confirm(), + _ => {} + } + break result; }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). - #[cfg(target_os = "windows")] - let options = { - let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options - }; - let started = eframe::run_native( - "FilmCraft", - options, - Box::new(move |cc| { - let mut session = Session::default(); - if let Some(dir) = data_dir.clone().or_else(default_data_dir) { - let mut cfg = AutosaveConfig::new(dir); - cfg.local_offset = local_offset; - if let Err(e) = session.start_autosave(cfg) { - log::warn!("auto-save and crash recovery unavailable: {e}"); - } - } - // voice-over recording reads the microphone through cpal - session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); - if let Some(p) = project { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { - // Settings ▸ General ▸ At Startup ▸ Open Most Recent - let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); - match recent { - Some(p) => { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } - None => { - let _ = session.execute("file.openDemoProject", json!({})); - } - } - } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { - } else if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); - if !media.is_empty() { - let _ = session.execute("file.import", json!({"paths": media})); - } - if recover == Some(true) && !session.recovery_candidates().is_empty() { - let id = session.recovery_candidates()[0].id.clone(); - match session.execute("file.recover", json!({"id": id})) { - Ok(r) => log::info!("recovered {r}"), - Err(e) => log::error!("recovery failed: {e}"), - } - } - let mut app = FilmcraftApp::new(session); - if recover == Some(false) { - app.dialog = None; - } - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(rs) = cc.wgpu_render_state.clone() - && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() - { - app.set_wgpu(rs); - } - // Keep device selection available even when the default device is unavailable. - // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). - app.audio = Some(Box::new(audio::CpalOut::new())); - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })); - // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. - app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); - app.hooks.pick_folder_at = - Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); - app.hooks.open_path = Some(Box::new(open_path)); - // Help menu, About and Discord links (#642): eframe is built without its `links` - // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher - // that waits for the browser never freezes the UI. - app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); - app.hooks.clipboard_media = Some(Box::new(clipboard_media)); - // Settings ▸ General ▸ Interface Language ▸ System Language (#218). - app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); - app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); - // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose - // compositor reports no theme to winit (no polling: see appearance.rs). - app.hooks.system_theme = appearance::service(); - app.hooks.raise_without_focus = Some(Box::new(|| { - window_raise::raise_without_focus(); - })); - app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_open_project = Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - #[cfg(target_os = "macos")] - { - let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); - app.command_inbox = Some(rx); - app.hooks.shortcuts_changed = Some(update); - app.ui.show_menu_bar = false; - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - Ok(Box::new(app)) - }), - ); + // However it ended, this start no longer counts as one that died. + graphics.finish(); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -288,6 +163,189 @@ fn main() -> eframe::Result { started } +/// The main window's options; built for each start attempt (they hold a one-shot hook). +fn native_options(agent: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("FilmCraft") + .with_inner_size([1600.0, 980.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + // Started for an agent (`--control`): open without taking the user's keyboard focus. + .with_active(!agent) + .with_icon(app_icon()) + // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. + .with_app_id(APP_ID), + event_loop_builder: agent_event_loop(agent), + ..Default::default() + } +} + +/// Creates the app once eframe has its window and graphics device; sets `created` when it runs, +/// and confirms the graphics mode once two frames have painted. +fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { + let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch; + Box::new(move |cc| { + created.store(true, Ordering::Relaxed); + // A fallback graphics mode says so in the window, with a button back to the default. + let notice = graphics.degraded().then(|| { + let start = graphics.clone(); + filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default())) + }); + let confirmed = AtomicBool::new(false); + cc.egui_ctx.on_end_pass( + "graphics-start", + Arc::new(move |ui| { + if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) { + graphics.confirm(); + } + }), + ); + let mut session = Session::default(); + if let Some(dir) = data_dir.clone().or_else(default_data_dir) { + let mut cfg = AutosaveConfig::new(dir); + cfg.local_offset = local_offset; + if let Err(e) = session.start_autosave(cfg) { + log::warn!("auto-save and crash recovery unavailable: {e}"); + } + } + // voice-over recording reads the microphone through cpal + session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); + let (project, media) = args::project_and_media(&files); + if let Some(p) = project { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { + // Settings ▸ General ▸ At Startup ▸ Open Most Recent + let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); + match recent { + Some(p) => { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } + None => { + let _ = session.execute("file.openDemoProject", json!({})); + } + } + } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { + } else if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + if !media.is_empty() { + let _ = session.execute("file.import", json!({"paths": media})); + } + if recover == Some(true) && !session.recovery_candidates().is_empty() { + let id = session.recovery_candidates()[0].id.clone(); + match session.execute("file.recover", json!({"id": id})) { + Ok(r) => log::info!("recovered {r}"), + Err(e) => log::error!("recovery failed: {e}"), + } + } + let mut app = FilmcraftApp::new(session); + app.graphics_notice = notice; + if recover == Some(false) { + app.dialog = None; + } + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() + { + app.set_wgpu(rs); + } + // Keep device selection available even when the default device is unavailable. + // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). + app.audio = Some(Box::new(audio::CpalOut::new())); + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save = Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + // a full path (Quick Export, Export mode) opens the dialog in its folder + let path = std::path::Path::new(name); + let mut dialog = rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy())); + if let Some(dir) = path.parent().filter(|d| d.is_dir()) { + dialog = dialog.set_directory(dir); + } + dialog.save_file().map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_folder_at = + Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); + // Linux: the XDG portal call blocks until the user chooses, which would stop the frame + // loop (GNOME offers to force-quit the window after about five seconds, #260). Show the + // dialog on its own thread instead; the app keeps running and acts on the answer. + #[cfg(target_os = "linux")] + { + app.hooks.run_dialog = Some(Box::new(run_dialog_off_thread)); + } + app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); + app.hooks.clipboard_media = Some(Box::new(clipboard_media)); + // Settings ▸ General ▸ Interface Language ▸ System Language (#218). + app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); + app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); + app.hooks.raise_without_focus = Some(Box::new(|| { + window_raise::raise_without_focus(); + })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + #[cfg(target_os = "macos")] + { + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); + app.command_inbox = Some(rx); + app.hooks.menu_changed = Some(update); + app.ui.show_menu_bar = false; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) }; + } + Ok(Box::new(app)) + }) +} + /// Files copied in the file manager, else an image, on the system clipboard (Paste, #611). fn clipboard_media() -> Option { let mut clipboard = arboard::Clipboard::new().ok()?; @@ -319,6 +377,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> { } c } else { + // Linux file managers have no select-file flag: open the parent folder instead. let mut c = std::process::Command::new("xdg-open"); let p = std::path::Path::new(path); c.arg(if reveal { p.parent().unwrap_or(p) } else { p }); @@ -379,6 +438,60 @@ fn agent_event_loop(agent: bool) -> Option { None } +/// Show `dialog` with rfd on a worker thread and answer on the returned channel (#260). Filter +/// names are translated here, on the UI thread (the interface language is per thread). A dialog +/// thread that can't start or panics drops the sender: the app takes that as cancelled. +#[cfg(target_os = "linux")] +fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> std::sync::mpsc::Receiver> { + use filmcraft_ui_egui::native_dialogs::FileDialog; + let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string(); + let media = t("Media"); + let project = t("FilmCraft Project"); + let dialog = match dialog { + FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts }, + FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name }, + other => other, + }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + let shown = std::panic::catch_unwind(move || { + let path = |p: std::path::PathBuf| p.to_string_lossy().into_owned(); + match dialog { + FileDialog::OpenFiles { exts } => { + rfd::FileDialog::new().add_filter(&media, &exts).pick_files().unwrap_or_default().into_iter().map(path).collect() + } + FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(), + FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(), + FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(), + FileDialog::SaveProject { name } => { + rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect() + } + FileDialog::SaveAs { filter, exts, name } => { + rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect() + } + FileDialog::Folder { at } => { + let d = rfd::FileDialog::new(); + let d = match at { + Some(dir) if !dir.is_empty() => d.set_directory(dir), + _ => d, + }; + d.pick_folder().map(path).into_iter().collect::>() + } + } + }); + match shown { + Ok(paths) => { + let _ = tx.send(paths); + } + Err(_) => log::warn!("the file dialog thread panicked; taken as cancelled"), + } + }); + if let Err(e) = spawned { + log::warn!("could not start the file dialog thread: {e}"); + } + rx +} + #[cfg(test)] mod tests { /// Start-up registers the hardware decoders whether or not anything is logged (no logger is diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 61241a8a2..8ac830698 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -4,7 +4,7 @@ use std::sync::mpsc::{Receiver, channel}; use filmcraft_ui_egui::FilmcraftApp; -use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items}; +use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items}; use muda::accelerator::Accelerator; use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; @@ -19,34 +19,53 @@ fn accel(s: &str) -> Option { mapped.parse().ok() } -/// Updates native key equivalents when the active keyboard shortcuts change. -pub type ShortcutUpdater = Box; +/// Updates native item labels, key equivalents and enablement when the menu tree changes. +pub type MenuUpdater = Box; -pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, ShortcutUpdater) { +pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, MenuUpdater) { let items = menu_items(app); - let mut native: Vec<(String, MenuItem)> = Vec::new(); + // each native item with the menu entry it currently shows, so updates touch only what changed + let mut native: Vec<(Item, MenuItem)> = Vec::new(); let mut titles: Vec<(String, Submenu)> = Vec::new(); let bar = Menu::new(); let app_menu = Submenu::new("FilmCraft", true); + let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU; // FilmCraft ▸ Settings ▸ (Premiere's app-menu layout; General is Cmd+,) - let settings = Submenu::new(app.ui.language.tr("Settings"), true); - titles.push(("Settings".into(), settings.clone())); + let settings = Submenu::new(app.ui.language.tr(settings_label), true); + titles.push((settings_label.into(), settings.clone())); for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) { let label = it.label.trim_end_matches('…').to_string(); - let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel)); - native.push((it.id.clone(), mi.clone())); + let mi = MenuItem::with_id(it.id.clone(), label, it.enabled, it.shortcut.as_deref().and_then(accel)); + native.push((it.clone(), mi.clone())); let _ = settings.append(&mi); } + // About and Discord repeat Help menu commands: same translated labels, updated with them + let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone()); + let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None); + let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None); + for (id, mi) in [("app.about", &about), ("help.discord", &discord)] { + if let Some(it) = items.iter().find(|i| i.id == id) { + // recorded as built (enabled, no shortcut), so the first update applies the real state + native.push((Item { enabled: true, shortcut: None, ..it.clone() }, mi.clone())); + } + } + // the predefined items keep their native actions; our text lets them follow the language + let predefined = [ + (hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))), + (hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))), + (quit_label, PredefinedMenuItem::quit(Some(app.ui.language.tr(quit_label)))), + ]; + let [(_, hide), (_, hide_others), (_, quit)] = &predefined; let _ = app_menu.append_items(&[ - &MenuItem::with_id("app.about", "About FilmCraft", true, None), - &MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None), + &about, + &discord, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(None), - &PredefinedMenuItem::hide_others(None), + hide, + hide_others, &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + quit, ]); let _ = bar.append(&app_menu); for top in MENUS { @@ -57,8 +76,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho // their first item appears let mut subs: Vec<(Vec, Submenu)> = Vec::new(); for it in mine { - let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel)); - native.push((it.id.clone(), mi.clone())); + let mi = MenuItem::with_id(it.id.clone(), &it.label, it.enabled, it.shortcut.as_deref().and_then(accel)); + native.push(((*it).clone(), mi.clone())); let mut parent = sub.clone(); for depth in 1..it.path.len() { let key = it.path[..=depth].to_vec(); @@ -91,7 +110,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho ctx.request_repaint(); } }); - let update: ShortcutUpdater = Box::new(move |items: &[Item]| { + let mut shown_language = app.ui.language; + let update: MenuUpdater = Box::new(move |items: &[Item]| { let checked = |id: &str| items.iter().any(|it| it.id == id && it.checked == Some(true)); let language = if checked("app.language.japanese") { filmcraft_ui_egui::i18n::Language::Ja @@ -109,17 +129,33 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::Ru } else if checked("app.language.french") { filmcraft_ui_egui::i18n::Language::Fr + } else if checked("app.language.indonesian") { + filmcraft_ui_egui::i18n::Language::Id } else { filmcraft_ui_egui::i18n::Language::En }; - for (title, submenu) in &titles { - submenu.set_text(language.tr(title)); + if language != shown_language { + shown_language = language; + for (title, submenu) in &titles { + submenu.set_text(language.tr(title)); + } } - for (id, mi) in &native { - if let Some(it) = items.iter().find(|i| &i.id == id) { - mi.set_text(&it.label); + for (label, item) in &predefined { + item.set_text(language.tr(label)); + } + for (shown, mi) in &mut native { + let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue }; + if it.label != shown.label { + // Settings entries drop the trailing ellipsis, as when the menu was built + mi.set_text(if it.id.starts_with("app.settings.") { it.label.trim_end_matches('…') } else { &it.label }); + } + if it.shortcut != shown.shortcut { let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel)); } + if it.enabled != shown.enabled { + mi.set_enabled(it.enabled); + } + *shown = it.clone(); } }); (rx, update) diff --git a/contributors/people.toml b/contributors/people.toml index 5f5be7dda..04ed72431 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -70,6 +70,9 @@ sync_github_name = false [people.ossini] sync_github_name = true +[people.opentechnepal] +sync_github_name = true + [people.vangel76] real_name = "Valentin Klein" display_name = "Valentin Klein" diff --git a/crates/aac/README.md b/crates/aac/README.md index d2b7e63d3..ffd5bf571 100644 --- a/crates/aac/README.md +++ b/crates/aac/README.md @@ -48,6 +48,9 @@ from bitrate/quality), `tns`, `ms_stereo`, `window_shape` (sine/KBD), `adts` (pr | 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) | | 8 | 7 | C Lc Rc L R Ls Rs LFE | +The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr +LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12. + `Decoder::layout()` reports the element layout (it follows a PCE for configuration 0). ## Encoder diff --git a/crates/aac/src/config.rs b/crates/aac/src/config.rs index d0da2f350..f0b326d0a 100644 --- a/crates/aac/src/config.rs +++ b/crates/aac/src/config.rs @@ -136,7 +136,8 @@ impl ProgramConfig { } } -/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned). +/// Element layout of `channelConfiguration` 1..=7, 11 and 12 (tags as conventionally assigned; 11 and +/// 12 from ISO/IEC 14496-3 Amd 4: 6.1 with a back centre, 7.1 with back surrounds). pub fn config_layout(channel_config: u8) -> Option> { use ElementType::*; Some(match channel_config { @@ -146,7 +147,8 @@ pub fn config_layout(channel_config: u8) -> Option> { 4 => vec![(Sce, 0), (Cpe, 0), (Sce, 1)], 5 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1)], 6 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Lfe, 0)], - 7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)], + 7 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)], + 11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)], _ => return None, }) } @@ -388,6 +390,14 @@ mod tests { assert_eq!(AudioSpecificConfig::parse(&a.to_bytes()).unwrap().channels(), 6); } + #[test] + fn amd4_channel_configurations() { + // 6.1 and 7.1 back (#922); 22.2 and 7.1 with heights stay unsupported + let channels = |c| AudioSpecificConfig::parse(&AudioSpecificConfig::lc(3, c).to_bytes()).map(|a| a.channels()).ok(); + assert_eq!([7, 11, 12].map(channels), [Some(8), Some(7), Some(8)]); + assert_eq!([13, 14].map(config_layout), [None, None]); + } + #[test] fn asc_with_pce() { let pce = ProgramConfig { diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs index b7d3e197a..5fdc2f0df 100644 --- a/crates/automation/src/bridge.rs +++ b/crates/automation/src/bridge.rs @@ -47,14 +47,14 @@ impl BridgeClient { } let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?; let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) }; - let result = loop { + let (result, offline) = loop { tokio::time::sleep(JOB_POLL).await; let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?; let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else { - break Value::Null; + break (Value::Null, None); }; if j["finished"].as_bool() == Some(true) { - break j["result"].clone(); + break (j["result"].clone(), j.get("offlineMedia").cloned()); } }; if let Some(e) = result.get("error").and_then(Value::as_str) { @@ -62,6 +62,9 @@ impl BridgeClient { } if let Some(o) = start.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } Ok(start) } @@ -121,6 +124,11 @@ mod tests { /// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports /// running twice and then finished with `result`. Every request is recorded. async fn fake_app(result: Value) -> (String, Arc>>) { + fake_app_with(result, None).await + } + + /// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`. + async fn fake_app_with(result: Value, offline: Option) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(Mutex::new(Vec::new())); @@ -138,7 +146,11 @@ mod tests { Some("jobs.list") => { polls += 1; let finished = polls >= 3; - json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}]) + let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}); + if let Some(o) = offline.clone().filter(|_| finished) { + j["offlineMedia"] = o; + } + json!([j]) } _ => json!(null), }; @@ -165,6 +177,16 @@ mod tests { assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3); } + /// The offline media a polled export drew comes back as in a blocking export's reply (#1054). + #[tokio::test] + async fn a_polled_export_reports_its_offline_media() { + let off = json!([{"item": 8, "status": "missing"}]); + let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await; + let b = BridgeClient::new(addr).unwrap(); + let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap(); + assert_eq!(r["offlineMedia"], off, "{r}"); + } + #[tokio::test] async fn a_failed_blocking_export_is_an_error() { let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await; diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 62549b903..bdc756cf5 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -72,6 +72,7 @@ impl FilmcraftMcp { rep.report(done, total, j["status"].as_str().unwrap_or_default()).await; } if finished { + let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned()); let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null); if let Some(e) = result.get("error").and_then(Value::as_str) { return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]); @@ -79,6 +80,9 @@ impl FilmcraftMcp { let mut out = start; if let Some(o) = out.as_object_mut() { o.insert("result".into(), result); + if let Some(off) = offline { + o.insert("offlineMedia".into(), off); + } } return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]); } diff --git a/crates/av1/README.md b/crates/av1/README.md index 5d4d5c50f..354484158 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -70,12 +70,15 @@ with libdav1d: **bit-exact** on all 6 fixtures. wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is then decoded by a frame worker once the reference frames it reads are finished, so independent frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into -private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests -compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a +private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop +restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are +independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads +and libdav1d). With frame threads, pictures may be returned by a later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it. `tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame -(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters, +(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames, +GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters, CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation. Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p diff --git a/crates/av1/src/cdef.rs b/crates/av1/src/cdef.rs index 13efa75db..4038a0f02 100644 --- a/crates/av1/src/cdef.rs +++ b/crates/av1/src/cdef.rs @@ -1,35 +1,45 @@ //! CDEF (7.15). -use crate::frame::{FrameBuf, Plane}; +use crate::frame::{FrameBuf, RowBand}; +use crate::par::Pool; +use crate::postfilter::bands; use crate::spec_tables::*; use crate::state::FrameState; /// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame -/// equals CurrFrame). -pub(crate) fn apply(fs: &FrameState) -> Option { +/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every +/// block reads only CurrFrame and writes only its own samples. +pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option { let fh = &fs.fh; if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef { return None; } let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize); let mut out = fs.cur.clone(); - let mut any = false; - let mut r = 0; - while r < mi_rows { - let mut c = 0; - while c < mi_cols { - let idx = fs.cdef_idx(r & !15, c & !15); - if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) { - any = true; + let ranges = bands(pool, mi_rows * 4); + let mut planes = out.row_bands(&ranges); + let any = pool.map_mut(&mut planes, |b, band| { + let (start, end) = ranges[b]; + let mut any = false; + let mut r = start / 4; + while r < mi_rows.min(end / 4) { + let mut c = 0; + while c < mi_cols { + let idx = fs.cdef_idx(r & !15, c & !15); + if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) { + any = true; + } + c += 2; } - c += 2; + r += 2; } - r += 2; - } - any.then_some(out) + any + }); + drop(planes); + any.contains(&true).then_some(out) } -fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool { +fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool { let mi = &fs.mi; let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } }; let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1); @@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 { } #[allow(clippy::too_many_arguments)] -fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) { +fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) { if pri == 0 && sec == 0 { // Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame. return; @@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32]; let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 }; let (pri_adj, sec_adj) = (adj(pri), adj(sec)); - let dst = &mut out.planes[plane]; + let dst = &mut out[plane]; if all_avail { // Every tap is available: whole rows at a time (constrain() with threshold 0 is 0). let mut taps = [(0isize, 0i32, 0i32, 0u32); 12]; @@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, /// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12 /// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width. #[inline(always)] -fn filter_rows(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) { +fn filter_rows( + buf: &[i32; PAD_STRIDE * PAD_STRIDE], + taps: &[(isize, i32, i32, u32); 12], + dst: &mut RowBand, + (y0, x0, h): (usize, usize, usize), +) { let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE]; for (d, &s) in b16.iter_mut().zip(buf.iter()) { *d = s as i16; diff --git a/crates/av1/src/frame.rs b/crates/av1/src/frame.rs index 3cf4b9f0b..a0e242345 100644 --- a/crates/av1/src/frame.rs +++ b/crates/av1/src/frame.rs @@ -106,6 +106,48 @@ impl Plane { dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s); } } + + /// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)` + /// range of rows (relative to `oy`; ascending and not overlapping, else the later band is + /// cut to what is left). Ranges are clipped to the stored rows. + pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec> { + let (stride, oy) = (self.stride.max(1), self.oy); + let mut out = Vec::with_capacity(ranges.len()); + let mut rest = self.data.as_mut_slice(); + // First row of `rest`. + let mut y = 0usize; + for &(start, end) in ranges { + let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len()); + rest = std::mem::take(&mut rest).split_at_mut(skip).1; + y = y.saturating_add(skip / stride); + let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len()); + let (band, tail) = std::mem::take(&mut rest).split_at_mut(len); + out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) }); + y = y.saturating_add(len / stride); + rest = tail; + } + out + } +} + +/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's +/// coordinates (see [`Plane::row_bands`]). +pub struct RowBand<'a> { + pub data: &'a mut [u16], + pub stride: usize, + pub y0: usize, +} + +impl RowBand<'_> { + #[inline(always)] + pub fn set(&mut self, x: usize, y: usize, v: u16) { + self.data[(y - self.y0) * self.stride + x] = v; + } + #[inline(always)] + pub fn row_mut(&mut self, y: usize) -> &mut [u16] { + let o = (y - self.y0) * self.stride; + &mut self.data[o..o + self.stride] + } } /// A frame's three planes, sized to the macroblock-aligned coded area plus margins. @@ -158,6 +200,16 @@ impl FrameBuf { } } + /// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled + /// down): element `i` holds the bands of range `i`. + pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> { + let ssy = self.subsampling_y; + let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect(); + let [p0, p1, p2] = &mut self.planes; + let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma)); + p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect() + } + pub fn plane_width(&self, plane: usize) -> usize { if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x } } @@ -285,3 +337,40 @@ impl MiInfo { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(b: &RowBand) -> (usize, usize) { + (b.y0, b.data.len() / b.stride) + } + + #[test] + fn row_bands_split_whole_rows() { + let mut p = Plane::new(8, 20); + for (i, v) in p.data.iter_mut().enumerate() { + *v = (i / 8) as u16; + } + let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(0, 8), (8, 8), (16, 4)]); + // Each band's row y holds the samples of plane row y. + for mut b in bands { + let y = b.y0 + 1; + assert!(b.row_mut(y).iter().all(|&v| v as usize == y)); + } + // Gaps between bands (the loop filter's even / odd turns). + let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(0, 4), (10, 4), (18, 2)]); + } + + #[test] + fn row_bands_never_overlap_or_panic() { + let mut p = Plane::new(4, 10); + // Overlapping, descending and out-of-range ranges are cut to what is left. + let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]); + assert_eq!(bands.iter().map(rows).collect::>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]); + let mut empty = Plane::default(); + assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty())); + } +} diff --git a/crates/av1/src/postfilter.rs b/crates/av1/src/postfilter.rs index ea157de54..46bf83562 100644 --- a/crates/av1/src/postfilter.rs +++ b/crates/av1/src/postfilter.rs @@ -1,20 +1,33 @@ //! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15), //! super-resolution upscaling (7.16) and loop restoration (7.17). +use crate::frame::RowBand; use crate::spec_tables::*; use crate::state::FrameState; use crate::stats::{DecodeStats, Stage, Timer}; -/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used). -/// `draft`: only super-resolution (which sets the output size) runs. -pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) { +/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no +/// transform block crosses a band edge. +const BAND_ROWS: usize = 64; + +/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one +/// runs to the end of the plane), or one band for everything when `pool` is sequential. +pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> { + let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 }; + (0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect() +} + +/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not +/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the +/// output size) runs. +pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) { let mut t = Timer::start(); let lvl = fs.fh.lf.level; if (lvl[0] != 0 || lvl[1] != 0) && !draft { - loop_filter(fs); + loop_filter(fs, pool); } stats.add(Stage::LoopFilter, t.lap()); - let cdef = if draft { None } else { crate::cdef::apply(fs) }; + let cdef = if draft { None } else { crate::cdef::apply(fs, pool) }; stats.add(Stage::Cdef, t.lap()); let (up_cur, up_cdef) = if fs.fh.use_superres { let c = crate::restoration::upscale(fs, &fs.cur); @@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate: }; stats.add(Stage::Superres, t.lap()); fs.cur = if fs.fh.lr.uses_lr && !draft { - crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur)) + crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool) } else { up_cdef.unwrap_or(up_cur) }; @@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate: // --------------------------------------------------------------------------------------------- // Loop filter (7.14) -fn loop_filter(fs: &mut FrameState) { - let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize); - for plane in 0..fs.num_planes { - if plane == 0 || fs.fh.lf.level[1 + plane] != 0 { - for pass in 0..2 { - let row_step = if plane == 0 { 1 } else { 1 << fs.ssy }; - let col_step = if plane == 0 { 1 } else { 1 << fs.ssx }; - let mut row = 0; - while row < mi_rows { - let mut col = 0; - while col < mi_cols { - edge(fs, plane, pass, row, col); - col += col_step; - } - row += row_step; +/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest +/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`. +const LF_MARGIN: usize = 8; + +/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a +/// worker wake-up, which is slow on a busy machine). +/// +/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1) +/// reads and writes at most `filter_size / 2` samples on each side, never more than the +/// transform blocks on either side are high, so no two edges touch the same sample and +/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so +/// the only edges reaching outside a band are those on its top row, whose p side lies in the +/// band above. Each band therefore also filters the vertical edges of a copy of the +/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top +/// horizontal edges against that copy, and afterwards the samples those edges changed are +/// written back; the band above never touches them. +fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) { + let luma = bands(pool, fs.fh.mi_rows as usize * 4); + let planes: Vec = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect(); + let mut cur = std::mem::take(&mut fs.cur); + let f: &FrameState = fs; + // Unfiltered copies of the rows above each band but the first. + let margins: Vec<[Vec; 3]> = luma + .iter() + .map(|&(start, _)| { + std::array::from_fn(|p| { + let sub_y = if p == 0 { 0 } else { f.ssy }; + let pl = &cur.planes[p]; + let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y); + let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride); + if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() } + }) + }) + .collect(); + let mut bands: Vec<([RowBand; 3], [Vec; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect(); + let changed = pool.map_mut(&mut bands, |b, (band, margin)| { + let mut changed: [Vec<(usize, u16)>; 3] = Default::default(); + for &p in &planes { + changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]); + } + changed + }); + drop(bands); + for (&(start, _), ch) in luma.iter().zip(&changed) { + for (p, list) in ch.iter().enumerate() { + let sub_y = if p == 0 { 0 } else { f.ssy }; + let pl = &mut cur.planes[p]; + let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride; + for &(i, v) in list { + if let Some(d) = pl.data.get_mut(first + i) { + *d = v; } } } } + fs.cur = cur; } -fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) { +/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is +/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples +/// of `margin` that the band's top horizontal edges changed, as (index, value). +fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> { + let mi_rows = fs.fh.mi_rows as usize; + let (r0, r1) = (start / 4, mi_rows.min(end / 4)); + let sub_y = if plane == 0 { 0 } else { fs.ssy }; + let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range| { + let row_step = if plane == 0 { 1 } else { 1 << fs.ssy }; + let col_step = if plane == 0 { 1 } else { 1 << fs.ssx }; + let mi_cols = fs.fh.mi_cols as usize; + let mut row = rows.start; + while row < rows.end { + let mut col = 0; + while col < mi_cols { + edge(fs, band, above.as_deref_mut(), plane, pass, row, col); + col += col_step; + } + row += row_step; + } + }; + edges(band, None, 0, r0..r1); + if margin.is_empty() { + edges(band, None, 1, r0..r1); + return Vec::new(); + } + let stride = band.stride; + let y0 = start.saturating_sub(LF_MARGIN) >> sub_y; + let mut above = RowBand { data: margin, stride, y0 }; + edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0); + let before = above.data.to_vec(); + edges(band, Some(&mut above), 1, r0..r1); + above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect() +} + +/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across +/// it. `above` holds the rows above `band` for horizontal edges on the band's top row. +fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) { let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) }; let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) }; let x = col * 4; @@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) return; } let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth); - let pl = &mut fs.cur.planes[plane]; - let stride = pl.stride; - let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) }; - let base = yp * stride + xp; let chroma = plane > 0; + let stride = band.stride; + if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) { + // The p side is in the rows above the band: filter each line in a copy that spans both. + let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1)); + for k in 0..4 { + let x = xp + k; + let mut line = [0u16; 2 * LF_LINE]; + for j in 1..=n_above.min(LF_LINE) { + line[LF_LINE - j] = above.data[(n_above - j) * stride + x]; + } + for j in 0..n_band.min(LF_LINE) { + line[LF_LINE + j] = band.data[j * stride + x]; + } + sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t); + for j in 1..=n_above.min(LF_LINE) { + above.data[(n_above - j) * stride + x] = line[LF_LINE - j]; + } + for j in 0..n_band.min(LF_LINE) { + band.data[j * stride + x] = line[LF_LINE + j]; + } + } + return; + } + let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) }; + let base = (yp - band.y0) * stride + xp; for k in 0..4 { - sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t); + sample_filter(band.data, base + k * along, step, chroma, filter_size, t); } } +/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6). +const LF_LINE: usize = 8; + /// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh). fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) { let mi = &fs.mi; diff --git a/crates/av1/src/restoration.rs b/crates/av1/src/restoration.rs index 693e27284..923d25957 100644 --- a/crates/av1/src/restoration.rs +++ b/crates/av1/src/restoration.rs @@ -1,6 +1,8 @@ //! Super-resolution upscaling (7.16) and loop restoration (7.17). -use crate::frame::{FrameBuf, Plane}; +use crate::frame::{FrameBuf, Plane, RowBand}; +use crate::par::Pool; +use crate::postfilter::bands; use crate::spec_tables::*; use crate::state::FrameState; @@ -70,56 +72,66 @@ impl LrCtx<'_> { } /// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame. -pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf { +/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames +/// and writes only its own samples. +pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf { let fh = &fs.fh; let mut lr = cdef.clone(); let up_w = fh.upscaled_width as i32; let fr_h = fh.frame_height as i32; let bd = fs.bit_depth; - for plane in 0..fs.num_planes { - let rtype_frame = fh.lr.frame_restoration_type[plane]; - if rtype_frame == RESTORE_NONE as u8 { - continue; - } - let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) }; - let unit_size = fh.lr.loop_restoration_size[plane] as i32; - let unit_rows = fs.lr_unit_rows[plane] as i32; - let unit_cols = fs.lr_unit_cols[plane] as i32; - let plane_end_x = ((up_w + sub_x) >> sub_x) - 1; - let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1; - let mut row = 0; - while row * 4 < fr_h { - let luma_y = row * 4; - let stripe_num = (luma_y + 8) / 64; - let stripe_start_y = (-8 + stripe_num * 64) >> sub_y; - let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1; - let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y }; - let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size); - let mut col = 0; - while col * 4 < up_w { - let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size); - let x = (col * 4) >> sub_x; - let y = (row * 4) >> sub_y; - let w = (4 >> sub_x).min(plane_end_x - x + 1); - let h = (4 >> sub_y).min(plane_end_y - y + 1); - if w > 0 && h > 0 { - let ui = (unit_row * unit_cols + unit_col) as usize; - match fs.lr.lr_type[plane][ui] { - t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd), - t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd), - _ => {} + let ranges = bands(pool, fh.frame_height as usize); + let mut planes = lr.row_bands(&ranges); + pool.map_mut(&mut planes, |b, band| { + // Rows of 4 luma samples in this band. + let (start, end) = ranges[b]; + let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32); + for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) { + let rtype_frame = fh.lr.frame_restoration_type[plane]; + if rtype_frame == RESTORE_NONE as u8 { + continue; + } + let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) }; + let unit_size = fh.lr.loop_restoration_size[plane] as i32; + let unit_rows = fs.lr_unit_rows[plane] as i32; + let unit_cols = fs.lr_unit_cols[plane] as i32; + let plane_end_x = ((up_w + sub_x) >> sub_x) - 1; + let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1; + let mut row = row0; + while row < row1 && row * 4 < fr_h { + let luma_y = row * 4; + let stripe_num = (luma_y + 8) / 64; + let stripe_start_y = (-8 + stripe_num * 64) >> sub_y; + let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1; + let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y }; + let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size); + let mut col = 0; + while col * 4 < up_w { + let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size); + let x = (col * 4) >> sub_x; + let y = (row * 4) >> sub_y; + let w = (4 >> sub_x).min(plane_end_x - x + 1); + let h = (4 >> sub_y).min(plane_end_y - y + 1); + if w > 0 && h > 0 { + let ui = (unit_row * unit_cols + unit_col) as usize; + match fs.lr.lr_type[plane][ui] { + t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd), + t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd), + _ => {} + } } + col += 1; } - col += 1; + row += 1; } - row += 1; } - } + }); + drop(planes); lr } #[allow(clippy::too_many_arguments)] -fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { +fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { let round0: u32 = if bd == 12 { 5 } else { 3 }; let round1: u32 = if bd == 12 { 9 } else { 11 }; let coef = &fs.lr.lr_wiener[plane][ui]; @@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize } #[allow(clippy::too_many_arguments)] -fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { +fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) { let set = fs.lr.lr_sgr_set[plane][ui] as usize; let mut flt0 = [[0i32; 4]; 4]; let mut flt1 = [[0i32; 4]; 4]; diff --git a/crates/av1/tests/common/mod.rs b/crates/av1/tests/common/mod.rs index 0892fd697..13b4567d6 100644 --- a/crates/av1/tests/common/mod.rs +++ b/crates/av1/tests/common/mod.rs @@ -16,6 +16,11 @@ pub fn ffmpeg_dav1d() -> Option { ffmpeg_with(&[("-decoders", "libdav1d")]) } +/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d. +pub fn ffmpeg_libaom() -> Option { + ffmpeg_with(&[("-encoders", "libaom-av1"), ("-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")?; @@ -38,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) { assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); } -/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF. +/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says +/// otherwise) into IVF. pub struct Spec { pub name: &'static str, + pub encoder: &'static str, pub lavfi: String, pub frames: u32, pub pix_fmt: &'static str, @@ -50,7 +57,12 @@ pub struct Spec { impl Spec { pub fn new(name: &'static str, lavfi: impl Into, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec { - Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() } + Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() } + } + + pub fn encoder(mut self, encoder: &'static str) -> Spec { + self.encoder = encoder; + self } } @@ -60,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf { let tmp = filmcraft_testkit::temp_path(&path); let frames = spec.frames.to_string(); let t = tmp.to_str().unwrap().to_string(); - let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"]; + let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder]; args.extend(spec.enc.iter().map(String::as_str)); args.extend(["-f", "ivf", t.as_str()]); run(ff, &args); diff --git a/crates/av1/tests/oracle_inter.rs b/crates/av1/tests/oracle_inter.rs index fcd43b087..06a4d88fe 100644 --- a/crates/av1/tests/oracle_inter.rs +++ b/crates/av1/tests/oracle_inter.rs @@ -101,3 +101,19 @@ fn inter_1080p() { check_bit_exact(&ff, &s); } } + +/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose +/// chroma rows depend on the vertical subsampling, so these check the band edges there too. +#[test] +fn inter_chroma_444_422() { + let Some(ff) = ffmpeg_libaom() else { return }; + let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"]; + let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t"; + for s in [ + Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"), + Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"), + Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"), + ] { + check_bit_exact(&ff, &s); + } +} diff --git a/crates/av1/tests/perf.rs b/crates/av1/tests/perf.rs index dd36a10e3..9047605aa 100644 --- a/crates/av1/tests/perf.rs +++ b/crates/av1/tests/perf.rs @@ -51,8 +51,8 @@ fn run(frames: &[Vec], threads: usize) -> Run { } fn bench(spec: &Spec) { - // AV1_PERF_ONLY= limits the run to matching fixtures. - if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) { + // AV1_PERF_ONLY=[,...] limits the run to matching fixtures. + if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) { return; } let Some(ff) = ffmpeg() else { return }; @@ -68,7 +68,6 @@ fn bench(spec: &Spec) { } } let b = best.unwrap(); - let n = b.stats.frames.max(1) as f64; let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into()); println!( "{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)", @@ -79,24 +78,35 @@ fn bench(spec: &Spec) { cpu_fps, b.shown as f64 / b.wall ); - let total = b.stats.total_secs(); - let mut line = String::from(" ms/frame:"); + println!("{}", stage_line(" ms/frame:", &b.stats)); + // All cores: best of 3 by wall time. + let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let mut best: Option = None; + for _ in 0..3 { + let r = run(&frames, threads); + assert_eq!(r.shown, b.shown); + if best.as_ref().is_none_or(|b| r.wall < b.wall) { + best = Some(r); + } + } + let m = best.unwrap(); + println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average()); + // Per-stage wall time on the frame workers (stages of different frames overlap). + println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats)); +} + +/// Per-stage milliseconds per frame and share of the total. +fn stage_line(label: &str, stats: &DecodeStats) -> String { + let n = stats.frames.max(1) as f64; + let total = stats.total_secs(); + let mut line = String::from(label); for s in Stage::ALL { - let v = b.stats.secs(s); + let v = stats.secs(s); if v > 0.0005 { line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total); } } - println!("{line}"); - // All cores: best of 3 by wall time. - let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); - let mut best_wall = f64::INFINITY; - for _ in 0..3 { - let r = run(&frames, threads); - assert_eq!(r.shown, b.shown); - best_wall = best_wall.min(r.wall); - } - println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average()); + line } #[test] @@ -110,4 +120,9 @@ fn decode_throughput() { // Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles). bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60"))); bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1"))); + // Low delay (each frame references the previous one, as screen and camera recorders encode): + // frames cannot overlap, so a frame's own parallelism decides the speed. + bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1"))); + // One tile at 2160p: the post-filters' row bands are most of the parallelism there. + bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30"))); } diff --git a/crates/avi/Cargo.toml b/crates/avi/Cargo.toml new file mode 100644 index 000000000..519c098dc --- /dev/null +++ b/crates/avi/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "filmcraft-avi" +description = "Clean-room AVI (RIFF) and OpenDML demuxer" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/avi/README.md b/crates/avi/README.md new file mode 100644 index 000000000..d10ba02f2 --- /dev/null +++ b/crates/avi/README.md @@ -0,0 +1,43 @@ +# filmcraft-avi + +A clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +OpenDML AVI File Format Extensions 1.02. Layer L0: no dependencies beyond `std`, no `unsafe`, builds +for `wasm32-unknown-unknown`. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg +and ffprobe are used only as external **test oracles**. + +## API + +```rust +use filmcraft_avi::{open, StreamKind}; + +let file = open(&bytes)?; // any ByteSource: a slice, a file, a web Blob… +for (i, s) in file.streams.iter().enumerate() { + match s.kind { + StreamKind::Video => println!("{:?} {} fps", s.video.as_ref().map(|b| b.compression), s.rate as f64 / s.scale as f64), + StreamKind::Audio => println!("format 0x{:04x}", s.audio.as_ref().map_or(0, |w| w.format_tag)), + _ => {} + } + let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand +} +``` + +## What it reads + +| Part | | +|---|---| +| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` | +| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists | +| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF | +| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) | +| Damage | sizes clamped to the file (a cut-off recording opens with what is there), `rec ` lists nested at most 3 deep, tables bounded by what the file could hold | + +`filmcraft-codecs` maps the streams onto decoders (`AviSource`): Motion JPEG, H.264, HEVC and +uncompressed RGB / YUV video; PCM, MP3, MP2 and AC-3 audio. + +## Tests + +`cargo test -p filmcraft-avi`: hand-built files for every index path (`idx1` relative and absolute, +`rec ` lists, OpenDML standard indexes, a scan), truncation, and every single-byte mutation and +truncation of them (never a panic). `tests/ffmpeg_oracle.rs` checks ffmpeg-made files (Motion JPEG, +H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb` +checks a 1.16 GB OpenDML file with `AVIX` extensions. diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs new file mode 100644 index 000000000..0294fdfea --- /dev/null +++ b/crates/avi/src/lib.rs @@ -0,0 +1,542 @@ +//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +//! OpenDML AVI File Format Extensions 1.02. +//! +//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` / +//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index +//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the +//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX` +//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with +//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there. +//! +//! Every size and count comes from the file and is treated as hostile: reads are bounded by the +//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth. +//! +//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod source; +#[cfg(test)] +mod tests; + +pub use source::ByteSource; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Not an AVI file (no `RIFF` … `AVI ` header). + NotAvi, + /// A read failed. + Io(String), + /// Structurally invalid data. + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotAvi => f.write_str("not an AVI file"), + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Invalid(e) => write!(f, "invalid AVI: {e}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +/// Whether `head` starts like an AVI file: `RIFF` `AVI `. +pub fn sniff(head: &[u8]) -> bool { + head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ") +} + +/// `avih`: the main header (frame period, flags, frame count, size). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MainHeader { + pub micro_sec_per_frame: u32, + pub flags: u32, + pub total_frames: u32, + pub streams: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamKind { + Video, + Audio, + /// Text, MIDI or anything else (`fccType`). + Other([u8; 4]), +} + +/// `strf` of a video stream: a `BITMAPINFOHEADER`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BitmapInfo { + pub width: i32, + /// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down. + pub height: i32, + pub bit_count: u16, + /// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`, + /// `BI_BITFIELDS`) in little-endian. + pub compression: [u8; 4], + pub size_image: u32, + /// Bytes after the 40-byte header (codec setup, palette). + pub extra: Vec, +} + +impl BitmapInfo { + /// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`). + pub fn is_rgb(&self) -> bool { + matches!(u32::from_le_bytes(self.compression), 0 | 3) + } +} + +/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WaveFormat { + /// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC… + pub format_tag: u16, + pub channels: u16, + pub sample_rate: u32, + pub avg_bytes_per_sec: u32, + pub block_align: u16, + pub bits_per_sample: u16, + /// `cbSize` bytes after the 18-byte header. + pub extra: Vec, +} + +/// One stored frame or audio chunk. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Chunk { + /// Offset of the data (after the 8-byte chunk header). + pub offset: u64, + pub size: u32, + /// A key frame (always true for audio and when the index does not say). + pub key: bool, +} + +/// Where the chunk tables came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IndexSource { + /// OpenDML `indx` super index and `ix##` standard indexes. + OpenDml, + /// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions). + Idx1, + /// No usable index: the `movi` lists were scanned (key frames unknown). + Scan, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stream { + pub kind: StreamKind, + /// `fccHandler`. + pub handler: [u8; 4], + /// Time base: `rate / scale` units per second (frames per second for video). + pub scale: u32, + pub rate: u32, + /// Start time in `scale / rate` units. + pub start: u32, + /// Length in `scale / rate` units. + pub length: u32, + /// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary. + pub sample_size: u32, + pub video: Option, + pub audio: Option, + /// `strn`. + pub name: Option, + pub chunks: Vec, + /// Whether [`Chunk::key`] comes from an index (a scan cannot tell). + pub keyframes_known: bool, + /// The OpenDML super index entries (offsets of the `ix##` chunks). + super_index: Vec, +} + +impl Stream { + /// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters. + fn number(id: &[u8; 4]) -> Option { + let d = |b: u8| (b as char).to_digit(16).map(|v| v as usize); + Some(d(id[0])? * 16 + d(id[1])?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AviFile { + pub header: MainHeader, + pub streams: Vec, + pub index: IndexSource, +} + +/// Options for [`open_with`] (tests reach the fallback indexes with them). +#[derive(Clone, Copy, Debug, Default)] +pub struct OpenOptions { + pub ignore_odml: bool, + pub ignore_idx1: bool, +} + +/// Headers larger than this are refused (a real `hdrl` is a few kilobytes). +const MAX_HEADER: u64 = 16 << 20; +/// Streams beyond this are ignored. +const MAX_STREAMS: usize = 64; +/// `LIST rec ` inside `movi` nests once; deeper is refused. +const MAX_DEPTH: u32 = 3; + +fn io(e: std::io::Error) -> Error { + Error::Io(e.to_string()) +} + +fn read(src: &S, at: u64, n: u64) -> Result> { + let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?; + let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?; + let mut buf = vec![0; len]; + src.read_at(at, &mut buf).map_err(io)?; + Ok(buf) +} + +fn fourcc(b: &[u8], at: usize) -> [u8; 4] { + let mut f = [0; 4]; + if let Some(s) = b.get(at..at + 4) { + f.copy_from_slice(s); + } + f +} + +fn le16(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn le64(b: &[u8], at: usize) -> u64 { + u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32) +} + +/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated +/// recording keeps what is there). +fn header_at(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> { + let h = read(src, at, 8)?; + let data = at + 8; + let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data)); + Ok((fourcc(&h, 0), size, data)) +} + +/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable +/// header; every step moves forward, so it ends. +fn children(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> { + let mut out = Vec::new(); + let mut at = from; + while at.checked_add(8).is_some_and(|e| e <= to) { + let Ok((id, size, data)) = header_at(src, at) else { break }; + out.push((id, size, data)); + // chunks are padded to an even size + let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break }; + if next <= at { + break; + } + at = next; + } + out +} + +/// Open an AVI file. +pub fn open(src: &S) -> Result { + open_with(src, OpenOptions::default()) +} + +pub fn open_with(src: &S, opts: OpenOptions) -> Result { + let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?; + if !sniff(&head) { + return Err(Error::NotAvi); + } + // the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML) + let mut hdrl = None; + let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end) + let mut idx1 = None; + for (id, size, data) in children(src, 0, src.len()) { + if id != *b"RIFF" || size < 4 { + continue; + } + let end = data + size; + let form = fourcc(&read(src, data, 4)?, 0); + if form != *b"AVI " && form != *b"AVIX" { + continue; + } + for (cid, csize, cdata) in children(src, data + 4, end) { + match &cid { + b"LIST" if csize >= 4 => { + let ltype = fourcc(&read(src, cdata, 4)?, 0); + if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " { + hdrl = Some((cdata + 4, cdata + csize)); + } else if ltype == *b"movi" { + movis.push((cdata, cdata + 4, cdata + csize)); + } + } + b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)), + _ => {} + } + } + } + let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?; + if hto - hfrom > MAX_HEADER { + return Err(Error::Invalid("header list too large".into())); + } + let (header, mut streams) = parse_hdrl(src, hfrom, hto)?; + if streams.is_empty() { + return Err(Error::Invalid("no streams".into())); + } + // chunk tables from the best index + let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty()); + let index = if odml && read_odml(src, &mut streams) { + IndexSource::OpenDml + } else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) { + clear(&mut streams); + read_idx1(src, &mut streams, at, size, first.0); + // idx1 only covers the first RIFF: the AVIX extensions are scanned + for &(_, from, to) in movis.iter().skip(1) { + scan(src, &mut streams, from, to, 0); + } + if streams.iter().all(|s| s.chunks.is_empty()) { scan_all(src, &mut streams, &movis) } else { IndexSource::Idx1 } + } else { + scan_all(src, &mut streams, &movis) + }; + Ok(AviFile { header, streams, index }) +} + +fn clear(streams: &mut [Stream]) { + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + } +} + +fn scan_all(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource { + clear(streams); + for &(_, from, to) in movis { + scan(src, streams, from, to, 0); + } + for s in streams.iter_mut() { + s.keyframes_known = false; + } + IndexSource::Scan +} + +fn parse_hdrl(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec)> { + let mut header = MainHeader::default(); + let mut streams = Vec::new(); + for (id, size, data) in children(src, from, to) { + match &id { + b"avih" => { + let b = read(src, data, size.min(64))?; + header = MainHeader { + micro_sec_per_frame: le32(&b, 0), + flags: le32(&b, 12), + total_frames: le32(&b, 16), + streams: le32(&b, 24), + width: le32(&b, 32), + height: le32(&b, 36), + }; + } + b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => { + streams.push(parse_strl(src, data + 4, data + size)?); + } + _ => {} + } + } + Ok((header, streams)) +} + +fn parse_strl(src: &S, from: u64, to: u64) -> Result { + let mut s = Stream { + kind: StreamKind::Other([0; 4]), + handler: [0; 4], + scale: 1, + rate: 1, + start: 0, + length: 0, + sample_size: 0, + video: None, + audio: None, + name: None, + chunks: Vec::new(), + keyframes_known: true, + super_index: Vec::new(), + }; + let mut strf = None; + for (id, size, data) in children(src, from, to) { + match &id { + b"strh" => { + let b = read(src, data, size.min(64))?; + s.kind = match &fourcc(&b, 0) { + b"vids" => StreamKind::Video, + b"auds" => StreamKind::Audio, + other => StreamKind::Other(*other), + }; + s.handler = fourcc(&b, 4); + s.scale = le32(&b, 20); + s.rate = le32(&b, 24); + s.start = le32(&b, 28); + s.length = le32(&b, 32); + s.sample_size = le32(&b, 44); + } + b"strf" => strf = Some(read(src, data, size.min(1 << 20))?), + b"strn" => { + let b = read(src, data, size.min(4096))?; + let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string(); + s.name = (!name.is_empty()).then_some(name); + } + b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?), + _ => {} + } + } + if let Some(b) = strf { + match s.kind { + StreamKind::Video => { + s.video = Some(BitmapInfo { + width: le32(&b, 4) as i32, + height: le32(&b, 8) as i32, + bit_count: le16(&b, 14), + compression: fourcc(&b, 16), + size_image: le32(&b, 20), + extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(), + }); + } + StreamKind::Audio => { + let cb = usize::from(le16(&b, 16)); + let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec(); + let mut tag = le16(&b, 0); + // WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag + if tag == 0xFFFE && extra.len() >= 22 { + tag = le16(&extra, 6); + } + s.audio = Some(WaveFormat { + format_tag: tag, + channels: le16(&b, 2), + sample_rate: le32(&b, 4), + avg_bytes_per_sec: le32(&b, 8), + block_align: le16(&b, 12), + bits_per_sample: le16(&b, 14), + extra, + }); + } + StreamKind::Other(_) => {} + } + } + if s.scale == 0 || s.rate == 0 { + (s.scale, s.rate) = (1, 1); + } + Ok(s) +} + +/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry). +fn super_index(b: &[u8]) -> Vec { + let longs = usize::from(le16(b, 0)); + let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize); + if itype != 0 || subtype != 0 || longs != 4 { + return Vec::new(); + } + // header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration + let n = n.min(b.len().saturating_sub(24) / 16); + (0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect() +} + +/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream +/// that has data lacks one or none could be read. +fn read_odml(src: &S, streams: &mut [Stream]) -> bool { + let limit = src.len() / 8; + let mut total = 0u64; + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + for &at in &s.super_index { + let Ok((id, size, data)) = header_at(src, at) else { continue }; + if &id[..2] != b"ix" { + continue; + } + let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue }; + let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)); + // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) + if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { + continue; + } + let base = le64(&b, 12); + let step = longs * 4; + let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step); + for k in 0..n { + let e = 24 + k * step; + let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); + let size = raw & 0x7FFF_FFFF; + let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue }; + total += 1; + if total > limit { + return false; + } + s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); + } + } + } + let ok = streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_))); + if !ok { + clear(streams); + } + ok +} + +/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are +/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides. +fn read_idx1(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) { + let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return }; + let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect(); + let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0)); + let first = entries.iter().find(|e| le32(e, 4) & 1 == 0); + let relative = match first { + Some(e) => { + let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8))); + id_at(movi + off) == Some(id) || id_at(off) != Some(id) + } + None => true, + }; + let base = if relative { movi } else { 0 }; + for e in entries { + let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12)); + // AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples + if flags & 1 != 0 || &id[2..] == b"pc" { + continue; + } + let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue }; + let Some(offset) = base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len())) + else { + continue; + }; + s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video }); + } +} + +/// Append the data chunks of a `movi` list (`[from, to)`) to the streams' tables, in file order. +fn scan(src: &S, streams: &mut [Stream], from: u64, to: u64, depth: u32) { + if depth >= MAX_DEPTH { + return; + } + for (id, size, data) in children(src, from, to) { + if id == *b"LIST" { + if size >= 4 { + scan(src, streams, data + 4, data + size, depth + 1); + } + continue; + } + if &id[..2] == b"ix" || &id[2..] == b"pc" { + continue; + } + if let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) { + s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true }); + } + } +} + +impl AviFile { + /// The bytes of chunk `i` of stream `stream`. + pub fn read_chunk(&self, src: &S, stream: usize, i: usize) -> Result> { + let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?; + read(src, c.offset, u64::from(c.size)) + } +} diff --git a/crates/avi/src/source.rs b/crates/avi/src/source.rs new file mode 100644 index 000000000..783cc1905 --- /dev/null +++ b/crates/avi/src/source.rs @@ -0,0 +1,59 @@ +//! Random-access byte sources for the demuxer (same shape as the other container crates', so one +//! adapter serves them all). + +use std::io; + +/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). +pub trait ByteSource { + /// Total length in bytes. + fn len(&self) -> u64; + /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + /// True if the source holds no bytes. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn eof() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") +} + +impl ByteSource for [u8] { + fn len(&self) -> u64 { + <[u8]>::len(self) as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let start = usize::try_from(offset).map_err(|_| eof())?; + let end = start.checked_add(buf.len()).ok_or_else(eof)?; + buf.copy_from_slice(self.get(start..end).ok_or_else(eof)?); + Ok(()) + } +} + +impl ByteSource for Vec { + fn len(&self) -> u64 { + self.as_slice().len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.as_slice().read_at(offset, buf) + } +} + +impl ByteSource for &T { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for std::sync::Arc { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs new file mode 100644 index 000000000..248364d6f --- /dev/null +++ b/crates/avi/src/tests.rs @@ -0,0 +1,269 @@ +use super::*; + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend_from_slice(&(data.len() as u32).to_le_bytes()); + v.extend_from_slice(data); + if data.len() % 2 == 1 { + v.push(0); + } + v +} + +fn list(ltype: &[u8; 4], body: &[u8]) -> Vec { + let mut d = ltype.to_vec(); + d.extend_from_slice(body); + chunk(b"LIST", &d) +} + +fn strl(video: bool) -> Vec { + let mut strh = vec![0u8; 56]; + strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" }); + strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] }); + let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) }; + strh[20..24].copy_from_slice(&scale.to_le_bytes()); + strh[24..28].copy_from_slice(&rate.to_le_bytes()); + strh[44..48].copy_from_slice(&ss.to_le_bytes()); + let strf = if video { + let mut b = vec![0u8; 40]; + b[..4].copy_from_slice(&40u32.to_le_bytes()); + b[4..8].copy_from_slice(&64i32.to_le_bytes()); + b[8..12].copy_from_slice(&48i32.to_le_bytes()); + b[14..16].copy_from_slice(&24u16.to_le_bytes()); + b[16..20].copy_from_slice(b"MJPG"); + b + } else { + let mut b = vec![0u8; 18]; + b[..2].copy_from_slice(&1u16.to_le_bytes()); + b[2..4].copy_from_slice(&2u16.to_le_bytes()); + b[4..8].copy_from_slice(&48_000u32.to_le_bytes()); + b[8..12].copy_from_slice(&192_000u32.to_le_bytes()); + b[12..14].copy_from_slice(&4u16.to_le_bytes()); + b[14..16].copy_from_slice(&16u16.to_le_bytes()); + b + }; + let mut body = chunk(b"strh", &strh); + body.extend(chunk(b"strf", &strf)); + body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" })); + list(b"strl", &body) +} + +/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks, +/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute. +fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec { + let mut avih = vec![0u8; 56]; + avih[0..4].copy_from_slice(&40_000u32.to_le_bytes()); + avih[12..16].copy_from_slice(&0x10u32.to_le_bytes()); + avih[16..20].copy_from_slice(&3u32.to_le_bytes()); + avih[24..28].copy_from_slice(&2u32.to_le_bytes()); + avih[32..36].copy_from_slice(&64u32.to_le_bytes()); + avih[36..40].copy_from_slice(&48u32.to_le_bytes()); + let mut hdrl = chunk(b"avih", &avih); + hdrl.extend(strl(true)); + hdrl.extend(strl(false)); + let hdrl = list(b"hdrl", &hdrl); + let samples: [(&[u8; 4], Vec, bool); 5] = + [(b"00dc", vec![1; 10], true), (b"01wb", vec![2; 16], true), (b"00dc", vec![3; 6], false), (b"01wb", vec![4; 8], true), (b"00dc", vec![5; 7], true)]; + let mut body = Vec::new(); + let mut entries = Vec::new(); + for (id, data, key) in &samples { + entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key)); + body.extend(chunk(id, data)); + } + let movi_body = if rec { + let r = list(b"rec ", &body); + // the samples moved 12 bytes in (the rec list's header and type) + for e in &mut entries { + e.1 += 12; + } + r + } else { + body + }; + let movi = list(b"movi", &movi_body); + let mut riff = b"AVI ".to_vec(); + riff.extend(hdrl); + // position of the `movi` type id: after `RIFF` , what `riff` holds so far, `LIST` + let movi_at = (8 + riff.len() + 8) as u32; + riff.extend(movi); + if idx1 { + let mut ix = Vec::new(); + for (id, off, len, key) in entries { + ix.extend_from_slice(&id); + ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes()); + ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes()); + ix.extend_from_slice(&len.to_le_bytes()); + } + riff.extend(chunk(b"idx1", &ix)); + } + chunk(b"RIFF", &riff) +} + +fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> { + f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect() +} + +#[test] +fn headers_and_every_index_path_agree() { + for (idx1, absolute, rec, want) in [ + (true, false, false, IndexSource::Idx1), + (true, true, false, IndexSource::Idx1), + (true, false, true, IndexSource::Idx1), + (false, false, false, IndexSource::Scan), + (false, false, true, IndexSource::Scan), + ] { + let file = avi(idx1, absolute, rec); + let f = open(&file).unwrap(); + let what = format!("idx1 {idx1} absolute {absolute} rec {rec}"); + assert_eq!(f.index, want, "{what}"); + assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000)); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera"))); + assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG"))); + let w = a.audio.as_ref().unwrap(); + assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4)); + // the index knows the delta frame; a scan cannot + let keys = if idx1 { [true, false, true] } else { [true; 3] }; + assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}"); + assert_eq!(f.streams[0].keyframes_known, idx1); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}"); + assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}"); + assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err()); + } + // the idx1 path can be skipped + let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap(); + assert_eq!(f.index, IndexSource::Scan); +} + +#[test] +fn not_avi_and_truncated_recordings() { + assert_eq!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi)); + assert_eq!(open(&Vec::new()), Err(Error::NotAvi)); + assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF")); + // a recording cut off in its last frame: the headers and the whole frames are there + let full = avi(false, false, false); + let cut = full[..full.len() - 4].to_vec(); + let f = open(&cut).unwrap(); + assert_eq!(f.streams[0].chunks.len(), 3); + assert!(f.read_chunk(&cut, 0, 0).is_ok()); +} + +/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and +/// whatever opens reads its chunks without panicking. +#[test] +fn mutations_never_panic() { + for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] { + for cut in 0..base.len() { + let b = base[..cut].to_vec(); + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + for at in 0..base.len() { + for v in [0x00u8, 0xFF, 0x7F, 0x80] { + let mut b = base.clone(); + b[at] = v; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + } + } +} + +/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base +/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second +/// pass knows where the `ix##` chunks landed. +fn avi_odml(ix_at: [u64; 2]) -> (Vec, [u64; 2]) { + let indx = |stream: u8, at: u64| { + let mut b = Vec::new(); + b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry + b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]); + b.extend_from_slice(&[0; 12]); + b.extend_from_slice(&at.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + chunk(b"indx", &b) + }; + let mut hdrl = chunk(b"avih", &[0; 56]); + for (s, at) in ix_at.iter().enumerate() { + let l = strl(s == 0); + // strl = LIST strl …: append indx inside it + let mut body = l[12..].to_vec(); + body.extend(indx(s as u8, *at)); + hdrl.extend(list(b"strl", &body)); + } + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", &hdrl)); + // movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01 + let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi` + let mut body = Vec::new(); + let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()]; + for (s, data, key) in [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] { + let id = if s == 0 { b"00dc" } else { b"01wb" }; + at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key)); + body.extend(chunk(id, &data)); + } + let mut where_ix = [0u64; 2]; + for (s, entries) in at.iter().enumerate() { + let base = 100u64; // offsets are relative to qwBaseOffset + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS + b.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" }); + b.extend_from_slice(&base.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + for &(off, size, key) in entries { + b.extend_from_slice(&((off - base) as u32).to_le_bytes()); + b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes()); + } + where_ix[s] = movi_data_at + body.len() as u64; + body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b)); + } + riff.extend(list(b"movi", &body)); + (chunk(b"RIFF", &riff), where_ix) +} + +#[test] +fn opendml_standard_indexes() { + let (_, at) = avi_odml([0, 0]); + let (file, again) = avi_odml(at); + assert_eq!(at, again, "the layout does not depend on the offsets"); + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]); + // ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks) + let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(s.index, IndexSource::Scan); + assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::>()); + // a broken super index (pointing nowhere) falls back to a scan + let (bad, _) = avi_odml([3, 5]); + assert_eq!(open(&bad).unwrap().index, IndexSource::Scan); + // and mutations of the OpenDML file never panic + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xFF; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for k in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, k); + } + } + } + } +} diff --git a/crates/avi/tests/ffmpeg_oracle.rs b/crates/avi/tests/ffmpeg_oracle.rs new file mode 100644 index 000000000..7f1b1662f --- /dev/null +++ b/crates/avi/tests/ffmpeg_oracle.rs @@ -0,0 +1,151 @@ +//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts, +//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe +//! the same chunks. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_avi::{IndexSource, OpenOptions, StreamKind, open, open_with}; + +fn fixture(name: &str, args: &[&str]) -> Option { + let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("avi oracle")?; + let out = filmcraft_testkit::fixtures_dir("avi").join(name); + filmcraft_testkit::fixtures::generate(&out, |tmp: &Path| { + let mut c = Command::new(&ffmpeg); + c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp); + c.status().is_ok_and(|s| s.success()) + }) +} + +fn packets(path: &Path, stream: usize) -> usize { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) +} + +fn packet_bytes(path: &Path, stream: usize) -> u64 { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::().ok()).sum() +} + +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> { + let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; + let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> { + let mut x = pic.clone(); + x.extend_from_slice(&TONE); + x.extend_from_slice(v); + x.extend_from_slice(a); + x + }; + vec![ + ("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"), + ("h264_mp3.avi", with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), *b"H264"), + ("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]), + ("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"), + ] +} + +#[test] +fn ffmpeg_files_match_ffprobe_on_every_index_path() { + for (name, args, fourcc) in cases() { + let Some(path) = fixture(name, &args) else { return }; + let bytes = std::fs::read(&path).unwrap(); + let f = open(&bytes).unwrap(); + // ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB + assert_eq!(f.index, IndexSource::Idx1, "{name}"); + assert_eq!(f.streams.len(), 2, "{name}"); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}"); + let bmp = v.video.as_ref().unwrap(); + assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}"); + assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}"); + assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}"); + // the tables hold what ffprobe reads + assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video"); + // PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not + // follow, so compressed audio compares by bytes + if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) { + assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio"); + } else { + let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum(); + assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes"); + } + assert!(v.chunks[0].key, "{name}: the first frame is a key frame"); + if fourcc == *b"H264" { + assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames"); + } + // a scan finds the same chunks (it cannot know key frames) + let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap(); + assert_eq!(scanned.index, IndexSource::Scan, "{name}"); + for s in 0..2 { + assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}"); + } + } +} + +fn sizes(f: &filmcraft_avi::AviFile, s: usize) -> Vec<(u64, u32)> { + f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect() +} + +/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the +/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p filmcraft-avi --test +/// ffmpeg_oracle -- --ignored`. +#[test] +#[ignore] +fn opendml_file_past_1_gb() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=640x480:r=25:d=50", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:d=50", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + "-c:a", + "pcm_s16le", + ]; + let Some(path) = fixture("large_opendml.avi", &args) else { return }; + let file = std::fs::File::open(&path).unwrap(); + let src = FileSource(file); + let f = open(&src).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(f.streams[0].chunks.len(), packets(&path, 0)); + assert_eq!(f.streams[1].chunks.len(), packets(&path, 1)); + assert_eq!(f.streams[0].chunks.len(), 1250); + assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension"); + // idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks + let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(idx1.index, IndexSource::Idx1); + for s in 0..2 { + assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}"); + } + assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3); +} + +/// A file on disk as a byte source, read where asked. +struct FileSource(std::fs::File); + +impl filmcraft_avi::ByteSource for FileSource { + fn len(&self) -> u64 { + self.0.metadata().map(|m| m.len()).unwrap_or(0) + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset) + } +} diff --git a/crates/captions/README.md b/crates/captions/README.md index d65b03b37..909636fdd 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -16,7 +16,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca | Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. | | MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. | | EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `` / ``, extension blocks for long subtitles, start-of-programme (TCP) subtracted. | -| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers, timed spans, referenced styles. | +| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `

` per cue, `
`, styled ``s for `` / `` / ``, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. | | DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). | All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC diff --git a/crates/captions/src/ttml.rs b/crates/captions/src/ttml.rs index 6c98a7ff7..c16d6976b 100644 --- a/crates/captions/src/ttml.rs +++ b/crates/captions/src/ttml.rs @@ -10,8 +10,8 @@ //! //! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read): //! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`, -//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers -//! (`body` / `div` begins add up), timed ``s inside an untimed `

`, `
`, whitespace +//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time +//! containers (`body` / `div` begins add up and their ends clip what is inside), timed ``s inside an untimed `

`, `
`, whitespace //! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`. use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; @@ -423,44 +423,84 @@ pub fn parse(text: &str) -> Result { let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else { return Ok(doc); }; - walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc); + if root.descendants().any(|n| attr(n, "timeContainer").is_some_and(|v| v.trim() == "seq")) { + doc.warnings.push("timeContainer=\"seq\" is not supported; its children are timed as in a par container".into()); + } + // the body is a time container like a div: its begin offsets, and its end clips, everything inside + let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc); + walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, Fmt::default(), &mut doc); doc.cues.sort_by_key(|c| (c.start, c.end)); Ok(doc) } -/// Begin and end of a timed element relative to `parent` (None when it has no timing). -fn times(n: roxmltree::Node, parent: Tick, tm: &Timing, doc: &mut Document) -> (Option, Option) { - let p = |k: &str, doc: &mut Document| -> Option { +/// The active interval of the enclosing time containers (`par`): child times are offsets from +/// `begin`, and nothing inside outlasts `end` (None when no ancestor sets an end). +#[derive(Clone, Copy)] +struct Scope { + begin: Tick, + end: Option, +} + +/// Whether `n` has timing attributes of its own. +fn has_timing(n: roxmltree::Node) -> bool { + ["begin", "end", "dur"].into_iter().any(|k| attr(n, k).is_some()) +} + +/// Timing of an element in its parent container. +struct Times { + /// The begin, when the element sets one. + begin: Option, + /// Its own end (`end`, or `begin` + `dur`, the earlier of the two) clipped to the parent's + /// end, else the parent's end. + end: Option, + /// A timing attribute did not parse (and was warned about). + bad: bool, +} + +fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> Times { + let mut bad = false; + let mut p = |k: &str, doc: &mut Document| -> Option { let v = attr(n, k)?; - let t = parse_time(v, *tm); + let t = parse_time(v, tm); if t.is_none() { + bad = true; doc.warnings.push(format!("bad time expression \"{v}\"")); } t }; - let begin = p("begin", doc).map(|b| parent + b); - let end = match (p("end", doc), p("dur", doc)) { - (Some(e), _) => Some(parent + e), - (None, Some(d)) => Some(begin.unwrap_or(parent) + d), - _ => None, + let begin = p("begin", doc).map(|b| parent.begin + b); + let own_end = match (p("end", doc).map(|e| parent.begin + e), p("dur", doc).map(|d| begin.unwrap_or(parent.begin) + d)) { + (Some(e), Some(d)) => Some(e.min(d)), + (e, d) => e.or(d), }; - (begin, end) + let end = match (own_end, parent.end) { + (Some(e), Some(pe)) => Some(e.min(pe)), + (e, pe) => e.or(pe), + }; + Times { begin, end, bad } } -fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { +fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &std::collections::HashMap, fmt: Fmt, doc: &mut Document) { for c in n.children().filter(|c| c.is_element()) { let f = style_of(c, styles, fmt); match c.tag_name().name() { "div" => { - let (b, _) = times(c, parent, tm, doc); - walk(c, b.unwrap_or(parent), tm, styles, f, doc); + let t = times(c, parent, *tm, doc); + walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, f, doc); } "p" => { - let (b, e) = times(c, parent, tm, doc); + let t = times(c, parent, *tm, doc); + if t.bad { + continue; + } + let (b, e) = (t.begin.unwrap_or(parent.begin), t.end); let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " ")); let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string()); - match (b, e) { - (Some(b), Some(e)) => { + let spans: Vec = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect(); + // a timed paragraph, or one without timed spans in a container with an end, is one cue + let whole = if has_timing(c) || spans.is_empty() { e } else { None }; + match whole { + Some(e) => { let mut o = Out { s: String::new(), pending: false }; let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect(); for t in &tags { @@ -475,12 +515,13 @@ fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() }); } } - _ => { + None => { // untimed paragraph: its timed spans are the cues - let base = b.unwrap_or(parent); - for sp in c.children().filter(|x| x.is_element() && x.tag_name().name() == "span") { - let (sb, se) = times(sp, base, tm, doc); - if let (Some(sb), Some(se)) = (sb, se) { + let scope = Scope { begin: b, end: e }; + for sp in spans { + let st = times(sp, scope, *tm, doc); + let sb = st.begin.unwrap_or(scope.begin); + if let (false, Some(se)) = (st.bad, st.end) { let mut o = Out { s: String::new(), pending: false }; collect(sp, styles, style_of(sp, styles, f), &mut o); let text = clean(&o.s); diff --git a/crates/captions/tests/ttml_body_timing.rs b/crates/captions/tests/ttml_body_timing.rs new file mode 100644 index 000000000..27da2d9f2 --- /dev/null +++ b/crates/captions/tests/ttml_body_timing.rs @@ -0,0 +1,92 @@ +//! TTML / DFXP inherited timing: `begin`, `end` and `dur` on `body` and `div` time containers +//! offset and clip the paragraphs inside them (#895). + +use filmcraft_captions::{Format, WriteOptions, parse, write}; + +/// (start, end, text) of every cue, in seconds; panics on a parse error. +fn cues(xml: &str, format: Format) -> Vec<(f64, f64, String)> { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + doc.cues.iter().map(|c| (c.start.seconds(), c.end.seconds(), c.text.clone())).collect() +} + +fn both(xml: &str, want: &[(f64, f64, &str)]) { + let want: Vec<(f64, f64, String)> = want.iter().map(|(s, e, t)| (*s, *e, t.to_string())).collect(); + for format in [Format::Ttml, Format::Dfxp] { + assert_eq!(cues(xml, format), want, "{format:?}: {xml}"); + } +} + +#[test] +fn body_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn div_begin_is_inherited() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn untimed_containers_leave_times_unchanged() { + both(r#"

x

"#, &[(1.0, 2.0, "x")]); +} + +#[test] +fn nested_begins_add_up() { + both( + r#"

deep

mid

top

"#, + &[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")], + ); +} + +#[test] +fn body_begin_offsets_timed_spans() { + both(r#"

x

"#, &[(6.0, 7.0, "x")]); +} + +#[test] +fn container_ends_clip_descendants() { + // div active 11 s to 13 s: a later paragraph is dropped, a longer one is cut at 13 s + both( + r#"

cut

gone

"#, + &[(12.0, 13.0, "cut")], + ); + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn paragraphs_without_an_end_last_until_their_container_ends() { + both( + r#"

open

whole

"#, + &[(3.0, 6.0, "open"), (10.0, 12.0, "whole")], + ); +} + +#[test] +fn end_and_dur_take_the_earlier() { + both(r#"

x

"#, &[(1.0, 3.0, "x")]); +} + +#[test] +fn seq_containers_warn() { + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + assert_eq!(doc.cues.len(), 1); + assert!(doc.warnings.iter().any(|w| w.contains("seq")), "{:?}", doc.warnings); + } +} + +#[test] +fn inherited_times_survive_export() { + // the writer uses untimed body / div and absolute paragraph times, so the resolved times stay + let xml = r#"

x

"#; + for format in [Format::Ttml, Format::Dfxp] { + let doc = parse(xml.as_bytes(), format).unwrap(); + let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap(); + let times = |d: &filmcraft_captions::Document| d.cues.iter().map(|c| (c.start, c.end, c.text.clone())).collect::>(); + assert_eq!(times(&back), times(&doc), "{format:?}"); + assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}"); + } +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c4..90b79e2e1 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -14,16 +14,19 @@ filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-avi = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-matroska = { workspace = true } filmcraft-mxf = { workspace = true } filmcraft-mpegts = { workspace = true } filmcraft-mpeg2v = { workspace = true } filmcraft-ac3 = { workspace = true } +filmcraft-mpa = { workspace = true } filmcraft-ogg = { workspace = true } filmcraft-bitstream = { workspace = true } filmcraft-h264 = { workspace = true } filmcraft-hevc = { workspace = true } +filmcraft-vp8 = { workspace = true } filmcraft-vp9 = { workspace = true } filmcraft-prores = { workspace = true } filmcraft-dnx = { workspace = true } @@ -31,6 +34,7 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } +rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index daba8f4cd..0651fe490 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -21,7 +21,7 @@ enum Inner { /// AAC whose configuration arrives with the first frame (ADTS / LATM). AacPending, /// MPEG audio at this rate whose layer arrives with the first frame (MP4 / QuickTime name the - /// codec, not the layer, and symphonia's decoder reads only the layer it was made for). + /// codec, not the layer; layers I / II go to our decoder, layer III to symphonia's). MpegPending(u32), /// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps). /// `order` maps output channel → decoded channel (Vorbis → WAV/SMPTE order for surround). @@ -30,6 +30,8 @@ enum Inner { Symphonia(Box), /// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E). Ac3(Box), + /// Our MPEG-1/2 audio Layer I and II decoder (ISO/IEC 11172-3, 13818-3). + Mpa(Box), } /// Opus always decodes at 48 kHz (RFC 7845 §5.1: the input rate in the header is informational). @@ -59,13 +61,16 @@ fn vorbis_to_wav_order(channels: usize) -> Option<&'static [usize]> { /// Channel order for AAC surround (ISO/IEC 14496-3 Table 1.19, our decoder's element order: C, L R, /// surrounds, LFE): output channel → decoded channel, giving the WAV/SMPTE order (L R C LFE Ls Rs) /// used elsewhere, as the AC-3 decoder does and the AAC encoder's caller undoes on export. Only the -/// unambiguous channel configurations 3–6; 1, 2, 7 and PCE layouts keep the decoded order. +/// unambiguous channel configurations 3–6, 11 (6.1: … LFE, back centre, sides) and 12 (7.1: … LFE, +/// backs, sides); 1, 2, 7 and PCE layouts keep the decoded order. fn aac_to_wav_order(channel_config: u8) -> Option<&'static [usize]> { Some(match channel_config { 3 => &[1, 2, 0], 4 => &[1, 2, 0, 3], 5 => &[1, 2, 0, 3, 4], 6 => &[1, 2, 0, 5, 3, 4], + 11 => &[1, 2, 0, 6, 5, 3, 4], + 12 => &[1, 2, 0, 7, 5, 6, 3, 4], _ => return None, }) } @@ -127,11 +132,12 @@ impl PacketDecoder { let order = if dec.head().mapping_family == 1 { vorbis_to_wav_order(channels) } else { None }; Ok(Self { inner: Inner::Opus { dec: Box::new(dec), order }, channels }) } - /// MPEG-1/2 audio layer I, II or III (symphonia). + /// MPEG-1/2 audio layer I, II (our decoder: symphonia rejects the bit rate / mode + /// combinations ISO/IEC 11172-3 does not allow, such as ffmpeg's default 384 kbit/s mono MP2) + /// or III (symphonia). pub fn mpeg_audio(layer: u8, sample_rate: u32) -> Result { let codec = match layer { - 1 => symphonia::core::codecs::CODEC_TYPE_MP1, - 2 => symphonia::core::codecs::CODEC_TYPE_MP2, + 1 | 2 => return Ok(Self { inner: Inner::Mpa(Box::default()), channels: 0 }), _ => CODEC_TYPE_MP3, }; Self::new(codec, sample_rate, None) @@ -172,7 +178,8 @@ impl PacketDecoder { C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)), C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), - C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), + // the decoder needs the STREAMINFO body: `dfLa` holds it after the 4-byte block header + C::Flac(f) => Self::new(CODEC_TYPE_FLAC, rate, f.metadata_blocks.get(4..38).map(<[u8]>::to_vec)), C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), other => Err(CodecError::Unsupported(format!("{} audio", other.name()))), } @@ -228,6 +235,31 @@ impl PacketDecoder { self.channels = out.len(); return Ok(out); } + Inner::Mpa(d) => { + // a packet holds one frame or several; a damaged first frame is an error, damage + // after a good frame ends the packet + let mut out: Vec> = Vec::new(); + let mut p = 0; + while let Some(rest) = data.get(p..).filter(|r| !r.is_empty()) { + let f = match d.decode(rest) { + Ok(f) => f, + Err(e) if out.is_empty() => return Err(CodecError::Decode(e.to_string())), + Err(_) => break, + }; + p += f.header.frame_bytes; + if out.is_empty() { + out = f.channels; + } else if out.len() == f.channels.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + break; + } + } + self.channels = out.len(); + return Ok(out); + } }; let pkt = Packet::new_from_slice(0, ts, 0, data); let buf = dec.decode(&pkt).map_err(|e| CodecError::Decode(e.to_string()))?; @@ -256,6 +288,7 @@ impl PacketDecoder { up.reset(); } } + Inner::Mpa(d) => d.reset(), Inner::Opus { dec, .. } => dec.reset(), Inner::Symphonia(d) => d.reset(), Inner::Ac3(d) => d.reset(), @@ -765,6 +798,22 @@ pub struct AudioFileSource { impl AudioFileSource { pub fn decode(name: &str, bytes: Arc<[u8]>) -> Result { let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + if matches!(ext.as_str(), "mp2" | "mp3") + && let Some((layer, rate, samples)) = decode_mpeg_layer12(&bytes) + { + let frames = samples.first().map_or(0, Vec::len); + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::AudioOnly, + duration: Tick::from_units(frames as i64, rate as i64), + video: None, + audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: format!("MP{layer}"), bits_per_sample: None }], + container: ext.to_uppercase(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + return Ok(Self { info, rate, samples }); + } let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default()); let mut hint = Hint::new(); hint.with_extension(&ext); @@ -820,6 +869,49 @@ impl AudioFileSource { } } +/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate, +/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag); +/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame +/// with another channel count keeps its duration as silence. `None` when the stream does not start +/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3. +fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec>)> { + let mut p = match bytes { + // ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged + [b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => { + let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize); + size.saturating_add(if flags & 0x10 != 0 { 20 } else { 10 }) + } + _ => 0, + }; + let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0); + let first = frame_at(p)?; + let next = p.saturating_add(first.frame_bytes); + if first.layer == 3 || (next < bytes.len() && frame_at(next).is_none_or(|h| h.layer != first.layer || h.sample_rate != first.sample_rate)) { + return None; + } + let mut dec = filmcraft_mpa::Decoder::new(); + let mut out: Vec> = vec![Vec::new(); first.channels()]; + while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 4) { + match dec.decode(rest) { + Ok(f) if f.header.layer == first.layer && f.header.sample_rate == first.sample_rate => { + let n = f.header.samples(); + if f.channels.len() == out.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + for o in out.iter_mut() { + o.resize(o.len() + n, 0.0); + } + } + p += f.header.frame_bytes; + } + _ => p += 1, + } + } + out.first().is_some_and(|c| !c.is_empty()).then_some((first.layer, first.sample_rate, out)) +} + impl MediaSource for AudioFileSource { fn info(&self) -> &MediaInfo { &self.info @@ -840,6 +932,57 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option Vec { + let h = [0xFF, 0xFD, bitrate_index << 4 | 0x04, mode << 6]; + let mut f = h.to_vec(); + f.resize(filmcraft_mpa::parse_header(&h).unwrap().frame_bytes, 0); + f + } + + /// #809: mono MP2 at 384 kbit/s (ffmpeg's default for mono) is outside ISO/IEC 11172-3's + /// allowed combinations, and symphonia rejected every frame, so the track played as silence. + #[test] + fn mono_mp2_at_384k_decodes() { + let mut d = PacketDecoder::mpeg_audio(2, 48_000).unwrap(); + let out = d.decode(&silent_frame(14, 3), 0).unwrap(); + assert_eq!((out.len(), out[0].len(), d.channels), (1, 1152, 1)); + // a packet of two stereo frames decodes to both + let two = [silent_frame(10, 0), silent_frame(10, 0)].concat(); + let out = d.decode(&two, 1152).unwrap(); + assert_eq!((out.len(), out[0].len()), (2, 2304)); + // damage: an error for the first frame, the good frames before it otherwise + assert!(d.decode(&[0u8; 8], 0).is_err()); + assert!(d.decode(&silent_frame(10, 0)[..100], 0).is_err()); + let partial = [silent_frame(10, 0), vec![0xFF, 0xFD, 0xA4]].concat(); + assert_eq!(d.decode(&partial, 0).unwrap()[0].len(), 1152); + d.reset(); + } + + /// Raw `.mp2` files: after an ID3v2 tag, frame after frame, skipping damage. + #[test] + fn raw_streams_resync_after_damage() { + let mono = silent_frame(14, 3); + let file = [&b"ID3\x04\0\0\0\0\0\x05abcde"[..], &mono, &mono, &[0xFF, 0xFD, 0x13, 0x37, 0x42], &mono].concat(); + let (layer, rate, samples) = decode_mpeg_layer12(&file).unwrap(); + assert_eq!((layer, rate, samples.len(), samples[0].len()), (2, 48_000, 1, 3 * 1152)); + let src = AudioFileSource::decode("x.mp2", file.clone().into()).unwrap(); + assert_eq!(src.info().audio().map(|a| (a.channels, a.codec.as_str())), Some((1, "MP2"))); + // MP3, junk and truncated data are not ours (symphonia gets them) + assert!(decode_mpeg_layer12(&[0xFF, 0xFB, 0x90, 0x44, 0, 0]).is_none()); + for n in 0..40 { + assert!(decode_mpeg_layer12(&file[..n]).is_none(), "{n}"); + } + assert!(decode_mpeg_layer12(&[&mono[..], &[0x12; 50]].concat()).is_none()); + // an ID3v2 tag claiming the largest size (with a footer) points past the end + assert!(decode_mpeg_layer12(&[&b"ID3\x04\0\x10\x7F\x7F\x7F\x7F"[..], &mono].concat()).is_none()); + } +} + #[cfg(test)] mod he_aac_tests { use super::*; diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 40e43a2d8..62f25cb10 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -176,3 +176,32 @@ fn playing_at_another_rate_leaves_no_holes() { let tail: Vec> = got.iter().map(|c| c[2048..].to_vec()).collect(); assert_eq!(holes(&tail), 0); } + +/// #790: the encoder priming stored as `CodecDelay` (1024 samples: 21.333 ms at 48 kHz, 23.220 ms +/// at 44.1 kHz) is trimmed exactly. The demuxer takes it off the millisecond timestamps rounded to +/// whole milliseconds; starting the first packet there played the track 16 (48 kHz) or 10 (44.1 kHz) +/// samples late. +#[test] +fn matroska_codec_delay_is_trimmed_to_the_sample() { + for rate in [48_000u32, 44_100] { + let (asc, units) = aac(rate); + let want = continuous(&asc, rate, &units); + let delay_ns = (1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); + spec.codec_private = asc.clone(); + spec.audio = Some((rate as f64, 2, None)); + spec.codec_delay_ns = delay_ns as u64; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).unwrap(); + for (k, u) in units.iter().enumerate() { + let start_ns = (k as i64 * 1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + w.write_frame(0, start_ns - delay_ns, true, u, None).unwrap(); + } + let bytes: Arc<[u8]> = w.finish().unwrap().into_inner().into(); + let src = MkvSource::open("primed.mkv", bytes).unwrap(); + let frames = units.len().saturating_sub(2) * 1024; + let got = played(&src, rate, frames); + let trimmed: Vec> = want.iter().map(|c| c[1024..].to_vec()).collect(); + let (n, first) = mismatches(&got, &trimmed, 0); + assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the decode with the priming trimmed, first (channel, sample) {first:?}"); + } +} diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs new file mode 100644 index 000000000..659524427 --- /dev/null +++ b/crates/codecs/src/avi.rs @@ -0,0 +1,585 @@ +//! AVI media source (`filmcraft-avi` demux, GOP-aware video via [`crate::gop`]). +//! +//! Video: Motion JPEG (frames without their own Huffman tables get the standard ones), H.264 and +//! HEVC (Annex B byte streams, our decoders), and uncompressed RGB / YUV ([`RawFormat`]). A frame +//! slot with an empty chunk (a "drop frame") repeats the picture before it. MPEG-4 Part 2 +//! (DivX / Xvid) and DV have no decoder yet: their streams are listed and report the codec. +//! +//! Audio: PCM (integer and float) straight from the chunks; MPEG audio (MP3, MP2) and AC-3 frames, +//! which AVI chunks need not follow, are found again across chunk boundaries +//! ([`filmcraft_mpegts::frame_bytes`]) and decoded as packets. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use filmcraft_avi::{AviFile, BitmapInfo, StreamKind, WaveFormat}; +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_isobmff::{PcmConfig, SampleEntry}; +use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; +use filmcraft_time::{FrameRate, Tick}; + +use crate::audio::{PacketDecoder, decode_pcm}; +use crate::gop::{GopCache, VideoSamples}; +use crate::video::{RawFormat, RawVideoDecoder, VideoDecoder}; +use crate::{CodecError, make_video_decoder}; + +pub fn sniff(b: &[u8]) -> bool { + filmcraft_avi::sniff(b) +} + +#[derive(Clone, Debug, PartialEq)] +enum VCodec { + Jpeg, + H264, + Hevc, + Raw(RawFormat), + /// No decoder: the codec's name. + Unsupported(String), +} + +fn fourcc_text(f: [u8; 4]) -> String { + let t: String = f.iter().map(|&c| if c.is_ascii_graphic() { c as char } else { '?' }).collect(); + t.trim_end_matches('?').to_string() +} + +/// The decoder for a video stream and its label. +fn video_codec(b: &BitmapInfo) -> (VCodec, String) { + if let Some(raw) = RawFormat::from_avi(b.compression, b.bit_count) { + return (VCodec::Raw(raw), raw.label().into()); + } + let mut f = b.compression; + f.make_ascii_uppercase(); + match &f { + b"MJPG" | b"AVRN" | b"LJPG" | b"JPGL" | b"DMB1" | b"JPEG" | b"MJPA" => (VCodec::Jpeg, "Motion JPEG".into()), + b"H264" | b"X264" | b"AVC1" | b"DAVC" | b"VSSH" => (VCodec::H264, "H.264".into()), + b"HEVC" | b"H265" | b"HEV1" | b"HVC1" | b"X265" => (VCodec::Hevc, "HEVC".into()), + b"DIVX" | b"DX50" | b"XVID" | b"FMP4" | b"MP4V" | b"M4S2" | b"3IV2" | b"DIV3" | b"MP43" | b"MP42" => { + (VCodec::Unsupported("MPEG-4 Part 2 (DivX / Xvid)".into()), "MPEG-4 Part 2 (DivX / Xvid)".into()) + } + b"DVSD" | b"DV25" | b"DV50" | b"DVHD" | b"DVSL" | b"CDVC" | b"DVCP" | b"DVPP" => (VCodec::Unsupported("DV".into()), "DV".into()), + _ => { + let name = fourcc_text(b.compression); + (VCodec::Unsupported(format!("\"{name}\"")), name) + } + } +} + +/// One picture of the video stream: its frame slot (in `scale / rate` units) and chunk. +#[derive(Clone, Copy, Debug)] +struct VFrame { + pts: i64, + chunk: usize, + key: bool, +} + +#[derive(Clone, Debug)] +enum ACodec { + Pcm(PcmConfig), + /// MPEG audio (layer 1-3) or AC-3 frames, found again across chunks. + Framed { + ts: filmcraft_mpegts::Codec, + layer: u8, + }, + Unsupported(String), +} + +fn audio_codec(w: &WaveFormat) -> (ACodec, String) { + let pcm = |float: bool| { + let bits = w.bits_per_sample; + let cfg = PcmConfig { bits, float, big_endian: false, signed: bits > 8, channels: u32::from(w.channels.max(1)), sample_rate: f64::from(w.sample_rate) }; + (ACodec::Pcm(cfg), format!("PCM {bits}-bit{}", if float { " float" } else { "" })) + }; + match w.format_tag { + 1 if matches!(w.bits_per_sample, 8 | 16 | 24 | 32) => pcm(false), + 3 if matches!(w.bits_per_sample, 32 | 64) => pcm(true), + 0x55 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 3 }, "MP3".into()), + 0x50 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 2 }, "MPEG Audio".into()), + 0x2000 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::Ac3, layer: 0 }, "AC-3".into()), + tag => { + let name = format!("audio format 0x{tag:04X}"); + (ACodec::Unsupported(name.clone()), name) + } + } +} + +/// A slice of a chunk: (chunk, offset in it, length). +type Piece = (usize, usize, usize); + +struct AudioState { + decoder: Option, + packets: HashMap>>>, + order: Vec, + last_decoded: Option, +} + +struct AviAudio { + stream: usize, + codec: ACodec, + /// The pieces of packet `k` are `pieces[first[k]..first[k + 1]]`. + pieces: Vec, + first: Vec, + /// Packet start positions in sample frames. + starts: Vec, + state: Mutex, +} + +pub struct AviSource { + info: MediaInfo, + bytes: crate::Src, + file: AviFile, + vstream: Option, + vcodec: VCodec, + /// Pictures in stored (decode) order. + vframes: Vec, + /// `vframes` indices in presentation order (H.264 with B-frames is stored in decode order), + /// and their presentation times. + vorder: Vec, + vorder_pts: Vec, + /// Frame slots of the video stream (where the last picture ends). + vslots: i64, + rate: FrameRate, + bottom_up: bool, + video: GopCache, + audios: Vec, +} + +/// Whether an Annex B access unit holds an H.264 IDR or HEVC IRAP picture. +fn random_access(data: &[u8], hevc: bool) -> bool { + filmcraft_bitstream::annexb_nals(data).iter().any(|n| match n.first() { + Some(h) if hevc => (16..=23).contains(&((h >> 1) & 0x3F)), + Some(h) => h & 0x1F == 5, + None => false, + }) +} + +/// The packets of a framed audio stream: every frame header found in the stream of chunks, and +/// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation). +fn split_frames( + file: &AviFile, + bytes: &crate::Src, + s: usize, + ts: &filmcraft_mpegts::Codec, +) -> (Vec, Vec, Vec, Option) { + let Some(st) = file.streams.get(s) else { return Default::default() }; + // chunk start positions in the concatenated stream + let mut at = Vec::with_capacity(st.chunks.len()); + let mut total = 0usize; + for c in &st.chunks { + at.push(total); + total = total.saturating_add(c.size as usize); + } + let locate = |pos: usize| -> Option<(usize, usize)> { + let k = at.partition_point(|&a| a <= pos).checked_sub(1)?; + Some((k, pos - at.get(k)?)) + }; + let (mut pieces, mut first, mut samples) = (Vec::new(), Vec::new(), Vec::new()); + let mut info = None; + // a sliding window over the chunks: `win` holds the stream from `base` + let (mut win, mut base, mut next) = (Vec::::new(), 0usize, 0usize); + let mut pos = 0usize; + while pos < total { + // keep at least a header's worth (and the frame, when we know its length) in the window + while win.len() < (pos - base) + 4096 && next < st.chunks.len() { + win.extend(file.read_chunk(&bytes, s, next).unwrap_or_default()); + next += 1; + } + let Some(here) = win.get(pos - base..) else { break }; + match filmcraft_mpegts::frame_bytes(ts, here) { + Some(n) if pos + n <= total => { + if info.is_none() { + info = filmcraft_mpegts::frame_info(ts, here); + } + let k = filmcraft_mpegts::frame_info(ts, here).map_or(0, |f| f.samples); + first.push(pieces.len()); + let mut p = pos; + while p < pos + n { + let Some((c, off)) = locate(p) else { break }; + let len = (st.chunks.get(c).map_or(0, |ch| ch.size as usize) - off).min(pos + n - p); + if len == 0 { + break; + } + pieces.push((c, off, len)); + p += len; + } + samples.push(k); + pos += n; + } + _ if here.len() < 8 && next >= st.chunks.len() => break, + _ => pos += 1, + } + // drop what is behind + if pos - base > 1 << 16 { + let cut = pos - base; + win.drain(..cut.min(win.len())); + base += cut; + } + } + (pieces, first, samples, info) +} + +impl AviSource { + pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result { + Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) + } + + /// Open from a random-access reader: the index now, frames and audio on demand. + pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { + let bytes = crate::Src(reader); + let file = filmcraft_avi::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let vstream = file.streams.iter().position(|s| s.kind == StreamKind::Video && s.video.is_some() && !s.chunks.is_empty()); + let astreams: Vec = file + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.kind == StreamKind::Audio && s.audio.is_some() && !s.chunks.is_empty()) + .map(|(i, _)| i) + .take(filmcraft_media::MAX_AUDIO_STREAMS) + .collect(); + if vstream.is_none() && astreams.is_empty() { + return Err(CodecError::Unsupported("no playable streams".into())); + } + // video: frame slots, pictures and key frames + let (mut vcodec, mut vframes, mut vslots, mut rate, mut bottom_up, mut video) = + (VCodec::Unsupported(String::new()), Vec::new(), 0i64, FrameRate::new(25, 1), false, None); + if let Some(vs) = vstream.and_then(|i| file.streams.get(i)) { + let b = vs.video.clone().unwrap_or_default(); + let (codec, label) = video_codec(&b); + rate = FrameRate::new(i64::from(vs.rate), i64::from(vs.scale)); + bottom_up = b.height > 0; + let (w, h) = (b.width.unsigned_abs(), b.height.unsigned_abs()); + for (k, c) in vs.chunks.iter().enumerate() { + if c.size == 0 { + continue; + } + let key = match &codec { + VCodec::H264 | VCodec::Hevc if !vs.keyframes_known => { + file.read_chunk(&bytes, vstream.unwrap_or(0), k).is_ok_and(|d| random_access(&d, codec == VCodec::Hevc)) + } + VCodec::H264 | VCodec::Hevc => c.key, + _ => true, + }; + vframes.push(VFrame { pts: i64::from(vs.start) + k as i64, chunk: k, key }); + } + vslots = i64::from(vs.start) + vs.chunks.len() as i64; + // AVI has no timestamps: H.264 with B-frames is stored in decode order, and the + // presentation order comes from the pictures' order counts. A first look at the start + // of the stream decides whether it reorders at all (reading every frame's header is the + // cost of a reordering stream only). + if codec == VCodec::H264 + && let Some(si) = vstream + { + let heads = |n: usize| -> Vec> { + vframes + .iter() + .take(n) + .map(|f| { + file.streams + .get(si) + .and_then(|s| s.chunks.get(f.chunk)) + .and_then(|c| filmcraft_media::reader::read_range(&*bytes.0, c.offset, (c.size as usize).min(8 << 10)).ok()) + .unwrap_or_default() + }) + .collect() + }; + let reorders = |p: &[i64]| p.windows(2).any(|w| w[1] < w[0]); + if crate::mxf::avc_presentation_order(&heads(300)).is_some_and(|p| reorders(&p)) + && let Some(ranks) = crate::mxf::avc_presentation_order(&heads(vframes.len())) + { + // the pictures keep the slots they occupy (empty drop-frame chunks between them + // stay), shown in their presentation order + let mut slots: Vec = vframes.iter().map(|f| f.pts).collect(); + slots.sort_unstable(); + for (f, r) in vframes.iter_mut().zip(ranks) { + if let Some(&p) = usize::try_from(r).ok().and_then(|r| slots.get(r)) { + f.pts = p; + } + } + } + } + let secs = vslots as f64 / rate.as_f64().max(1e-9); + // The rate the pictures play at: a writer that kept a fine time base (ffmpeg copying an + // MP4's 1/600) fills the slots between pictures with empty chunks, every 20th slot + // holding a picture at 30 fps. A regular spacing makes the rate; slots stay the units. + let shown = if vframes.len() > 1 { + let mut gaps: Vec = vframes.windows(2).map(|w| w[1].chunk as i64 - w[0].chunk as i64).filter(|g| *g > 0).take(1000).collect(); + gaps.sort_unstable(); + let gap = gaps.get(gaps.len() / 2).copied().unwrap_or(1).max(1); + let regular = gaps.iter().filter(|g| **g == gap).count() * 10 >= gaps.len() * 9; + if gap > 1 && regular { FrameRate::new(i64::from(vs.rate), i64::from(vs.scale).saturating_mul(gap)) } else { rate } + } else { + rate + }; + let bitrate = (secs > 0.0).then(|| (vs.chunks.iter().map(|c| u64::from(c.size)).sum::() as f64 * 8.0 / secs) as u64); + video = Some(VideoStreamInfo { + width: w, + height: h, + frame_rate: shown, + par: (1, 1), + codec: label, + pixel_format: String::new(), + color: crate::stream_color::resolve(w, h, &[]), + has_alpha: false, + bitrate, + hdr: None, + }); + vcodec = codec; + } + // audio: packets and their start positions + let mut audios = Vec::new(); + let mut audio_streams = Vec::new(); + for &s in &astreams { + let Some(st) = file.streams.get(s) else { continue }; + let w = st.audio.clone().unwrap_or_default(); + let (codec, label) = audio_codec(&w); + let mut sample_rate = w.sample_rate.max(1); + let mut channels = u32::from(w.channels.max(1)); + let (pieces, first, starts) = match &codec { + ACodec::Pcm(cfg) => { + let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize); + let mut t = 0i64; + let mut starts = Vec::with_capacity(st.chunks.len()); + for c in &st.chunks { + starts.push(t); + t = t.saturating_add((c.size as usize / block) as i64); + } + let pieces: Vec = st.chunks.iter().enumerate().map(|(k, c)| (k, 0, c.size as usize)).collect(); + let first = (0..pieces.len()).collect(); + (pieces, first, starts) + } + ACodec::Framed { ts, .. } => { + let (pieces, first, samples, info) = split_frames(&file, &bytes, s, ts); + if let Some(i) = info { + sample_rate = i.sample_rate.max(1); + channels = i.channels.max(1); + } + let mut t = 0i64; + let starts = samples + .iter() + .map(|&k| { + let s = t; + t = t.saturating_add(i64::from(k)); + s + }) + .collect(); + (pieces, first, starts) + } + ACodec::Unsupported(_) => (Vec::new(), Vec::new(), Vec::new()), + }; + // a stream that starts late (dwStart, in scale / rate units) + let delay = (i128::from(st.start) * i128::from(st.scale) * i128::from(sample_rate) / i128::from(st.rate.max(1))) as i64; + let starts: Vec = starts.into_iter().map(|s: i64| s.saturating_add(delay)).collect(); + audio_streams.push(AudioStreamInfo { + sample_rate, + channels, + codec: label, + bits_per_sample: (w.bits_per_sample > 0).then_some(u32::from(w.bits_per_sample)), + }); + let state = Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }); + audios.push(AviAudio { stream: s, codec, pieces, first, starts, state }); + } + // the longer of the picture and the sound + let vdur = if vslots > 0 { rate.tick_of(vslots) } else { Tick::ZERO }; + let adur = audios + .iter() + .zip(&audio_streams) + .filter_map(|(a, i)| { + let last = a.starts.last()?; + let tail = a.starts.len().checked_sub(2).and_then(|k| a.starts.get(k)).map_or(0, |p| last - p); + Some(Tick::from_rational(last + tail, 1, i64::from(i.sample_rate))) + }) + .max() + .unwrap_or_default(); + let info = MediaInfo { + name: name.to_string(), + kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, + duration: vdur.max(adur), + video, + audio_streams, + container: "AVI".into(), + start_timecode: None, + file_size: Some(bytes.0.len()), + }; + let mut vorder: Vec = (0..vframes.len()).collect(); + vorder.sort_by_key(|&i| vframes.get(i).map_or(0, |f| f.pts)); + let vorder_pts = vorder.iter().map(|&i| vframes.get(i).map_or(0, |f| f.pts)).collect(); + Ok(Self { info, bytes, file, vstream, vcodec, vframes, vorder, vorder_pts, vslots, rate, bottom_up, video: GopCache::new(None), audios }) + } + + fn read(&self, stream: usize, chunk: usize) -> crate::Result> { + self.file.read_chunk(&self.bytes, stream, chunk).map_err(|e| CodecError::Container(e.to_string())) + } + + /// The bytes of audio packet `k`. + fn packet(&self, a: &AviAudio, k: usize) -> crate::Result> { + let (from, to) = ( + a.first.get(k).copied().ok_or_else(|| CodecError::Container("audio packet out of range".into()))?, + a.first.get(k + 1).copied().unwrap_or(a.pieces.len()), + ); + let mut out = Vec::new(); + for &(c, off, len) in a.pieces.get(from..to).unwrap_or_default() { + let chunk = self.read(a.stream, c)?; + out.extend_from_slice(chunk.get(off..off + len).ok_or_else(|| CodecError::Container("audio chunk shorter than its index".into()))?); + } + Ok(out) + } + + fn audio_packet(&self, a: &AviAudio, st: &mut AudioState, ainfo: &AudioStreamInfo, i: usize) -> crate::Result>>> { + if let Some(p) = st.packets.get(&i) { + return Ok(p.clone()); + } + let data = self.packet(a, i)?; + let decoded = match &a.codec { + ACodec::Pcm(cfg) => decode_pcm(&data, cfg), + ACodec::Unsupported(name) => return Err(CodecError::Unsupported(name.clone())), + ACodec::Framed { ts, layer } => { + if st.decoder.is_none() { + st.decoder = Some(match ts { + filmcraft_mpegts::Codec::Ac3 => PacketDecoder::ac3()?, + _ => PacketDecoder::mpeg_audio(*layer, ainfo.sample_rate)?, + }); + } + let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?; + // non-sequential access: reset and prime with the packet before (the bit reservoir) + if st.last_decoded.is_none_or(|l| l + 1 != i) { + d.reset(); + if let Some(prev) = i.checked_sub(1).and_then(|j| self.packet(a, j).ok()) { + let _ = d.decode(&prev, 0); + } + } + let start = a.starts.get(i).copied().unwrap_or(0).max(0) as u64; + let r = d.decode(&data, start); + st.last_decoded = Some(i); + r.unwrap_or_default() + } + }; + let p = Arc::new(decoded); + st.packets.insert(i, p.clone()); + st.order.push(i); + if st.order.len() > 4096 { + let old = st.order.remove(0); + st.packets.remove(&old); + } + Ok(p) + } +} + +/// The video stream as a [`VideoSamples`] table. +struct AviVideo<'a> { + src: &'a AviSource, + stream: usize, +} + +impl VideoSamples for AviVideo<'_> { + fn count(&self) -> usize { + self.src.vframes.len() + } + fn pts(&self, i: usize) -> i64 { + self.src.vframes.get(i).map_or(0, |f| f.pts) + } + fn sync_before(&self, i: usize) -> usize { + let i = i.min(self.src.vframes.len().saturating_sub(1)); + self.src.vframes.get(..=i).and_then(|f| f.iter().rposition(|f| f.key)).unwrap_or(0) + } + fn sample_at(&self, t: i64) -> Option { + // the picture shown in slot `t`: the last one at or before it (empty chunks repeat it) + if t >= self.src.vslots { + return None; + } + let k = self.src.vorder_pts.partition_point(|&p| p <= t).checked_sub(1)?; + self.src.vorder.get(k).copied() + } + fn read(&self, i: usize) -> crate::Result> { + let f = self.src.vframes.get(i).ok_or_else(|| CodecError::Container("frame out of range".into()))?; + self.src.read(self.stream, f.chunk) + } + fn make_decoder(&self) -> crate::Result> { + let v = self.src.info.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video stream".into()))?; + let (w, h) = (v.width.min(u32::from(u16::MAX)) as u16, v.height.min(u32::from(u16::MAX)) as u16); + match &self.src.vcodec { + VCodec::Jpeg => make_video_decoder(&SampleEntry::jpeg(w, h)), + VCodec::H264 => Ok(Box::new(crate::video::H264Decoder::annexb())), + VCodec::Hevc => Ok(Box::new(crate::video::HevcDecoder::annexb())), + VCodec::Raw(f) => Ok(Box::new(RawVideoDecoder::new(*f, v.width, v.height, self.src.bottom_up)?)), + VCodec::Unsupported(name) => Err(CodecError::Unsupported(format!("no decoder for {name} video"))), + } + } +} + +impl MediaSource for AviSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result, MediaError> { + let s = self.vstream.ok_or(MediaError::NoStream("video"))?; + let t = req.time.max(Tick::ZERO); + let slot = |t: Tick| self.rate.frame_at(t); + let late = filmcraft_media::cancel::catch_up().map(|m| slot(t - m)); + Ok(self.video.frame_late(&AviVideo { src: self, stream: s }, slot(t), late)?) + } + + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { + self.audio_stream(0, start, frames, sample_rate) + } + + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?; + let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; + if let ACodec::Unsupported(name) = &a.codec { + return Err(MediaError::Unsupported(format!("{name} audio"))); + } + let ch = ainfo.channels.max(1) as usize; + let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1)); + let s0 = (start as f64 * ratio).floor() as i64; + let need = (frames as f64 * ratio).ceil() as i64 + 2; + let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); + let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); + while let Some(&pk_start) = a.starts.get(i) { + if pk_start >= s0 + need { + break; + } + let pk = self.audio_packet(a, &mut st, ainfo, i)?; + for (c, dst) in src.iter_mut().enumerate() { + let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue }; + for (k, v) in chan.iter().enumerate() { + let pos = pk_start + k as i64 - s0; + if let Some(d) = usize::try_from(pos).ok().and_then(|p| dst.get_mut(p)) { + *d = *v; + } + } + } + i += 1; + } + drop(st); + let frac = s0 as f64 - start as f64 * ratio; + let mut out = AudioBuffer::silence(sample_rate, ch, frames); + for (c, o) in out.channels.iter_mut().enumerate() { + let Some(s) = src.get(c) else { continue }; + for (k, d) in o.iter_mut().enumerate() { + let pos = k as f64 * ratio - frac; + let i0 = pos.floor().max(0.0) as usize; + let f = (pos - i0 as f64) as f32; + let a = s.get(i0).copied().unwrap_or(0.0); + let b = s.get(i0 + 1).copied().unwrap_or(a); + *d = a + (b - a) * f; + } + } + Ok(out) + } +} + +pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { + if !sniff(&bytes) { + return None; + } + Some(AviSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} + +/// [`filmcraft_media::ReaderOpener`] for AVI. +pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option> { + if !sniff(head) { + return None; + } + Some(AviSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} diff --git a/crates/codecs/src/avi_tests.rs b/crates/codecs/src/avi_tests.rs new file mode 100644 index 000000000..714eda190 --- /dev/null +++ b/crates/codecs/src/avi_tests.rs @@ -0,0 +1,232 @@ +//! AVI files made by ffmpeg (skipped without it), checked against ffmpeg's decode of the same file: +//! bit-exact where the decoder is (H.264, uncompressed video, PCM), within decoder tolerance where +//! it differs (JPEG, MP3 / MP2 / AC-3). + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use filmcraft_media::FrameRequest; + +use crate::tests::{ffmpeg, fixture_path, reference_yuv, yuv_bytes}; + +const PIC: [&str; 4] = ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn avi(name: &str, video: &[&str], audio: &[&str]) -> Option<(std::path::PathBuf, filmcraft_media::SharedSource)> { + let mut args: Vec<&str> = PIC.to_vec(); + args.extend_from_slice(&TONE); + args.extend_from_slice(video); + args.extend_from_slice(audio); + let path = fixture_path(name, &args)?; + let bytes: Arc<[u8]> = std::fs::read(&path).ok()?.into(); + Some((path.clone(), crate::open_bytes(name, bytes).unwrap_or_else(|e| panic!("{name}: {e}")))) +} + +/// ffmpeg's decode of the first audio stream as interleaved f32 at its own rate. +fn reference_audio(path: &Path) -> Vec { + let out = Command::new(ffmpeg().unwrap()) + .args(["-v", "error", "-i"]) + .arg(path) + .args(["-map", "0:a:0", "-f", "f32le", "-c:a", "pcm_f32le", "-"]) + .output() + .unwrap(); + out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// The first `n` mono samples of stream 0 as FilmCraft decodes them. +fn ours(src: &filmcraft_media::SharedSource, n: usize) -> Vec { + src.audio(0, n, 48_000).unwrap().channels[0].clone() +} + +fn check_info(name: &str, src: &filmcraft_media::SharedSource, vcodec: &str, acodec: &str) { + let info = src.info(); + assert_eq!(info.container, "AVI", "{name}"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, vcodec), "{name}"); + assert_eq!(v.frame_rate.as_f64(), 25.0, "{name}"); + let a = &info.audio_streams[0]; + assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (48_000, 1, acodec), "{name}"); + let secs = info.duration.seconds(); + assert!((secs - 2.0).abs() < 0.1, "{name}: duration {secs}"); +} + +#[test] +fn h264_frames_are_bit_exact_with_random_access_and_mp3_plays() { + let Some((path, src)) = + avi("avi_h264_mp3.avi", &["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]) + else { + return; + }; + check_info("h264", &src, "H.264", "MP3"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [37, 3, 24, 49, 0, 13, 12, 11, 30, 31, 32] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "H.264 frame {k} differs from ffmpeg"); + } + // MP3 (a different decoder): the tone, in time with ffmpeg's decode + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + // ffmpeg's test tone is at 1/8 of full scale + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "MP3: tone {rms_ref}, difference {rms_diff}"); + // random access into the middle matches sequential decoding + let mid = src.audio(24_000, 4_800, 48_000).unwrap().channels[0].clone(); + let worst = mid.iter().zip(&a[24_000..28_800]).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(worst < 0.01, "MP3 seek: {worst}"); +} + +fn rms_and_diff(reference: &[f32], ours: &[f32], range: std::ops::Range) -> (f32, f32) { + let n = range.len() as f32; + let rms = (range.clone().map(|i| reference[i].powi(2)).sum::() / n).sqrt(); + let diff = (range.map(|i| (reference[i] - ours[i]).powi(2)).sum::() / n).sqrt(); + (rms, diff) +} + +#[test] +fn mjpeg_and_pcm() { + let Some((path, src)) = avi("avi_mjpeg_pcm.avi", &["-c:v", "mjpeg", "-q:v", "3", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]) else { return }; + check_info("mjpeg", &src, "Motion JPEG", "PCM 16-bit"); + let reference = reference_yuv(&path, "rgba").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [10, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap().to_rgba8(); + let r = &reference[k * frame..(k + 1) * frame]; + let mean = f.iter().zip(r).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::() as f64 / f.len() as f64; + // another JPEG decoder and chroma upsampling than ffmpeg's: close, not identical + assert!(mean < 6.0, "JPEG frame {k}: mean difference {mean}"); + } + // PCM: exactly ffmpeg's samples + let r = reference_audio(&path); + assert_eq!(ours(&src, 48_000), r[..48_000].to_vec()); +} + +#[test] +fn uncompressed_video_is_bit_exact() { + for (name, pix, codec, reference_fmt) in [ + ("avi_bgr24.avi", "bgr24", "Uncompressed RGB 24-bit", "rgba"), + ("avi_yuyv.avi", "yuyv422", "Uncompressed YUV 4:2:2", "yuv422p"), + ("avi_i420.avi", "yuv420p", "Uncompressed YUV 4:2:0", "yuv420p"), + ("avi_nv12.avi", "nv12", "Uncompressed YUV 4:2:0", "yuv420p"), + ] { + let Some((path, src)) = avi(name, &["-c:v", "rawvideo", "-pix_fmt", pix], &["-c:a", "pcm_s16le"]) else { return }; + check_info(name, &src, codec, "PCM 16-bit"); + let reference = reference_yuv(&path, reference_fmt).unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [7, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + let got = if reference_fmt == "rgba" { f.to_rgba8() } else { yuv_bytes(&f) }; + assert!(got == reference[k * frame..(k + 1) * frame], "{name}: frame {k} differs from ffmpeg"); + } + } +} + +#[test] +fn mp2_and_ac3_audio() { + for (name, codec, label) in [("avi_mp2.avi", "mp2", "MPEG Audio"), ("avi_ac3.avi", "ac3", "AC-3")] { + let Some((path, src)) = avi(name, &["-c:v", "mjpeg"], &["-c:a", codec, "-b:a", "192k"]) else { return }; + assert_eq!(src.info().audio_streams[0].codec, label, "{name}"); + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "{name}: tone {rms_ref}, difference {rms_diff}"); + } +} + +/// Dropped frames are stored as empty chunks: their slot shows the picture before. +#[test] +fn drop_frames_hold_the_previous_picture() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=160x120:r=25:d=2", + "-vf", + "select='not(eq(n\\,10))'", + "-fps_mode", + "passthrough", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + ]; + let Some(path) = fixture_path("avi_drop.avi", &args) else { return }; + let src = crate::open_bytes("avi_drop.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let rate = src.info().frame_rate(); + let at = |k: i64| src.video_frame(FrameRequest::full(rate.tick_of(k))).unwrap().to_rgba8(); + assert!(at(10) == at(9), "the dropped slot holds frame 9"); + assert!(at(11) != at(9)); +} + +/// DivX / Xvid has no decoder yet: the file opens, the codec is named, the picture reports it, and +/// the sound plays. +#[test] +fn mpeg4_part2_is_named_and_its_audio_plays() { + let Some((_, src)) = avi("avi_xvid.avi", &["-c:v", "mpeg4", "-vtag", "XVID"], &["-c:a", "pcm_s16le"]) else { return }; + assert_eq!(src.info().video.as_ref().unwrap().codec, "MPEG-4 Part 2 (DivX / Xvid)"); + let e = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap_err().to_string(); + assert!(e.contains("MPEG-4 Part 2"), "{e}"); + assert!(ours(&src, 4_800).iter().any(|s| s.abs() > 0.1)); +} + +/// A Motion JPEG frame without Huffman tables (the AVI1 convention) decodes with the standard ones: +/// exactly as the same frame with them. +#[test] +fn jpeg_without_huffman_tables_uses_the_standard_ones() { + let Some(path) = fixture_path( + "default_huffman.jpg", + &["-f", "lavfi", "-i", "testsrc2=s=160x120", "-frames:v", "1", "-c:v", "mjpeg", "-huffman", "default", "-q:v", "3"], + ) else { + return; + }; + let jpeg = std::fs::read(&path).unwrap(); + // remove every DHT segment + let mut stripped = jpeg[..2].to_vec(); + let mut at = 2; + while at + 4 <= jpeg.len() && jpeg[at] == 0xFF && jpeg[at + 1] != 0xDA { + let len = u16::from_be_bytes([jpeg[at + 2], jpeg[at + 3]]) as usize; + if jpeg[at + 1] != 0xC4 { + stripped.extend_from_slice(&jpeg[at..at + 2 + len]); + } + at += 2 + len; + } + stripped.extend_from_slice(&jpeg[at..]); + assert!(stripped.len() < jpeg.len() && !stripped.windows(2).any(|w| w == [0xFF, 0xC4])); + use crate::video::VideoDecoder; + let mut d = crate::video::MjpegDecoder; + let a = d.decode(&jpeg, 0).unwrap().remove(0).frame.to_rgba8(); + let b = d.decode(&stripped, 0).unwrap().remove(0).frame.to_rgba8(); + assert!(a == b); + // the inserted segment is 418 bytes and well-formed: 4 tables of 12, 12, 162, 162 codes + let with = crate::video::with_default_huffman(&stripped); + assert_eq!(with.len(), stripped.len() + 420); +} + +/// H.264 with B-frames copied from an MP4 whose time base is 1/600 (ffmpeg keeps it): 600 frame +/// slots a second, a picture in every 24th at 25 fps, empty drop chunks between. The pictures stay +/// at their times in presentation order, and the stream reports the rate they play at. +#[test] +fn copied_h264_keeps_its_timing_with_a_fine_time_base() { + let mp4 = + ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-video_track_timescale", "600"]; + let Some(src_mp4) = fixture_path("avi_copy_source.mp4", &mp4) else { return }; + let src_path = src_mp4.to_string_lossy().into_owned(); + let Some(path) = fixture_path("avi_copy_600.avi", &["-i", &src_path, "-c:v", "copy", "-bsf:v", "h264_mp4toannexb", "-f", "avi"]) else { return }; + let src = crate::open_bytes("avi_copy_600.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let v = src.info().video.clone().unwrap(); + assert_eq!(v.frame_rate.as_f64(), 25.0, "the rate the pictures play at, not the 600 slots"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + for k in [37usize, 3, 24, 49, 0, 13, 12, 11, 30] { + let at = filmcraft_time::Tick::from_rational(k as i64, 1, 25); + let f = src.video_frame(FrameRequest::full(at)).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "picture {k} at {:.2} s differs from ffmpeg", k as f64 / 25.0); + } + // between pictures, the one before holds + let mid = src.video_frame(FrameRequest::full(filmcraft_time::Tick::from_rational(1, 1, 50))).unwrap(); + assert!(yuv_bytes(&mid) == reference[0..frame]); +} diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index c1395e430..522b46b43 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,8 +109,9 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. -pub trait VideoSamples { +/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own +/// threads ([`isolated`]). +pub trait VideoSamples: Sync { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -152,6 +153,10 @@ struct State { bytes: usize, /// When a frame was last asked of this cache. last_used: Instant, + /// Presentation pts of the frame the last request wanted: eviction keeps a window around it + /// (not around the newest decoded picture). Walking a long GOP dumps pictures in decode + /// order; a window around those would throw away the playhead frame and force another walk. + pin: i64, /// Only background requests (thumbnails, [`filmcraft_media::cancel::with_background`]) have /// used the decoder since it was made: it is given up as soon as another background request /// is done, whatever the caches' recency. @@ -437,6 +442,57 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; +/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. +#[cfg(not(target_arch = "wasm32"))] +fn decode_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() + }) + .as_ref() +} + +/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting +/// without doing other rayon work meanwhile (#480). +/// +/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the +/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, +/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then +/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an +/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever +/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a +/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some +/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. +fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { + let ctx = filmcraft_media::cancel::Context::current(); + let job = move || ctx.run(f); + #[cfg(not(target_arch = "wasm32"))] + if let Some(pool) = decode_pool() { + if rayon::current_thread_index().is_none() { + return pool.install(job); + } + let slot = Mutex::new(Some(job)); + let waited = std::thread::scope(|scope| { + let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { + let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + job.map(|job| pool.install(job)) + }); + spawned.map(|h| h.join()) + }); + return match waited { + Ok(Ok(Some(r))) => r, + Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), + // no thread to wait on: decode here, as before + Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { + Some(job) => job(), + None => Err(CodecError::Decode("internal error while decoding".into())), + }, + }; + } + job() +} + impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -457,6 +513,7 @@ impl GopCache { drafts: Default::default(), bytes: 0, last_used: Instant::now(), + pin: i64::MIN, background: false, }), pool: pool.clone(), @@ -494,7 +551,11 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - let f = self.finish(f); + self.store_finished(st, pts, self.finish(f), draft); + } + + /// Cache a frame [`Self::finish`] has been applied to. + fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { st.last_used = Instant::now(); if draft { st.drafts.insert(pts); @@ -509,10 +570,19 @@ impl GopCache { st.bytes -= old.byte_size(); filmcraft_frame::pool::recycle(old); } - // evict frames far from the most recent (keep a window around the working position) + // Evict frames far from the request (pin), not the newest decode. A frame-threaded + // decoder can emit a burst past the playhead; pivoting on those pts used to drop the + // wanted picture and walk the GOP again. + let pivot = if st.pin != i64::MIN { st.pin } else { pts }; while st.bytes > budget && st.frames.len() > 2 { let (Some(&first), Some(&last)) = (st.frames.keys().next(), st.frames.keys().next_back()) else { break }; - let victim = if pts - first > last - pts { first } else { last }; + let mut victim = if pivot - first > last - pivot { first } else { last }; + if victim == pivot || victim == pts { + victim = if victim == first { last } else { first }; + if victim == pivot || victim == pts { + break; + } + } if let Some(v) = st.frames.remove(&victim) { st.drafts.remove(&victim); st.bytes -= v.byte_size(); @@ -583,6 +653,7 @@ impl GopCache { let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); let _decoding = DecodingGuard::enter(me); st.last_used = Instant::now(); + st.pin = want_pts; let background = filmcraft_media::cancel::background(); if let Some(f) = st.cached(want_pts, draft).cloned() { HITS.fetch_add(1, Ordering::Relaxed); @@ -616,6 +687,22 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } + // with the lock held: see `isolated` + let st = &mut *st; + isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) + } + + /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. + fn decode_until( + &self, + st: &mut State, + s: &dyn VideoSamples, + i: usize, + want_pts: i64, + late_before: Option, + draft: bool, + ) -> crate::Result> { + let n = s.count(); let mut key = decode_start(s, i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -669,14 +756,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(&mut st, out); + self.store_output(st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(&mut st, out); + self.store_output(st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -761,14 +848,17 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); + // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) + let res = res.map(|out| { + count_frames(&out); + out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() + }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - let res = res?; - count_frames(&res); - for d in res { - self.store(&mut st, d.pts, d.frame, d.draft); + for (pts, frame, draft) in res? { + self.store_finished(&mut st, pts, frame, draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -900,6 +990,77 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } + /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. + struct ParDec(Box); + + impl VideoDecoder for ParDec { + fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { + use rayon::prelude::*; + let rows: u64 = (0..64u32) + .into_par_iter() + .with_max_len(1) + .map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))) + .reduce(|| 0, u64::wrapping_add); + std::hint::black_box(rows); + self.0.decode(sample, pts) + } + fn flush(&mut self) -> Vec { + self.0.flush() + } + fn reset(&mut self) { + self.0.reset() + } + fn name(&self) -> &str { + "par" + } + } + + struct ParSamples(Samples); + + impl VideoSamples for ParSamples { + fn count(&self) -> usize { + self.0.count() + } + fn pts(&self, i: usize) -> i64 { + self.0.pts(i) + } + fn sync_before(&self, i: usize) -> usize { + self.0.sync_before(i) + } + fn sample_at(&self, t: i64) -> Option { + self.0.sample_at(t) + } + fn read(&self, i: usize) -> crate::Result> { + self.0.read(i) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(ParDec(self.0.make_decoder()?))) + } + } + + /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's + /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, + /// waiting inside it, picked up another frame request blocked on the cache's lock for good: + /// an export of rotated iPhone clips stopped making frames. + #[test] + fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { + use rayon::prelude::*; + let s = Arc::new(ParSamples(samples(240, 30, 4, false))); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for round in 0..40usize { + // export-like: many frame workers, frames of several GOPs of a fresh source at once + let c = GopCache::new(None).with_rotation(1); + (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { + let i = (k * 37 + round * 53) % 240; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + }); + } + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); + } + #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); @@ -919,19 +1080,27 @@ mod tests { fn intra_frames_decode_in_parallel_once_each() { let s = Arc::new(samples(64, 1, 0, true)); let c = Arc::new(GopCache::new(None)); - std::thread::scope(|scope| { - for t in 0..4usize { - let (s, c) = (s.clone(), c.clone()); - scope.spawn(move || { - for k in 0..64usize { - let i = (k * 7 + t * 16) % 64; - assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); - } - }); - } - }); - // a frame two threads miss at the same moment may decode twice; nothing more - assert!(s.decodes.load(Ordering::Relaxed) <= 64 + 4 * 4); + // First decode 64 distinct cold frames: each of the four workers owns + // 16 indices. Multiplication by seven permutes them without overlap. + // Then let every worker request all 64 cached frames in overlapping order. + // Concurrent misses for the same cold frame may legitimately decode twice, + // so they cannot support a scheduler-independent duplicate allowance. + for requests_per_worker in [16usize, 64] { + let start = Arc::new(std::sync::Barrier::new(4)); + std::thread::scope(|scope| { + for t in 0..4usize { + let (s, c, start) = (s.clone(), c.clone(), start.clone()); + scope.spawn(move || { + start.wait(); + for k in 0..requests_per_worker { + let i = (k * 7 + t * 16) % 64; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + } + }); + } + }); + assert_eq!(s.decodes.load(Ordering::Relaxed), 64, "cold frames decode once; warm requests hit the cache"); + } assert_eq!(s.resets.load(Ordering::Relaxed), 0); } @@ -1364,4 +1533,90 @@ mod tests { } assert_eq!(s.resets.load(Ordering::Relaxed), 1); } + + /// Many pictures in one GOP; a decoder that emits a burst past the wanted sample (frame- + /// threaded H.264). Eviction used to pivot on the newest pts and drop the playhead frame. + struct Burst { + n: usize, + /// Pictures held before a decode dumps them all. + burst: usize, + held: VecDeque, + resets: Arc, + decodes: Arc, + } + + impl VideoDecoder for Burst { + fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result> { + self.decodes.fetch_add(1, Ordering::Relaxed); + self.held.push_back(pts); + if self.held.len() < self.burst { + return Ok(Vec::new()); + } + Ok(self.held.drain(..).map(|p| picture((p, false))).collect()) + } + fn flush(&mut self) -> Vec { + self.held.drain(..).map(|p| picture((p, false))).collect() + } + fn reset(&mut self) { + self.resets.fetch_add(1, Ordering::Relaxed); + self.held.clear(); + } + fn name(&self) -> &str { + "burst" + } + } + + impl VideoSamples for Burst { + fn count(&self) -> usize { + self.n + } + fn pts(&self, i: usize) -> i64 { + i as i64 * 1000 + } + fn sync_before(&self, _i: usize) -> usize { + 0 + } + fn sample_at(&self, t: i64) -> Option { + let i = (t / 1000) as usize; + (i < self.n).then_some(i) + } + fn read(&self, i: usize) -> crate::Result> { + Ok(vec![i as u8]) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(Burst { n: self.n, burst: self.burst, held: VecDeque::new(), resets: self.resets.clone(), decodes: self.decodes.clone() })) + } + } + + #[test] + fn long_gop_seek_keeps_the_target_and_does_not_walk_twice() { + let s = Burst { n: 200, burst: 90, held: VecDeque::new(), resets: Default::default(), decodes: Default::default() }; + let mut c = GopCache::new(None); + c.budget = 0; + // Want a picture early in the GOP; the burst dumps 90 pictures at once (past MIN_FRAMES). + assert_eq!(index_of(&c.frame(&s, 10_000).expect("frame")), 10); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + let decodes = s.decodes.load(Ordering::Relaxed); + assert!(decodes <= 90 + 1, "walked {decodes} samples"); + // The wanted picture is still cached: asking again must not restart or re-feed. + assert_eq!(index_of(&c.frame(&s, 10_000).expect("cached")), 10); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + assert_eq!(s.decodes.load(Ordering::Relaxed), decodes); + // Playback continues from the same run. + assert_eq!(index_of(&c.frame(&s, 11_000).expect("frame")), 11); + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + } + + #[test] + fn long_gop_continue_does_not_restart_for_the_next_frame() { + let s = samples(300, 250, 8, false); + let mut c = GopCache::new(None); + c.budget = 0; + assert_eq!(index_of(&c.frame(&s, 80_000).expect("frame")), 80); + let resets = s.resets.load(Ordering::Relaxed); + let decodes = s.decodes.load(Ordering::Relaxed); + assert_eq!(index_of(&c.frame(&s, 81_000).expect("frame")), 81); + assert_eq!(s.resets.load(Ordering::Relaxed), resets); + assert!(s.decodes.load(Ordering::Relaxed) - decodes <= 8 + 2); + } } diff --git a/crates/codecs/src/hw.rs b/crates/codecs/src/hw.rs index 771de6476..b73b963c4 100644 --- a/crates/codecs/src/hw.rs +++ b/crates/codecs/src/hw.rs @@ -258,7 +258,7 @@ impl NalStreamInfo { bit_depth_chroma: sps.bit_depth_chroma, interlaced: !sps.frame_mbs_only, profile_idc: sps.profile_idc, - color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), + color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), par: sar_par(vui.sar), // as the software decoder's DPB (`max_reorder`) reorder: sps.max_num_reorder_frames().min(sps.max_dpb_frames()), @@ -304,7 +304,7 @@ impl NalStreamInfo { bit_depth_chroma: sps.bit_depth_chroma, interlaced: false, profile_idc: sps.ptl.profile_idc, - color: vui_color(crop.2, crop.3, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), + color: vui_color(crop.2, crop.3, vui.colour_primaries, vui.matrix_coefficients, vui.transfer_characteristics, vui.full_range), par: sar_par(vui.sar), reorder: sps.max_num_reorder as usize, }) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index f7de9e3d2..a6a937333 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -13,6 +13,8 @@ //! - [`MpegSource`]: MPEG-2 transport streams (`.ts`, `.m2ts`, `.mts`), program streams (`.mpg`, //! `.vob`, `.mod`) and MPEG-1/2 video elementary streams: MPEG-1/2, H.264 and HEVC video; MPEG //! audio, AAC (ADTS / LATM), AC-3 and LPCM. +//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM, +//! MP3 / MP2 and AC-3 audio. //! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …). //! - [`openers`]: the openers to register with the engine's media pool. @@ -20,6 +22,7 @@ pub mod apv; pub mod audio; +pub mod avi; pub mod gop; pub mod hw; mod hw_frame; @@ -36,6 +39,7 @@ use std::sync::{Arc, RwLock}; pub use apv::ApvSource; pub use audio::AudioFileSource; +pub use avi::AviSource; pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders}; pub use mkv::MkvSource; pub use mp4::Mp4Source; @@ -70,9 +74,10 @@ impl From for filmcraft_media::MediaError { } /// Our own (pure-Rust) decoders, in the order they are tried. -const BUILTIN_FACTORIES: [VideoDecoderFactory; 9] = [ +const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [ video::h264_factory, video::hevc_factory, + video::vp8_factory, video::vp9_factory, video::av1_factory, video::apv_factory, @@ -125,12 +130,22 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result< /// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and /// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio). pub fn openers() -> Vec { - vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener] + vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener] } fn reader_registry() -> &'static RwLock> { static R: std::sync::OnceLock>> = std::sync::OnceLock::new(); - R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener])) + R.get_or_init(|| { + RwLock::new(vec![ + mp4::reader_opener, + mkv::reader_opener, + mxf::reader_opener, + ogg::reader_opener, + mpeg::reader_opener, + apv::reader_opener, + avi::reader_opener, + ]) + }) } /// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on @@ -161,6 +176,15 @@ impl filmcraft_isobmff::ByteSource for Src { } } +impl filmcraft_avi::ByteSource for Src { + fn len(&self) -> u64 { + self.0.len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.0.read_at(offset, buf) + } +} + impl filmcraft_matroska::ByteSource for Src { fn len(&self) -> u64 { self.0.len() @@ -178,6 +202,8 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result, video: GopCache, - /// The alpha layer of a VP9 track with `AlphaMode` 1 (WebM transparency, #402). + /// The alpha layer of a VP8 / VP9 track with `AlphaMode` 1 (WebM transparency, #402). alpha: Option>, /// The playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`. audios: Vec, @@ -71,15 +71,14 @@ pub struct MkvSource { /// length (AAC, MPEG audio, AC-3, E-AC-3) therefore run on from one another, resynchronising to the /// timestamp only across gaps of more than half a packet (and more than two ticks); see /// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their -/// timestamps. +/// timestamps. Timestamps are taken with the exact `CodecDelay` off (see [`stamp_to_samples`]). fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec { use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples}; let Some(t) = file.tracks.get(ti) else { return Vec::new() }; if let Some(h) = opus_head(&t.codec) { return opus_starts(file, bytes, ti, &h, rate); } - let (n, d) = tb(t); - let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64; + let at = stamp_to_samples(t, rate, codec_delay_samples(t, rate)); let first = || file.read_sample(bytes, ti, 0).unwrap_or_default(); let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { @@ -98,18 +97,13 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec /// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed). /// -/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated -/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more -/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is -/// absent). +/// Starts are accumulated from each packet's TOC duration, resynchronising to the timestamp only +/// across real gaps (more than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's +/// pre-skip when it is absent). fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec { let Some(t) = file.tracks.get(ti) else { return Vec::new() }; - let (n, d) = tb(t); - let delay_ns = t.codec_delay_ns as i128; - // The demuxer's rounding of CodecDelay to ticks, undone here. - let delay_ticks = codec_delay_ticks(t); - let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 }; - let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip); + let skip = if t.codec_delay_ns > 0 { codec_delay_samples(t, rate) } else { head.pre_skip as i64 }; + let at = stamp_to_samples(t, rate, skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { stamp: at(s.pts), stamped: own_stamp(s), @@ -118,6 +112,28 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// A block timestamp (with the demuxer's rounded `CodecDelay` taken off) as a position in sample +/// frames at `rate`, `skip` frames of priming removed (#790). The demuxer's rounding is undone first: +/// `CodecDelay` is rarely a whole number of ticks (1024 AAC samples at 48 kHz are 21.333 ms, 256 +/// AC-3 samples at 44.1 kHz 5.805 ms), so starting the first packet at the rounded delay played the +/// whole track up to 16 samples late. +fn stamp_to_samples(t: &filmcraft_matroska::Track, rate: i64, skip: i64) -> impl Fn(i64) -> i64 { + let (n, d) = tb(t); + let delay_ticks = codec_delay_ticks(t); + move |pts: i64| { + let ticks = pts.saturating_add(delay_ticks) as i128; + let at = ticks.checked_mul(n as i128).and_then(|x| x.checked_mul(rate as i128)).map_or(ticks.signum() * i128::MAX, |x| x / d as i128); + i64::try_from(at).unwrap_or(if at < 0 { i64::MIN } else { i64::MAX }).saturating_sub(skip) + } +} + +/// `CodecDelay` in sample frames at `rate`, rounded to the nearest (the element holds whole +/// nanoseconds: 1024 samples at 48 kHz are stored as 21 333 333 ns). +fn codec_delay_samples(t: &filmcraft_matroska::Track, rate: i64) -> i64 { + let ns = (t.codec_delay_ns as i128).saturating_mul(rate.max(0) as i128); + i64::try_from(ns.saturating_add(500_000_000) / 1_000_000_000).unwrap_or(i64::MAX) +} + /// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before /// subtracting it from every timestamp of the track. fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { @@ -232,6 +248,7 @@ fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcC fn sample_entry(c: &Codec, private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>, w: u16, h: u16) -> Option { Some(match c { + Codec::Vp8 => SampleEntry::video(FourCc(*b"vp08"), CodecConfig::Unknown { fourcc: FourCc(*b"vp08"), raw: Vec::new() }, w, h), Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h), Codec::Av1 { av1c } => SampleEntry::video(FourCc(*b"av01"), CodecConfig::Av1(filmcraft_isobmff::Av1Config::parse(av1c).unwrap_or_default()), w, h), Codec::Apv { apvc } => SampleEntry::apv(filmcraft_isobmff::ApvConfig::parse(apvc).unwrap_or_default(), w, h), @@ -358,7 +375,11 @@ impl MkvSource { let rate = match t.default_duration_ns { Some(ns) if ns > 0 => FrameRate::from_f64(1e9 / ns as f64), _ => { - let mut d: Vec = t.samples.windows(2).take(240).map(|p| (p[1].pts - p[0].pts).abs()).filter(|d| *d > 0).collect(); + // blocks are stored in decode order: with B-frames, neighbours in the file are + // up to a few frames apart, so step between presentation times (#923) + let mut pts: Vec = t.samples.iter().take(241).map(|s| s.pts).collect(); + pts.sort_unstable(); + let mut d: Vec = pts.windows(2).map(|p| p[1].saturating_sub(p[0])).filter(|d| *d > 0).collect(); d.sort_unstable(); let (n, dd) = tb(t); let step = d.get(d.len() / 2).copied().unwrap_or(1).max(1) as f64 * n as f64 / dd as f64; @@ -429,9 +450,11 @@ impl MkvSource { Some(MkvAudio { track: i, state, starts, preroll }) }) .collect(); - // WebM transparency: VP9 alpha in each block's BlockAdditional (ID 1) + // WebM transparency: VP8 / VP9 alpha in each block's BlockAdditional (ID 1) let alpha = vtrack.and_then(|i| file.tracks.get(i)).filter(|t| { - matches!(t.codec, Codec::Vp9 { .. }) && t.video.as_ref().is_some_and(|v| v.alpha_mode != 0) && t.samples.iter().any(|s| s.addition.is_some()) + matches!(t.codec, Codec::Vp8 | Codec::Vp9 { .. }) + && t.video.as_ref().is_some_and(|v| v.alpha_mode != 0) + && t.samples.iter().any(|s| s.addition.is_some()) }); let alpha = alpha.map(|t| Arc::new(crate::webm_alpha::AlphaLayer::new(bytes.clone(), t.samples.iter().map(|s| (s.pts, s.addition))))); Ok(Self { info, bytes, file, vtrack, ventry, video: GopCache::new(explicit_color).with_rotation(rotation), alpha, audios }) @@ -441,6 +464,21 @@ impl MkvSource { self.file.read_sample(&self.bytes, track, i).map_err(|e| CodecError::Container(e.to_string())) } + /// Whether sample `i` of a VP8 track is a hidden frame (`show_frame` 0, RFC 6386 9.1): only the + /// first byte of the frame is read. + fn vp8_hidden(&self, track: usize, i: usize) -> bool { + let Some(t) = self.file.tracks.get(track) else { return false }; + let Some(s) = t.samples.get(i) else { return false }; + let first = match t.frame_prefix(u64::from(s.size)).first() { + Some(&b) => Some(b), + None => { + let mut b = [0u8; 1]; + (s.size > 0 && self.bytes.0.read_at(s.offset, &mut b).is_ok()).then_some(b[0]) + } + }; + first.is_some_and(|b| b & 0x10 == 0) + } + fn audio_decoder(&self, c: &Codec, rate: u32) -> crate::Result { use symphonia::core::codecs::{CODEC_TYPE_FLAC, CODEC_TYPE_MP3, CODEC_TYPE_VORBIS}; match c { @@ -534,7 +572,20 @@ impl VideoSamples for MkvVideo<'_> { self.src.file.tracks[self.track].sync_sample_before(i) } fn sample_at(&self, t: i64) -> Option { - self.src.file.tracks[self.track].sample_at_pts(t) + let track = &self.src.file.tracks[self.track]; + let i = track.sample_at_pts(t)?; + if !matches!(track.codec, Codec::Vp8) { + return Some(i); + } + // VP8 has no superframes: an encoder stores a hidden (altref) frame as a block of its own + // with the time of the frame that follows it, which vpxenc moves 1 ms later. The hidden + // frame shows nothing, so its time asks for that next shown frame (as decoders that pass + // the two on as one packet show it). + let mut k = i; + while k + 1 < track.samples.len() && k < i + 8 && self.src.vp8_hidden(self.track, k) { + k += 1; + } + Some(k) } fn read(&self, i: usize) -> crate::Result> { self.src.read(self.track, i) diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index c7ea9d069..9d6f0eaf7 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -540,7 +540,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option None => ap.sample_rate as u32, } }, - if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels }, + // the configuration is an index (7 and 12 are both eight channels), not a count + if a.channels() > 0 { a.channels() } else { ap.channels }, None, ), CodecConfig::Pcm(p) => (p.sample_rate as u32, p.channels, Some(p.bits as u32)), @@ -577,6 +578,7 @@ fn codec_label(c: &CodecConfig) -> String { ), CodecConfig::Hevc(_) => "HEVC".into(), CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile), + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"vp08" => "VP8".into(), CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")), CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) { Some(p) => p.name().into(), diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index ca72c88b6..e2cd09ba1 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -7,8 +7,9 @@ //! first random-access picture; display order comes from the PTS (or, without timestamps, the //! GOP and temporal reference). Leading pictures of an open GOP decode from the previous GOP. //! - Audio: MPEG audio layers I-III (symphonia), AAC in ADTS or LATM (our decoder), AC-3 (our -//! decoder), Blu-ray / AVCHD and DVD LPCM. Audio frames are placed on the timeline from their -//! PTS relative to the first video frame, sample-contiguously (re-anchored only at gaps). +//! decoder), Blu-ray / AVCHD and DVD LPCM. Every audio PID is a stream (capped). Audio frames +//! are placed on the timeline from their PTS relative to the first video frame, +//! sample-contiguously (re-anchored only at gaps). use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; @@ -104,7 +105,8 @@ pub struct MpegSource { es: Option<(Arc<[u8]>, Vec>)>, video: Option, gop: GopCache, - audio: Option, + /// Playable audio tracks in file order: `info.audio_streams[k]` describes `audios[k]`. + audios: Vec, /// Why the video or audio stream cannot be decoded. unsupported_video: Option, unsupported_audio: Option, @@ -201,8 +203,8 @@ impl MpegSource { log::warn!("{name}: {w}"); } let vstream = file.find(Kind::Video, |c| vkind(c).is_some()).or_else(|| file.find(Kind::Video, |_| true)); - let astream = file.find(Kind::Audio, audio_supported).or_else(|| file.find(Kind::Audio, |_| true)); - if vstream.is_none() && astream.is_none() { + let has_audio = file.streams.iter().any(|s| s.kind() == Kind::Audio && !s.units.is_empty()); + if vstream.is_none() && !has_audio { return Err(CodecError::Unsupported(format!("{}: no video or audio streams", file.format.name()))); } let mut src = MpegSource { @@ -221,7 +223,7 @@ impl MpegSource { es: None, video: None, gop: GopCache::new(None), - audio: None, + audios: Vec::new(), unsupported_video: None, unsupported_audio: None, }; @@ -256,7 +258,7 @@ impl MpegSource { src.video = Some(track); src.describe_video(rate_from_pts)?; if let Some(file) = src.file.take() { - src.setup_audio(file, astream, origin); + src.setup_audio(file, origin); } } None => { @@ -273,12 +275,12 @@ impl MpegSource { bitrate: None, hdr: None, }); - src.setup_audio(file, astream, None); + src.setup_audio(file, None); } } } else { src.info.kind = MediaKind::AudioOnly; - src.setup_audio(file, astream, None); + src.setup_audio(file, None); } Ok(src) } @@ -326,7 +328,7 @@ impl MpegSource { es: Some((all, aus)), video: Some(track), gop: GopCache::new(None), - audio: None, + audios: Vec::new(), unsupported_video: None, unsupported_audio: None, }; @@ -347,15 +349,15 @@ impl MpegSource { /// Timeline position (samples at the audio stream's rate) of the first audio frame, and the /// number of samples in the audio stream. pub fn audio_extent(&self) -> Option<(i64, i64)> { - let a = self.audio.as_ref()?; + let a = self.audios.first()?; let first = *a.starts.first()?; let end = a.starts.last()? + *a.lens.last()? as i64; Some((first, end - first)) } - /// The demuxed stream indices of the presented video and audio. + /// The demuxed stream indices of the presented video and first audio. pub fn stream_indices(&self) -> (Option, Option) { - (self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audio.as_ref().map(|a| a.stream)) + (self.video.as_ref().filter(|_| self.es.is_none()).map(|v| v.stream), self.audios.first().map(|a| a.stream)) } /// Presentation timestamps (90 kHz) of the presented video frames in display order. Frames @@ -475,15 +477,31 @@ impl MpegSource { Ok(()) } - /// Choose and index the audio stream; `origin`: PTS of the first video frame. - fn setup_audio(&mut self, file: File, astream: Option, origin: Option) { - let track = astream.and_then(|ai| self.audio_track(&file, ai, origin)); + /// Index every audio stream the container carries (capped); `origin`: PTS of the first video frame. + fn setup_audio(&mut self, file: File, origin: Option) { + let indices: Vec = file + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.kind() == Kind::Audio && !s.units.is_empty()) + .map(|(i, _)| i) + .take(filmcraft_media::MAX_AUDIO_STREAMS) + .collect(); + let mut tracks = Vec::new(); + for ai in indices { + if let Some(t) = self.audio_track(&file, ai, origin) { + tracks.push(t); + } + } self.file = Some(file); - self.audio = track; + self.audios = tracks; } - /// Mark the audio stream as not decodable (it still shows in the media info). + /// Mark the audio stream as not decodable (it still shows in the media info when nothing plays). fn audio_unsupported(&mut self, codec_name: &str, why: String) -> Option { + if !self.info.audio_streams.is_empty() { + return None; + } self.unsupported_audio = Some(why); self.info.audio_streams = vec![AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: format!("{codec_name} (unsupported)"), bits_per_sample: None }]; @@ -594,14 +612,16 @@ impl MpegSource { next = Some(s + n as i64); } let total = next.unwrap_or(0).max(0); - if self.info.video.is_none() || self.unsupported_video.is_some() { + if self.unsupported_audio.take().is_some() { + self.info.audio_streams.clear(); + } + if self.info.audio_streams.is_empty() && (self.info.video.is_none() || self.unsupported_video.is_some()) { self.info.duration = Tick::from_units(total, rate.max(1) as i64); if self.info.video.is_none() { self.info.kind = MediaKind::AudioOnly; } } - self.info.audio_streams = - vec![AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }]; + self.info.audio_streams.push(AudioStreamInfo { sample_rate: rate.max(1), channels: channels.max(1) as u32, codec: codec_name, bits_per_sample: bits }); Some(AudioTrack { stream: ai, codec, @@ -783,10 +803,16 @@ impl MediaSource for MpegSource { } fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { - if let Some(why) = &self.unsupported_audio { + self.audio_stream(0, start, frames, sample_rate) + } + + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + if self.audios.is_empty() + && let Some(why) = &self.unsupported_audio + { return Err(MediaError::Unsupported(why.clone())); } - let a = self.audio.as_ref().ok_or(MediaError::NoStream("audio"))?; + let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?; let ch = a.channels; let ratio = a.rate as f64 / sample_rate as f64; let exact = a.rate == sample_rate; @@ -852,6 +878,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe #[cfg(test)] mod tests { use super::*; + use filmcraft_media::MediaSource; /// A mutated transport stream whose PTS span gave ~0 fps panicked on open with "attempt to /// divide by zero" (`FrameRate::tick_of`). @@ -868,6 +895,118 @@ mod tests { assert_eq!(stream_rate(None, Some(50.0)), FrameRate::from_f64(50.0)); } + /// MPEG-1 layer II frame, 48 kHz 192 kb/s (576 bytes). + fn mp2_frame(k: u8) -> Vec { + let mut v = vec![0xFF, 0xFD, 0xA4, 0x00]; + v.extend((0..572).map(|i| (i as u8) ^ k)); + v + } + + fn ts_bytes(prefix: u8, v: i64) -> [u8; 5] { + let v = v & ((1 << 33) - 1); + [(prefix << 4) | (((v >> 30) & 7) as u8) << 1 | 1, (v >> 22) as u8, (((v >> 15) & 0x7F) as u8) << 1 | 1, (v >> 7) as u8, ((v & 0x7F) as u8) << 1 | 1] + } + + fn pes(stream_id: u8, pts: Option, payload: &[u8]) -> Vec { + let mut h = Vec::new(); + if let Some(p) = pts { + h.extend(ts_bytes(2, p)); + } + let flags = if pts.is_some() { 0x80 } else { 0 }; + let len = 3 + h.len() + payload.len(); + let mut v = vec![0, 0, 1, stream_id, (len >> 8) as u8, len as u8, 0x80, flags, h.len() as u8]; + v.extend(h); + v.extend_from_slice(payload); + v + } + + /// Tiny TS muxer: PAT, PMT and PES packets in 188-byte packets. + struct TsMux { + out: Vec, + cc: std::collections::HashMap, + } + + impl TsMux { + fn packet(&mut self, pid: u16, pusi: bool, payload: &[u8]) -> usize { + let cc = self.cc.entry(pid).or_insert(0); + let mut p = vec![0x47, (pusi as u8) << 6 | (pid >> 8) as u8, pid as u8]; + let n = payload.len().min(184); + if n < 184 { + let al = 183 - n; + p.push(0x30 | *cc); + p.push(al as u8); + if al > 0 { + p.push(0); + p.extend(std::iter::repeat_n(0xFF, al - 1)); + } + } else { + p.push(0x10 | *cc); + } + *cc = (*cc + 1) & 15; + p.extend_from_slice(&payload[..n]); + self.out.extend(&p); + n + } + fn section(&mut self, pid: u16, body: &[u8]) { + let mut s = body.to_vec(); + s.extend(crc32_mpeg(&s).to_be_bytes()); + let mut payload = vec![0]; + payload.extend(s); + self.packet(pid, true, &payload); + } + fn psi(&mut self, streams: &[(u8, u16)]) { + let pat = [0x00, 0xB0, 13, 0, 1, 0xC1, 0, 0, 0, 1, 0xE1, 0x00]; + self.section(0, &pat); + let mut pmt = vec![0x02, 0xB0, 0, 0, 1, 0xC1, 0, 0, 0xE1, 0x00, 0xF0, 0]; + for &(st, pid) in streams { + pmt.extend([st, 0xE0 | (pid >> 8) as u8, pid as u8, 0xF0, 0]); + } + let len = pmt.len() - 3 + 4; + pmt[1] = 0xB0 | (len >> 8) as u8; + pmt[2] = len as u8; + self.section(0x100, &pmt); + } + fn pes(&mut self, pid: u16, data: &[u8]) { + let mut rest = data; + let mut first = true; + while !rest.is_empty() { + let n = self.packet(pid, first, rest); + rest = &rest[n..]; + first = false; + } + } + } + + fn crc32_mpeg(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + crc ^= (b as u32) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { (crc << 1) ^ 0x04C1_1DB7 } else { crc << 1 }; + } + } + crc + } + + /// Dual MPEG audio in one transport stream: import used to keep only the first PID. + #[test] + fn every_audio_pid_is_a_stream() { + let mut mux = TsMux { out: Vec::new(), cc: Default::default() }; + mux.psi(&[(0x03, 0x1100), (0x03, 0x1101)]); + for k in 0..4u16 { + mux.pes(0x1100, &pes(0xC0, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8))); + mux.pes(0x1101, &pes(0xC1, Some(90_000 + 2160 * k as i64), &mp2_frame(k as u8 + 16))); + } + let source = MpegSource::open("dual.ts", mux.out.into()).unwrap(); + assert_eq!(source.info().audio_streams.len(), 2, "{:?}", source.info().audio_streams); + assert_eq!(source.info().audio_streams.iter().map(|a| a.sample_rate).collect::>(), vec![48_000, 48_000]); + let a0 = source.audio(0, 64, 48_000).unwrap(); + let a1 = source.audio_stream(1, 0, 64, 48_000).unwrap(); + assert_eq!(a0.channels.len(), 2); + assert_eq!(a1.channels.len(), 2); + assert!(source.audio_stream(2, 0, 64, 48_000).is_err()); + } + #[test] fn presentation_drops_leading_pictures_and_orders_by_key() { // decode order I(2) B(0) B(1) P(5) B(3) B(4): the leading B pictures are not presented diff --git a/crates/codecs/src/stream_color.rs b/crates/codecs/src/stream_color.rs index d01d6a640..e89a110b4 100644 --- a/crates/codecs/src/stream_color.rs +++ b/crates/codecs/src/stream_color.rs @@ -42,6 +42,8 @@ pub fn from_codec_config(codec: &CodecConfig) -> Option { CodecConfig::Avc(c) => avc(c), CodecConfig::Hevc(c) => hevc(c), CodecConfig::Av1(c) => av1(&c.config_obus), + // VP8 has no colour signalling: its one colour space is BT.601 YUV, studio swing (RFC 6386 9.2) + CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"vp08" => Some(ColorCodes { primaries: 2, transfer: 2, matrix: 6, full_range: Some(false) }), _ => None, } } diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 751b18fdf..52af0574c 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use filmcraft_media::FrameRequest; use filmcraft_time::{TICKS_PER_SECOND, Tick}; -fn ffmpeg() -> Option<&'static str> { +pub(crate) fn ffmpeg() -> Option<&'static str> { ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) } @@ -224,20 +224,63 @@ fn matroska_hevc_flac() { check_mkv("blue_hevc_flac.mkv", &["-c:v", "libx265", "-x265-params", "log-level=error", "-pix_fmt", "yuv420p"], &["-c:a", "flac"]); } +/// FLAC in MP4 (`fLaC` + `dfLa`): the decoder gets the STREAMINFO from `dfLa` (it was given +/// nothing, and every FLAC track in an MP4 failed with "missing extra data"). +#[test] +fn mp4_flac_audio() { + let args = ["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=2", "-c:a", "flac", "-strict", "-2"]; + let Some(b) = fixture("tone_flac.mp4", &args) else { return }; + let src = crate::open_bytes("tone_flac.mp4", b).expect("open"); + let a = src.audio(24_000, 4800, 48_000).expect("audio"); + let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}"); +} + #[test] fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); } +/// `bytes` with its first `DefaultDuration` element (0x23E383) overwritten by an EBML Void of the +/// same length, as a muxer that leaves the optional element out would write the track. +fn without_default_duration(bytes: &[u8]) -> Vec { + let mut out = bytes.to_vec(); + let at = out.windows(3).position(|w| w == [0x23, 0xE3, 0x83]).expect("DefaultDuration"); + // ffmpeg writes a one-byte size (0x80 | n) + let total = 4 + (out[at + 3] & 0x7F) as usize; + out[at] = 0xEC; + out[at + 1] = 0x80 | (total - 2) as u8; + out[at + 2..at + total].fill(0); + out +} + +#[test] +fn matroska_reordered_video_without_default_duration_keeps_its_rate() { + // blocks are in decode order, so B-frames put file neighbours 2–3 frames apart (#923) + for (bf, name) in [(0, "rate25_bf0.mkv"), (1, "rate25_bf1.mkv"), (2, "rate25_bf2.mkv")] { + let params = format!("bframes={bf}:b-adapt=0:b-pyramid=0:keyint=250:scenecut=0"); + let args = + ["-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "25", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", ¶ms, "-an"]; + let Some(control) = fixture(name, &args) else { return }; + let stripped = without_default_duration(&control); + for (label, bytes) in [("with DefaultDuration", control.to_vec()), ("without", stripped)] { + let src = crate::open_bytes(name, bytes.into()).expect("open"); + let rate = src.info().video.as_ref().expect("video").frame_rate; + assert_eq!((rate.num, rate.den), (25, 1), "{bf} B-frames, {label}"); + assert_eq!(src.info().duration, Tick::from_rational(1, 1, 1), "{bf} B-frames, {label}"); + } + } +} + /// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing /// (`None` when ffmpeg or the encoder is unavailable). -fn fixture_path(name: &str, args: &[&str]) -> Option { +pub(crate) fn fixture_path(name: &str, args: &[&str]) -> Option { fixture(name, args)?; Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs").join(name)) } /// ffmpeg's decode of `src` as raw planar video (the oracle). -fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { +pub(crate) fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { let name = format!("{}.{pix_fmt}.yuv", src.file_name()?.to_str()?); let path = src.to_str()?; let b = fixture(&name, &["-i", path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt])?; @@ -245,7 +288,7 @@ fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { } /// Planes of a decoded YUV frame as little-endian bytes (ffmpeg rawvideo layout). -fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { +pub(crate) fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { match &f.data { filmcraft_frame::PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(), filmcraft_frame::PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(), @@ -449,6 +492,119 @@ fn vp9_webm_alpha_layer_matches_libvpx() { } } +/// #432: VP8 in WebM with hidden altref frames (vpxenc, two-pass with ARNR: 11 of its 161 +/// frames are not shown). Frames requested out of order (seeks back and forth, then sequential +/// playback across the hidden frames) are sample-exact against libvpx's decode, and the stream's +/// colour is BT.601 (RFC 6386 9.2). Skipped without ffmpeg or vpxenc. +#[test] +fn vp8_webm_random_access_matches_libvpx() { + let frames = 150usize; + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs"); + let path = dir.join("vp8_hidden_altref.webm"); + if !path.exists() { + let Some(y4m) = + fixture_path("mandelbrot_352x288_150.y4m", &["-f", "lavfi", "-i", "mandelbrot=s=352x288:r=25", "-frames:v", "150", "-pix_fmt", "yuv420p"]) + else { + return; + }; + let tmp = dir.join(format!("vp8_hidden_altref.{}.webm", std::process::id())); + let enc = Command::new("vpxenc") + .args(["--codec=vp8", "--passes=2", "--auto-alt-ref=1", "--lag-in-frames=25", "--arnr-maxframes=7", "--arnr-strength=5"]) + .args(["--target-bitrate=300", "--kf-max-dist=150", "-q", "-o"]) + .arg(&tmp) + .arg(&y4m) + .status(); + if !enc.is_ok_and(|s| s.success()) { + eprintln!("vpxenc not available; skipping"); + return; + } + let _ = std::fs::rename(&tmp, &path); + } + let p = path.to_string_lossy().into_owned(); + let Some(reference) = + fixture("vp8_hidden_altref.webm.yuv", &["-c:v", "libvpx", "-i", &p, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]) + else { + return; + }; + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = crate::open_bytes("vp8_hidden_altref.webm", bytes).unwrap(); + let info = src.info().clone(); + assert_eq!(info.container, "WebM"); + let v = info.video.as_ref().unwrap(); + assert_eq!(v.codec, "VP8"); + assert_eq!((v.width, v.height), (352, 288)); + assert_eq!(v.color.matrix, filmcraft_color::Matrix::Bt601); + assert_eq!(reference.len(), frames * 352 * 288 * 3 / 2); + let frame_len = reference.len() / frames; + let rate = info.frame_rate(); + let order: Vec = [147, 3, 90, 26, 24, 149, 0, 112, 61].into_iter().chain(40..frames).collect(); + for k in order { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + assert_eq!((f.width, f.height), (352, 288)); + assert_eq!(f.color.matrix, filmcraft_color::Matrix::Bt601); + assert!(yuv_bytes(&f) == reference[k * frame_len..(k + 1) * frame_len], "frame {k} differs from libvpx"); + } +} + +/// #432 / #402: a VP8 WebM with an alpha layer (`AlphaMode` 1, a second VP8 stream in each +/// block's `BlockAdditional`, as browsers and GIF converters write transparent WebM) decodes +/// with that alpha; frames requested out of order match libvpx exactly, colour and alpha. +#[test] +fn vp8_webm_alpha_layer_matches_libvpx() { + let (w, h, frames) = (176usize, 144usize, 30usize); + let Some(path) = fixture_path( + "alpha_vp8.webm", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=s=176x144:r=25,format=yuva420p,geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':a='mod(X*2+N*7\\,256)'", + "-frames:v", + "30", + "-c:v", + "libvpx", + "-pix_fmt", + "yuva420p", + "-g", + "10", + "-auto-alt-ref", + "0", + "-b:v", + "600k", + ], + ) else { + return; + }; + let src_path = path.to_string_lossy().into_owned(); + let Some(reference) = + fixture("alpha_vp8.webm.yuva420p.yuv", &["-c:v", "libvpx", "-i", &src_path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuva420p"]) + else { + return; + }; + let (luma, chroma) = (w * h, (w / 2) * (h / 2)); + let frame_len = luma * 2 + chroma * 2; + if reference.len() != frame_len * frames { + eprintln!("libvpx reference has {} bytes, not {}; skipping", reference.len(), frame_len * frames); + return; + } + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = crate::open_bytes("alpha_vp8.webm", bytes).unwrap(); + let info = src.info().clone(); + assert_eq!(info.container, "WebM"); + assert!(info.video.as_ref().unwrap().has_alpha); + let rate = info.frame_rate(); + let order: Vec = [29, 3, 17, 12, 0, 25, 21, 9].into_iter().chain(10..frames).collect(); + for k in order { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + let r = &reference[k * frame_len..(k + 1) * frame_len]; + let filmcraft_frame::PixelData::Yuv8 { planes, alpha, .. } = &f.data else { panic!("frame {k}: not 8-bit YUV: {}", f.format_label()) }; + assert_eq!(planes[0].as_slice(), &r[..luma], "frame {k}: luma"); + assert_eq!(planes[1].as_slice(), &r[luma..luma + chroma], "frame {k}: U"); + let alpha = alpha.as_ref().unwrap_or_else(|| panic!("frame {k}: no alpha plane")); + assert_eq!(alpha.as_slice(), &r[luma + 2 * chroma..], "frame {k}: alpha"); + } +} + /// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start /// decoding at a real VP9 key frame. #[test] diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index fa8eb9de3..28b3f4c66 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -140,11 +140,14 @@ pub(crate) fn tight_plane(src: Vec, stride: usize, w: usize, h: usiz } /// Colour of an H.264 / HEVC picture from its VUI code points (ITU-T H.273), as the decoders -/// report it: BT.709 by default with the matrix guessed from the size, the signalled matrix and -/// transfer when known, and full range when flagged. Shared by the software and hardware -/// decoders so the two are interchangeable. -pub fn vui_color(width: u32, height: u32, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo { +/// report it: BT.709 by default with the matrix guessed from the size, the signalled primaries, +/// matrix and transfer when known, and full range when flagged. Shared by the software and +/// hardware decoders so the two are interchangeable. +pub fn vui_color(width: u32, height: u32, primaries: u8, matrix: u8, transfer: u8, full_range: bool) -> filmcraft_color::ColorInfo { let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(width, height), ..filmcraft_color::ColorInfo::REC709 }; + if let Some(p) = primaries_from_code(primaries) { + color.primaries = p; + } if let Some(m) = filmcraft_color::Matrix::from_code(matrix) { color.matrix = m; } @@ -178,13 +181,69 @@ pub fn hvcc_length_size_and_tid(hvcc: &[u8]) -> (usize, Option) { /// A factory returns `None` when it does not handle the entry. pub type VideoDecoderFactory = fn(&SampleEntry) -> Option>>; +/// The typical Huffman tables of ITU-T T.81 Annex K.3 as one DHT segment (luminance and +/// chrominance, DC and AC): what a Motion JPEG frame without its own tables is coded with (AVI's +/// "MJPG", the AVI1 convention of capture cards and cameras). +const DEFAULT_DHT: &[u8] = &[ + 0xFF, 0xC4, 0x01, 0xA2, // + 0x00, 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x01, 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x10, 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7D, // + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, // + 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, // + 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, // + 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, // + 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, // + 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, // + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, // + 0x11, 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77, // + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, // + 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, // + 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, // + 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, // + 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, // + 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, // + 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, +]; + +/// A JPEG with the [`DEFAULT_DHT`] inserted before its first scan when it defines no Huffman +/// table of its own (else unchanged, borrowed). +pub fn with_default_huffman(jpeg: &[u8]) -> std::borrow::Cow<'_, [u8]> { + use std::borrow::Cow; + if jpeg.get(..2) != Some(&[0xFF, 0xD8]) { + return Cow::Borrowed(jpeg); + } + // walk the marker segments up to the start of scan + let mut at = 2usize; + while let (Some(&0xFF), Some(&m)) = (jpeg.get(at), jpeg.get(at + 1)) { + match m { + 0xFF => at += 1, // fill byte + 0xC4 => return Cow::Borrowed(jpeg), + 0xDA => { + let mut out = Vec::with_capacity(jpeg.len() + DEFAULT_DHT.len()); + out.extend_from_slice(jpeg.get(..at).unwrap_or_default()); + out.extend_from_slice(DEFAULT_DHT); + out.extend_from_slice(jpeg.get(at..).unwrap_or_default()); + return Cow::Owned(out); + } + 0xD0..=0xD9 | 0x01 => at += 2, + _ => { + let Some(len) = jpeg.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]]))) else { break }; + at += 2 + len; + } + } + } + Cow::Borrowed(jpeg) +} + /// Motion-JPEG / Photo-JPEG (each sample is a complete JPEG). pub struct MjpegDecoder; impl VideoDecoder for MjpegDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let sample = with_default_huffman(sample); // mjpa samples may contain two fields; decode the first image (field-merging lands with interlace support). - let img = image::load_from_memory_with_format(sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; + let img = image::load_from_memory_with_format(&sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; let rgba = img.to_rgba8(); let (w, h) = rgba.dimensions(); Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(w, h, rgba.into_raw()), draft: false }]) @@ -250,7 +309,7 @@ impl H264Decoder { let y = tight_plane(p.y, p.y_stride, w, h); let u = tight_plane(p.u, p.uv_stride, cw, ch); let v = tight_plane(p.v, p.uv_stride, cw, ch); - let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range); + let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range); let par = sar_par(p.sar); let frame = VideoFrame { width: p.width, @@ -371,7 +430,7 @@ impl HevcDecoder { alpha: None, }, }; - let color = vui_color(p.width, p.height, p.color.matrix, p.color.transfer, p.color.full_range); + let color = vui_color(p.width, p.height, p.color.primaries, p.color.matrix, p.color.transfer, p.color.full_range); let par = sar_par(p.sar); let frame = VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO }; DecodedFrame { pts: p.pts, frame, draft: p.draft } @@ -547,6 +606,60 @@ pub fn vp9_factory(e: &SampleEntry) -> Option>> { } } +/// Our pure-Rust VP8 decoder (RFC 6386; #432): WebM / Matroska `V_VP8` and MP4 `vp08`. +pub struct Vp8Decoder { + dec: filmcraft_vp8::Decoder, +} + +impl Default for Vp8Decoder { + fn default() -> Self { + Self::new() + } +} + +impl Vp8Decoder { + pub fn new() -> Self { + Self { dec: filmcraft_vp8::Decoder::new() } + } + + fn convert(p: filmcraft_vp8::Picture) -> DecodedFrame { + use filmcraft_frame::{Chroma, PixelData}; + use std::sync::Arc; + // color_space 0 is BT.601 YUV (RFC 6386 9.2), studio swing; a container's Colour wins + let color = filmcraft_color::ColorInfo { matrix: filmcraft_color::Matrix::Bt601, ..filmcraft_color::ColorInfo::REC709 }; + let data = PixelData::Yuv8 { planes: [Arc::new(p.y), Arc::new(p.u), Arc::new(p.v)], chroma: Chroma::C420, alpha: None }; + let frame = VideoFrame { width: p.width, height: p.height, data, color, par: (1, 1), pts: filmcraft_time::Tick::ZERO }; + DecodedFrame { pts: p.pts, frame, draft: false } + } +} + +impl VideoDecoder for Vp8Decoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let pic = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + Ok(pic.into_iter().map(Self::convert).collect()) + } + fn flush(&mut self) -> Vec { + // no reordering: every picture comes out with its frame + Vec::new() + } + fn reset(&mut self) { + self.dec.reset(); + } + fn name(&self) -> &str { + "FilmCraft VP8" + } + fn is_random_access(&self, sample: &[u8]) -> Option { + Some(filmcraft_vp8::is_keyframe(sample)) + } + fn is_disposable(&self, sample: &[u8]) -> bool { + self.dec.is_disposable(sample) + } +} + +pub fn vp8_factory(e: &SampleEntry) -> Option>> { + (e.format.0 == *b"vp08").then(|| Ok(Box::new(Vp8Decoder::new()) as Box)) +} + /// Our AV1 decoder. The `av1C` configuration OBUs (sequence header) are fed before the first /// sample and again after every reset. pub struct Av1Decoder { @@ -1035,3 +1148,165 @@ mod disposable_tests { assert!(!hevc_disposable(&sample(&[&nal(0, 1)]), 4, None)); } } + +/// Uncompressed video layouts (AVI `BI_RGB` and YUV FourCCs). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RawFormat { + /// 24-bit B, G, R; rows padded to 4 bytes. + Bgr24, + /// 32-bit B, G, R, X. + Bgrx32, + /// Packed 4:2:2: Y0 U Y1 V (`YUY2`, `YUYV`). + Yuy2, + /// Packed 4:2:2: U Y0 V Y1 (`UYVY`, `2vuy`). + Uyvy, + /// Planar 4:2:0: Y, U, V (`I420`, `IYUV`). + I420, + /// Planar 4:2:0: Y, V, U (`YV12`). + Yv12, + /// Y plane, interleaved U V at half resolution (`NV12`). + Nv12, + /// Luma only (`Y800`, `GREY`, `Y8 `). + Gray8, +} + +impl RawFormat { + /// The layout of an AVI video stream: `BI_RGB` with its bit count, or a YUV FourCC. + pub fn from_avi(compression: [u8; 4], bit_count: u16) -> Option { + if matches!(u32::from_le_bytes(compression), 0 | 3) { + return match bit_count { + 24 => Some(RawFormat::Bgr24), + 32 => Some(RawFormat::Bgrx32), + _ => None, + }; + } + let mut upper = compression; + upper.make_ascii_uppercase(); + Some(match &upper { + b"YUY2" | b"YUYV" | b"YUNV" | b"V422" => RawFormat::Yuy2, + b"UYVY" | b"2VUY" | b"UYNV" | b"HDYC" => RawFormat::Uyvy, + b"I420" | b"IYUV" => RawFormat::I420, + b"YV12" => RawFormat::Yv12, + b"NV12" => RawFormat::Nv12, + b"Y800" | b"GREY" | b"GRAY" | b"Y8 " => RawFormat::Gray8, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + RawFormat::Bgr24 => "Uncompressed RGB 24-bit", + RawFormat::Bgrx32 => "Uncompressed RGB 32-bit", + RawFormat::Yuy2 | RawFormat::Uyvy => "Uncompressed YUV 4:2:2", + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 => "Uncompressed YUV 4:2:0", + RawFormat::Gray8 => "Uncompressed greyscale", + } + } +} + +/// Uncompressed frames (each sample one picture of a [`RawFormat`]). +pub struct RawVideoDecoder { + format: RawFormat, + width: u32, + height: u32, + /// RGB rows are stored bottom-up (a positive `BITMAPINFOHEADER` height). + bottom_up: bool, +} + +impl RawVideoDecoder { + pub fn new(format: RawFormat, width: u32, height: u32, bottom_up: bool) -> Result { + if width == 0 || height == 0 || width > 16_384 || height > 16_384 { + return Err(CodecError::Unsupported(format!("{width}x{height} uncompressed video"))); + } + Ok(Self { format, width, height, bottom_up }) + } + + fn yuv(&self, planes: [Vec; 3], chroma: filmcraft_frame::Chroma) -> VideoFrame { + use std::sync::Arc; + let [y, u, v] = planes; + VideoFrame { + width: self.width, + height: self.height, + data: filmcraft_frame::PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha: None }, + color: vui_color(self.width, self.height, 2, 2, 2, false), + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + } + } + + fn picture(&self, s: &[u8]) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + let short = || CodecError::Decode(format!("{} frame of {} bytes is too short for {w}x{h}", self.format.label(), s.len())); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + Ok(match self.format { + RawFormat::Bgr24 | RawFormat::Bgrx32 => { + let bpp = if self.format == RawFormat::Bgr24 { 3 } else { 4 }; + let stride = (w * bpp).div_ceil(4) * 4; + if s.len() < stride * h { + return Err(short()); + } + let mut rgba = Vec::with_capacity(w * h * 4); + for row in 0..h { + let src = if self.bottom_up { h - 1 - row } else { row }; + for px in s.get(src * stride..src * stride + w * bpp).unwrap_or_default().chunks_exact(bpp) { + rgba.extend_from_slice(&[px[2], px[1], px[0], 255]); + } + } + VideoFrame::rgba8(self.width, self.height, rgba) + } + RawFormat::Yuy2 | RawFormat::Uyvy => { + let stride = cw * 4; + if s.len() < stride * h { + return Err(short()); + } + let (yo, uo, vo, y2) = if self.format == RawFormat::Yuy2 { (0, 1, 3, 2) } else { (1, 0, 2, 3) }; + let (mut y, mut u, mut v) = (Vec::with_capacity(w * h), Vec::with_capacity(cw * h), Vec::with_capacity(cw * h)); + for row in s.chunks_exact(stride).take(h) { + for (k, q) in row.as_chunks::<4>().0.iter().enumerate() { + y.push(q[yo]); + if 2 * k + 1 < w { + y.push(q[y2]); + } + u.push(q[uo]); + v.push(q[vo]); + } + } + self.yuv([y, u, v], filmcraft_frame::Chroma::C422) + } + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 | RawFormat::Gray8 => { + let luma = w * h; + let need = if self.format == RawFormat::Gray8 { luma } else { luma + 2 * cw * ch }; + if s.len() < need { + return Err(short()); + } + let y = s.get(..luma).unwrap_or_default().to_vec(); + let (u, v) = match self.format { + RawFormat::Gray8 => (vec![128; cw * ch], vec![128; cw * ch]), + RawFormat::Nv12 => s.get(luma..luma + 2 * cw * ch).unwrap_or_default().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).unzip(), + _ => { + let a = s.get(luma..luma + cw * ch).unwrap_or_default().to_vec(); + let b = s.get(luma + cw * ch..luma + 2 * cw * ch).unwrap_or_default().to_vec(); + if self.format == RawFormat::Yv12 { (b, a) } else { (a, b) } + } + }; + self.yuv([y, u, v], filmcraft_frame::Chroma::C420) + } + }) + } +} + +impl VideoDecoder for RawVideoDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + Ok(vec![DecodedFrame { pts, frame: self.picture(sample)?, draft: false }]) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) {} + fn name(&self) -> &str { + self.format.label() + } + fn intra_only(&self) -> bool { + true + } +} diff --git a/crates/codecs/src/webm_alpha.rs b/crates/codecs/src/webm_alpha.rs index e02c905d9..12750b21f 100644 --- a/crates/codecs/src/webm_alpha.rs +++ b/crates/codecs/src/webm_alpha.rs @@ -1,9 +1,10 @@ -//! WebM alpha (#402): a VP9 track with `AlphaMode` 1 carries, in each block's `BlockAdditional` -//! with `BlockAddID` 1, a second VP9 stream coded independently of the colour one. Its luma plane, +//! WebM alpha (#402): a VP8 or VP9 track with `AlphaMode` 1 carries, in each block's +//! `BlockAdditional` with `BlockAddID` 1, a second stream of the same codec coded independently of +//! the colour one. Its luma plane, //! full range, is the frame's alpha (the WebM convention, as libvpx-based decoders read it). //! //! [`AlphaDecoder`] wraps the colour decoder: every sample it decodes, it also decodes that -//! sample's alpha frame with a second (software) VP9 decoder and attaches the luma plane to the +//! sample's alpha frame with a second (software) decoder and attaches the luma plane to the //! colour picture with the same timestamp, holding a colour picture back until its alpha is out. The alpha stream has key frames of its own, which need //! not coincide with the colour ones: after a seek (or any other break in the order samples are //! fed), the alpha decoder restarts from the nearest alpha key frame at or before the sample. @@ -21,7 +22,7 @@ use crate::video::{DecodedFrame, VideoDecoder}; /// How far back the alpha decoder looks for a key frame when it resynchronises. const MAX_RESYNC: usize = 600; /// Decoded alpha pictures waiting for their colour picture, and colour pictures waiting for their -/// alpha (VP9 pictures come out in order, so a few are plenty; past that a picture goes out opaque +/// alpha (VP8 / VP9 pictures come out in order, so a few are plenty; past that a picture goes out opaque /// rather than stall). const MAX_PENDING: usize = 8; @@ -106,7 +107,7 @@ impl AlphaDecoder { self.alpha.reset(); self.pending.clear(); self.alpha_max = None; - let is_key = |k: usize| self.layer.read(k).is_some_and(|d| filmcraft_vp9::is_keyframe(&d)); + let is_key = |k: usize| self.layer.read(k).is_some_and(|d| self.alpha.is_random_access(&d) == Some(true)); let start = (i.saturating_sub(MAX_RESYNC)..=i).rev().find(|&k| is_key(k)); if let Some(start) = start { for k in start..i { diff --git a/crates/codecs/tests/aac_channels_oracle.rs b/crates/codecs/tests/aac_channels_oracle.rs new file mode 100644 index 000000000..5f850ac3e --- /dev/null +++ b/crates/codecs/tests/aac_channels_oracle.rs @@ -0,0 +1,108 @@ +//! Multichannel AAC in MP4 against ffmpeg (#922). The AudioSpecificConfig's channel configuration +//! is an index into ISO/IEC 14496-3 Table 1.19 (with Amd 4), not a channel count: 7 and 12 are +//! both 7.1 (eight channels) and 11 is 6.1 (seven). Each channel carries its own tone, so a +//! dropped or swapped channel shows up against ffmpeg's decode. + +mod common; + +use std::io::Cursor; + +use common::*; +use filmcraft_isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const RATE: u32 = 48_000; + +/// A one-second AAC MP4 of `layout` (`n` channels), channel `i` a tone of 200 + 100·i Hz, except +/// the LFE (fourth in all these layouts) at 60 Hz: AAC band-limits it. +fn tones(ff: &std::path::Path, name: &str, layout: &str, n: usize) -> Option { + let mut graph: String = + (0..n).map(|i| format!("sine=frequency={}:sample_rate={RATE}:duration=1[a{i}];", if i == 3 { 60 } else { 200 + 100 * i })).collect(); + graph.extend((0..n).map(|i| format!("[a{i}]"))); + graph.push_str(&format!("join=inputs={n}:channel_layout={layout}[out]")); + fixture(ff, name, &["-filter_complex", &graph, "-map", "[out]", "-c:a", "aac", "-b:a", "512k"]) +} + +/// The AAC access units of `path`'s audio track, rewrapped in a new MP4 whose AudioSpecificConfig +/// says `channel_config` (AAC LC, 48 kHz), keeping each sample's duration and the priming edit. +fn rewrap(path: &std::path::Path, name: &str, channel_config: u8, channels: u32) -> std::path::PathBuf { + let data = bytes(path); + let file = filmcraft_isobmff::open(&data).unwrap(); + let track = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Audio).unwrap(); + // object type 2, rate index 3 (48 kHz), then the configuration + let asc = (((2u16 << 11) | (3 << 7) | ((channel_config as u16) << 3)).to_be_bytes()).to_vec(); + let mut mux = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + let id = mux.add_track(TrackConfig::new(SampleEntry::aac(asc, channels, RATE), RATE)).unwrap(); + for s in &track.samples { + let au = &data[s.offset as usize..s.offset as usize + s.size as usize]; + mux.write_sample(id, WriteSample { data: au, duration: s.duration, composition_offset: 0, is_sync: true }).unwrap(); + } + let out = dir().join(name); + std::fs::write(&out, mux.finish().unwrap().into_inner()).unwrap(); + out +} + +/// Our decode of the whole file, channel by channel. +fn ours(path: &std::path::Path, name: &str) -> (u32, Vec>) { + let src = filmcraft_codecs::open_bytes(name, bytes(path)).unwrap(); + let ch = src.info().audio().map(|a| a.channels).unwrap(); + (ch, src.audio(0, RATE as usize * 9 / 10, RATE).unwrap().channels) +} + +/// ffmpeg's decode, deinterleaved into `n` channels. +fn theirs(ff: &std::path::Path, path: &std::path::Path, n: usize) -> Vec> { + let all = ffmpeg_audio_f32(ff, path, &[]); + (0..n).map(|c| all.iter().skip(c).step_by(n).copied().collect()).collect() +} + +/// Largest sample difference over the first `len` samples, best over a small lag either way (the +/// two decoders may place the priming differently by a few samples). +fn diff(a: &[f32], b: &[f32]) -> f32 { + let len = a.len().min(b.len()).saturating_sub(64); + (0..=32).flat_map(|l| [(l, 0), (0, l)]).map(|(x, y)| (32..len).map(|i| (a[i + x] - b[i + y]).abs()).fold(0.0, f32::max)).fold(f32::MAX, f32::min) +} + +fn peak(x: &[f32]) -> f32 { + x.iter().fold(0.0, |m, v| m.max(v.abs())) +} + +#[test] +fn aac_channel_configurations_report_and_decode_every_channel() { + let ff = filmcraft_testkit::require_ffmpeg!(); + // ffmpeg 8 writes 7.1 as configuration 7 and ffmpeg 9 as 12: the same five elements + let Some(seven) = tones(&ff, "aac_71.mp4", "7.1", 8) else { + eprintln!("SKIPPED: this ffmpeg can't encode 7.1 AAC"); + return; + }; + let asc = filmcraft_isobmff::open(bytes(&seven)).unwrap().tracks.iter().find_map(|t| match &t.entries.first()?.codec { + filmcraft_isobmff::CodecConfig::Aac(a) => Some(a.channel_config), + _ => None, + }); + let twelve = match asc { + Some(7) => rewrap(&seven, "aac_71_cfg12.mp4", 12, 8), + Some(12) => seven.clone(), + other => panic!("ffmpeg wrote 7.1 AAC as configuration {other:?}"), + }; + let seven = if asc == Some(7) { seven } else { rewrap(&seven, "aac_71_cfg7.mp4", 7, 8) }; + + // configuration 7: eight channels reported and returned, the last one too + let (ch, got) = ours(&seven, "aac_71_cfg7.mp4"); + assert_eq!((ch, got.len()), (8, 8), "configuration 7"); + assert!(got.iter().all(|c| peak(c) > 0.05), "configuration 7: a silent channel"); + + // configuration 12 (7.1 back): decoded, in ffmpeg's (and WAV's) L R C LFE Lb Rb Ls Rs order + let (ch, got) = ours(&twelve, "aac_71_cfg12.mp4"); + assert_eq!((ch, got.len()), (8, 8), "configuration 12"); + let want = theirs(&ff, &twelve, 8); + for (c, (a, b)) in got.iter().zip(&want).enumerate() { + let d = diff(a, b); + println!("configuration 12, channel {c}: peak {:.3}, max |ours - ffmpeg| {d:.5}", peak(a)); + assert!(peak(a) > 0.05 && d < 0.01, "configuration 12, channel {c}: off ffmpeg's decode by {d}"); + } + + // controls: 5.1 (configuration 6) and 6.1 (ffmpeg writes a PCE) + for (layout, n, name) in [("5.1", 6, "aac_51.mp4"), ("6.1", 7, "aac_61.mp4")] { + let path = tones(&ff, name, layout, n).unwrap(); + let (ch, got) = ours(&path, name); + assert_eq!((ch as usize, got.len()), (n, n), "{layout}"); + } +} diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/eac3_oracle.rs index ee7f335d3..7d875d49c 100644 --- a/crates/codecs/tests/eac3_oracle.rs +++ b/crates/codecs/tests/eac3_oracle.rs @@ -53,12 +53,18 @@ fn eac3_in_mp4_matroska_and_transport_streams() { }; let ours = src.audio(start, n, 48_000).unwrap().channels; assert_eq!(ours.len(), ch, "{name}"); - // Matroska stores CodecDelay (the encoder's 256-sample priming) in ns and timestamps in - // ms, and we place packets at the rounded value (240 samples): a shared Matroska timing - // limit (AAC and AC-3 alike), not a decoding one. Elsewhere the samples line up exactly. - let (worst, lag) = (-32..=32).map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }); - let max_lag = if name.ends_with(".mkv") { 16 } else { 0 }; - assert!(lag.abs() <= max_lag, "{name}: decode is {lag} samples off ffmpeg's"); + // Matroska stores the encoder's 256-sample priming as CodecDelay, which we trim exactly + // (checked to the sample against the encoded signal in `matroska_audio_oracle`). Whether + // ffmpeg's CLI trims it too depends on its version (9.x does; 2023-24 builds keep the + // priming samples), so for .mkv its decode may start 256 samples earlier than ours. + // Elsewhere the samples line up exactly, whatever the version. + let lags: Vec = match name.ends_with(".mkv") { + true => (-32..=32).chain(-256 - 32..=-256 + 32).collect(), + false => (-32..=32).collect(), + }; + let (worst, lag) = lags.into_iter().map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }); + let ok = if name.ends_with(".mkv") { lag == 0 || lag == -256 } else { lag == 0 }; + assert!(ok, "{name}: decode is {lag} samples off ffmpeg's"); // a window read on a fresh source (random access) gives the same samples let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); let at = n / 3; diff --git a/crates/codecs/tests/frame_color_oracle.rs b/crates/codecs/tests/frame_color_oracle.rs new file mode 100644 index 000000000..7da3baf09 --- /dev/null +++ b/crates/codecs/tests/frame_color_oracle.rs @@ -0,0 +1,61 @@ +//! Decoded H.264 / HEVC frames carry the colour primaries their VUI signals (#924). ffmpeg writes +//! MP4 without a `colr` box unless asked, so for most files the bitstream is the only place the +//! primaries are described: stream info already read them, the decoded frames reported BT.709. + +mod common; + +use common::*; +use filmcraft_color::{ColorInfo, ColorSpace, Matrix, Primaries, Range, Transfer}; +use filmcraft_media::FrameRequest; +use filmcraft_time::Tick; + +/// One 64×64 frame of `codec` with the colour `params` (x264 / x265 syntax), and our stream and +/// frame colour for it. +fn colors(ff: &std::path::Path, name: &str, codec: &[&str]) -> Option<(ColorInfo, ColorInfo)> { + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=64x64:rate=1", "-frames:v", "1"]; + args.extend_from_slice(codec); + let path = fixture(ff, name, &args)?; + let isobmff = filmcraft_isobmff::open(bytes(&path)).unwrap(); + let track = isobmff.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).unwrap(); + assert!(track.entries[0].video.as_ref().unwrap().color.is_none(), "{name}: the fixture has a colr box"); + let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let stream = src.info().video.as_ref().unwrap().color; + let frame = src.video_frame(FrameRequest::full(Tick::ZERO)).unwrap().color; + Some((stream, frame)) +} + +#[test] +fn decoded_frames_keep_the_signalled_primaries() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let x264 = |p: &'static str| ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-x264-params", p]; + let x265 = |p: &'static str, pix: &'static str| ["-c:v", "libx265", "-pix_fmt", pix, "-tag:v", "hvc1", "-x265-params", p]; + let sdr2020 = ColorInfo { matrix: Matrix::Bt709, transfer: Transfer::Bt709, primaries: Primaries::Bt2020, range: Range::Limited }; + let cases: [(&str, Vec<&str>, ColorInfo, ColorSpace); 4] = [ + ("prim2020_h264.mp4", x264("colorprim=bt2020:transfer=bt709:colormatrix=bt709").to_vec(), sdr2020, ColorSpace::Rec2020), + ("prim2020_hevc.mp4", x265("log-level=error:colorprim=bt2020:transfer=bt709:colormatrix=bt709", "yuv420p").to_vec(), sdr2020, ColorSpace::Rec2020), + // control: BT.709 throughout + ( + "prim709_h264.mp4", + x264("colorprim=bt709:transfer=bt709:colormatrix=bt709").to_vec(), + ColorInfo { primaries: Primaries::Bt709, ..sdr2020 }, + ColorSpace::Rec709, + ), + // HDR keeps its transfer and now its primaries too + ( + "prim2020_pq_hevc.mp4", + x265("log-level=error:colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc", "yuv420p10le").to_vec(), + ColorInfo { matrix: Matrix::Bt2020Ncl, transfer: Transfer::Pq, primaries: Primaries::Bt2020, range: Range::Limited }, + ColorSpace::Rec2100Pq, + ), + ]; + for (name, codec, want, space) in cases { + let Some((stream, frame)) = colors(&ff, name, &codec) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + println!("{name}: stream {stream:?}, frame {frame:?}"); + assert_eq!(stream, want, "{name}: stream"); + assert_eq!(frame, want, "{name}: decoded frame"); + assert_eq!(ColorSpace::from_info(&frame), space, "{name}"); + } +} diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs index 1298ccd48..3ded7af07 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -48,3 +48,100 @@ fn matroska_audio_plays_without_holes() { assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})"); } } + +/// Mean |difference| between `ours[i]` and `theirs[i + lag]` over `from..from + len`. +fn mean_err(ours: &[f32], theirs: &[f32], from: usize, len: usize, lag: i64) -> f64 { + (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 +} + +/// The lag in `lags` at which `ours` best matches `theirs` (least [`mean_err`]), and that error. +fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, lags: std::ops::RangeInclusive) -> (i64, f64) { + lags.map(|lag| (lag, mean_err(ours, theirs, from, len, lag))).min_by(|a, b| a.1.total_cmp(&b.1)).unwrap() +} + +/// A reference signal we control: low-passed white noise from a fixed xorshift seed. Its +/// autocorrelation dies out within a few samples, so even a one-sample misalignment shows. +fn noise(n: usize) -> Vec { + let (mut state, mut y) = (0x9E37_79B9_7F4A_7C15u64, 0f32); + (0..n) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let white = (state >> 40) as f32 / (1u64 << 24) as f32 - 0.5; + y = 0.5 * y + 0.4 * white; + y + }) + .collect() +} + +/// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at +/// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to +/// the millisecond. Taking the rounded delay as the start of the first packet played the whole +/// track up to 16 samples late. +/// +/// The strict check is version-independent: ffmpeg encodes a signal we generate, and our decode +/// must line up with that signal to the sample (the stream's `CodecDelay` trimmed exactly). ffmpeg's +/// own decode is compared only after a bounded best-lag search: how its CLI handles Matroska +/// priming depends on the version (9.x trims `CodecDelay`, 2023-24 builds keep the priming +/// samples), so its lag is reported, not asserted. +#[test] +fn matroska_audio_is_sample_aligned_with_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, u32, &[&str]); 6] = [ + ("ref_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), + ("ref_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), + ("ref_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), + ("ref_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), + ("ref_eac3_48k.mkv", 48_000, &["-c:a", "eac3", "-b:a", "384k"]), + ("ref_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), + ]; + for (name, rate, codec) in cases { + let reference = noise(rate as usize * 2); + let raw = format!("ref_noise_{rate}.f32"); + let raw_path = filmcraft_testkit::fixtures::generate(&dir().join(&raw), |tmp| { + std::fs::write(tmp, reference.iter().flat_map(|v| v.to_le_bytes()).collect::>()).is_ok() + }) + .unwrap(); + let (raw_arg, rate_arg) = (raw_path.to_str().unwrap().to_string(), rate.to_string()); + let args: Vec<&str> = ["-f", "f32le", "-ar", &rate_arg, "-ac", "1", "-i", &raw_arg].iter().copied().chain(codec.iter().copied()).collect(); + let Some(path) = fixture(&ff, name, &args) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let file = bytes(&path); + let mkv = filmcraft_matroska::open(file.clone()).unwrap(); + let delay_ns = mkv.tracks.first().map_or(0, |t| t.codec_delay_ns) as i64; + let delay = (delay_ns * rate as i64 + 500_000_000) / 1_000_000_000; + assert!(delay > 0, "{name}: no CodecDelay"); + let src = filmcraft_codecs::open_bytes(name, file).unwrap(); + let frames = rate as usize * 3 / 2; + let mut ours = Vec::with_capacity(frames); + while ours.len() < frames { + let n = 1000.min(frames - ours.len()); + ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]); + } + let (from, len, bound) = (rate as usize / 4, rate as usize / 4, delay + 32); + // strict: the encoder's input, to the sample + let (lag, err) = best_lag(&ours, &reference, from, len, -bound..=bound); + let level = reference[from..from + len].iter().map(|v| v.abs() as f64).sum::() / len as f64; + assert_eq!(lag, 0, "{name}: our decode is {lag} samples off the encoded signal (CodecDelay {delay} samples)"); + assert!(err < 0.25 * level, "{name}: decode error {err:.2e} against a signal level of {level:.2e}"); + // ffmpeg's decode, wherever its version puts the priming + let theirs = ffmpeg_audio_f32(&ff, &path, &[]); + assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len()); + let (ff_lag, ff_err) = best_lag(&ours, &theirs, from, len, -bound..=bound); + let priming = match ff_lag { + 0 => "trims CodecDelay".to_string(), + l if l == delay => "keeps the priming samples".to_string(), + l => format!("is {l} samples off"), + }; + println!("{name}: CodecDelay {delay} samples; vs the encoded signal lag 0, error {err:.2e} (level {level:.2e}); ffmpeg {priming}, error {ff_err:.2e}"); + assert!(ff_err < 1e-3, "{name}: differs from ffmpeg's decode by {ff_err:.2e} at its best lag {ff_lag}"); + // read from the middle, as a seek does + let at = rate as i64 / 2; + let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone(); + let diff = mid.iter().zip(&ours[at as usize..]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(diff < 1e-3, "{name}: a read from {at} differs from continuous decoding by {diff}"); + } +} diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index 4fc6638e7..da95c5eb6 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -171,6 +171,98 @@ fn dpx_stills_every_layout() { } } +/// A big-endian DPX file holding `data` as its one image element (`w` × `h`, the given +/// descriptor, bit depth, packing and colorimetric code; reference codes undefined). +fn dpx_file(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, colorimetric: u8, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let put = |f: &mut Vec, at: usize, v: &[u8]| f[at..at + v.len()].copy_from_slice(v); + put(&mut f, 0, b"SDPX"); + put(&mut f, 4, &2048u32.to_be_bytes()); + put(&mut f, 8, b"V2.0"); + put(&mut f, 16, &(2048 + data.len() as u32).to_be_bytes()); + put(&mut f, 770, &1u16.to_be_bytes()); + put(&mut f, 772, &w.to_be_bytes()); + put(&mut f, 776, &h.to_be_bytes()); + let el = 780; + put(&mut f, el + 4, &u32::MAX.to_be_bytes()); + put(&mut f, el + 12, &u32::MAX.to_be_bytes()); + f[el + 20] = descriptor; + f[el + 22] = colorimetric; + f[el + 23] = bits; + put(&mut f, el + 24, &packing.to_be_bytes()); + put(&mut f, el + 28, &2048u32.to_be_bytes()); + f.extend_from_slice(data); + f +} + +/// `pix_fmt` raw frame of ffmpeg's test picture at `w` × `h`. +fn raw_picture(ff: &Path, w: u32, h: u32, pix_fmt: &str) -> Vec { + let alpha = if pix_fmt.contains('a') { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale={w}:{h}{alpha}"); + ffmpeg_out(ff, &["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) +} + +/// A DPX file we built must import exactly like ffmpeg decodes it, within `tol` of its 16-bit +/// decode rounded to 8 bits. +fn dpx_matches(ff: &Path, name: &str, file: Vec, tol: u8) { + let path = dir().join(name); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&path, &file).unwrap(); + let src = filmcraft_codecs::open_bytes(name, file.into()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + let wide = ffmpeg_out(ff, &["-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + let want: Vec = wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect(); + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + eprintln!("{name}: max diff {d}"); +} + +/// Packed 12-bit RGB (packing 0: components back to back, low bits first, lines on 32-bit +/// boundaries) and 8-bit Y'CbCr (4:2:2, 4:4:4, 4:4:4:4 with alpha; BT.709, BT.601 and unspecified +/// colorimetry), layouts ffmpeg reads but does not write, built here from ffmpeg's test picture +/// (#841). +#[test] +fn dpx_packed_and_ycbcr_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (w, h) = (95u32, 63u32); + let rgb: Vec = raw_picture(&ff, w, h, "rgb48le").as_chunks::<2>().0.iter().map(|c| u32::from(u16::from_le_bytes(*c)) >> 4).collect(); + let mut data = Vec::new(); + for line in rgb.chunks(w as usize * 3) { + let (mut acc, mut n) = (0u64, 0u32); + for &v in line { + acc |= u64::from(v) << n; + n += 12; + while n >= 32 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + } + } + dpx_matches(&ff, "dpx_packed12.dpx", dpx_file(w, h, 50, 12, 0, 6, &data), 1); + + // Y'CbCr: even width, scan lines a multiple of 4 bytes + let (w, h) = (96u32, 64u32); + let px = (w * h) as usize; + let uyvy = raw_picture(&ff, w, h, "uyvy422"); + let yuv = raw_picture(&ff, w, h, "yuv444p"); + let yuva = raw_picture(&ff, w, h, "yuva444p"); + let interleave = |planes: &[u8], order: &[usize]| -> Vec { (0..px).flat_map(|i| order.iter().map(move |&p| planes[p * px + i])).collect() }; + // planes are Y, U (Cb), V (Cr), A; DPX stores Cb Y Cr A + let cbycr = interleave(&yuv, &[1, 0, 2]); + let cbycra = interleave(&yuva, &[1, 0, 2, 3]); + for colorimetric in [6u8, 7, 0] { + dpx_matches(&ff, &format!("dpx_cbycry_{colorimetric}.dpx"), dpx_file(w, h, 100, 8, 0, colorimetric, &uyvy), 1); + dpx_matches(&ff, &format!("dpx_cbycr_{colorimetric}.dpx"), dpx_file(w, h, 102, 8, 0, colorimetric, &cbycr), 1); + } + dpx_matches(&ff, "dpx_cbycra.dpx", dpx_file(w, h, 103, 8, 0, 6, &cbycra), 1); +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index d6a7042a0..e6d5a39dd 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -16,6 +16,9 @@ use filmcraft_media::{FrameRequest, MediaError, MediaSource}; const NOISE: &[&str] = &["-f", "lavfi", "-i", "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000"]; +/// A mono tone. +const MONO: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000"]; + fn spec(name: &str) -> Vec { let cat = |p: &[&[&str]]| p.concat().into_iter().map(String::from).collect::>(); match name { @@ -142,6 +145,12 @@ fn spec(name: &str) -> Vec { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], NOISE, &["-t", "0.6", "-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-c:a", "mp2"]]) } "audio.mp2" => cat(&[NOISE, &["-t", "1", "-c:a", "mp2", "-b:a", "224k", "-f", "mp2"]]), + // mono MP2 at ffmpeg's default bit rate, 384 kbit/s, which ISO/IEC 11172-3 does not allow + "audio_mono.mp2" => cat(&[MONO, &["-t", "1", "-c:a", "mp2", "-f", "mp2"]]), + "ts_mpeg2_mono_mp2.ts" => { + cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], MONO, &["-t", "1", "-c:v", "mpeg2video", "-c:a", "mp2", "-f", "mpegts"]]) + } + "mkv_mono_mp2.mkv" => cat(&[MONO, &["-t", "1", "-c:a", "mp2"]]), "es_mpeg2.m2v" => { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25"], &["-t", "1", "-c:v", "mpeg2video", "-bf", "2", "-g", "9", "-f", "mpeg2video"]]) } @@ -164,6 +173,9 @@ pub const ALL: &[&str] = &[ "mp4_mpeg2.mp4", "mkv_mpeg2.mkv", "audio.mp2", + "audio_mono.mp2", + "ts_mpeg2_mono_mp2.ts", + "mkv_mono_mp2.mkv", "es_mpeg2.m2v", ]; @@ -515,6 +527,53 @@ fn standalone_mp2_file() { assert!(worst <= 2e-4, "{worst}"); } +/// #809: mono MP2 at 384 kbit/s, ffmpeg's default, played as silence in every container because +/// symphonia rejects the bit rate / mode combinations ISO/IEC 11172-3 does not allow. +#[test] +fn mono_mp2_at_ffmpegs_default_bit_rate() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let f = make(&ff, "audio_mono.mp2"); + assert_eq!(std::fs::read(&f).unwrap()[2] >> 4, 14, "384 kbit/s"); + let worst = check_audio(&ff, &make(&ff, "ts_mpeg2_mono_mp2.ts"), 2e-4); + println!("ts_mpeg2_mono_mp2.ts: max error {worst}"); + // Matroska stores the encoder's 481-sample priming as `CodecDelay`. Whether ffmpeg's CLI trims + // it depends on its version (9.x does; 2023-24 builds keep the priming samples), and until + // #790 we trim it rounded to the millisecond. So for .mkv ffmpeg's decode is searched at + // lags within a sample or two of 0 (trimmed) and of `CodecDelay` (kept). The plain .mp2 file + // has no such ambiguity and must line up exactly. + for name in ["audio_mono.mp2", "mkv_mono_mp2.mkv"] { + let f = make(&ff, name); + let file = bytes(&f); + let src = filmcraft_codecs::open_bytes(name, file.clone()).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, 1), "{name}"); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let (delay, lags): (usize, Vec) = match name.ends_with(".mkv") { + true => { + let mkv = filmcraft_matroska::open(file).unwrap(); + let ns = mkv.tracks.first().map_or(0, |t| t.codec_delay_ns); + let delay = ((ns * 48_000 + 500_000_000) / 1_000_000_000) as usize; + assert!(delay > 0, "{name}: no CodecDelay"); + (delay, (-2..=2).chain(delay as i64 - 2..=delay as i64 + 2).collect()) + } + false => (0, vec![0]), + }; + // ours from sample 2 on, against ffmpeg's from sample 2 + lag (inside both decodes) + let n = want.len() - delay - 6; + let buf = src.audio(0, n, 48_000).unwrap(); + let (worst, lag) = lags + .into_iter() + .map(|lag| { + let err = (2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - buf.channels[0][i]).abs()).fold(0f32, f32::max); + (err, lag) + }) + .fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m }); + let peak = want.iter().fold(0f32, |m, x| m.max(x.abs())); + println!("{name}: max error {worst} at lag {lag} (CodecDelay {delay}), peak {peak}"); + assert!(peak > 0.1 && worst <= 2e-4, "{name}: peak {peak}, max error {worst} at lag {lag}"); + } +} + #[test] fn truncated_and_corrupt_files_never_panic() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/codecs/tests/rf64_metadata.rs b/crates/codecs/tests/rf64_metadata.rs new file mode 100644 index 000000000..164c01d65 --- /dev/null +++ b/crates/codecs/tests/rf64_metadata.rs @@ -0,0 +1,69 @@ +//! #925: a chunk after an RF64 `data` sentinel must not become audio. + +use std::sync::Arc; + +use filmcraft_time::Tick; + +fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut out = id.to_vec(); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } + out +} + +/// Four 16-bit mono frames of silence at 48 kHz, as RIFF or RF64, optionally with a trailing iXML. +fn fixture(rf64: bool, trailing: bool) -> Vec { + let mut fmt = Vec::new(); + for v in [1u16, 1] { + fmt.extend_from_slice(&v.to_le_bytes()); + } + fmt.extend_from_slice(&48_000u32.to_le_bytes()); + fmt.extend_from_slice(&96_000u32.to_le_bytes()); + fmt.extend_from_slice(&2u16.to_le_bytes()); + fmt.extend_from_slice(&16u16.to_le_bytes()); + let mut out = if rf64 { b"RF64\xff\xff\xff\xffWAVE".to_vec() } else { b"RIFF\0\0\0\0WAVE".to_vec() }; + if rf64 { + let mut ds = vec![0u8; 28]; + ds[8..16].copy_from_slice(&8u64.to_le_bytes()); + ds[16..24].copy_from_slice(&4u64.to_le_bytes()); + out.extend(chunk(b"ds64", &ds)); + } + out.extend(chunk(b"fmt ", &fmt)); + let offset = out.len(); + out.extend(chunk(b"data", &[0u8; 8])); + if rf64 { + out[offset + 4..offset + 8].copy_from_slice(&u32::MAX.to_le_bytes()); + } + if trailing { + out.extend(chunk(b"iXML", b"")); + } + let size = out.len() - 8; + if rf64 { + out[20..28].copy_from_slice(&(size as u64).to_le_bytes()); + } else { + out[4..8].copy_from_slice(&(size as u32).to_le_bytes()); + } + out +} + +#[test] +fn rf64_metadata_stays_outside_audio() { + for (name, rf64, trailing) in [("rf64.wav", true, false), ("riff_ixml.wav", false, true), ("rf64_ixml.wav", true, true)] { + let bytes: Arc<[u8]> = fixture(rf64, trailing).into(); + let src = filmcraft_codecs::open_bytes(name, bytes.clone()).unwrap(); + let stream = filmcraft_media::reader::open_reader( + name, + Arc::new(filmcraft_media::reader::MemReader(bytes)), + &filmcraft_codecs::reader_openers(), + &filmcraft_codecs::openers(), + ) + .unwrap(); + for s in [&src, &stream] { + assert_eq!(s.info().duration, Tick::from_units(4, 48_000), "{name}"); + assert_eq!(s.audio(0, 20, 48_000).unwrap().channels.remove(0), vec![0.0; 20], "{name}"); + } + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2ad1fa5f2..ce3340162 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -20,7 +20,7 @@ pub use grade::GradeSpace; pub use log::LogCurve; pub use lut::{Lut, Lut1d, Lut3d, LutFormat}; pub use spaces::{ColorPipeline, ColorSpace, Curve, Gamut, WorkingSpace}; -pub use transform::{DecodeTable, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap}; +pub use transform::{DecodeTable, GpuCurve, GpuInputParams, InputTransform, OutputTransform, REFERENCE_WHITE_NITS, ToneMap}; /// YUV ↔ RGB matrix coefficients. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/color/src/lut.rs b/crates/color/src/lut.rs index 92e919738..ac5642ac7 100644 --- a/crates/color/src/lut.rs +++ b/crates/color/src/lut.rs @@ -186,7 +186,7 @@ impl LutFormat { } fn floats(line: &str) -> Result, String> { - line.split_whitespace().map(|x| x.parse::().map_err(|_| format!("not a number: {x:?}"))).collect() + line.split_whitespace().map(|x| x.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("not a finite number: {x:?}"))).collect() } impl Lut { @@ -252,6 +252,9 @@ impl Lut { if v.len() != 2 { return Err(err(&format!("{key} needs 2 values"))); } + if v[1] <= v[0] { + return Err(err(&format!("{key} maximum must exceed its minimum"))); + } let r = Some([v[0], v[1]]); if key == "LUT_1D_INPUT_RANGE" { range1 = r } else { range3 = r } } @@ -591,6 +594,16 @@ mod tests { assert!(Lut::parse_cube("TITLE \"x\"\n").is_err()); assert!(Lut::parse_cube("LUT_3D_SIZE 2\n0 0 0\nLUT_1D_SIZE 2\n").is_err()); assert!(Lut::parse_cube("LUT_3D_SIZE 2\nDOMAIN_MIN 1 1 1\nDOMAIN_MAX 0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n0 0 0\n").is_err()); + for key in ["LUT_1D_INPUT_RANGE", "LUT_3D_INPUT_RANGE"] { + let (size, rows) = if key.starts_with("LUT_1D") { ("LUT_1D_SIZE 2", 2) } else { ("LUT_3D_SIZE 2", 8) }; + let table = "0 0 0\n".repeat(rows); + for range in ["0 0", "1 0", "NaN 1", "0 inf"] { + assert!(Lut::parse_cube(&format!("{size}\n{key} {range}\n{table}")).is_err(), "accepted {key} {range}"); + } + assert!(Lut::parse_cube(&format!("{size}\n{key} -0.5 2\n{table}")).is_ok()); + } + assert!(Lut::parse_cube("LUT_1D_SIZE 2\n0 0 0\nNaN 1 1\n").is_err()); + assert!(Lut::parse_cube("LUT_1D_SIZE 2\nDOMAIN_MAX inf 1 1\n0 0 0\n1 1 1\n").is_err()); // comments, blank lines, Windows newlines, inline comments let txt = "# hi\r\nTITLE \"t\"\r\n\r\nLUT_3D_SIZE 2 # two\r\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n"; let l = Lut3d::parse_cube(txt).unwrap(); diff --git a/crates/color/src/transform.rs b/crates/color/src/transform.rs index 52788d443..99aac66e4 100644 --- a/crates/color/src/transform.rs +++ b/crates/color/src/transform.rs @@ -259,6 +259,54 @@ impl InputTransform { pub fn convert(&self, rgb: [f32; 3]) -> [f32; 3] { self.apply(rgb.map(|v| self.table.lookup(v))) } + + /// The transform as plain numbers for a shader, which evaluates the curve and the BT.2390 + /// EETF directly instead of sampling the tables. `None` for what a shader does not do: log + /// curves and the camera-gamut compression. + pub fn gpu_params(&self) -> Option { + let curve = match self.source.curve() { + Curve::Sdr | Curve::Srgb => GpuCurve::Sdr, + Curve::Linear => GpuCurve::Linear, + Curve::Pq => GpuCurve::Pq, + Curve::Hlg => GpuCurve::Hlg, + Curve::Log(_) => return None, + }; + if self.compress { + return None; + } + Some(GpuInputParams { + curve, + hlg: self.hlg, + matrix: (!self.identity_matrix).then_some(self.matrix), + tone: self.tone.as_ref().map(|t| (t.src_peak_nits as f32, t.dst_peak_nits as f32)), + desaturate: self.desaturate, + }) + } +} + +/// How a shader decodes a channel ([`decode_channel`] without the table): SDR with the sRGB curve, +/// linear, PQ in working units (cd/m² over [`REFERENCE_WHITE_NITS`]), HLG to scene light. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GpuCurve { + Sdr, + Linear, + Pq, + Hlg, +} + +/// [`InputTransform`] for a shader ([`InputTransform::gpu_params`]), stage by stage as +/// [`InputTransform::apply`] runs them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GpuInputParams { + pub curve: GpuCurve, + /// HLG OOTF: BT.2020 luma weights and the scale to working units. + pub hlg: Option<([f32; 3], f32)>, + /// Into the working primaries (row-major), when they differ. + pub matrix: Option<[[f32; 3]; 3]>, + /// BT.2390 tone mapping (source peak, target peak) in cd/m², on max(R,G,B) as a ratio. + pub tone: Option<(f32, f32)>, + /// Luma weights to desaturate into the working gamut with ([`desaturate_into_gamut`]). + pub desaturate: Option<[f32; 3]>, } /// Whether gamut `a` lies inside gamut `b` (so no compression is needed going a → b). @@ -462,6 +510,25 @@ mod tests { } } + #[test] + fn gpu_params_cover_display_referred_media_only() { + // HLG / PQ / BT.2020 into Rec. 709: every stage the shader has (#965) + let p = |cs| InputTransform::new(cs, Range::Limited, &ColorPipeline::REC709, None).gpu_params(); + let hlg = p(ColorSpace::Rec2100Hlg).unwrap(); + assert_eq!(hlg.curve, GpuCurve::Hlg); + assert!(hlg.hlg.is_some() && hlg.matrix.is_some() && hlg.desaturate.is_some()); + assert_eq!(hlg.tone, Some((HDR_PEAK_NITS as f32, REFERENCE_WHITE_NITS as f32))); + let pq = InputTransform::new(ColorSpace::Rec2100Pq, Range::Limited, &ColorPipeline::REC709, Some(4000.0)).gpu_params().unwrap(); + assert_eq!((pq.curve, pq.hlg, pq.tone), (GpuCurve::Pq, None, Some((4000.0, REFERENCE_WHITE_NITS as f32)))); + let sdr2020 = p(ColorSpace::Rec2020).unwrap(); + assert_eq!((sdr2020.curve, sdr2020.tone), (GpuCurve::Sdr, None)); + assert!(sdr2020.matrix.is_some() && sdr2020.desaturate.is_some()); + // log curves and camera-gamut compression stay on the CPU + for cs in ColorSpace::ALL.into_iter().filter(|c| c.is_log()) { + assert_eq!(p(cs), None, "{cs:?}"); + } + } + #[test] fn sdr_in_hdr_keeps_values_and_display_tone_maps() { let pipe = hdr(WorkingSpace::Rec2100Pq); diff --git a/crates/dng/Cargo.toml b/crates/dng/Cargo.toml new file mode 100644 index 000000000..1fce4930c --- /dev/null +++ b/crates/dng/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "filmcraft-dng" +description = "Clean-room DNG / CinemaDNG camera RAW decoder (TIFF container, lossless JPEG, DNG colour pipeline)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[features] +default = ["threads"] +threads = ["dep:rayon"] +# A small DNG and lossless JPEG writer for synthesising test files (not used by the app). +writer = [] + +[dependencies] +rayon = { workspace = true, optional = true } +thiserror.workspace = true + +[dev-dependencies] +filmcraft-dng = { path = ".", features = ["writer"] } + +[lints] +workspace = true diff --git a/crates/dng/README.md b/crates/dng/README.md new file mode 100644 index 000000000..4c2b1e7e9 --- /dev/null +++ b/crates/dng/README.md @@ -0,0 +1,88 @@ +# filmcraft-dng + +Clean-room DNG and CinemaDNG camera RAW decoder in pure Rust. Layer L0: depends only on +`thiserror` and (optional, feature `threads`, default on) `rayon`. Builds for +`wasm32-unknown-unknown`; no `unsafe`. Implemented from the public specifications below only; the +source of no other raw decoder (dcraw, LibRaw, rawspeed, darktable, RawTherapee, FFmpeg, the Adobe +DNG SDK…) was read. LibRaw's `unprocessed_raw` sample program is run as an external test oracle +when it is installed, never linked. + +## Specifications + +| Document | Edition | Used for | +|---|---|---| +| Adobe Digital Negative (DNG) Specification | 1.4.0.0 (2012) – 1.7.1.0 (2023) | raw IFD selection (NewSubFileType 0), CFA / LinearRaw data, BitsPerSample packing, LinearizationTable, BlackLevel (+ RepeatDim, DeltaH / DeltaV), WhiteLevel, ActiveArea, DefaultCrop, the colour model of chapter 6 (ColorMatrix, CameraCalibration, AnalogBalance, ForwardMatrix, AsShotNeutral / AsShotWhiteXY, CalibrationIlluminant interpolation, BaselineExposure) | +| TIFF Revision 6.0 | June 1992 | header, IFDs, field types, strips and tiles, PlanarConfiguration, row padding | +| TIFF/EP, ISO 12234-2 | 2001 | CFARepeatPatternDim, CFAPattern | +| TIFF Technical Note 1 (SubIFDs) | 1993 | the SubIFD tree | +| ITU-T T.81 \| ISO/IEC 10918-1 | 09/1992 | lossless JPEG (process 14, SOF3): Annex B syntax, Annex C Huffman tables, F.2.2.3 decoding, Annex H prediction and point transform | +| CinemaDNG Image Data Format Specification | 1.1.0.0 (2009) | frames as numbered DNG files, FrameRate, TimeCodes, ReelName tags | +| SMPTE ST 12-1 | 2014 | time code BCD packing (via the CinemaDNG TimeCodes tag) | +| CIPA DC-008 (Exif) | 2.3 | LightSource codes → illuminant temperatures | +| Malvar, He, Cutler, "High-quality linear interpolation for demosaicing of Bayer-patterned color images", IEEE ICASSP | 2004 | the Bayer demosaic filters | +| McCamy, "Correlated color temperature as an explicit function of chromaticity coordinates", Color Res. Appl. 17(2) | 1992 | white xy → temperature for the calibration interpolation | +| IEC 61966-2-1 (sRGB) | 1999 | Rec. 709 / sRGB primaries and D65 white | + +## API + +```rust +if filmcraft_dng::sniff(&bytes) { + let dng = filmcraft_dng::Dng::parse(&bytes)?; + let info = dng.info()?; // size after crop, bits, CFA or LinearRaw, frame rate, time code, camera, reel + let raw = dng.raw()?; // the stored samples (u16), bit-exact + let img = dng.develop()?; // linear Rec. 709 RGBA f32 (alpha 1), info.width × info.height +} +let img = filmcraft_dng::decode(&bytes)?; // parse + develop +``` + +`writer` (feature `writer`, tests only) writes synthetic DNGs: uncompressed at any depth or +lossless JPEG with any predictor, strips or tiles, either byte order, an optional preview IFD with +the raw image in a SubIFD, and arbitrary extra tags. + +## What it decodes + +- **Container:** little and big-endian TIFF, IFD chains and SubIFDs (at most 64 IFDs, 4 levels, + cycles end the walk). The main image is the largest NewSubFileType 0 IFD with a CFA or LinearRaw + photometric interpretation. +- **Raw data:** 1–16-bit unsigned samples, uncompressed (8 and 16-bit in file byte order, other + depths packed most significant bit first) or lossless JPEG (all predictors 1–7, 1–4 components, + point transform, restart intervals of whole lines), in strips or tiles; tiles decode in + parallel. +- **Processing:** ActiveArea, LinearizationTable, per-site black levels with row / column deltas, + per-sample white levels, as-shot white balance (clipped highlights stay neutral), demosaic + (Malvar–He–Cutler for 2 × 2 Bayer patterns of any phase, bilinear for other patterns up to + 8 × 8), DefaultCrop, the DNG colour model to XYZ D50 and Bradford to linear Rec. 709, and + BaselineExposure. LinearRaw (3 samples) skips the demosaic; monochrome LinearRaw is grey. +- **Output:** scene-linear Rec. 709 floats: a white the sensor clipped is 1.0 × 2^BaselineExposure; + brighter values are kept (FilmCraft's compositor works in linear float). Negative results of the + colour matrix are clipped to 0. Black levels scale per pixel by (white − black). +- **White balance without a ForwardMatrix** follows the specification: the camera matrix takes the + values to XYZ and a Bradford adaptation moves the as-shot white to D50. Decoders in the dcraw + tradition (LibRaw included) scale the camera channels instead, so under a white far from D65 + their saturated colours differ from ours (greys agree). +- **Speed:** a 4096 × 2160 12-bit frame develops in about 80–100 ms on 16 threads (raw decode + ~15 ms, the rest demosaic and colour). + +## Not supported (errors name them) + +Lossy JPEG and JPEG XL DNG, deflate / floating-point DNG (HDR merges), planar raw data, colour +filters other than red / green / blue (CMY, RGBE), non-rectangular CFA layouts, opcode lists +(warp, vignette, bad pixels: ignored), DefaultScale and BestQualityScale (non-square pixels are +shown square), DNG 1.6 IlluminantData for illuminant 255 (calibration 1 is used), lossless JPEG +restart intervals that split a line, and MXF-wrapped CinemaDNG. + +## Tests + +- `tests/roundtrip.rs`: container parsing in both byte orders with and without a preview IFD; + uncompressed samples bit-exact at 8–16 bits, strips and tiles; lossless JPEG bit-exact for every + predictor and component count, full-swing 16-bit data (every difference category, SSSS = 16), + restarts and point transform; developed values against hand-computed ones (black / white, + white balance, ForwardMatrix, BaselineExposure, ActiveArea, DefaultCrop, LinearRaw); CinemaDNG + FrameRate / TimeCodes / ReelName; hostile files (every truncation, 12 000 mutations under + `catch_unwind`, cyclic IFDs, huge sizes, bad offsets, zero denominators). +- `tests/oracle_libraw.rs`: LibRaw's `unprocessed_raw` reads the same samples from our synthetic + files (uncompressed 8–16-bit in both byte orders, lossless JPEG predictors 1–7 with one and two + components). Skipped when it is not installed. +- Unit tests: matrix maths, the published sRGB matrix, McCamy temperatures, calibration + interpolation, sRGB / ForwardMatrix cameras mapping to themselves, demosaic on flat fields + (every Bayer phase), ramps and edges, black / white / linearisation maths. diff --git a/crates/dng/src/color.rs b/crates/dng/src/color.rs new file mode 100644 index 000000000..fb02f2c8d --- /dev/null +++ b/crates/dng/src/color.rs @@ -0,0 +1,385 @@ +//! Camera colour to linear Rec. 709, following the DNG specification's "Mapping Camera Color Space +//! to CIE XYZ Space" (DNG 1.4–1.7 chapter 6): +//! +//! - The two calibrations (ColorMatrix, CameraCalibration, ForwardMatrix 1 and 2) are blended +//! linearly in inverse correlated colour temperature between their CalibrationIlluminants. +//! - The white balance is AsShotNeutral (found as a white xy by the specification's iteration) or +//! AsShotWhiteXY; without either, D65. +//! - With ForwardMatrix: CameraToXYZ_D50 = FM · D · (AB · CC)⁻¹, D = diag((AB · CC)⁻¹ · neutral)⁻¹. +//! Without: CameraToXYZ_D50 = CA · (AB · CC · CM)⁻¹, CA a linear Bradford adaptation from the +//! white to D50. +//! - XYZ (D50) goes to linear Rec. 709 / sRGB primaries (D65 white) through Bradford again. +//! +//! The correlated colour temperature of an xy white comes from McCamy's cubic approximation +//! (C. S. McCamy, "Correlated color temperature as an explicit function of chromaticity +//! coordinates", Color Research & Application 17(2), 1992); the illuminant temperatures are the +//! CIE / EXIF 2.3 LightSource values. + +use crate::{Error, Result}; + +pub type M3 = [[f64; 3]; 3]; +pub type V3 = [f64; 3]; + +pub const IDENTITY: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +/// CIE D50 and D65 white chromaticities (the DNG specification's D50). +pub const D50: (f64, f64) = (0.3457, 0.3585); +pub const D65: (f64, f64) = (0.3127, 0.3290); + +/// Linear Bradford cone response matrix. +const BRADFORD: M3 = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + +pub fn mul(a: &M3, b: &M3) -> M3 { + let mut m = [[0.0; 3]; 3]; + for (i, row) in m.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + m +} + +pub fn mulv(a: &M3, v: &V3) -> V3 { + [0, 1, 2].map(|i| a[i][0] * v[0] + a[i][1] * v[1] + a[i][2] * v[2]) +} + +pub fn diag(v: &V3) -> M3 { + [[v[0], 0.0, 0.0], [0.0, v[1], 0.0], [0.0, 0.0, v[2]]] +} + +pub fn scale(a: &M3, s: f64) -> M3 { + a.map(|r| r.map(|v| v * s)) +} + +fn blend(a: &M3, b: &M3, w: f64) -> M3 { + let mut m = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = w * a[i][j] + (1.0 - w) * b[i][j]; + } + } + m +} + +/// The inverse of `a`, or `None` when it is (nearly) singular. +pub fn inverse(a: &M3) -> Option { + let c = |r: usize, s: usize| a[(r + 1) % 3][(s + 1) % 3] * a[(r + 2) % 3][(s + 2) % 3] - a[(r + 1) % 3][(s + 2) % 3] * a[(r + 2) % 3][(s + 1) % 3]; + let det = a[0][0] * c(0, 0) + a[0][1] * c(0, 1) + a[0][2] * c(0, 2); + let norm: f64 = a.iter().flatten().map(|v| v.abs()).fold(0.0, f64::max); + if !det.is_finite() || det.abs() <= 1e-12 * norm.powi(3).max(1e-300) { + return None; + } + let mut m = [[0.0; 3]; 3]; + for (i, row) in m.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = c(j, i) / det; + } + } + Some(m) +} + +/// XYZ (Y = 1) of a chromaticity. +pub fn xy_to_xyz((x, y): (f64, f64)) -> V3 { + let y = y.max(1e-6); + [x / y, 1.0, (1.0 - x - y) / y] +} + +pub fn xyz_to_xy(v: &V3) -> Option<(f64, f64)> { + let s = v[0] + v[1] + v[2]; + (s.is_finite() && s > 1e-12).then(|| (v[0] / s, v[1] / s)) +} + +/// Correlated colour temperature (kelvin) of a chromaticity, McCamy (1992), limited to +/// 1 000–50 000 K. +pub fn cct((x, y): (f64, f64)) -> f64 { + let d = 0.1858 - y; + if d.abs() < 1e-9 { + return 50_000.0; + } + let n = (x - 0.3320) / d; + let t = 449.0 * n.powi(3) + 3525.0 * n.powi(2) + 6823.3 * n + 5520.33; + if t.is_finite() { t.clamp(1_000.0, 50_000.0) } else { 50_000.0 } +} + +/// Colour temperature of an EXIF LightSource code used as a DNG CalibrationIlluminant. +pub fn illuminant_kelvin(code: u64) -> Option { + Some(match code { + 1 | 4 | 9 => 5500.0, // daylight, flash, fine weather + 2 | 14 => 4150.0, // fluorescent, cool white fluorescent (W 3800–4500 K) + 3 => 2850.0, // tungsten + 10 => 6500.0, // cloudy + 11 => 7500.0, // shade + 12 => 6430.0, // daylight fluorescent (D 5700–7100 K) + 13 => 5050.0, // day white fluorescent (N 4600–5500 K) + 15 => 3525.0, // white fluorescent (WW 3250–3800 K) + 16 => 2925.0, // warm white fluorescent (L 2600–3250 K) + 17 => 2856.0, // standard light A + 18 => 4874.0, // standard light B + 19 => 6774.0, // standard light C + 20 => 5503.0, // D55 + 21 => 6504.0, // D65 + 22 => 7504.0, // D75 + 23 => 5003.0, // D50 + 24 => 3200.0, // ISO studio tungsten + _ => return None, + }) +} + +/// Linear Bradford adaptation from one white (XYZ) to another. +pub fn bradford(src: &V3, dst: &V3) -> Option { + let s = mulv(&BRADFORD, src); + let d = mulv(&BRADFORD, dst); + if s.iter().any(|v| v.abs() < 1e-12) { + return None; + } + let g = [d[0] / s[0], d[1] / s[1], d[2] / s[2]]; + Some(mul(&inverse(&BRADFORD)?, &mul(&diag(&g), &BRADFORD))) +} + +/// Linear RGB (Rec. 709 / sRGB primaries, D65 white) → XYZ. +pub fn rec709_to_xyz() -> Option { + let p = [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)].map(xy_to_xyz); + let pm: M3 = [[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]; + let s = mulv(&inverse(&pm)?, &xy_to_xyz(D65)); + Some(mul(&pm, &diag(&s))) +} + +/// XYZ relative to D50 → linear Rec. 709. +pub fn xyz_d50_to_rec709() -> Option { + let to_d65 = bradford(&xy_to_xyz(D50), &xy_to_xyz(D65))?; + Some(mul(&inverse(&rec709_to_xyz()?)?, &to_d65)) +} + +/// The colour tags of a three-colour DNG. +#[derive(Clone, Debug, Default)] +pub struct Calibration { + pub color_matrix: [Option; 2], + pub camera_calibration: [Option; 2], + pub forward_matrix: [Option; 2], + /// EXIF LightSource codes of the two calibrations. + pub illuminant: [Option; 2], + pub analog_balance: Option, + pub as_shot_neutral: Option, + pub as_shot_white_xy: Option<(f64, f64)>, + pub baseline_exposure: f64, +} + +/// The result of [`Calibration::solve`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Solved { + /// Camera neutral: raw (normalised) camera values of a white surface. Dividing by it white + /// balances the camera values. + pub neutral: V3, + /// White-balanced camera values → linear Rec. 709, including the baseline exposure. The white + /// balanced white (1, 1, 1) goes to Y = 2^BaselineExposure. + pub matrix: M3, + /// The white point found, for information. + pub white_xy: (f64, f64), +} + +impl Calibration { + /// Weight of calibration 1 for a white of temperature `t` (1 when only one is usable). + fn weight(&self, t: f64) -> f64 { + let (Some(_), Some(_)) = (self.color_matrix[0], self.color_matrix[1]) else { return 1.0 }; + let (Some(t1), Some(t2)) = (self.illuminant[0].and_then(illuminant_kelvin), self.illuminant[1].and_then(illuminant_kelvin)) else { + return 1.0; + }; + if (t1 - t2).abs() < 1.0 { + return 1.0; + } + // the formula needs t1 < t2; swap the roles otherwise + let (lo, hi, lo_is_1) = if t1 < t2 { (t1, t2, true) } else { (t2, t1, false) }; + let w_lo = if t <= lo { + 1.0 + } else if t >= hi { + 0.0 + } else { + (1.0 / t - 1.0 / hi) / (1.0 / lo - 1.0 / hi) + }; + if lo_is_1 { w_lo } else { 1.0 - w_lo } + } + + fn pick(&self, m: &[Option; 2], w: f64, default: M3) -> M3 { + match (m[0], m[1]) { + (Some(a), Some(b)) => blend(&a, &b, w), + (Some(a), None) => a, + (None, Some(b)) if self.color_matrix[0].is_none() => b, + _ => default, + } + } + + /// XYZ → camera at weight `w`: AB · CC · CM. + fn xyz_to_camera(&self, w: f64) -> Option { + let cm = match self.color_matrix { + [None, None] => return None, + _ => self.pick(&self.color_matrix, w, IDENTITY), + }; + let cc = self.pick(&self.camera_calibration, w, IDENTITY); + let ab = diag(&self.analog_balance.unwrap_or([1.0; 3])); + Some(mul(&ab, &mul(&cc, &cm))) + } + + /// Work out the white balance and the camera → linear Rec. 709 matrix. + pub fn solve(&self) -> Result { + let out_matrix = xyz_d50_to_rec709().ok_or(Error::Malformed("colour maths"))?; + let exposure = 2f64.powf(self.baseline_exposure.clamp(-16.0, 16.0)); + if self.color_matrix.iter().all(Option::is_none) { + // no colour calibration: the camera values are taken as Rec. 709, white balanced + let neutral = self.as_shot_neutral.unwrap_or([1.0; 3]); + if neutral.iter().any(|v| !v.is_finite() || *v <= 1e-9) { + return Err(Error::Malformed("camera neutral must be positive")); + } + return Ok(Solved { neutral, matrix: scale(&IDENTITY, exposure), white_xy: D65 }); + } + let bad = || Error::Malformed("singular colour matrix"); + // the white point and the camera neutral + let (white_xy, neutral) = if let Some(n) = self.as_shot_neutral { + let mut xy = D50; + for _ in 0..50 { + let w = self.weight(cct(xy)); + let to_xyz = inverse(&self.xyz_to_camera(w).ok_or_else(bad)?).ok_or_else(bad)?; + let next = xyz_to_xy(&mulv(&to_xyz, &n)).ok_or(Error::Malformed("AsShotNeutral is not a colour"))?; + let done = (next.0 - xy.0).abs() + (next.1 - xy.1).abs() < 1e-9; + xy = next; + if done { + break; + } + } + (xy, n) + } else { + let xy = self.as_shot_white_xy.unwrap_or(D65); + let w = self.weight(cct(xy)); + let n = mulv(&self.xyz_to_camera(w).ok_or_else(bad)?, &xy_to_xyz(xy)); + (xy, n) + }; + if neutral.iter().any(|v| !v.is_finite() || *v <= 1e-9) { + return Err(Error::Malformed("camera neutral must be positive")); + } + let w = self.weight(cct(white_xy)); + let ab_cc = mul(&diag(&self.analog_balance.unwrap_or([1.0; 3])), &self.pick(&self.camera_calibration, w, IDENTITY)); + let camera_to_xyz_d50 = if self.forward_matrix.iter().any(Option::is_some) { + let fm = self.pick(&self.forward_matrix, w, IDENTITY); + let inv_ab_cc = inverse(&ab_cc).ok_or_else(bad)?; + let reference = mulv(&inv_ab_cc, &neutral); + if reference.iter().any(|v| v.abs() < 1e-12) { + return Err(bad()); + } + mul(&fm, &mul(&diag(&reference.map(|v| 1.0 / v)), &inv_ab_cc)) + } else { + let to_xyz = inverse(&self.xyz_to_camera(w).ok_or_else(bad)?).ok_or_else(bad)?; + let ca = bradford(&xy_to_xyz(white_xy), &xy_to_xyz(D50)).ok_or_else(bad)?; + mul(&ca, &to_xyz) + }; + // white-balanced camera values: camera = neutral · wb + let wb_to_xyz = mul(&camera_to_xyz_d50, &diag(&neutral)); + let y = mulv(&wb_to_xyz, &[1.0; 3])[1]; + if !y.is_finite() || y <= 1e-9 { + return Err(bad()); + } + let matrix = scale(&mul(&out_matrix, &wb_to_xyz), exposure / y); + if matrix.iter().flatten().any(|v| !v.is_finite()) { + return Err(bad()); + } + Ok(Solved { neutral, matrix, white_xy }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: &M3, b: &M3, tol: f64) -> bool { + a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol) + } + + #[test] + fn rec709_matrix_matches_the_published_one() { + // IEC 61966-2-1 (sRGB) linear RGB → XYZ, four decimals + let m = rec709_to_xyz().unwrap(); + let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]; + assert!(close(&m, &want, 1e-3), "{m:?}"); + } + + #[test] + fn inverse_round_trips_and_refuses_singular() { + let a = [[2.0, 1.0, 0.5], [0.1, 3.0, 0.2], [0.3, 0.4, 1.5]]; + assert!(close(&mul(&a, &inverse(&a).unwrap()), &IDENTITY, 1e-12)); + assert!(inverse(&[[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.0, 0.0, 1.0]]).is_none()); + assert!(inverse(&[[f64::NAN; 3]; 3]).is_none()); + } + + #[test] + fn mccamy_temperatures() { + assert!((cct(D65) - 6504.0).abs() < 15.0, "{}", cct(D65)); + assert!((cct(D50) - 5003.0).abs() < 15.0, "{}", cct(D50)); + assert!((cct((0.44757, 0.40745)) - 2856.0).abs() < 15.0, "illuminant A"); + assert_eq!(cct((0.3320, 0.1858)), 50_000.0, "no division by zero"); + } + + #[test] + fn interpolation_weight_follows_inverse_temperature() { + let c = Calibration { color_matrix: [Some(IDENTITY), Some(IDENTITY)], illuminant: [Some(17), Some(21)], ..Default::default() }; + assert_eq!(c.weight(2000.0), 1.0); + assert_eq!(c.weight(9000.0), 0.0); + let mid = c.weight(4000.0); + let want = (1.0 / 4000.0 - 1.0 / 6504.0) / (1.0 / 2856.0 - 1.0 / 6504.0); + assert!((mid - want).abs() < 1e-12); + // swapped order: weight of calibration 1 (now D65) + let s = Calibration { illuminant: [Some(21), Some(17)], ..c.clone() }; + assert!((s.weight(4000.0) - (1.0 - want)).abs() < 1e-12); + // unknown illuminant: calibration 1 only + let u = Calibration { illuminant: [Some(0), Some(21)], ..c }; + assert_eq!(u.weight(4000.0), 1.0); + } + + #[test] + fn an_srgb_camera_maps_to_itself() { + // ColorMatrix = XYZ → linear sRGB, white D65: the camera is Rec. 709 already + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { color_matrix: [Some(cm), None], illuminant: [Some(21), None], as_shot_neutral: Some([1.0; 3]), ..Default::default() }; + let s = c.solve().unwrap(); + assert!(close(&s.matrix, &IDENTITY, 1e-6), "{:?}", s.matrix); + assert!((s.white_xy.0 - D65.0).abs() < 1e-4 && (s.white_xy.1 - D65.1).abs() < 1e-4, "{:?}", s.white_xy); + // a baseline exposure of +1 doubles everything + let e = Calibration { baseline_exposure: 1.0, ..c }.solve().unwrap(); + assert!(close(&e.matrix, &scale(&IDENTITY, 2.0), 1e-6)); + } + + #[test] + fn forward_matrix_white_balances_by_the_neutral() { + // FM = linear Rec. 709 → XYZ D50: the white-balanced camera values come out unchanged + let fm = mul(&bradford(&xy_to_xyz(D65), &xy_to_xyz(D50)).unwrap(), &rec709_to_xyz().unwrap()); + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { + color_matrix: [Some(cm), None], + forward_matrix: [Some(fm), None], + illuminant: [Some(21), None], + as_shot_neutral: Some([0.5, 1.0, 0.8]), + ..Default::default() + }; + let s = c.solve().unwrap(); + assert_eq!(s.neutral, [0.5, 1.0, 0.8]); + assert!(close(&s.matrix, &IDENTITY, 1e-6), "{:?}", s.matrix); + } + + #[test] + fn white_xy_gives_the_neutral() { + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let c = Calibration { color_matrix: [Some(cm), None], illuminant: [Some(21), None], as_shot_white_xy: Some(D65), ..Default::default() }; + let s = c.solve().unwrap(); + for v in s.neutral { + assert!((v - s.neutral[1]).abs() < 1e-3, "D65 is neutral for an sRGB camera: {:?}", s.neutral); + } + } + + #[test] + fn hostile_calibrations_are_errors() { + let zero = Calibration { color_matrix: [Some([[0.0; 3]; 3]), None], as_shot_neutral: Some([1.0; 3]), ..Default::default() }; + assert!(zero.solve().is_err()); + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let neg = Calibration { color_matrix: [Some(cm), None], as_shot_neutral: Some([-1.0, 1.0, 1.0]), ..Default::default() }; + assert!(neg.solve().is_err()); + let none = Calibration { as_shot_neutral: Some([0.5, 1.0, 0.5]), ..Default::default() }.solve().unwrap(); + assert_eq!(none.matrix, IDENTITY); + } +} diff --git a/crates/dng/src/demosaic.rs b/crates/dng/src/demosaic.rs new file mode 100644 index 000000000..911236929 --- /dev/null +++ b/crates/dng/src/demosaic.rs @@ -0,0 +1,255 @@ +//! Demosaicing: one colour sample per pixel (a colour filter array) to three. +//! +//! - A 2 × 2 Bayer pattern (one red, two green, one blue) uses the gradient-corrected linear +//! interpolation of H. S. Malvar, L.-W. He and R. Cutler, "High-quality linear interpolation for +//! demosaicing of Bayer-patterned color images", IEEE ICASSP 2004: bilinear interpolation plus a +//! Laplacian correction from the pixel's own colour, as 5 × 5 filters. +//! - Any other pattern (up to 8 × 8) uses bilinear interpolation: the mean of the nearest samples +//! of each missing colour in the 3 × 3 neighbourhood, else the 5 × 5 one. +//! +//! The picture is extended by two pixels on every side by mirroring (without repeating the edge), +//! which keeps a 2 × 2 pattern's phase. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +/// A CFA pattern: `rows` × `cols` colour indices (0 red, 1 green, 2 blue), origin at the top left +/// of the image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pattern { + pub rows: usize, + pub cols: usize, + pub colors: Vec, +} + +impl Pattern { + pub fn color(&self, x: usize, y: usize) -> u8 { + self.colors.get((y % self.rows) * self.cols + x % self.cols).copied().unwrap_or(1) + } + + /// Whether this is a 2 × 2 Bayer pattern. + pub fn is_bayer(&self) -> bool { + if (self.rows, self.cols) != (2, 2) { + return false; + } + let [a, b, c, d] = self.colors[..] else { return false }; + let count = |k: u8| [a, b, c, d].iter().filter(|&&v| v == k).count(); + // the two greens sit on a diagonal + count(0) == 1 && count(1) == 2 && count(2) == 1 && ((a == 1 && d == 1) || (b == 1 && c == 1)) + } +} + +const PAD: usize = 2; + +/// Mirror index `i` (which may be up to PAD outside) into 0..n. +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + let mut i = i; + if i < 0 { + i = -i; + } + if i >= n { + i = 2 * (n - 1) - i; + } + i.clamp(0, n - 1) as usize +} + +/// The plane extended by PAD mirrored pixels on each side. +fn padded(plane: &[f32], w: usize, h: usize) -> (Vec, usize) { + let pw = w + 2 * PAD; + let ph = h + 2 * PAD; + let mut out = vec![0f32; pw * ph]; + for y in 0..ph { + let sy = mirror(y as isize - PAD as isize, h); + for x in 0..pw { + let sx = mirror(x as isize - PAD as isize, w); + out[y * pw + x] = plane.get(sy * w + sx).copied().unwrap_or(0.0); + } + } + (out, pw) +} + +/// Demosaic `plane` (`w` × `h`, one sample per pixel) into interleaved RGB. +pub fn demosaic(plane: &[f32], w: usize, h: usize, pattern: &Pattern) -> Vec { + let mut out = vec![0f32; w * h * 3]; + if w == 0 || h == 0 || plane.len() < w * h { + return out; + } + let (p, pw) = padded(plane, w, h); + let bayer = pattern.is_bayer() && w >= 3 && h >= 3; + let row = |y: usize, o: &mut [f32]| { + if bayer { + malvar_row(&p, pw, y, w, pattern, o); + } else { + bilinear_row(&p, pw, y, w, h, pattern, o); + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut(w * 3).enumerate().for_each(|(y, o)| row(y, o)); + out +} + +fn malvar_row(p: &[f32], pw: usize, y: usize, w: usize, pat: &Pattern, out: &mut [f32]) { + let py = y + PAD; + for x in 0..w { + let px = x + PAD; + let at = |dx: isize, dy: isize| p[((py as isize + dy) as usize) * pw + (px as isize + dx) as usize]; + let c = at(0, 0); + let cross1 = at(-1, 0) + at(1, 0) + at(0, -1) + at(0, 1); + let cross2 = at(-2, 0) + at(2, 0) + at(0, -2) + at(0, 2); + let diag = at(-1, -1) + at(1, -1) + at(-1, 1) + at(1, 1); + let me = pat.color(x, y); + let rgb = match me { + 1 => { + let h1 = at(-1, 0) + at(1, 0); + let h2 = at(-2, 0) + at(2, 0); + let v1 = at(0, -1) + at(0, 1); + let v2 = at(0, -2) + at(0, 2); + // colour along the row and along the column + let along_row = (5.0 * c + 4.0 * h1 - h2 - diag + 0.5 * v2) / 8.0; + let along_col = (5.0 * c + 4.0 * v1 - v2 - diag + 0.5 * h2) / 8.0; + if pat.color(x + 1, y) == 0 { [along_row, c, along_col] } else { [along_col, c, along_row] } + } + _ => { + let g = (4.0 * c + 2.0 * cross1 - cross2) / 8.0; + let other = (6.0 * c + 2.0 * diag - 1.5 * cross2) / 8.0; + if me == 0 { [c, g, other] } else { [other, g, c] } + } + }; + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} + +fn bilinear_row(p: &[f32], pw: usize, y: usize, w: usize, h: usize, pat: &Pattern, out: &mut [f32]) { + for x in 0..w { + let me = pat.color(x, y); + let mut rgb = [0f32; 3]; + for (k, v) in rgb.iter_mut().enumerate() { + if usize::from(me) == k { + *v = p[(y + PAD) * pw + x + PAD]; + continue; + } + for r in 1..=PAD as isize { + let (mut sum, mut n) = (0f32, 0u32); + for dy in -r..=r { + for dx in -r..=r { + // the colour of a mirrored neighbour is the colour where it was mirrored from + let sx = mirror(x as isize + dx, w); + let sy = mirror(y as isize + dy, h); + if usize::from(pat.color(sx, sy)) == k { + sum += p[(sy + PAD) * pw + sx + PAD]; + n += 1; + } + } + } + if n > 0 { + *v = sum / n as f32; + break; + } + } + } + if let Some(o) = out.get_mut(x * 3..x * 3 + 3) { + o.copy_from_slice(&rgb); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rggb() -> Pattern { + Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] } + } + + /// A mosaic of an RGB picture given per pixel. + fn mosaic(w: usize, h: usize, pat: &Pattern, rgb: impl Fn(usize, usize) -> [f32; 3]) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| rgb(x, y)[usize::from(pat.color(x, y))]).collect() + } + + #[test] + fn bayer_detection() { + assert!(rggb().is_bayer()); + for c in [[1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]] { + assert!(Pattern { rows: 2, cols: 2, colors: c.to_vec() }.is_bayer(), "{c:?}"); + } + assert!(!Pattern { rows: 2, cols: 2, colors: vec![0, 1, 2, 1] }.is_bayer(), "greens side by side"); + assert!(!Pattern { rows: 2, cols: 2, colors: vec![0, 0, 1, 2] }.is_bayer()); + assert!(!Pattern { rows: 1, cols: 3, colors: vec![0, 1, 2] }.is_bayer()); + } + + #[test] + fn flat_fields_stay_flat_for_every_bayer_phase_and_other_patterns() { + let pats = [[0, 1, 1, 2], [1, 0, 2, 1], [1, 2, 0, 1], [2, 1, 1, 0]].map(|c| Pattern { rows: 2, cols: 2, colors: c.to_vec() }); + let stripes = Pattern { rows: 1, cols: 3, colors: vec![0, 1, 2] }; + for pat in pats.iter().chain([&stripes]) { + let (w, h) = (9, 7); + let m = mosaic(w, h, pat, |_, _| [0.25, 0.5, 0.75]); + let out = demosaic(&m, w, h, pat); + for (i, px) in out.chunks(3).enumerate() { + for (k, want) in [0.25, 0.5, 0.75].iter().enumerate() { + assert!((px[k] - want).abs() < 1e-6, "{pat:?} pixel {i} channel {k}: {}", px[k]); + } + } + } + } + + #[test] + fn known_samples_are_kept_and_linear_ramps_are_exact() { + // a horizontal ramp is reconstructed exactly away from the border (all filters are + // symmetric and sum to one, the correction terms sum to zero) + let pat = rggb(); + let (w, h) = (16, 12); + let f = |x: usize, _y: usize| { + let v = x as f32 / 16.0; + [v, 0.5 * v + 0.1, 0.2 + 0.25 * v] + }; + let m = mosaic(w, h, &pat, f); + let out = demosaic(&m, w, h, &pat); + for y in 0..h { + for x in 0..w { + let px = &out[(y * w + x) * 3..][..3]; + let own = usize::from(pat.color(x, y)); + assert_eq!(px[own], m[y * w + x], "own sample kept"); + if (2..w - 2).contains(&x) { + let want = f(x, y); + for k in 0..3 { + assert!((px[k] - want[k]).abs() < 1e-5, "({x},{y}) ch {k}: {} vs {}", px[k], want[k]); + } + } + } + } + } + + #[test] + fn a_vertical_edge_does_not_smear_far() { + // white left half, black right half: pixels two columns from the edge are untouched + let pat = rggb(); + let (w, h) = (12, 8); + let m = mosaic(w, h, &pat, |x, _| if x < 6 { [1.0; 3] } else { [0.0; 3] }); + let out = demosaic(&m, w, h, &pat); + for y in 0..h { + for x in (0..3).chain(9..12) { + let want = if x < 6 { 1.0 } else { 0.0 }; + for k in 0..3 { + assert!((out[(y * w + x) * 3 + k] - want).abs() < 1e-6, "({x},{y}) ch {k}"); + } + } + } + } + + #[test] + fn tiny_and_empty_pictures_do_not_panic() { + let pat = rggb(); + for (w, h) in [(0, 0), (1, 1), (2, 1), (1, 5), (2, 2), (3, 2)] { + let m = vec![0.5; w * h]; + let out = demosaic(&m, w, h, &pat); + assert_eq!(out.len(), w * h * 3); + } + assert_eq!(demosaic(&[0.5; 3], 4, 4, &pat).len(), 48, "short plane"); + } +} diff --git a/crates/dng/src/lib.rs b/crates/dng/src/lib.rs new file mode 100644 index 000000000..c0b3e9e0b --- /dev/null +++ b/crates/dng/src/lib.rs @@ -0,0 +1,358 @@ +//! Clean-room DNG and CinemaDNG decoder, implemented from the public specifications only (see the +//! crate README for editions): the TIFF 6.0 / TIFF-EP container, uncompressed and lossless JPEG +//! (ITU-T T.81 process 14) raw data, and the DNG processing model (linearisation, black and white +//! levels, white balance, demosaic, default crop, colour matrices) to a scene-linear Rec. 709 +//! picture. +//! +//! - [`sniff`]: is this a DNG (a TIFF with a DNGVersion tag)? +//! - [`Dng::parse`] → [`Dng::info`] (size, bit depth, CinemaDNG frame rate and time code), +//! [`Dng::raw`] (the raw samples) and [`Dng::develop`] (linear RGBA floats). +//! - [`decode`]: parse and develop in one call. +//! +//! Every number in the file is hostile: offsets, counts and sizes are checked, allocations are +//! capped by [`raw::MAX_SAMPLES`] and by the amount of data present, and the IFD walk is bounded. + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +pub mod color; +pub mod demosaic; +pub mod ljpeg; +pub mod process; +pub mod raw; +pub mod tiff; +#[cfg(any(test, feature = "writer"))] +pub mod writer; + +use color::{Calibration, M3, V3}; +use demosaic::Pattern; +use process::Params; +use raw::{PHOTOMETRIC_CFA, RawImage}; +use tiff::{Entry, Ifd, Tiff, tags}; + +/// DNG decoding errors. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not a DNG file")] + NotDng, + #[error("truncated DNG data")] + Truncated, + #[error("raw image too large")] + TooLarge, + #[error("malformed DNG: {0}")] + Malformed(&'static str), + #[error("unsupported DNG: {0}")] + Unsupported(String), +} + +pub type Result = std::result::Result; + +/// Whether `b` is a DNG: a TIFF file whose first IFD has a DNGVersion tag. +pub fn sniff(b: &[u8]) -> bool { + let Ok((t, first)) = Tiff::new(b) else { return false }; + t.read_ifd(first, 0).is_ok_and(|(ifd, _)| ifd.get(tags::DNG_VERSION).is_some()) +} + +/// SMPTE ST 12-1 time code of a CinemaDNG frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Timecode { + pub hours: u8, + pub minutes: u8, + pub seconds: u8, + pub frames: u8, + pub drop_frame: bool, +} + +impl Timecode { + /// The first time code of a CinemaDNG TimeCodes tag (SMPTE ST 331 / 12-1 packing: BCD frames, + /// seconds, minutes, hours in bytes 0–3, the drop-frame flag in bit 6 of byte 0). + pub fn from_bytes(b: &[u8]) -> Option { + let bcd = |v: u8, tens_mask: u8| { + let (u, t) = (v & 0x0F, (v >> 4) & tens_mask); + (u <= 9).then_some(t * 10 + u) + }; + let [f, s, m, h, ..] = *b else { return None }; + let tc = Timecode { frames: bcd(f, 0x3)?, seconds: bcd(s, 0x7)?, minutes: bcd(m, 0x7)?, hours: bcd(h, 0x3)?, drop_frame: f & 0x40 != 0 }; + (tc.seconds < 60 && tc.minutes < 60 && tc.hours < 24).then_some(tc) + } +} + +/// What a DNG holds, without decoding the pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct Info { + /// Size of the developed picture (the default crop). + pub width: usize, + pub height: usize, + /// Size of the stored raw image. + pub raw_width: usize, + pub raw_height: usize, + pub bits: u32, + pub samples_per_pixel: usize, + /// Colour filter array data (else LinearRaw). + pub cfa: bool, + /// "uncompressed" or "lossless JPEG". + pub compression: &'static str, + pub dng_version: [u8; 4], + /// CinemaDNG FrameRate (numerator, denominator). + pub frame_rate: Option<(i64, i64)>, + /// CinemaDNG time code of this frame. + pub timecode: Option, + /// UniqueCameraModel, else Make and Model. + pub camera: Option, + /// CinemaDNG ReelName. + pub reel: Option, +} + +/// A developed DNG: premultiplied, scene-linear Rec. 709 RGBA floats (alpha 1), top row first. +/// A white surface the sensor saw at its clipping level is 1.0 times 2^BaselineExposure. +pub struct Image { + pub info: Info, + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + +/// A parsed DNG file. +pub struct Dng<'a> { + tiff: Tiff<'a>, + ifds: Vec, + /// Index of the main raw IFD in `ifds` (the first is IFD 0). + raw: usize, +} + +impl<'a> Dng<'a> { + /// Parse the container and find the main raw image. + pub fn parse(bytes: &'a [u8]) -> Result> { + let (tiff, first) = Tiff::new(bytes)?; + let ifds = tiff.all_ifds(first)?; + if ifds.first().and_then(|i| i.get(tags::DNG_VERSION)).is_none() { + return Err(Error::NotDng); + } + let main = raw::find_raw_ifd(&tiff, &ifds)?; + let raw = ifds.iter().position(|i| i.offset == main.offset).ok_or(Error::Malformed("raw IFD"))?; + Ok(Dng { tiff, ifds, raw }) + } + + fn ifd0(&self) -> &Ifd { + static EMPTY: Ifd = Ifd { offset: 0, entries: Vec::new(), depth: 0 }; + self.ifds.first().unwrap_or(&EMPTY) + } + + fn raw_ifd(&self) -> &Ifd { + self.ifds.get(self.raw).unwrap_or(self.ifd0()) + } + + /// A raw-image tag: from the raw IFD, else IFD 0. + fn raw_tag(&self, tag: u16) -> Option<&Entry> { + self.raw_ifd().get(tag).or_else(|| self.ifd0().get(tag)) + } + + /// A camera-wide tag: from IFD 0, else the raw IFD. + fn main_tag(&self, tag: u16) -> Option<&Entry> { + self.ifd0().get(tag).or_else(|| self.raw_ifd().get(tag)) + } + + /// A numeric tag's values; a value outside the file (or a zero denominator) is an error, so a + /// damaged file does not silently lose its white balance. + fn floats(&self, e: Option<&Entry>) -> Result>> { + e.map(|e| self.tiff.floats(e)).transpose() + } + + /// A 3 × 3 matrix tag (`None` when absent or not 3 × 3, e.g. for other colour counts). + fn matrix(&self, tag: u16) -> Result> { + let Some(v) = self.floats(self.main_tag(tag))? else { return Ok(None) }; + let Ok(v) = <[f64; 9]>::try_from(v.as_slice()) else { return Ok(None) }; + Ok(Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])) + } + + fn vec3(&self, tag: u16) -> Result> { + Ok(self.floats(self.main_tag(tag))?.and_then(|v| <[f64; 3]>::try_from(v.as_slice()).ok())) + } + + fn text(&self, tag: u16) -> Option { + self.main_tag(tag).and_then(|e| self.tiff.ascii(e).ok()).filter(|s| !s.is_empty()) + } + + /// The CFA pattern in camera planes (`None` for LinearRaw). + fn cfa(&self) -> Result> { + let l = raw::layout(&self.tiff, self.raw_ifd())?; + if l.photometric != PHOTOMETRIC_CFA { + return Ok(None); + } + let dims = self.raw_tag(tags::CFA_REPEAT_PATTERN_DIM).map(|e| self.tiff.uints(e)).transpose()?.unwrap_or_else(|| vec![2, 2]); + let (rows, cols) = match dims[..] { + [r, c, ..] => (r as usize, c as usize), + _ => return Err(Error::Malformed("CFARepeatPatternDim")), + }; + if !(1..=8).contains(&rows) || !(1..=8).contains(&cols) { + return Err(Error::Unsupported(format!("{rows} × {cols} CFA pattern"))); + } + let pattern = self.tiff.uints(self.raw_tag(tags::CFA_PATTERN).ok_or(Error::Malformed("no CFAPattern"))?)?; + if pattern.len() < rows * cols { + return Err(Error::Malformed("CFAPattern shorter than its repeat")); + } + let planes = match self.raw_tag(tags::CFA_PLANE_COLOR) { + Some(e) => self.tiff.uints(e)?, + None => vec![0, 1, 2], + }; + let mut sorted = planes.clone(); + sorted.sort_unstable(); + if sorted != [0, 1, 2] { + return Err(Error::Unsupported("CFA colours other than red, green and blue".into())); + } + if let Some(e) = self.raw_tag(tags::CFA_LAYOUT) + && self.tiff.uint(e)? != 1 + { + return Err(Error::Unsupported("a non-rectangular CFA layout".into())); + } + let colors = pattern[..rows * cols] + .iter() + .map(|&v| planes.iter().position(|&p| p == v).map(|i| i as u8).ok_or(Error::Malformed("CFAPattern colour not in CFAPlaneColor"))) + .collect::>>()?; + Ok(Some(Pattern { rows, cols, colors })) + } + + /// The processing tags of the raw image. + pub fn params(&self) -> Result { + let uint_pair = |tag| -> Result> { + match self.raw_tag(tag) { + Some(e) => match self.tiff.uints(e)?[..] { + [a, b, ..] => Ok(Some((a as usize, b as usize))), + _ => Err(Error::Malformed("short field")), + }, + None => Ok(None), + } + }; + let active_area = match self.raw_tag(tags::ACTIVE_AREA) { + Some(e) => match self.tiff.uints(e)?[..] { + [t, l, b, r, ..] => Some([t, l, b, r].map(|v| usize::try_from(v).unwrap_or(usize::MAX))), + _ => return Err(Error::Malformed("ActiveArea")), + }, + None => None, + }; + let linearization = match self.raw_tag(tags::LINEARIZATION_TABLE) { + Some(e) => { + let v = self.tiff.uints(e)?; + if v.len() > 65_536 { + return Err(Error::Malformed("LinearizationTable longer than 65536")); + } + Some(v.into_iter().map(|x| x.min(65_535) as u16).collect()) + } + None => None, + }; + let pair = |tag| -> Result> { + match self.raw_tag(tag) { + Some(e) => match self.tiff.floats(e)?[..] { + [a, b, ..] => Ok(Some((a, b))), + _ => Err(Error::Malformed("short field")), + }, + None => Ok(None), + } + }; + let list = |tag| -> Result> { self.raw_tag(tag).map(|e| self.tiff.floats(e)).transpose().map(Option::unwrap_or_default) }; + Ok(Params { + active_area, + linearization, + black_repeat: uint_pair(tags::BLACK_LEVEL_REPEAT_DIM)?.unwrap_or((1, 1)), + black: list(tags::BLACK_LEVEL)?, + black_delta_h: list(tags::BLACK_LEVEL_DELTA_H)?, + black_delta_v: list(tags::BLACK_LEVEL_DELTA_V)?, + white: list(tags::WHITE_LEVEL)?, + crop_origin: pair(tags::DEFAULT_CROP_ORIGIN)?, + crop_size: pair(tags::DEFAULT_CROP_SIZE)?, + cfa: self.cfa()?, + }) + } + + /// The colour tags. + pub fn calibration(&self) -> Result { + let illum = |tag| self.main_tag(tag).map(|e| self.tiff.uint(e)).transpose(); + let white_xy = self.floats(self.main_tag(tags::AS_SHOT_WHITE_XY))?.and_then(|v| match v[..] { + [x, y] => Some((x, y)), + _ => None, + }); + Ok(Calibration { + color_matrix: [self.matrix(tags::COLOR_MATRIX_1)?, self.matrix(tags::COLOR_MATRIX_2)?], + camera_calibration: [self.matrix(tags::CAMERA_CALIBRATION_1)?, self.matrix(tags::CAMERA_CALIBRATION_2)?], + forward_matrix: [self.matrix(tags::FORWARD_MATRIX_1)?, self.matrix(tags::FORWARD_MATRIX_2)?], + illuminant: [illum(tags::CALIBRATION_ILLUMINANT_1)?, illum(tags::CALIBRATION_ILLUMINANT_2)?], + analog_balance: self.vec3(tags::ANALOG_BALANCE)?, + as_shot_neutral: self.vec3(tags::AS_SHOT_NEUTRAL)?, + as_shot_white_xy: white_xy, + baseline_exposure: self.floats(self.main_tag(tags::BASELINE_EXPOSURE))?.and_then(|v| v.first().copied()).unwrap_or(0.0), + }) + } + + /// Size, depth and CinemaDNG metadata. + pub fn info(&self) -> Result { + let l = raw::layout(&self.tiff, self.raw_ifd())?; + let p = self.params()?; + let (_, _, aw, ah) = process::active_rect(&p, l.width, l.height)?; + let (_, _, width, height) = process::crop_rect(&p, aw, ah); + let dng_version = self + .main_tag(tags::DNG_VERSION) + .and_then(|e| self.tiff.uints(e).ok()) + .and_then(|v| v.get(..4).map(|v| [0, 1, 2, 3].map(|i| v[i] as u8))) + .unwrap_or_default(); + let frame_rate = self.main_tag(tags::FRAME_RATE).and_then(|e| { + let b = self.tiff.value_bytes(e).ok()?; + if !matches!(e.ty, tiff::Type::SRational | tiff::Type::Rational) { + return None; + } + let n = i64::from(self.tiff.u32_of(b.get(0..4)?.try_into().ok()?) as i32); + let d = i64::from(self.tiff.u32_of(b.get(4..8)?.try_into().ok()?) as i32); + (n > 0 && d > 0 && n <= 1000 * d && n * 1000 >= d).then_some((n, d)) + }); + let timecode = self.main_tag(tags::TIME_CODES).and_then(|e| self.tiff.value_bytes(e).ok()).and_then(Timecode::from_bytes); + let camera = self.text(tags::UNIQUE_CAMERA_MODEL).or_else(|| { + let mm = [self.text(tags::MAKE), self.text(tags::MODEL)].into_iter().flatten().collect::>().join(" "); + (!mm.is_empty()).then_some(mm) + }); + Ok(Info { + width, + height, + raw_width: l.width, + raw_height: l.height, + bits: l.bits, + samples_per_pixel: l.samples_per_pixel, + cfa: p.cfa.is_some(), + compression: if l.compression == 7 { "lossless JPEG" } else { "uncompressed" }, + dng_version, + frame_rate, + timecode, + camera, + reel: self.text(tags::REEL_NAME), + }) + } + + /// The raw samples of the main image, as stored (before any processing). + pub fn raw(&self) -> Result { + raw::read(&self.tiff, self.raw_ifd()) + } + + /// Develop the main image to scene-linear Rec. 709. + pub fn develop(&self) -> Result { + let info = self.info()?; + let p = self.params()?; + let raw = self.raw()?; + let calibration = self.calibration()?; + let solved = if p.cfa.is_none() && raw.samples_per_pixel == 1 { + // monochrome: no colour, only the exposure + let e = 2f64.powf(calibration.baseline_exposure.clamp(-16.0, 16.0)); + color::Solved { neutral: [1.0; 3], matrix: color::scale(&color::IDENTITY, e), white_xy: color::D65 } + } else { + calibration.solve()? + }; + let d = process::develop(&raw, &p, &solved.neutral, &solved.matrix)?; + Ok(Image { info, width: d.width, height: d.height, rgba: d.rgba }) + } +} + +/// Parse and develop a DNG file. +pub fn decode(bytes: &[u8]) -> Result { + Dng::parse(bytes)?.develop() +} + +/// Parse a DNG file's metadata. +pub fn probe(bytes: &[u8]) -> Result { + Dng::parse(bytes)?.info() +} diff --git a/crates/dng/src/ljpeg.rs b/crates/dng/src/ljpeg.rs new file mode 100644 index 000000000..e76dbd707 --- /dev/null +++ b/crates/dng/src/ljpeg.rs @@ -0,0 +1,395 @@ +//! Lossless JPEG decoding: ITU-T T.81 | ISO/IEC 10918-1 Annex H (process 14, Huffman coded, +//! "SOF3"), the compression DNG calls lossless JPEG (Compression = 7). +//! +//! - Frame header (B.2.2): 2–16-bit precision, 1–4 components, each sampled 1×1. +//! - Huffman tables (B.2.4.2, Annex C) decoded with a 9-bit lookup and the F.2.2.3 code-length +//! walk for longer codes. +//! - Scan header (B.2.3): one scan holding every component, predictor `Ss` 1–7 (Table H.1) and +//! point transform `Al`. +//! - Prediction (H.1.2.1): the first line (and the first line after a restart) predicts from the +//! left neighbour with 2^(P − Pt − 1) for its first sample; later lines predict their first +//! sample from the one above and the rest with the selected predictor. Differences are added +//! modulo 2^16 (H.1.2.2) and the SSSS = 16 category is 32768 with no extra bits. +//! - Restart intervals (DRI, RSTn) that span whole lines. + +use crate::{Error, Result}; + +/// A decoded lossless JPEG image. +#[derive(Debug)] +pub struct Decoded { + pub width: usize, + pub height: usize, + pub components: usize, + pub precision: u8, + pub predictor: u8, + /// Samples, interleaved by component, line after line, shifted back up by the point transform. + pub samples: Vec, +} + +const LOOKUP_BITS: u32 = 9; + +/// A Huffman table for decoding (T.81 Annex C and F.2.2.3). +#[derive(Clone)] +struct Huffman { + /// For codes of up to LOOKUP_BITS bits: (length, value) indexed by the next LOOKUP_BITS bits; + /// length 0 means "longer code". + fast: Vec<(u8, u8)>, + maxcode: [i32; 17], + valptr: [i32; 17], + mincode: [i32; 17], + values: Vec, +} + +impl Huffman { + fn new(bits: &[u8; 16], values: Vec) -> Result { + // C.2: code sizes and codes + let mut sizes = Vec::new(); + for (l, &n) in bits.iter().enumerate() { + for _ in 0..n { + sizes.push(l as u8 + 1); + } + } + if sizes.len() != values.len() || sizes.is_empty() { + return Err(Error::Malformed("lossless JPEG: Huffman table counts")); + } + let mut codes = Vec::with_capacity(sizes.len()); + let mut code: u32 = 0; + let mut si = sizes[0]; + let mut k = 0; + while k < sizes.len() { + while k < sizes.len() && sizes[k] == si { + codes.push(code); + code += 1; + k += 1; + } + // the codes of one length must fit in that length + if code > (1u32 << si) { + return Err(Error::Malformed("lossless JPEG: Huffman table over-subscribed")); + } + code <<= 1; + si += 1; + } + // F.2.2.3 decoder tables + let mut maxcode = [-1i32; 17]; + let mut valptr = [0i32; 17]; + let mut mincode = [0i32; 17]; + let mut j = 0usize; + for l in 1..=16usize { + let n = usize::from(bits[l - 1]); + if n == 0 { + maxcode[l] = -1; + } else { + valptr[l] = j as i32; + mincode[l] = codes[j] as i32; + j += n; + maxcode[l] = codes[j - 1] as i32; + } + } + let mut fast = vec![(0u8, 0u8); 1 << LOOKUP_BITS]; + for (i, (&c, &s)) in codes.iter().zip(&sizes).enumerate() { + let s = u32::from(s); + if s <= LOOKUP_BITS { + let shift = LOOKUP_BITS - s; + let base = (c << shift) as usize; + for f in fast.iter_mut().skip(base).take(1 << shift) { + *f = (s as u8, values[i]); + } + } + } + Ok(Huffman { fast, maxcode, valptr, mincode, values }) + } +} + +/// Entropy-coded data reader: removes stuffed zero bytes and stops at markers (feeding zeros). +struct Bits<'a> { + data: &'a [u8], + pos: usize, + acc: u64, + n: u32, + /// The marker that ended the data (after 0xFF), if one was met. + marker: Option, +} + +impl<'a> Bits<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Bits { data, pos, acc: 0, n: 0, marker: None } + } + + fn fill(&mut self) { + while self.n <= 56 { + let mut byte = 0u8; + if self.marker.is_none() { + match self.data.get(self.pos) { + Some(&0xFF) => match self.data.get(self.pos + 1) { + Some(&0x00) => { + byte = 0xFF; + self.pos += 2; + } + Some(&m) => self.marker = Some(m), + None => self.marker = Some(0xD9), + }, + Some(&b) => { + byte = b; + self.pos += 1; + } + None => self.marker = Some(0xD9), + } + } + self.acc |= u64::from(byte) << (56 - self.n); + self.n += 8; + } + } + + fn peek(&mut self, k: u32) -> u32 { + if self.n < k { + self.fill(); + } + (self.acc >> (64 - k)) as u32 + } + + fn skip(&mut self, k: u32) { + self.acc <<= k; + self.n -= k; + } + + fn bits(&mut self, k: u32) -> u32 { + if k == 0 { + return 0; + } + let v = self.peek(k); + self.skip(k); + v + } + + /// Decode one Huffman symbol (F.2.2.3). + fn decode(&mut self, h: &Huffman) -> Result { + let p = self.peek(LOOKUP_BITS) as usize; + if let Some(&(len, v)) = h.fast.get(p) + && len > 0 + { + self.skip(u32::from(len)); + return Ok(v); + } + let mut code = self.bits(1) as i32; + for l in 1..=16usize { + if code <= h.maxcode[l] { + let i = h.valptr[l] + code - h.mincode[l]; + return h.values.get(i as usize).copied().ok_or(Error::Malformed("lossless JPEG: bad Huffman code")); + } + code = (code << 1) | self.bits(1) as i32; + } + Err(Error::Malformed("lossless JPEG: bad Huffman code")) + } + + /// Skip to the byte after the next RSTn marker, starting a new bit stream there. + fn restart(&mut self) -> Result<()> { + // the decoder may have looked ahead into fill bits; find the marker in the bytes + let mut p = self.pos; + loop { + match (self.data.get(p), self.data.get(p + 1)) { + (Some(0xFF), Some(&m)) if (0xD0..=0xD7).contains(&m) => { + *self = Bits::new(self.data, p + 2); + return Ok(()); + } + (Some(_), Some(_)) => p += 1, + _ => return Err(Error::Malformed("lossless JPEG: missing restart marker")), + } + } + } +} + +struct Component { + id: u8, + table: usize, +} + +/// Decode a lossless JPEG stream. `max_samples` caps the picture (width × height × components). +pub fn decode(data: &[u8], max_samples: usize) -> Result { + if data.get(0..2) != Some(&[0xFF, 0xD8]) { + return Err(Error::Malformed("lossless JPEG: no SOI marker")); + } + let mut pos = 2usize; + let mut tables: [Option; 4] = [None, None, None, None]; + let mut frame: Option<(u8, usize, usize, Vec)> = None; + let mut restart_interval = 0usize; + loop { + // markers may be preceded by fill bytes 0xFF + while data.get(pos) == Some(&0xFF) && data.get(pos + 1) == Some(&0xFF) { + pos += 1; + } + let (Some(&0xFF), Some(&m)) = (data.get(pos), data.get(pos + 1)) else { + return Err(Error::Malformed("lossless JPEG: expected a marker")); + }; + pos += 2; + if m == 0xD9 { + return Err(Error::Malformed("lossless JPEG: no scan")); + } + if (0xD0..=0xD8).contains(&m) || m == 0x01 { + continue; // markers without a segment + } + let len_b = data.get(pos..pos + 2).ok_or(Error::Truncated)?; + let len = usize::from(u16::from_be_bytes([len_b[0], len_b[1]])); + if len < 2 { + return Err(Error::Malformed("lossless JPEG: segment length")); + } + let seg = data.get(pos + 2..pos + len).ok_or(Error::Truncated)?; + match m { + 0xC3 => { + let [p, y0, y1, x0, x1, nf, rest @ ..] = seg else { + return Err(Error::Malformed("lossless JPEG: short frame header")); + }; + let (precision, nf) = (*p, usize::from(*nf)); + let height = usize::from(u16::from_be_bytes([*y0, *y1])); + let width = usize::from(u16::from_be_bytes([*x0, *x1])); + if !(2..=16).contains(&precision) { + return Err(Error::Unsupported(format!("lossless JPEG precision {precision}"))); + } + if height == 0 { + return Err(Error::Unsupported("lossless JPEG with the height in a DNL marker".into())); + } + if width == 0 || !(1..=4).contains(&nf) || rest.len() < nf * 3 { + return Err(Error::Malformed("lossless JPEG: frame header")); + } + let mut comps = Vec::with_capacity(nf); + for c in rest.as_chunks::<3>().0.iter().take(nf) { + if c[1] != 0x11 { + return Err(Error::Unsupported("subsampled lossless JPEG components".into())); + } + comps.push(Component { id: c[0], table: 0 }); + } + frame = Some((precision, width, height, comps)); + } + 0xC4 => { + let mut s = seg; + while !s.is_empty() { + let [tcth, counts @ ..] = s else { break }; + let bits: [u8; 16] = counts.get(..16).and_then(|b| b.try_into().ok()).ok_or(Error::Truncated)?; + let n: usize = bits.iter().map(|&b| usize::from(b)).sum(); + let values = counts.get(16..16 + n).ok_or(Error::Truncated)?.to_vec(); + let th = usize::from(tcth & 0x0F); + if tcth >> 4 != 0 || th > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table class or id")); + } + if values.iter().any(|&v| v > 16) { + return Err(Error::Malformed("lossless JPEG: difference category above 16")); + } + tables[th] = Some(Huffman::new(&bits, values)?); + s = counts.get(16 + n..).unwrap_or(&[]); + } + } + 0xDD => { + let r = seg.get(0..2).ok_or(Error::Truncated)?; + restart_interval = usize::from(u16::from_be_bytes([r[0], r[1]])); + } + 0xDA => { + let Some((precision, width, height, mut comps)) = frame.take() else { + return Err(Error::Malformed("lossless JPEG: scan before the frame header")); + }; + let ns = usize::from(*seg.first().ok_or(Error::Truncated)?); + if ns != comps.len() { + return Err(Error::Unsupported("lossless JPEG with more than one scan".into())); + } + for k in 0..ns { + let c = seg.get(1 + 2 * k..3 + 2 * k).ok_or(Error::Truncated)?; + let comp = comps.iter_mut().find(|x| x.id == c[0]).ok_or(Error::Malformed("lossless JPEG: unknown scan component"))?; + comp.table = usize::from(c[1] >> 4); + if comp.table > 3 { + return Err(Error::Malformed("lossless JPEG: Huffman table id")); + } + } + let tail = seg.get(1 + 2 * ns..4 + 2 * ns).ok_or(Error::Truncated)?; + let (predictor, pt) = (tail[0], tail[2] & 0x0F); + if !(1..=7).contains(&predictor) { + return Err(Error::Unsupported(format!("lossless JPEG predictor {predictor}"))); + } + if pt >= precision { + return Err(Error::Malformed("lossless JPEG: point transform")); + } + let n = width.checked_mul(height).and_then(|v| v.checked_mul(comps.len())).ok_or(Error::TooLarge)?; + if n > max_samples { + return Err(Error::TooLarge); + } + let mut hufs = Vec::with_capacity(comps.len()); + for c in &comps { + hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?); + } + let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?; + return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples }); + } + 0xC0..=0xC2 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => { + return Err(Error::Unsupported("lossy (DCT) JPEG in a DNG".into())); + } + _ => {} // APPn, COM, DQT … are skipped + } + pos += len; + } +} + +/// Decode the entropy-coded data of one scan (H.1.2). +fn scan(data: &[u8], start: usize, hufs: &[&Huffman], width: usize, height: usize, precision: u8, predictor: u8, pt: u8, restart: usize) -> Result> { + let nc = hufs.len(); + let row_len = width * nc; + if restart != 0 && !restart.is_multiple_of(width) { + return Err(Error::Unsupported("lossless JPEG restart interval that splits a line".into())); + } + let lines_per_interval = if restart == 0 { usize::MAX } else { restart / width }; + let mut out = vec![0u16; row_len * height]; + let mut bits = Bits::new(data, start); + let initial = 1i32 << (precision - pt - 1); + let mut line_in_interval = 0usize; + for y in 0..height { + if line_in_interval == lines_per_interval { + bits.restart()?; + line_in_interval = 0; + } + let first_line = line_in_interval == 0; + let (above, cur) = if y == 0 { + (None, &mut out[..row_len]) + } else { + let (a, c) = out.split_at_mut(y * row_len); + (Some(&a[(y - 1) * row_len..]), &mut c[..row_len]) + }; + for x in 0..width { + for (c, h) in hufs.iter().enumerate() { + let i = x * nc + c; + let ssss = u32::from(bits.decode(h)?); + let diff = match ssss { + 0 => 0, + 16 => 32768, + s => { + let v = bits.bits(s) as i32; + if v < (1 << (s - 1)) { v - (1 << s) + 1 } else { v } + } + }; + let px = match above { + _ if first_line && x == 0 => initial, + _ if first_line => i32::from(cur[i - nc]), + Some(a) if x == 0 => i32::from(a[i]), + Some(a) => predict(predictor, i32::from(cur[i - nc]), i32::from(a[i]), i32::from(a[i - nc])), + None => initial, + }; + cur[i] = ((px + diff) & 0xFFFF) as u16; + } + } + line_in_interval += 1; + } + if pt > 0 { + for s in &mut out { + *s <<= pt; + } + } + Ok(out) +} + +/// Table H.1 predictors (`ra` left, `rb` above, `rc` above left). +pub fn predict(p: u8, ra: i32, rb: i32, rc: i32) -> i32 { + match p { + 1 => ra, + 2 => rb, + 3 => rc, + 4 => ra + rb - rc, + 5 => ra + ((rb - rc) >> 1), + 6 => rb + ((ra - rc) >> 1), + _ => (ra + rb) >> 1, + } +} diff --git a/crates/dng/src/process.rs b/crates/dng/src/process.rs new file mode 100644 index 000000000..41d9b25ae --- /dev/null +++ b/crates/dng/src/process.rs @@ -0,0 +1,264 @@ +//! Raw samples to a linear Rec. 709 picture, in the order of the DNG specification's chapter 5 +//! ("Mapping Raw Values to Linear Reference Values") and chapter 6 (colour): +//! +//! 1. ActiveArea: only the active (unmasked) sensor area is used; black levels, the CFA pattern +//! and the default crop are relative to its top left corner. +//! 2. LinearizationTable maps each sample. +//! 3. The black level (BlackLevel over its BlackLevelRepeatDim pattern, plus BlackLevelDeltaH for +//! the column and BlackLevelDeltaV for the row) is subtracted and the range up to WhiteLevel +//! scaled to 0–1 (clipped there). +//! 4. White balance: each sample is divided by the camera neutral of its colour and clipped at +//! 1, so highlights the sensor clipped stay neutral. +//! 5. A CFA is demosaiced ([`crate::demosaic`]). +//! 6. DefaultCropOrigin / DefaultCropSize cut the picture. +//! 7. The colour matrix of [`crate::color`] takes the white-balanced camera values to linear +//! Rec. 709 (including BaselineExposure); negative results are clipped to 0. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use crate::color::{M3, V3}; +use crate::demosaic::{Pattern, demosaic}; +use crate::raw::RawImage; +use crate::{Error, Result}; + +/// The processing tags of a raw image. +#[derive(Clone, Debug, Default)] +pub struct Params { + /// top, left, bottom, right + pub active_area: Option<[usize; 4]>, + pub linearization: Option>, + /// rows, columns of the BlackLevel pattern + pub black_repeat: (usize, usize), + /// rows × columns × samples per pixel values + pub black: Vec, + pub black_delta_h: Vec, + pub black_delta_v: Vec, + /// per sample + pub white: Vec, + pub crop_origin: Option<(f64, f64)>, + pub crop_size: Option<(f64, f64)>, + /// CFA pattern in camera planes (`None`: LinearRaw). + pub cfa: Option, +} + +/// A rectangle: x, y, width, height. +type Rect = (usize, usize, usize, usize); + +/// The active area of a `w` × `h` raw image. +pub fn active_rect(p: &Params, w: usize, h: usize) -> Result { + match p.active_area { + None => Ok((0, 0, w, h)), + Some([top, left, bottom, right]) => { + if top >= bottom || left >= right || bottom > h || right > w { + return Err(Error::Malformed("ActiveArea outside the image")); + } + Ok((left, top, right - left, bottom - top)) + } + } +} + +/// The default crop inside an active area of `aw` × `ah`. +pub fn crop_rect(p: &Params, aw: usize, ah: usize) -> Rect { + let (ox, oy) = p.crop_origin.unwrap_or((0.0, 0.0)); + let (sw, sh) = p.crop_size.unwrap_or((aw as f64, ah as f64)); + let to = |v: f64, max: usize| if v.is_finite() && v > 0.0 { (v.min(max as f64)) as usize } else { 0 }; + let x = to(ox.floor(), aw.saturating_sub(1)); + let y = to(oy.floor(), ah.saturating_sub(1)); + let w = to(sw.round(), aw - x).max(1); + let h = to(sh.round(), ah - y).max(1); + (x, y, w, h) +} + +/// Normalised, white-balanced samples of the active area (steps 1–4), one plane per sample. +fn normalise(raw: &RawImage, p: &Params, (ax, ay, aw, ah): Rect, neutral: &V3, planes: usize) -> Result> { + let spp = raw.samples_per_pixel; + let (br, bc) = (p.black_repeat.0.max(1), p.black_repeat.1.max(1)); + if br > 64 || bc > 64 { + return Err(Error::Unsupported("a BlackLevelRepeatDim larger than 64".into())); + } + let need = br * bc * spp; + let black: Vec = match p.black.len() { + 0 => vec![0.0; need], + 1 => vec![p.black[0]; need], + n if n >= need => p.black[..need].to_vec(), + _ => return Err(Error::Malformed("BlackLevel has too few values")), + }; + let max_code = ((1u32 << raw.bits) - 1) as f64; + let white: Vec = (0..spp).map(|s| p.white.get(s).or(p.white.first()).copied().unwrap_or(max_code)).collect(); + let lin = p.linearization.as_deref().filter(|t| !t.is_empty()); + let inv_neutral: Vec = (0..planes).map(|c| (1.0 / neutral.get(c).copied().unwrap_or(1.0)) as f32).collect(); + let cfa = p.cfa.as_ref(); + let mut out = vec![0f32; aw * ah * spp]; + let row = |y: usize, o: &mut [f32]| { + let dv = p.black_delta_v.get(y).copied().unwrap_or(0.0); + let src_row = (ay + y) * raw.width * spp; + for x in 0..aw { + let dh = p.black_delta_h.get(x).copied().unwrap_or(0.0); + for s in 0..spp { + let code = raw.data.get(src_row + (ax + x) * spp + s).copied().unwrap_or(0); + let v = match lin { + Some(t) => f64::from(t.get(usize::from(code)).or(t.last()).copied().unwrap_or(0)), + None => f64::from(code), + }; + let b = black[((y % br) * bc + x % bc) * spp + s] + dh + dv; + let range = white[s] - b; + let n = if range > 0.0 { ((v - b) / range).clamp(0.0, 1.0) } else { 0.0 }; + let plane = match cfa { + Some(c) => usize::from(c.color(x, y)), + None => s, + }; + let wb = inv_neutral.get(plane).copied().unwrap_or(1.0); + if let Some(o) = o.get_mut(x * spp + s) { + *o = (n as f32 * wb).min(1.0); + } + } + } + }; + #[cfg(feature = "threads")] + out.par_chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + out.chunks_mut((aw * spp).max(1)).enumerate().for_each(|(y, o)| row(y, o)); + Ok(out) +} + +/// A developed picture: premultiplied linear Rec. 709 RGBA floats (alpha 1). +pub struct Developed { + pub width: usize, + pub height: usize, + pub rgba: Vec, +} + +/// Develop `raw` (steps 1–7). `neutral` and `matrix` come from [`crate::color::Calibration::solve`]. +pub fn develop(raw: &RawImage, p: &Params, neutral: &V3, matrix: &M3) -> Result { + let spp = raw.samples_per_pixel; + if p.cfa.is_some() && spp != 1 { + return Err(Error::Malformed("a CFA image with more than one sample per pixel")); + } + if p.cfa.is_none() && !matches!(spp, 1 | 3) { + return Err(Error::Unsupported(format!("LinearRaw with {spp} samples per pixel"))); + } + let active = active_rect(p, raw.width, raw.height)?; + let (_, _, aw, ah) = active; + let planes = if p.cfa.is_some() { 3 } else { spp }; + let norm = normalise(raw, p, active, neutral, planes)?; + // three samples per pixel + let rgb = match (&p.cfa, spp) { + (Some(pat), _) => demosaic(&norm, aw, ah, pat), + (None, 3) => norm, + _ => norm.iter().flat_map(|&v| [v, v, v]).collect(), + }; + let (cx, cy, cw, ch) = crop_rect(p, aw, ah); + let m: [[f32; 3]; 3] = matrix.map(|r| r.map(|v| v as f32)); + let mono = p.cfa.is_none() && spp == 1; + let mut rgba = vec![0f32; cw * ch * 4]; + let row = |y: usize, o: &mut [f32]| { + let src = (cy + y) * aw + cx; + for x in 0..cw { + let Some(c) = rgb.get((src + x) * 3..(src + x) * 3 + 3) else { continue }; + let px = if mono { + // grey: the matrix's luminance gain only + let g = c[0] * (m[1][0] + m[1][1] + m[1][2]); + [g, g, g] + } else { + [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) + }; + if let Some(d) = o.get_mut(x * 4..x * 4 + 4) { + d.copy_from_slice(&[px[0].max(0.0), px[1].max(0.0), px[2].max(0.0), 1.0]); + } + } + }; + #[cfg(feature = "threads")] + rgba.par_chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + #[cfg(not(feature = "threads"))] + rgba.chunks_mut(cw * 4).enumerate().for_each(|(y, o)| row(y, o)); + Ok(Developed { width: cw, height: ch, rgba }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::IDENTITY; + + fn raw(w: usize, h: usize, spp: usize, data: Vec) -> RawImage { + RawImage { width: w, height: h, samples_per_pixel: spp, bits: 12, data } + } + + #[test] + fn black_white_linearisation_and_white_balance() { + // LinearRaw 2 × 1: black 100, white 1100; neutral (0.5, 1, 0.25) + let r = raw(2, 1, 3, vec![600, 600, 600, 100, 1100, 2000]); + let p = Params { black: vec![100.0], white: vec![1100.0], ..Default::default() }; + let d = develop(&r, &p, &[0.5, 1.0, 0.25], &IDENTITY).unwrap(); + assert_eq!((d.width, d.height), (2, 1)); + // 0.5 / 0.5 = 1, 0.5 / 1, 0.5 / 0.25 = 2 → clipped to 1 + assert_eq!(&d.rgba[..4], &[1.0, 0.5, 1.0, 1.0]); + // below black → 0, at white → 1, above white → 1 + assert_eq!(&d.rgba[4..], &[0.0, 1.0, 1.0, 1.0]); + // a linearisation table doubling the codes + let t: Vec = (0..4096u32).map(|v| (v * 2).min(65_535) as u16).collect(); + let p = Params { linearization: Some(t), black: vec![200.0], white: vec![2200.0], ..p }; + let d = develop(&r, &p, &[1.0; 3], &IDENTITY).unwrap(); + assert_eq!(&d.rgba[..3], &[0.5, 0.5, 0.5]); + } + + #[test] + fn per_cfa_black_levels_and_deltas() { + // 2 × 2 CFA, black repeat 2 × 2 with a different black per site, plus row/column deltas + let pat = Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] }; + let p = Params { + black_repeat: (2, 2), + black: vec![10.0, 20.0, 30.0, 40.0], + black_delta_h: vec![0.0, 5.0], + black_delta_v: vec![0.0, 1.0], + white: vec![1010.0], + cfa: Some(pat), + ..Default::default() + }; + let r = raw(2, 2, 1, vec![510, 525, 531, 546]); + let n = normalise(&r, &p, (0, 0, 2, 2), &[1.0; 3], 3).unwrap(); + // (v - black) / (white - black) for black 10, 25, 31, 46 + let want = [500.0 / 1000.0, 500.0 / 985.0, 500.0 / 979.0, 500.0 / 964.0]; + for (a, b) in n.iter().zip(want) { + assert!((a - b).abs() < 1e-6, "{a} vs {b}"); + } + } + + #[test] + fn active_area_and_default_crop() { + // 6 × 4 LinearRaw grey ramp; active area cuts a 1-pixel border, crop takes 2 × 1 at (1, 1) + let data: Vec = (0..24u16).flat_map(|i| [i * 10; 3]).collect(); + let r = raw(6, 4, 3, data); + let p = + Params { active_area: Some([1, 1, 3, 5]), white: vec![4095.0], crop_origin: Some((1.0, 1.0)), crop_size: Some((2.0, 1.0)), ..Default::default() }; + let d = develop(&r, &p, &[1.0; 3], &IDENTITY).unwrap(); + assert_eq!((d.width, d.height), (2, 1)); + // active (1,1)–(5,3); crop origin (1,1) inside it → raw (2, 2) and (3, 2): samples 14, 15 + assert!((d.rgba[0] - 140.0 / 4095.0).abs() < 1e-6); + assert!((d.rgba[4] - 150.0 / 4095.0).abs() < 1e-6); + // hostile active areas and crops + let bad = Params { active_area: Some([3, 0, 1, 6]), ..Default::default() }; + assert!(develop(&r, &bad, &[1.0; 3], &IDENTITY).is_err()); + let bad = Params { active_area: Some([0, 0, 4, 7]), ..Default::default() }; + assert!(develop(&r, &bad, &[1.0; 3], &IDENTITY).is_err()); + let huge = Params { crop_origin: Some((1e12, -5.0)), crop_size: Some((f64::NAN, 1e9)), white: vec![4095.0], ..Default::default() }; + let d = develop(&r, &huge, &[1.0; 3], &IDENTITY).unwrap(); + assert!(d.width >= 1 && d.width <= 6 && d.height >= 1 && d.height <= 4); + } + + #[test] + fn matrix_and_clipping_of_negatives() { + let r = raw(1, 1, 3, vec![4095, 0, 0]); + let p = Params { white: vec![4095.0], ..Default::default() }; + let m = [[1.0, 0.0, 0.0], [-0.5, 1.0, 0.0], [0.0, 0.0, 2.0]]; + let d = develop(&r, &p, &[1.0; 3], &m).unwrap(); + assert_eq!(&d.rgba, &[1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn short_black_level_is_an_error() { + let r = raw(2, 2, 1, vec![0; 4]); + let p = Params { black_repeat: (2, 2), black: vec![1.0, 2.0], cfa: Some(Pattern { rows: 2, cols: 2, colors: vec![0, 1, 1, 2] }), ..Default::default() }; + assert!(develop(&r, &p, &[1.0; 3], &IDENTITY).is_err()); + } +} diff --git a/crates/dng/src/raw.rs b/crates/dng/src/raw.rs new file mode 100644 index 000000000..b1c448ffc --- /dev/null +++ b/crates/dng/src/raw.rs @@ -0,0 +1,254 @@ +//! The raw image data of a DNG: finding the main raw IFD, and reading its strips or tiles +//! (uncompressed or lossless JPEG) into one array of 16-bit samples. + +#[cfg(feature = "threads")] +use rayon::prelude::*; + +use crate::tiff::{Ifd, Tiff, tags}; +use crate::{Error, Result, ljpeg}; + +/// Largest raw image decoded, in samples (width × height × samples per pixel). +pub const MAX_SAMPLES: usize = 1 << 27; + +/// Photometric interpretations of DNG raw data. +pub const PHOTOMETRIC_CFA: u64 = 32803; +pub const PHOTOMETRIC_LINEAR_RAW: u64 = 34892; + +/// The layout of a raw IFD's image data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawLayout { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + /// 1 uncompressed, 7 lossless JPEG. + pub compression: u64, + pub photometric: u64, +} + +/// Raw samples: `width` × `height` pixels of `samples_per_pixel` interleaved 16-bit samples. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RawImage { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + pub data: Vec, +} + +fn uint_or(t: &Tiff, ifd: &Ifd, tag: u16, default: u64) -> Result { + match ifd.get(tag) { + Some(e) => t.uint(e), + None => Ok(default), + } +} + +/// The main raw image IFD: the largest full-resolution (NewSubFileType 0) IFD whose photometric +/// interpretation is CFA or LinearRaw. +pub fn find_raw_ifd<'i>(t: &Tiff, ifds: &'i [Ifd]) -> Result<&'i Ifd> { + let mut best: Option<(&Ifd, u64)> = None; + for ifd in ifds { + let Ok(kind) = uint_or(t, ifd, tags::NEW_SUBFILE_TYPE, 0) else { continue }; + let Ok(photometric) = uint_or(t, ifd, tags::PHOTOMETRIC, 0) else { continue }; + if kind != 0 || !matches!(photometric, PHOTOMETRIC_CFA | PHOTOMETRIC_LINEAR_RAW) { + continue; + } + let w = uint_or(t, ifd, tags::IMAGE_WIDTH, 0).unwrap_or(0); + let h = uint_or(t, ifd, tags::IMAGE_LENGTH, 0).unwrap_or(0); + let area = w.saturating_mul(h); + if best.is_none_or(|(_, a)| area > a) { + best = Some((ifd, area)); + } + } + best.map(|(i, _)| i).ok_or(Error::Unsupported("no raw image in this DNG".into())) +} + +/// Read and check the layout tags of a raw IFD. +pub fn layout(t: &Tiff, ifd: &Ifd) -> Result { + let width = uint_or(t, ifd, tags::IMAGE_WIDTH, 0)?; + let height = uint_or(t, ifd, tags::IMAGE_LENGTH, 0)?; + let spp = uint_or(t, ifd, tags::SAMPLES_PER_PIXEL, 1)?; + let photometric = uint_or(t, ifd, tags::PHOTOMETRIC, 0)?; + let compression = uint_or(t, ifd, tags::COMPRESSION, 1)?; + let bits = match ifd.get(tags::BITS_PER_SAMPLE) { + Some(e) => { + let b = t.uints(e)?; + let first = *b.first().ok_or(Error::Malformed("empty BitsPerSample"))?; + if b.iter().any(|&x| x != first) { + return Err(Error::Unsupported("different bit depths per sample".into())); + } + first + } + None => 1, + }; + if width == 0 || height == 0 || width > 65_535 || height > 65_535 { + return Err(Error::Malformed("raw image size")); + } + if !(1..=4).contains(&spp) { + return Err(Error::Unsupported(format!("{spp} samples per pixel"))); + } + if photometric == PHOTOMETRIC_CFA && spp != 1 { + return Err(Error::Malformed("a CFA image with more than one sample per pixel")); + } + if !(1..=16).contains(&bits) { + return Err(Error::Unsupported(format!("{bits}-bit raw samples"))); + } + if uint_or(t, ifd, tags::SAMPLE_FORMAT, 1)? != 1 { + return Err(Error::Unsupported("floating-point raw data".into())); + } + if spp > 1 && uint_or(t, ifd, tags::PLANAR_CONFIGURATION, 1)? != 1 { + return Err(Error::Unsupported("planar raw data".into())); + } + match compression { + 1 => { + if uint_or(t, ifd, tags::PREDICTOR, 1)? != 1 { + return Err(Error::Unsupported("a TIFF predictor on uncompressed raw data".into())); + } + } + 7 => {} + 8 => return Err(Error::Unsupported("deflate-compressed DNG".into())), + 34892 => return Err(Error::Unsupported("lossy JPEG DNG".into())), + 52546 => return Err(Error::Unsupported("JPEG XL DNG".into())), + c => return Err(Error::Unsupported(format!("DNG compression {c}"))), + } + Ok(RawLayout { width: width as usize, height: height as usize, samples_per_pixel: spp as usize, bits: bits as u32, compression, photometric }) +} + +/// One strip or tile: where it sits in the image and its bytes. +struct Chunk<'a> { + x: usize, + y: usize, + /// Pixel size of the chunk as coded (tiles are whole even at the image edge). + w: usize, + h: usize, + bytes: &'a [u8], +} + +fn chunks<'a>(t: &Tiff<'a>, ifd: &Ifd, l: &RawLayout) -> Result>> { + let (offs, counts, tw, th) = if let (Some(o), Some(c)) = (ifd.get(tags::TILE_OFFSETS), ifd.get(tags::TILE_BYTE_COUNTS)) { + let tw = uint_or(t, ifd, tags::TILE_WIDTH, 0)? as usize; + let th = uint_or(t, ifd, tags::TILE_LENGTH, 0)? as usize; + if tw == 0 || th == 0 || tw > 65_535 || th > 65_535 { + return Err(Error::Malformed("tile size")); + } + (t.uints(o)?, t.uints(c)?, tw, th) + } else if let (Some(o), Some(c)) = (ifd.get(tags::STRIP_OFFSETS), ifd.get(tags::STRIP_BYTE_COUNTS)) { + let rows = (uint_or(t, ifd, tags::ROWS_PER_STRIP, l.height as u64)? as usize).clamp(1, l.height); + (t.uints(o)?, t.uints(c)?, l.width, rows) + } else { + return Err(Error::Malformed("no strips or tiles")); + }; + let across = l.width.div_ceil(tw); + let down = l.height.div_ceil(th); + let n = across * down; + if offs.len() < n || counts.len() < n { + return Err(Error::Malformed("fewer strips or tiles than the image needs")); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (x, y) = ((i % across) * tw, (i / across) * th); + let start = usize::try_from(offs[i]).map_err(|_| Error::Truncated)?; + let len = usize::try_from(counts[i]).map_err(|_| Error::Truncated)?; + let end = start.checked_add(len).ok_or(Error::Truncated)?; + let bytes = t.data.get(start..end).ok_or(Error::Truncated)?; + // strips end at the image; tiles are coded whole + let h = if ifd.get(tags::TILE_OFFSETS).is_some() { th } else { th.min(l.height - y) }; + out.push(Chunk { x, y, w: tw, h, bytes }); + } + Ok(out) +} + +/// Unpack `w` × `h` × `spp` uncompressed samples of `bits` bits: 8 and 16-bit samples as bytes / +/// words in the file's byte order, other depths packed most significant bit first with each row +/// starting on a byte boundary (TIFF 6.0 §7, DNG BitsPerSample). +pub fn unpack(bytes: &[u8], w: usize, h: usize, spp: usize, bits: u32, big_endian: bool) -> Result> { + let per_row = w * spp; + let row_bytes = (per_row * bits as usize).div_ceil(8); + let need = row_bytes.checked_mul(h).ok_or(Error::TooLarge)?; + if bytes.len() < need { + return Err(Error::Truncated); + } + let mut out = Vec::with_capacity(per_row * h); + for row in bytes.chunks_exact(row_bytes.max(1)).take(h) { + match bits { + 8 => out.extend(row.iter().map(|&b| u16::from(b))), + 16 => out.extend(row.as_chunks::<2>().0.iter().map(|&b| if big_endian { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })), + _ => { + let mut acc: u32 = 0; + let mut n: u32 = 0; + let mut it = row.iter(); + for _ in 0..per_row { + while n < bits { + acc = (acc << 8) | u32::from(*it.next().ok_or(Error::Truncated)?); + n += 8; + } + n -= bits; + out.push(((acc >> n) & ((1 << bits) - 1)) as u16); + acc &= (1 << n) - 1; + } + } + } + } + Ok(out) +} + +/// Decode one chunk into `w` × `h` × `spp` samples. +fn decode_chunk(c: &Chunk, l: &RawLayout, big_endian: bool) -> Result> { + let need = c.w * c.h * l.samples_per_pixel; + match l.compression { + 1 => unpack(c.bytes, c.w, c.h, l.samples_per_pixel, l.bits, big_endian), + _ => { + // the JPEG's samples fill the chunk in raster order (its own width × components may + // differ from the chunk's, e.g. two components of half the width) + let d = ljpeg::decode(c.bytes, need.saturating_add(65_536 * 4))?; + if d.samples.len() < need { + return Err(Error::Malformed("lossless JPEG smaller than its tile or strip")); + } + let mut s = d.samples; + s.truncate(need); + Ok(s) + } + } +} + +/// Read the raw image of `ifd`. +pub fn read(t: &Tiff, ifd: &Ifd) -> Result { + let l = layout(t, ifd)?; + let spp = l.samples_per_pixel; + let total = l.width.checked_mul(l.height).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if total > MAX_SAMPLES { + return Err(Error::TooLarge); + } + let chunks = chunks(t, ifd, &l)?; + // every sample takes at least one bit of file (a Huffman code is at least one bit long) + let coded: usize = chunks.iter().map(|c| c.bytes.len()).fold(0usize, usize::saturating_add); + if total / 8 > coded { + return Err(Error::Truncated); + } + for c in &chunks { + let n = c.w.checked_mul(c.h).and_then(|v| v.checked_mul(spp)).ok_or(Error::TooLarge)?; + if n > MAX_SAMPLES { + return Err(Error::TooLarge); + } + if n / 8 > c.bytes.len() { + return Err(Error::Truncated); + } + } + #[cfg(feature = "threads")] + let decoded: Vec>> = chunks.par_iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + #[cfg(not(feature = "threads"))] + let decoded: Vec>> = chunks.iter().map(|c| decode_chunk(c, &l, t.big_endian)).collect(); + let mut data = vec![0u16; total]; + let row_len = l.width * spp; + for (c, d) in chunks.iter().zip(decoded) { + let d = d?; + let cw = c.w.min(l.width - c.x); + let ch = c.h.min(l.height - c.y); + for r in 0..ch { + let src = d.get(r * c.w * spp..(r * c.w + cw) * spp).ok_or(Error::Truncated)?; + let at = (c.y + r) * row_len + c.x * spp; + data.get_mut(at..at + cw * spp).ok_or(Error::Truncated)?.copy_from_slice(src); + } + } + Ok(RawImage { width: l.width, height: l.height, samples_per_pixel: spp, bits: l.bits, data }) +} diff --git a/crates/dng/src/tiff.rs b/crates/dng/src/tiff.rs new file mode 100644 index 000000000..ba479649c --- /dev/null +++ b/crates/dng/src/tiff.rs @@ -0,0 +1,334 @@ +//! The TIFF container (TIFF 6.0 §2, as DNG uses it): byte order, image file directories (IFDs), +//! their entries and values, and the SubIFD tree. +//! +//! Every offset and count comes from the file and is checked before use: an entry whose value lies +//! outside the file is an error when it is read, the walk visits at most [`MAX_IFDS`] directories, +//! never the same one twice (cyclic chains end), and goes at most [`MAX_DEPTH`] SubIFD levels deep. + +use crate::{Error, Result}; + +/// Most IFDs one file may have (main chain and SubIFDs together). +pub const MAX_IFDS: usize = 64; +/// Deepest SubIFD nesting followed. +pub const MAX_DEPTH: usize = 4; + +/// TIFF field types (TIFF 6.0 §2, plus type 13 IFD from TIFF Technical Note 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Type { + Byte, + Ascii, + Short, + Long, + Rational, + SByte, + Undefined, + SShort, + SLong, + SRational, + Float, + Double, + Ifd, +} + +impl Type { + fn from_code(c: u16) -> Option { + Some(match c { + 1 => Type::Byte, + 2 => Type::Ascii, + 3 => Type::Short, + 4 => Type::Long, + 5 => Type::Rational, + 6 => Type::SByte, + 7 => Type::Undefined, + 8 => Type::SShort, + 9 => Type::SLong, + 10 => Type::SRational, + 11 => Type::Float, + 12 => Type::Double, + 13 => Type::Ifd, + _ => return None, + }) + } + + /// Bytes per value. + pub fn size(self) -> usize { + match self { + Type::Byte | Type::Ascii | Type::SByte | Type::Undefined => 1, + Type::Short | Type::SShort => 2, + Type::Long | Type::SLong | Type::Float | Type::Ifd => 4, + Type::Rational | Type::SRational | Type::Double => 8, + } + } +} + +/// One directory entry. +#[derive(Clone, Copy, Debug)] +pub struct Entry { + pub tag: u16, + pub ty: Type, + pub count: u32, + /// Where the value bytes start: inside the entry when they fit in four bytes, else the offset + /// the entry holds (not yet checked against the file length). + value_at: u64, +} + +/// One image file directory. +#[derive(Clone, Debug, Default)] +pub struct Ifd { + /// File offset of the directory. + pub offset: u64, + pub entries: Vec, + /// Nesting: 0 for the main chain, 1 for its SubIFDs, … + pub depth: usize, +} + +impl Ifd { + pub fn get(&self, tag: u16) -> Option<&Entry> { + self.entries.iter().find(|e| e.tag == tag) + } +} + +/// A TIFF file in memory. +#[derive(Clone, Copy)] +pub struct Tiff<'a> { + pub data: &'a [u8], + pub big_endian: bool, +} + +impl<'a> Tiff<'a> { + /// Read the header. Classic TIFF only (BigTIFF, version 43, is not used by DNG). + pub fn new(data: &'a [u8]) -> Result<(Tiff<'a>, u64)> { + let big_endian = match data.get(0..4) { + Some(b"II*\0") => false, + Some(b"MM\0*") => true, + _ => return Err(Error::NotDng), + }; + let t = Tiff { data, big_endian }; + let first = u64::from(t.u32_at(4)?); + Ok((t, first)) + } + + fn bytes_at(&self, off: u64, len: usize) -> Result<&'a [u8]> { + let start = usize::try_from(off).map_err(|_| Error::Malformed("offset outside the file"))?; + let end = start.checked_add(len).ok_or(Error::Malformed("offset outside the file"))?; + self.data.get(start..end).ok_or(Error::Malformed("offset outside the file")) + } + + pub fn u16_of(&self, b: [u8; 2]) -> u16 { + if self.big_endian { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) } + } + + pub fn u32_of(&self, b: [u8; 4]) -> u32 { + if self.big_endian { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) } + } + + fn u16_at(&self, off: u64) -> Result { + let b = self.bytes_at(off, 2)?; + Ok(self.u16_of([b[0], b[1]])) + } + + fn u32_at(&self, off: u64) -> Result { + let b = self.bytes_at(off, 4)?; + Ok(self.u32_of([b[0], b[1], b[2], b[3]])) + } + + /// Read the IFD at `off`: its entries and the offset of the next IFD (0 at the end of a chain). + pub fn read_ifd(&self, off: u64, depth: usize) -> Result<(Ifd, u64)> { + let n = self.u16_at(off)?; + let mut entries = Vec::with_capacity(usize::from(n)); + for i in 0..u64::from(n) { + let at = off + 2 + i * 12; + let e = self.bytes_at(at, 12)?; + let tag = self.u16_of([e[0], e[1]]); + let code = self.u16_of([e[2], e[3]]); + let count = self.u32_of([e[4], e[5], e[6], e[7]]); + // unknown field types are skipped (TIFF 6.0: readers ignore them) + let Some(ty) = Type::from_code(code) else { continue }; + let len = u64::from(count).saturating_mul(ty.size() as u64); + let value_at = if len <= 4 { at + 8 } else { u64::from(self.u32_of([e[8], e[9], e[10], e[11]])) }; + entries.push(Entry { tag, ty, count, value_at }); + } + let next = u64::from(self.u32_at(off + 2 + u64::from(n) * 12)?); + Ok((Ifd { offset: off, entries, depth }, next)) + } + + /// Every IFD of the file: the main chain from `first` and, depth first, the SubIFDs (tag 330) of + /// each. Bounded by [`MAX_IFDS`] and [`MAX_DEPTH`]; a directory reached twice ends that branch. + pub fn all_ifds(&self, first: u64) -> Result> { + let mut out: Vec = Vec::new(); + let mut seen: Vec = Vec::new(); + let mut stack: Vec<(u64, usize)> = vec![(first, 0)]; + while let Some((off, depth)) = stack.pop() { + let mut next = off; + while next != 0 { + if seen.contains(&next) { + break; + } + if out.len() >= MAX_IFDS { + return Err(Error::Malformed("too many IFDs")); + } + seen.push(next); + let (ifd, after) = match self.read_ifd(next, depth) { + Ok(x) => x, + // a broken directory ends the chain; the first one must be readable + Err(e) if out.is_empty() => return Err(e), + Err(_) => break, + }; + if depth < MAX_DEPTH + && let Some(sub) = ifd.get(tags::SUB_IFDS) + && let Ok(offs) = self.uints(sub) + { + for o in offs.into_iter().rev() { + stack.push((o, depth + 1)); + } + } + out.push(ifd); + next = after; + } + } + Ok(out) + } + + /// The value bytes of an entry. + pub fn value_bytes(&self, e: &Entry) -> Result<&'a [u8]> { + let len = (e.count as usize).checked_mul(e.ty.size()).ok_or(Error::Malformed("entry too large"))?; + self.bytes_at(e.value_at, len) + } + + /// The `i`-th value of a numeric entry as f64 (rationals divided; a zero denominator is an + /// error). + fn number(&self, e: &Entry, b: &[u8], i: usize) -> Result { + let s = e.ty.size(); + let v = b.get(i * s..i * s + s).ok_or(Error::Malformed("entry too short"))?; + let u16v = |o: usize| self.u16_of([v[o], v[o + 1]]); + let u32v = |o: usize| self.u32_of([v[o], v[o + 1], v[o + 2], v[o + 3]]); + Ok(match e.ty { + Type::Byte | Type::Undefined => f64::from(v[0]), + Type::SByte => f64::from(v[0] as i8), + Type::Short => f64::from(u16v(0)), + Type::SShort => f64::from(u16v(0) as i16), + Type::Long | Type::Ifd => f64::from(u32v(0)), + Type::SLong => f64::from(u32v(0) as i32), + Type::Rational => { + let d = u32v(4); + if d == 0 { + return Err(Error::Malformed("rational with a zero denominator")); + } + f64::from(u32v(0)) / f64::from(d) + } + Type::SRational => { + let d = u32v(4) as i32; + if d == 0 { + return Err(Error::Malformed("rational with a zero denominator")); + } + f64::from(u32v(0) as i32) / f64::from(d) + } + Type::Float => f64::from(f32::from_bits(u32v(0))), + Type::Double => { + let mut a = [0u8; 8]; + a.copy_from_slice(&v[..8]); + if self.big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) } + } + Type::Ascii => return Err(Error::Malformed("text where a number belongs")), + }) + } + + /// All values of a numeric entry as f64. Non-finite values are an error. + pub fn floats(&self, e: &Entry) -> Result> { + let b = self.value_bytes(e)?; + let mut out = Vec::with_capacity(e.count as usize); + for i in 0..e.count as usize { + let v = self.number(e, b, i)?; + if !v.is_finite() { + return Err(Error::Malformed("non-finite number")); + } + out.push(v); + } + Ok(out) + } + + /// All values of an unsigned integer entry (BYTE, SHORT, LONG, IFD). + pub fn uints(&self, e: &Entry) -> Result> { + if !matches!(e.ty, Type::Byte | Type::Undefined | Type::Short | Type::Long | Type::Ifd) { + return Err(Error::Malformed("expected an unsigned integer field")); + } + let b = self.value_bytes(e)?; + let s = e.ty.size(); + Ok(b.chunks_exact(s) + .map(|v| match s { + 1 => u64::from(v[0]), + 2 => u64::from(self.u16_of([v[0], v[1]])), + _ => u64::from(self.u32_of([v[0], v[1], v[2], v[3]])), + }) + .collect()) + } + + /// The first value of an unsigned integer entry. + pub fn uint(&self, e: &Entry) -> Result { + self.uints(e)?.first().copied().ok_or(Error::Malformed("empty field")) + } + + /// An ASCII entry up to its first NUL, lossily decoded. + pub fn ascii(&self, e: &Entry) -> Result { + let b = self.value_bytes(e)?; + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + Ok(String::from_utf8_lossy(&b[..end]).trim().to_string()) + } +} + +/// Tag numbers (TIFF 6.0, TIFF/EP, DNG 1.7, CinemaDNG). +pub mod tags { + pub const NEW_SUBFILE_TYPE: u16 = 254; + pub const IMAGE_WIDTH: u16 = 256; + pub const IMAGE_LENGTH: u16 = 257; + pub const BITS_PER_SAMPLE: u16 = 258; + pub const COMPRESSION: u16 = 259; + pub const PHOTOMETRIC: u16 = 262; + pub const MAKE: u16 = 271; + pub const MODEL: u16 = 272; + pub const STRIP_OFFSETS: u16 = 273; + pub const SAMPLES_PER_PIXEL: u16 = 277; + pub const ROWS_PER_STRIP: u16 = 278; + pub const STRIP_BYTE_COUNTS: u16 = 279; + pub const PLANAR_CONFIGURATION: u16 = 284; + pub const PREDICTOR: u16 = 317; + pub const TILE_WIDTH: u16 = 322; + pub const TILE_LENGTH: u16 = 323; + pub const TILE_OFFSETS: u16 = 324; + pub const TILE_BYTE_COUNTS: u16 = 325; + pub const SUB_IFDS: u16 = 330; + pub const SAMPLE_FORMAT: u16 = 339; + pub const CFA_REPEAT_PATTERN_DIM: u16 = 33421; + pub const CFA_PATTERN: u16 = 33422; + pub const DNG_VERSION: u16 = 50706; + pub const UNIQUE_CAMERA_MODEL: u16 = 50708; + pub const CFA_PLANE_COLOR: u16 = 50710; + pub const CFA_LAYOUT: u16 = 50711; + pub const LINEARIZATION_TABLE: u16 = 50712; + pub const BLACK_LEVEL_REPEAT_DIM: u16 = 50713; + pub const BLACK_LEVEL: u16 = 50714; + pub const BLACK_LEVEL_DELTA_H: u16 = 50715; + pub const BLACK_LEVEL_DELTA_V: u16 = 50716; + pub const WHITE_LEVEL: u16 = 50717; + pub const DEFAULT_CROP_ORIGIN: u16 = 50719; + pub const DEFAULT_CROP_SIZE: u16 = 50720; + pub const COLOR_MATRIX_1: u16 = 50721; + pub const COLOR_MATRIX_2: u16 = 50722; + pub const CAMERA_CALIBRATION_1: u16 = 50723; + pub const CAMERA_CALIBRATION_2: u16 = 50724; + pub const ANALOG_BALANCE: u16 = 50727; + pub const AS_SHOT_NEUTRAL: u16 = 50728; + pub const AS_SHOT_WHITE_XY: u16 = 50729; + pub const BASELINE_EXPOSURE: u16 = 50730; + pub const CALIBRATION_ILLUMINANT_1: u16 = 50778; + pub const CALIBRATION_ILLUMINANT_2: u16 = 50779; + pub const ACTIVE_AREA: u16 = 50829; + pub const FORWARD_MATRIX_1: u16 = 50964; + pub const FORWARD_MATRIX_2: u16 = 50965; + /// CinemaDNG: SMPTE ST 12-1 time codes, 8 bytes each. + pub const TIME_CODES: u16 = 51043; + /// CinemaDNG: frames per second (SRATIONAL). + pub const FRAME_RATE: u16 = 51044; + /// CinemaDNG: the camera's reel name. + pub const REEL_NAME: u16 = 51081; +} diff --git a/crates/dng/src/writer.rs b/crates/dng/src/writer.rs new file mode 100644 index 000000000..fc53be648 --- /dev/null +++ b/crates/dng/src/writer.rs @@ -0,0 +1,449 @@ +//! A small DNG writer for synthesising test files (feature `writer`; not used by the app): a +//! TIFF container with the raw image in IFD 0 or in a SubIFD behind an 8-bit preview, strips or +//! tiles, uncompressed samples of any depth or lossless JPEG (T.81 process 14) with any predictor, +//! and whatever extra tags the caller adds. + +use crate::ljpeg::predict; +use crate::tiff::tags; + +/// A tag value. +#[derive(Clone, Debug)] +pub enum Value { + Byte(Vec), + Ascii(String), + Short(Vec), + Long(Vec), + Rational(Vec<(u32, u32)>), + SRational(Vec<(i32, i32)>), + Undefined(Vec), +} + +impl Value { + fn code(&self) -> u16 { + match self { + Value::Byte(_) => 1, + Value::Ascii(_) => 2, + Value::Short(_) => 3, + Value::Long(_) => 4, + Value::Rational(_) => 5, + Value::Undefined(_) => 7, + Value::SRational(_) => 10, + } + } + + fn count(&self) -> usize { + match self { + Value::Byte(v) | Value::Undefined(v) => v.len(), + Value::Ascii(s) => s.len() + 1, + Value::Short(v) => v.len(), + Value::Long(v) => v.len(), + Value::Rational(v) => v.len(), + Value::SRational(v) => v.len(), + } + } + + fn bytes(&self, be: bool) -> Vec { + let w16 = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let w32 = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + match self { + Value::Byte(v) | Value::Undefined(v) => v.clone(), + Value::Ascii(s) => s.bytes().chain([0]).collect(), + Value::Short(v) => v.iter().flat_map(|&x| w16(x)).collect(), + Value::Long(v) => v.iter().flat_map(|&x| w32(x)).collect(), + Value::Rational(v) => v.iter().flat_map(|&(n, d)| w32(n).into_iter().chain(w32(d))).collect(), + Value::SRational(v) => v.iter().flat_map(|&(n, d)| w32(n as u32).into_iter().chain(w32(d as u32))).collect(), + } + } + + /// Signed rationals with denominator 10000 from floats (colour matrices and the like). + pub fn srationals(v: &[f64]) -> Value { + Value::SRational(v.iter().map(|&x| ((x * 10_000.0).round() as i32, 10_000)).collect()) + } + + /// Unsigned rationals with denominator 10000. + pub fn rationals(v: &[f64]) -> Value { + Value::Rational(v.iter().map(|&x| ((x * 10_000.0).round().max(0.0) as u32, 10_000)).collect()) + } +} + +/// How the raw data is stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Compression { + None, + /// Lossless JPEG: `predictor` 1–7; each tile / strip is coded as a JPEG of `components` + /// interleaved components (its width × samples per pixel divided by `components`), with a + /// restart marker every `restart_lines` lines (0: none) and point transform `pt`. + LosslessJpeg { + predictor: u8, + components: usize, + restart_lines: usize, + pt: u8, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Layout { + Strips { rows: usize }, + Tiles { width: usize, height: usize }, +} + +/// What to write. +#[derive(Clone, Debug)] +pub struct Options { + pub width: usize, + pub height: usize, + pub samples_per_pixel: usize, + pub bits: u32, + pub big_endian: bool, + pub compression: Compression, + pub layout: Layout, + /// CFA repeat (rows, columns) and pattern colour codes; `None` writes LinearRaw. + pub cfa: Option<(usize, usize, Vec)>, + /// Put an 8-bit preview in IFD 0 and the raw image in a SubIFD (as cameras do). + pub preview: bool, + /// Extra tags of IFD 0 (colour, CinemaDNG…). + pub main_tags: Vec<(u16, Value)>, + /// Extra tags of the raw IFD (black and white levels, crop…). + pub raw_tags: Vec<(u16, Value)>, +} + +impl Default for Options { + fn default() -> Self { + Options { + width: 8, + height: 8, + samples_per_pixel: 1, + bits: 16, + big_endian: false, + compression: Compression::None, + layout: Layout::Strips { rows: 8 }, + cfa: Some((2, 2, vec![0, 1, 1, 2])), + preview: false, + main_tags: Vec::new(), + raw_tags: Vec::new(), + } + } +} + +/// Pack `rows` rows of `per_row` samples of `bits` bits (8/16-bit in byte order `be`, other +/// depths most significant bit first, each row starting on a byte boundary). +pub fn pack(samples: &[u16], per_row: usize, bits: u32, be: bool) -> Vec { + let mut out = Vec::new(); + for row in samples.chunks(per_row.max(1)) { + match bits { + 8 => out.extend(row.iter().map(|&v| v as u8)), + 16 => out.extend(row.iter().flat_map(|&v| if be { v.to_be_bytes() } else { v.to_le_bytes() })), + _ => { + let (mut acc, mut n) = (0u32, 0u32); + for &v in row { + acc = (acc << bits) | (u32::from(v) & ((1 << bits) - 1)); + n += bits; + while n >= 8 { + n -= 8; + out.push((acc >> n) as u8); + } + acc &= (1 << n) - 1; + } + if n > 0 { + out.push((acc << (8 - n)) as u8); + } + } + } + } + out +} + +/// Entropy-coded bit output with 0xFF stuffing. +struct BitOut { + out: Vec, + acc: u32, + n: u32, +} + +impl BitOut { + fn put(&mut self, v: u32, len: u32) { + for i in (0..len).rev() { + self.acc = (self.acc << 1) | ((v >> i) & 1); + self.n += 1; + if self.n == 8 { + let b = self.acc as u8; + self.out.push(b); + if b == 0xFF { + self.out.push(0); + } + self.acc = 0; + self.n = 0; + } + } + } + + /// Pad the last byte with 1 bits. + fn flush(&mut self) { + while self.n != 0 { + self.put(1, 1); + } + } +} + +/// Encode `samples` (`width` × `height` × `comps`, interleaved) as lossless JPEG. +pub fn lj92(samples: &[u16], width: usize, height: usize, comps: usize, precision: u8, predictor: u8, restart_lines: usize, pt: u8) -> Vec { + let comps = comps.clamp(1, 4); + let row = width * comps; + let initial = 1i32 << (precision - pt - 1); + // differences, line by line + let mut cats: Vec> = vec![vec![0; 17]; comps]; + let mut coded: Vec<(usize, u32, u32, i32)> = Vec::with_capacity(samples.len()); // (comp, ssss, extra, line) + let s = |i: usize| i32::from(samples.get(i).copied().unwrap_or(0) >> pt); + for y in 0..height { + let first_line = restart_lines == 0 && y == 0 || restart_lines != 0 && y % restart_lines == 0; + for x in 0..width { + for c in 0..comps { + let i = y * row + x * comps + c; + let px = if first_line && x == 0 { + initial + } else if first_line { + s(i - comps) + } else if x == 0 { + s(i - row) + } else { + predict(predictor, s(i - comps), s(i - row), s(i - row - comps)) + }; + let d16 = (s(i) - px).rem_euclid(65_536); + let (ssss, extra) = if d16 == 32_768 { + (16, 0) + } else { + let d = if d16 >= 32_768 { d16 - 65_536 } else { d16 }; + let ssss = 32 - d.unsigned_abs().leading_zeros(); + let extra = if d >= 0 { d as u32 } else { (d + (1 << ssss) - 1) as u32 }; + (ssss, extra) + }; + cats[c][ssss as usize] += 1; + coded.push((c, ssss, extra, y as i32)); + } + } + } + // per component: the used categories by frequency get 2-bit then 6-bit codes + let mut tables = Vec::new(); + for counts in &cats { + let mut syms: Vec = (0..17u8).filter(|&k| counts[usize::from(k)] > 0).collect(); + syms.sort_by_key(|&k| std::cmp::Reverse(counts[usize::from(k)])); + if syms.is_empty() { + syms.push(0); + } + let mut code_of = [(0u32, 0u32); 17]; + for (i, &k) in syms.iter().enumerate() { + code_of[usize::from(k)] = if i < 3 { (i as u32, 2) } else { (0b110000 + (i as u32 - 3), 6) }; + } + let mut bits = [0u8; 16]; + bits[1] = syms.len().min(3) as u8; + bits[5] = syms.len().saturating_sub(3) as u8; + tables.push((bits, syms, code_of)); + } + let mut out = vec![0xFF, 0xD8]; + // SOF3 + let mut sof = vec![precision, (height >> 8) as u8, height as u8, (width >> 8) as u8, width as u8, comps as u8]; + for c in 0..comps { + sof.extend([c as u8, 0x11, 0]); + } + segment(&mut out, 0xC3, &sof); + let mut dht = Vec::new(); + for (c, (bits, syms, _)) in tables.iter().enumerate() { + dht.push(c as u8); + dht.extend(bits); + dht.extend(syms); + } + segment(&mut out, 0xC4, &dht); + if restart_lines != 0 { + let ri = (restart_lines * width) as u16; + segment(&mut out, 0xDD, &ri.to_be_bytes()); + } + let mut sos = vec![comps as u8]; + for c in 0..comps { + sos.extend([c as u8, (c as u8) << 4]); + } + sos.extend([predictor, 0, pt]); + segment(&mut out, 0xDA, &sos); + let mut bo = BitOut { out, acc: 0, n: 0 }; + let mut line = 0; + let mut rst = 0u8; + for (c, ssss, extra, y) in coded { + if restart_lines != 0 && y != line && (y as usize).is_multiple_of(restart_lines) { + bo.flush(); + bo.out.extend([0xFF, 0xD0 + rst]); + rst = (rst + 1) % 8; + } + line = y; + let (code, len) = tables[c].2[ssss as usize]; + bo.put(code, len); + if ssss != 16 { + bo.put(extra, ssss); + } + } + bo.flush(); + let mut out = bo.out; + out.extend([0xFF, 0xD9]); + out +} + +fn segment(out: &mut Vec, marker: u8, body: &[u8]) { + out.extend([0xFF, marker]); + out.extend(((body.len() + 2) as u16).to_be_bytes()); + out.extend(body); +} + +/// Append an IFD at the end of `buf` (word aligned) and return its offset. +fn put_ifd(buf: &mut Vec, mut entries: Vec<(u16, Value)>, be: bool) -> u32 { + if buf.len() % 2 == 1 { + buf.push(0); + } + entries.sort_by_key(|e| e.0); + let at = buf.len(); + let n = entries.len(); + let w16 = |v: u16| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let w32 = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut ext_at = at + 2 + 12 * n + 4; + let mut head = Vec::new(); + let mut ext = Vec::new(); + head.extend(w16(n as u16)); + for (tag, v) in &entries { + let b = v.bytes(be); + head.extend(w16(*tag)); + head.extend(w16(v.code())); + head.extend(w32(v.count() as u32)); + if b.len() <= 4 { + let mut inline = b.clone(); + inline.resize(4, 0); + head.extend(inline); + } else { + head.extend(w32(ext_at as u32)); + ext.extend(&b); + if b.len() % 2 == 1 { + ext.push(0); + } + ext_at += b.len().div_ceil(2) * 2; + } + } + head.extend(w32(0)); + buf.extend(head); + buf.extend(ext); + at as u32 +} + +/// Encode the strips or tiles of `samples`, appending them to `buf`; returns (offsets, counts). +fn put_chunks(buf: &mut Vec, samples: &[u16], o: &Options) -> (Vec, Vec) { + let spp = o.samples_per_pixel; + let (cw, ch) = match o.layout { + Layout::Strips { rows } => (o.width, rows.max(1)), + Layout::Tiles { width, height } => (width.max(1), height.max(1)), + }; + let tiles = matches!(o.layout, Layout::Tiles { .. }); + let (mut offs, mut counts) = (Vec::new(), Vec::new()); + for ty in (0..o.height).step_by(ch) { + for tx in (0..o.width).step_by(cw) { + let rows = if tiles { ch } else { ch.min(o.height - ty) }; + // the chunk's samples; tiles past the image edge repeat the edge pixel + let mut chunk = Vec::with_capacity(cw * rows * spp); + for r in 0..rows { + let y = (ty + r).min(o.height - 1); + for c in 0..cw { + let x = (tx + c).min(o.width - 1); + for s in 0..spp { + chunk.push(samples.get((y * o.width + x) * spp + s).copied().unwrap_or(0)); + } + } + } + let data = match o.compression { + Compression::None => pack(&chunk, cw * spp, o.bits, o.big_endian), + Compression::LosslessJpeg { predictor, components, restart_lines, pt } => { + let comps = components.max(1); + lj92(&chunk, cw * spp / comps, rows, comps, o.bits as u8, predictor, restart_lines, pt) + } + }; + if buf.len() % 2 == 1 { + buf.push(0); + } + offs.push(buf.len() as u32); + counts.push(data.len() as u32); + buf.extend(data); + } + } + (offs, counts) +} + +/// Write a DNG holding `samples` (`width` × `height` × `samples_per_pixel`, interleaved). +pub fn write(samples: &[u16], o: &Options) -> Vec { + let be = o.big_endian; + let mut buf = if be { b"MM\0*\0\0\0\0".to_vec() } else { b"II*\0\0\0\0\0".to_vec() }; + let (offs, counts) = put_chunks(&mut buf, samples, o); + let mut raw = vec![ + (tags::NEW_SUBFILE_TYPE, Value::Long(vec![0])), + (tags::IMAGE_WIDTH, Value::Long(vec![o.width as u32])), + (tags::IMAGE_LENGTH, Value::Long(vec![o.height as u32])), + (tags::BITS_PER_SAMPLE, Value::Short(vec![o.bits as u16; o.samples_per_pixel])), + (tags::COMPRESSION, Value::Short(vec![if o.compression == Compression::None { 1 } else { 7 }])), + (tags::SAMPLES_PER_PIXEL, Value::Short(vec![o.samples_per_pixel as u16])), + (tags::PLANAR_CONFIGURATION, Value::Short(vec![1])), + ]; + match o.layout { + Layout::Strips { rows } => { + raw.push((tags::ROWS_PER_STRIP, Value::Long(vec![rows as u32]))); + raw.push((tags::STRIP_OFFSETS, Value::Long(offs))); + raw.push((tags::STRIP_BYTE_COUNTS, Value::Long(counts))); + } + Layout::Tiles { width, height } => { + raw.push((tags::TILE_WIDTH, Value::Long(vec![width as u32]))); + raw.push((tags::TILE_LENGTH, Value::Long(vec![height as u32]))); + raw.push((tags::TILE_OFFSETS, Value::Long(offs))); + raw.push((tags::TILE_BYTE_COUNTS, Value::Long(counts))); + } + } + match &o.cfa { + Some((r, c, p)) => { + raw.push((tags::PHOTOMETRIC, Value::Short(vec![32803]))); + raw.push((tags::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![*r as u16, *c as u16]))); + raw.push((tags::CFA_PATTERN, Value::Byte(p.clone()))); + } + None => raw.push((tags::PHOTOMETRIC, Value::Short(vec![34892]))), + } + // later entries replace earlier ones with the same tag + let merge = |base: &mut Vec<(u16, Value)>, extra: &[(u16, Value)]| { + for (t, v) in extra { + base.retain(|e| e.0 != *t); + base.push((*t, v.clone())); + } + }; + merge(&mut raw, &o.raw_tags); + let mut main = vec![(tags::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])), (tags::UNIQUE_CAMERA_MODEL, Value::Ascii("FilmCraft Synthetic".into()))]; + let ifd0 = if o.preview { + let raw_at = put_ifd(&mut buf, raw, be); + // a 2 × 2 RGB preview + if buf.len() % 2 == 1 { + buf.push(0); + } + let px_at = buf.len() as u32; + buf.extend([128u8; 12]); + let mut p = vec![ + (tags::NEW_SUBFILE_TYPE, Value::Long(vec![1])), + (tags::IMAGE_WIDTH, Value::Long(vec![2])), + (tags::IMAGE_LENGTH, Value::Long(vec![2])), + (tags::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])), + (tags::COMPRESSION, Value::Short(vec![1])), + (tags::PHOTOMETRIC, Value::Short(vec![2])), + (tags::STRIP_OFFSETS, Value::Long(vec![px_at])), + (tags::SAMPLES_PER_PIXEL, Value::Short(vec![3])), + (tags::ROWS_PER_STRIP, Value::Long(vec![2])), + (tags::STRIP_BYTE_COUNTS, Value::Long(vec![12])), + (tags::SUB_IFDS, Value::Long(vec![raw_at])), + ]; + p.append(&mut main); + merge(&mut p, &o.main_tags); + put_ifd(&mut buf, p, be) + } else { + raw.append(&mut main); + merge(&mut raw, &o.main_tags); + put_ifd(&mut buf, raw, be) + }; + let off = if be { ifd0.to_be_bytes() } else { ifd0.to_le_bytes() }; + buf[4..8].copy_from_slice(&off); + buf +} diff --git a/crates/dng/tests/oracle_libraw.rs b/crates/dng/tests/oracle_libraw.rs new file mode 100644 index 000000000..5f5776790 --- /dev/null +++ b/crates/dng/tests/oracle_libraw.rs @@ -0,0 +1,97 @@ +//! External oracle: LibRaw's `unprocessed_raw` sample program (an independent DNG reader) must +//! read the raw samples of our synthetic DNGs exactly as written and as we decode them: packed +//! uncompressed data at every depth in both byte orders, and lossless JPEG with every predictor. +//! Skipped when `unprocessed_raw` is not installed (or `FILMCRAFT_UNPROCESSED_RAW` names it). +//! LibRaw is only run, never linked or read. + +use std::path::PathBuf; +use std::process::Command; + +use filmcraft_dng::Dng; +use filmcraft_dng::writer::{Compression, Layout, Options, write}; + +fn tool() -> Option { + let t = std::env::var("FILMCRAFT_UNPROCESSED_RAW").unwrap_or_else(|_| "unprocessed_raw".into()); + Command::new(&t).arg("-q").output().ok().map(|_| t) +} + +fn picture(w: usize, h: usize, bits: u32, seed: u32) -> Vec { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(7); + let max = (1u32 << bits) - 1; + (0..w * h) + .map(|i| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let (x, y) = (i % w, i / w); + (((x * 97 + y * 41) as u32 % (max + 1)) ^ (s & 15)).min(max) as u16 + }) + .collect() +} + +/// The 16-bit samples of a binary PGM. +fn pgm(b: &[u8]) -> Option<(usize, usize, Vec)> { + let mut fields = Vec::new(); + let mut i = 0; + while fields.len() < 4 { + while b.get(i)?.is_ascii_whitespace() { + i += 1; + } + let start = i; + while !b.get(i)?.is_ascii_whitespace() { + i += 1; + } + fields.push(std::str::from_utf8(&b[start..i]).ok()?.to_string()); + } + let (w, h): (usize, usize) = (fields[1].parse().ok()?, fields[2].parse().ok()?); + let data = b.get(i + 1..)?; + Some((w, h, data.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect())) +} + +#[test] +fn libraw_reads_our_raw_samples() { + let Some(tool) = tool() else { + eprintln!("SKIPPED: unprocessed_raw (LibRaw samples) not installed"); + return; + }; + let dir: PathBuf = std::env::temp_dir().join(format!("filmcraft-dng-oracle-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (w, h) = (40, 26); + let mut cases: Vec<(String, Options, u32)> = Vec::new(); + for bits in [8, 10, 12, 14, 16] { + for be in [false, true] { + cases.push(( + format!("u{bits}{}", if be { "be" } else { "le" }), + Options { width: w, height: h, bits, big_endian: be, layout: Layout::Strips { rows: 7 }, ..Default::default() }, + bits, + )); + } + } + for predictor in 1..=7 { + for components in [1, 2] { + let o = Options { + width: w, + height: h, + bits: 14, + layout: Layout::Tiles { width: 16, height: 16 }, + compression: Compression::LosslessJpeg { predictor, components, restart_lines: 0, pt: 0 }, + ..Default::default() + }; + cases.push((format!("lj{predictor}c{components}"), o, 14)); + } + } + for (i, (name, o, bits)) in cases.iter().enumerate() { + let px = picture(w, h, *bits, i as u32); + let f = write(&px, o); + assert_eq!(Dng::parse(&f).unwrap().raw().unwrap().data, px, "{name}: ours"); + let path = dir.join(format!("{name}.dng")); + std::fs::write(&path, &f).unwrap(); + let out = Command::new(&tool).arg("-q").arg(&path).output().unwrap(); + assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + let (pw, ph, theirs) = pgm(&std::fs::read(dir.join(format!("{name}.dng.pgm"))).unwrap()).unwrap(); + assert_eq!((pw, ph), (w, h), "{name}"); + assert_eq!(theirs, px, "{name}: LibRaw"); + eprintln!("{name}: LibRaw agrees"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/dng/tests/roundtrip.rs b/crates/dng/tests/roundtrip.rs new file mode 100644 index 000000000..386aef5b2 --- /dev/null +++ b/crates/dng/tests/roundtrip.rs @@ -0,0 +1,366 @@ +//! Round trips through the test writer: container parsing, bit-exact raw decoding (uncompressed at +//! every depth, lossless JPEG with every predictor), the processing maths against hand-computed +//! values, CinemaDNG metadata, and hostile files. + +use filmcraft_dng::color::{D50, D65, bradford, inverse, mul, rec709_to_xyz, xy_to_xyz}; +use filmcraft_dng::tiff::tags; +use filmcraft_dng::writer::{Compression, Layout, Options, Value, lj92, write}; +use filmcraft_dng::{Dng, Error, Timecode, decode, ljpeg, probe, sniff}; + +/// A deterministic pseudo-random sequence. +fn noise(n: usize, bits: u32, seed: u32) -> Vec { + let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..n) + .map(|_| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s & ((1u32 << bits) - 1)) as u16 + }) + .collect() +} + +/// A smooth picture with some noise (like a real sensor), `bits` deep. +fn picture(w: usize, h: usize, spp: usize, bits: u32, seed: u32) -> Vec { + let max = (1u32 << bits) - 1; + let n = noise(w * h * spp, 4.min(bits), seed); + (0..w * h * spp) + .map(|i| { + let (x, y) = ((i / spp) % w, (i / spp) / w); + let v = (x * 37 + y * 23 + (i % spp) * 101) as u32 % (max + 1); + (v ^ u32::from(n[i])).min(max) as u16 + }) + .collect() +} + +#[test] +fn sniff_and_container() { + let px = picture(16, 8, 1, 12, 1); + for be in [false, true] { + for preview in [false, true] { + let o = Options { width: 16, height: 8, bits: 12, big_endian: be, preview, ..Default::default() }; + let f = write(&px, &o); + assert!(sniff(&f), "be {be} preview {preview}"); + let d = Dng::parse(&f).unwrap(); + let info = d.info().unwrap(); + assert_eq!((info.raw_width, info.raw_height, info.width, info.height, info.bits), (16, 8, 16, 8, 12)); + assert!(info.cfa); + assert_eq!(info.dng_version, [1, 4, 0, 0]); + assert_eq!(info.camera.as_deref(), Some("FilmCraft Synthetic")); + assert_eq!(d.raw().unwrap().data, px, "be {be} preview {preview}"); + } + } + // a plain TIFF is not a DNG + let mut plain = write(&px, &Options::default()); + // remove the DNGVersion tag by renaming it to an unknown tag number + let pos = find_tag(&plain, tags::DNG_VERSION).unwrap(); + plain[pos..pos + 2].copy_from_slice(&40000u16.to_le_bytes()); + assert!(!sniff(&plain)); + assert_eq!(Dng::parse(&plain).err(), Some(Error::NotDng)); + assert!(!sniff(b"\x89PNG\r\n\x1a\n")); + assert!(!sniff(b"")); +} + +/// Offset of the directory entry for `tag` in a little-endian file's first IFD. +fn find_tag(f: &[u8], tag: u16) -> Option { + let ifd = u32::from_le_bytes(f[4..8].try_into().unwrap()) as usize; + let n = u16::from_le_bytes(f[ifd..ifd + 2].try_into().unwrap()) as usize; + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| u16::from_le_bytes(f[e..e + 2].try_into().unwrap()) == tag) +} + +#[test] +fn uncompressed_every_depth_strips_and_tiles() { + for bits in [8, 9, 10, 12, 14, 16] { + for (spp, cfa) in [(1, true), (3, false)] { + for layout in [Layout::Strips { rows: 3 }, Layout::Strips { rows: 100 }, Layout::Tiles { width: 16, height: 8 }] { + for be in [false, true] { + let (w, h) = (21, 13); + let px = picture(w, h, spp, bits, bits + spp as u32); + let o = Options { + width: w, + height: h, + samples_per_pixel: spp, + bits, + big_endian: be, + layout, + cfa: cfa.then(|| (2, 2, vec![0, 1, 1, 2])), + ..Default::default() + }; + let raw = Dng::parse(&write(&px, &o)).unwrap().raw().unwrap(); + assert_eq!(raw.data, px, "bits {bits} spp {spp} {layout:?} be {be}"); + assert_eq!(raw.bits, bits); + } + } + } + } +} + +#[test] +fn lossless_jpeg_every_predictor_bit_exact() { + for predictor in 1..=7u8 { + for bits in [8u32, 12, 14, 16] { + for (components, layout) in + [(1, Layout::Strips { rows: 5 }), (2, Layout::Tiles { width: 16, height: 16 }), (4, Layout::Tiles { width: 8, height: 4 })] + { + let (w, h) = (36, 22); + let px = picture(w, h, 1, bits, u32::from(predictor) * 7 + bits); + let o = Options { + width: w, + height: h, + bits, + layout, + compression: Compression::LosslessJpeg { predictor, components, restart_lines: 0, pt: 0 }, + ..Default::default() + }; + let f = write(&px, &o); + assert_eq!(probe(&f).unwrap().compression, "lossless JPEG"); + let raw = Dng::parse(&f).unwrap().raw().unwrap(); + assert_eq!(raw.data, px, "predictor {predictor} bits {bits} components {components}"); + } + } + } +} + +#[test] +fn lossless_jpeg_extremes_restarts_and_point_transform() { + // full-swing 16-bit noise hits every difference category including SSSS = 16 + let (w, h) = (17, 9); + let mut px = noise(w * h * 2, 16, 3); + px[0] = 0; + px[2] = 32_768; // 0 after the 2^15 initial prediction is a difference of 32768 (SSSS 16) + for predictor in 1..=7 { + let j = lj92(&px, w, h, 2, 16, predictor, 0, 0); + let d = ljpeg::decode(&j, 1 << 20).unwrap(); + assert_eq!((d.width, d.height, d.components, d.precision, d.predictor), (w, h, 2, 16, predictor)); + assert_eq!(d.samples, px, "predictor {predictor}"); + // restart markers every 2 and every 3 lines + for r in [2, 3] { + let j = lj92(&px, w, h, 2, 16, predictor, r, 0); + assert!(j.windows(2).any(|b| b == [0xFF, 0xD0]), "has RST0"); + assert_eq!(ljpeg::decode(&j, 1 << 20).unwrap().samples, px, "predictor {predictor} restart {r}"); + } + } + // point transform: the low bits are dropped and the rest shifted back + let px = picture(12, 6, 1, 14, 9); + let j = lj92(&px, 12, 6, 1, 14, 4, 0, 2); + let want: Vec = px.iter().map(|v| v >> 2 << 2).collect(); + assert_eq!(ljpeg::decode(&j, 1 << 20).unwrap().samples, want); + // the sample cap + assert_eq!(ljpeg::decode(&lj92(&px, 12, 6, 1, 14, 1, 0, 0), 71).err(), Some(Error::TooLarge)); +} + +/// The colour tags of a camera whose white-balanced values are Rec. 709 already. +fn rec709_camera_tags(neutral: [f64; 3]) -> Vec<(u16, Value)> { + let cm = inverse(&rec709_to_xyz().unwrap()).unwrap(); + let fm = mul(&bradford(&xy_to_xyz(D65), &xy_to_xyz(D50)).unwrap(), &rec709_to_xyz().unwrap()); + let flat = |m: [[f64; 3]; 3]| m.iter().flatten().copied().collect::>(); + vec![ + (tags::COLOR_MATRIX_1, Value::srationals(&flat(cm))), + (tags::FORWARD_MATRIX_1, Value::srationals(&flat(fm))), + (tags::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])), + (tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral)), + ] +} + +#[test] +fn develop_matches_hand_computed_values() { + // 12-bit RGGB, black 64, white 4064 (range 4000); four colour quadrants seen through a camera + // with neutral (0.5, 1, 0.8). White-balanced values come out as Rec. 709 (the ForwardMatrix + // is Rec. 709 → XYZ D50), so a quadrant of colour c reads back c. + let (w, h) = (32, 24); + let neutral: [f64; 3] = [0.5, 1.0, 0.8]; + let quad = |x: usize, y: usize| -> [f64; 3] { + match (x < w / 2, y < h / 2) { + (true, true) => [0.5, 0.5, 0.5], + (false, true) => [0.8, 0.2, 0.1], + (true, false) => [0.05, 0.6, 0.3], + (false, false) => [0.25, 0.25, 0.9], + } + }; + let pat = [0u8, 1, 1, 2]; + let px: Vec = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let c = usize::from(pat[(y % 2) * 2 + x % 2]); + let camera = quad(x, y)[c] * neutral[c]; + (64.0 + camera * 4000.0).round() as u16 + }) + .collect(); + let mut main = rec709_camera_tags(neutral); + main.push((tags::BASELINE_EXPOSURE, Value::SRational(vec![(0, 1)]))); + let raw_tags = vec![(tags::BLACK_LEVEL, Value::Short(vec![64])), (tags::WHITE_LEVEL, Value::Short(vec![4064]))]; + for compression in [Compression::None, Compression::LosslessJpeg { predictor: 6, components: 2, restart_lines: 0, pt: 0 }] { + let o = Options { width: w, height: h, bits: 12, compression, main_tags: main.clone(), raw_tags: raw_tags.clone(), ..Default::default() }; + let img = decode(&write(&px, &o)).unwrap(); + assert_eq!((img.width, img.height), (w, h)); + for y in 0..h { + for x in 0..w { + // away from the quadrant edges (the demosaic reaches two pixels) + if (x as isize - w as isize / 2).abs() < 3 || (y as isize - h as isize / 2).abs() < 3 { + continue; + } + let got = &img.rgba[(y * w + x) * 4..][..4]; + let want = quad(x, y); + for k in 0..3 { + assert!((got[k] - want[k] as f32).abs() < 2e-3, "({x},{y}) ch {k}: {} vs {}", got[k], want[k]); + } + assert_eq!(got[3], 1.0); + } + } + } + // BaselineExposure +1: twice as bright + let mut main1 = rec709_camera_tags(neutral); + main1.push((tags::BASELINE_EXPOSURE, Value::SRational(vec![(1, 1)]))); + let o = Options { width: w, height: h, bits: 12, main_tags: main1, raw_tags, ..Default::default() }; + let img = decode(&write(&px, &o)).unwrap(); + assert!((img.rgba[(4 * w + 4) * 4] - 1.0).abs() < 4e-3, "{}", img.rgba[(4 * w + 4) * 4]); +} + +#[test] +fn active_area_default_crop_and_linear_raw() { + // LinearRaw 3 samples, 10 × 6, active area cuts one pixel off each side, crop 4 × 2 at (2, 1) + let (w, h) = (10, 6); + let px: Vec = (0..w * h).flat_map(|i| [i as u16 * 100, 50, 7]).collect(); + let o = Options { + width: w, + height: h, + samples_per_pixel: 3, + bits: 16, + cfa: None, + raw_tags: vec![ + (tags::ACTIVE_AREA, Value::Long(vec![1, 1, 5, 9])), + (tags::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 1])), + (tags::DEFAULT_CROP_SIZE, Value::Rational(vec![(8, 2), (2, 1)])), + (tags::WHITE_LEVEL, Value::Long(vec![65_535])), + ], + ..Default::default() + }; + let f = write(&px, &o); + let info = probe(&f).unwrap(); + assert_eq!((info.width, info.height, info.cfa, info.samples_per_pixel), (4, 2, false, 3)); + let img = decode(&f).unwrap(); + assert_eq!((img.width, img.height), (4, 2)); + // output (0, 0) is raw (1 + 2, 1 + 1) = sample 23 + assert!((img.rgba[0] - 2300.0 / 65_535.0).abs() < 1e-6); + assert!((img.rgba[1] - 50.0 / 65_535.0).abs() < 1e-6); + // output (3, 1) is raw (6, 3) = sample 36 + assert!((img.rgba[(4 + 3) * 4] - 3600.0 / 65_535.0).abs() < 1e-6); +} + +#[test] +fn cinemadng_frame_rate_time_code_and_reel() { + let px = picture(8, 8, 1, 12, 2); + let o = Options { + bits: 12, + main_tags: vec![ + (tags::FRAME_RATE, Value::SRational(vec![(24_000, 1001)])), + // 01:02:03:04, drop frame + (tags::TIME_CODES, Value::Byte(vec![0x44, 0x03, 0x02, 0x01, 0, 0, 0, 0])), + (tags::REEL_NAME, Value::Ascii("A001".into())), + ], + ..Default::default() + }; + let info = probe(&write(&px, &o)).unwrap(); + assert_eq!(info.frame_rate, Some((24_000, 1001))); + assert_eq!(info.timecode, Some(Timecode { hours: 1, minutes: 2, seconds: 3, frames: 4, drop_frame: true })); + assert_eq!(info.reel.as_deref(), Some("A001")); + // nonsense rates and time codes are ignored + let o = Options { + bits: 12, + main_tags: vec![(tags::FRAME_RATE, Value::SRational(vec![(-25, 1)])), (tags::TIME_CODES, Value::Byte(vec![0x0A, 0x99, 0, 0, 0, 0, 0, 0]))], + ..Default::default() + }; + let info = probe(&write(&px, &o)).unwrap(); + assert_eq!((info.frame_rate, info.timecode), (None, None)); +} + +/// Every hostile variant must give an error or a picture, never a panic. +fn survives(f: &[u8]) -> bool { + std::panic::catch_unwind(|| { + let _ = probe(f); + let _ = decode(f); + }) + .is_ok() +} + +#[test] +fn hostile_files_are_errors_not_panics() { + let px = picture(24, 16, 1, 12, 5); + let files: Vec> = [ + Options { width: 24, height: 16, bits: 12, ..Default::default() }, + Options { width: 24, height: 16, bits: 12, preview: true, layout: Layout::Tiles { width: 16, height: 16 }, ..Default::default() }, + Options { + width: 24, + height: 16, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 4, components: 2, restart_lines: 2, pt: 0 }, + main_tags: rec709_camera_tags([0.5, 1.0, 0.7]), + ..Default::default() + }, + ] + .iter() + .map(|o| write(&px, o)) + .collect(); + for f in &files { + assert!(decode(f).is_ok()); + // every truncation + for n in 0..f.len() { + assert!(survives(&f[..n]), "truncated to {n}"); + if n < f.len() / 2 { + assert!(decode(&f[..n]).is_err(), "truncated to {n}"); + } + } + // bit flips and corrupt bytes everywhere + let mut s = 12345u32; + for i in 0..4000 { + let mut g = f.clone(); + for _ in 0..1 + i % 4 { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + let at = s as usize % g.len(); + g[at] = if i % 3 == 0 { 0xFF } else { g[at] ^ (1 << (s >> 29)) }; + } + assert!(survives(&g), "mutation {i}"); + } + } +} + +#[test] +fn cyclic_ifds_huge_sizes_and_bad_offsets() { + let px = picture(8, 8, 1, 16, 4); + let f = write(&px, &Options { preview: true, ..Default::default() }); + let ifd0 = u32::from_le_bytes(f[4..8].try_into().unwrap()) as usize; + let n = u16::from_le_bytes(f[ifd0..ifd0 + 2].try_into().unwrap()) as usize; + // IFD 0's next pointer to itself + let mut g = f.clone(); + g[ifd0 + 2 + 12 * n..ifd0 + 6 + 12 * n].copy_from_slice(&(ifd0 as u32).to_le_bytes()); + assert!(decode(&g).is_ok(), "a self-loop ends the chain"); + // the SubIFD pointing back at IFD 0 + let mut g = f.clone(); + let sub = find_tag(&g, tags::SUB_IFDS).unwrap(); + g[sub + 8..sub + 12].copy_from_slice(&(ifd0 as u32).to_le_bytes()); + assert!(matches!(decode(&g), Err(Error::Unsupported(_))), "no raw image left"); + // huge dimensions with a tiny file + for (wtag, v) in [(tags::IMAGE_WIDTH, 65_535u32), (tags::IMAGE_LENGTH, 65_535), (tags::IMAGE_WIDTH, 0), (tags::IMAGE_WIDTH, u32::MAX)] { + let mut g = write(&px, &Options::default()); + let e = find_tag(&g, wtag).unwrap(); + g[e + 8..e + 12].copy_from_slice(&v.to_le_bytes()); + assert!(decode(&g).is_err(), "{wtag} = {v}"); + } + // a strip offset past the end of the file + let mut g = write(&px, &Options::default()); + let e = find_tag(&g, tags::STRIP_OFFSETS).unwrap(); + g[e + 8..e + 12].copy_from_slice(&0xFFFF_FF00u32.to_le_bytes()); + assert_eq!(decode(&g).err(), Some(Error::Truncated)); + // an IFD offset past the end + let mut g = write(&px, &Options::default()); + g[4..8].copy_from_slice(&0x7FFF_FFFFu32.to_le_bytes()); + assert!(decode(&g).is_err()); + // a rational with a zero denominator in a raw tag + let o = Options { raw_tags: vec![(tags::DEFAULT_CROP_SIZE, Value::Rational(vec![(8, 0), (8, 1)]))], ..Default::default() }; + assert!(matches!(decode(&write(&px, &o)), Err(Error::Malformed(_)))); + // unsupported compressions and depths are named + let o = Options { raw_tags: vec![(tags::COMPRESSION, Value::Short(vec![34892]))], ..Default::default() }; + assert_eq!(decode(&write(&px, &o)).err(), Some(Error::Unsupported("lossy JPEG DNG".into()))); +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index bb4e6415b..17306f28d 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -247,6 +247,15 @@ fn track_mut(seq: &mut Sequence, id: TrackId) -> Result<&mut Track> { seq.track_mut(id).ok_or(EditError::NoTrack(id)) } +/// The clip's track, or [`EditError::Locked`] when that track may not change. +fn unlocked_track(seq: &Sequence, clip: ClipId) -> Result { + let (tid, _) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; + if seq.track(tid).ok_or(EditError::NoTrack(tid))?.locked { + return Err(EditError::Locked); + } + Ok(tid) +} + /// Overwrite edits: each item replaces material on its track. pub fn overwrite(seq: &mut Sequence, placements: Vec<(TrackId, TrackItem)>, ctx: &mut EditCtx) -> Result> { let mut links = HashMap::new(); @@ -503,7 +512,10 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ let mut work = seq.clone(); let mut placed = Vec::new(); for (c, dest, start) in moves { - let (_, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?; + let (src, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?; + if work.track(src).is_some_and(|t| t.locked) { + return Err(EditError::Locked); + } let mut it = it.clone(); if work.track(*dest).ok_or(EditError::NoTrack(*dest))?.locked { return Err(EditError::Locked); @@ -633,7 +645,8 @@ fn neighbours(track: &Track, id: ClipId) -> (Tick, Tick) { /// Compute the clamped delta a trim may apply (for UI feedback while dragging). pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &EditCtx) -> Result { - let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; + let tid = unlocked_track(seq, clip)?; + let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; let (prev_end, next_start) = neighbours(tr, clip); let media = media_len(ctx, it); @@ -703,6 +716,7 @@ pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) { /// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple). /// Linked partners should be trimmed by the caller with the same delta. pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, clip)?; let d = clamp_trim(seq, clip, edge, mode, delta, ctx)?; if d == Tick::ZERO { return Ok(d); @@ -759,6 +773,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: /// overwrite material on a sync-locked track, or when such a linked clip has no room to follow. pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { let Some(&first) = clips.first() else { return Ok(Tick::ZERO) }; + for c in clips { + unlocked_track(seq, *c)?; + } let mut d = delta; for c in clips { let x = clamp_trim(seq, *c, edge, TrimMode::Ripple, d, ctx)?; @@ -861,6 +878,8 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta /// Rolling edit between two adjacent items on one track: moves the cut by `delta`. pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, left)?; + unlocked_track(seq, right)?; let (_, l) = seq.find_item(left).ok_or(EditError::NoItem(left))?; let (_, r) = seq.find_item(right).ok_or(EditError::NoItem(right))?; if l.end() != r.start { @@ -908,6 +927,7 @@ pub fn roll(seq: &mut Sequence, left: ClipId, right: ClipId, delta: Tick, ctx: & /// Slip: change which part of the media an item shows without moving it. pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, clip)?; let (_, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let used = src_of(it.duration, it.speed); let max_in = media_len(ctx, it).map(|m| m - used).unwrap_or(Tick::MAX); @@ -921,6 +941,7 @@ pub fn slip(seq: &mut Sequence, clip: ClipId, delta_media: Tick, ctx: &mut EditC /// Slide: move an item between its neighbours, trimming them to keep the gapless span. pub fn slide(seq: &mut Sequence, clip: ClipId, delta: Tick, ctx: &mut EditCtx) -> Result { + unlocked_track(seq, clip)?; let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let it = it.clone(); let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; @@ -999,27 +1020,65 @@ pub fn slide_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut Ok(d) } -/// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media. -pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { +/// How far `clip` can rate-stretch on `edge` without overlapping a neighbour or going shorter than +/// one frame. Linked partners use this so they all take the tightest room. +pub fn clamp_rate_stretch(seq: &Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &EditCtx) -> Result { let (tid, it) = seq.find_item(clip).ok_or(EditError::NoItem(clip))?; let tr = seq.track(tid).ok_or(EditError::NoTrack(tid))?; let (prev_end, next_start) = neighbours(tr, clip); - let src_len = it.source_out() - it.source_in; - let d = match edge { + Ok(match edge { Edge::Out => delta.clamp(-(it.duration - ctx.min_duration), next_start - it.end()), Edge::In => delta.clamp(-(it.start - prev_end), it.duration - ctx.min_duration), - }; - let new_dur = match edge { - Edge::Out => it.duration + d, - Edge::In => it.duration - d, - }; - let speed = src_len.0 as f64 / new_dur.0 as f64; - let (_, it) = seq.find_item_mut(clip).ok_or(EditError::NoItem(clip))?; - if edge == Edge::In { - it.start += d; + }) +} + +/// Rate stretch: change an item's duration by dragging an edge, adjusting speed to keep the same media. +pub fn rate_stretch(seq: &mut Sequence, clip: ClipId, edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { + rate_stretch_items(seq, &[clip], edge, delta, ctx) +} + +/// Rate stretch a clip and its linked partners as one edit: the tightest neighbour on any member +/// clamps them all, so they keep one speed. A neighbour on the sound track cannot leave the picture +/// stretching further (and the pair still linked at different rates). +pub fn rate_stretch_items(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result { + // clips on locked tracks are left as they are; with every clip locked the edit is refused + let mut open = Vec::with_capacity(clips.len()); + for &c in clips { + match unlocked_track(seq, c) { + Ok(_) => open.push(c), + Err(EditError::Locked) => {} + Err(e) => return Err(e), + } + } + if open.is_empty() { + return Err(EditError::Locked); + } + let clips = open.as_slice(); + let mut d = delta; + for c in clips { + let x = clamp_rate_stretch(seq, *c, edge, d, ctx)?; + if x.0.signum() != d.0.signum() { + d = Tick::ZERO; + } else if x.abs() < d.abs() { + d = x; + } + } + let mut speed = 1.0; + for c in clips { + let (_, it) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?; + let src_len = it.source_out() - it.source_in; + let new_dur = match edge { + Edge::Out => it.duration + d, + Edge::In => it.duration - d, + }; + speed = src_len.0 as f64 / new_dur.0 as f64; + let (_, it) = seq.find_item_mut(*c).ok_or(EditError::NoItem(*c))?; + if edge == Edge::In { + it.start += d; + } + it.duration = new_dur; + it.speed = speed; } - it.duration = new_dur; - it.speed = speed; Ok(speed) } @@ -1038,6 +1097,9 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse if !(speed.is_finite() && speed > 0.0) { return Err(EditError::Other("speed must be positive".into())); } + for clip in clips { + unlocked_track(seq, *clip)?; + } let mut work = seq.clone(); // per member track: where its later material starts, and by how much the member's length changed let mut origins: Vec<(TrackId, Tick, Tick)> = Vec::new(); @@ -1061,6 +1123,7 @@ pub fn set_speed_group(seq: &mut Sequence, clips: &[ClipId], speed: f64, reverse it.speed = speed; it.reverse = reverse; it.duration = new_dur; + fades_follow_edges(work.track_mut(tid).ok_or(EditError::NoTrack(tid))?, *clip); origins.push((tid, old_end, delta)); } let main_from = origins.first().map(|o| o.1).unwrap_or_default(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9272a94da..dd054cdfd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -274,6 +274,17 @@ fn regular_trim_respects_neighbours_and_handles() { fx.seq.check().unwrap(); } +#[test] +fn trim_on_a_locked_track_is_refused() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 100, 0); + fx.seq.track_mut(v1).unwrap().locked = true; + let mut n = fx.next; + assert_eq!(trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(20), &mut Fx::ctx(&mut n)), Err(EditError::Locked)); + assert_eq!(fx.spans(v1), vec![(0, 100)]); +} + #[test] fn held_regular_in_trim_stops_at_previous_clip() { let mut fx = Fx::new(); @@ -492,6 +503,30 @@ fn fades_follow_a_regular_trim() { fx.seq.check().unwrap(); } +#[test] +fn fades_follow_a_non_ripple_speed_change() { + // #918: faster without ripple shortens the clip; the fade-out stays on the new out point. + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 0, 100, 0); + fx.seq.track_mut(v1).unwrap().transitions = vec![Transition { + id: TransitionId(1), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(90), + duration: f(10), + from: Some(a), + to: None, + align: Default::default(), + reverse: false, + }]; + let mut n = fx.next; + set_speed(&mut fx.seq, a, 2.0, false, false, &mut Fx::ctx(&mut n)).unwrap(); + let tr = &fx.seq.track(v1).unwrap().transitions[0]; + assert_eq!(fx.spans(v1), vec![(0, 50)]); + assert_eq!((tr.start, tr.duration), (f(40), f(10))); + fx.seq.check().unwrap(); +} + #[test] fn transitions_travel_with_moved_clips() { // V1: a [0,10) fading in, b [10,20), c [20,30); b→c crossfade, c fading out @@ -537,6 +572,29 @@ fn rate_stretch_and_speed() { assert_eq!(fx.spans(v1), vec![(0, 5)]); } +#[test] +fn linked_rate_stretch_uses_the_tighter_neighbour() { + // picture and sound are 60 frames; a later clip on A1 starts at 70, so the sound has only 10 + // frames of room. Stretching the pair +20 must not leave the picture at 80f / 0.75x and the + // sound at 70f / 6/7x while still linked. + let mut fx = Fx::new(); + let (v1, a1) = (fx.v(0), fx.a(0)); + let pic = fx.put(v1, 0, 60, 0); + let snd = fx.put(a1, 0, 60, 0); + fx.put(a1, 70, 10, 0); + for c in [pic, snd] { + fx.seq.find_item_mut(c).unwrap().1.link = Some(1); + } + let mut n = fx.next; + let speed = rate_stretch_items(&mut fx.seq, &[pic, snd], Edge::Out, f(20), &mut Fx::ctx(&mut n)).unwrap(); + assert!((speed - 60.0 / 70.0).abs() < 1e-9); + let (p, s) = (fx.seq.find_item(pic).unwrap().1, fx.seq.find_item(snd).unwrap().1); + assert_eq!((R.frame_at(p.duration), R.frame_at(s.duration)), (70, 70)); + assert!((p.speed - s.speed).abs() < 1e-12); + assert_eq!(fx.spans(a1), vec![(0, 70), (70, 10)]); + fx.seq.check().unwrap(); +} + #[test] fn speed_on_a_linked_group_ripples_once() { // V1: a [0,10), b [10,20). A1: a's sound [0,10), b's sound [10,20). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index defe08a87..16283a375 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -46,4 +46,6 @@ web-time = "1" [dev-dependencies] filmcraft-testkit = { workspace = true } -image = { version = "0.25", default-features = false, features = ["png"] } +filmcraft-dng = { workspace = true, features = ["writer"] } +filmcraft-isobmff = { workspace = true } +image = { version = "0.25", default-features = false, features = ["png", "gif"] } diff --git a/crates/engine/src/animated_image_tests.rs b/crates/engine/src/animated_image_tests.rs new file mode 100644 index 000000000..8fe0dc675 --- /dev/null +++ b/crates/engine/src/animated_image_tests.rs @@ -0,0 +1,62 @@ +//! Animated GIF and APNG files import as movie clips that play every frame with its transparency +//! (#402), and reopen that way from a saved project. + +use filmcraft_media::{FrameRequest, MediaKind}; +use filmcraft_project::ItemId; +use filmcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::media_test_util::tmp_dir; + +/// A 16×8 animated GIF: frame `i` is red `40 * i` with its left half transparent, 100 ms each. +fn write_gif(path: &std::path::Path, frames: u8) { + let mut out = Vec::new(); + { + let mut e = image::codecs::gif::GifEncoder::new(&mut out); + let frames = (0..frames).map(|i| { + let img = image::RgbaImage::from_fn(16, 8, |x, _| image::Rgba([40 * i, 0, 0, if x < 8 { 0 } else { 255 }])); + image::Frame::from_parts(img, 0, 0, image::Delay::from_numer_denom_ms(100, 1)) + }); + e.encode_frames(frames).unwrap(); + } + std::fs::write(path, out).unwrap(); +} + +fn item_of(r: &Value) -> ItemId { + ItemId(r["items"][0].as_u64().unwrap_or_else(|| panic!("{r}"))) +} + +/// The pixel at (`x`, 0) at media time `ms`. +fn pixel_at(s: &Session, item: ItemId, ms: i64, x: usize) -> [u8; 4] { + let src = s.media.source_for(&s.project, item, &*s.services).unwrap(); + let f = src.video_frame(FrameRequest::full(Tick(ms * TICKS_PER_SECOND / 1000))).unwrap(); + match &f.data { + filmcraft_frame::PixelData::Rgba8(p) => [p[4 * x], p[4 * x + 1], p[4 * x + 2], p[4 * x + 3]], + _ => panic!("rgba frames"), + } +} + +#[test] +fn animated_gif_imports_as_a_clip_and_reopens() { + let dir = tmp_dir("animated-gif"); + let gif = dir.join("loop.gif"); + write_gif(&gif, 4); + let mut s = Session::default(); + let id = item_of(&s.execute("file.import", json!({"paths": [gif.to_string_lossy()]})).unwrap()); + let m = s.project.item(id).unwrap().as_media().unwrap(); + assert_eq!(m.info.kind, MediaKind::Movie, "not a still showing the first frame"); + assert_eq!(m.info.duration, Tick(4 * TICKS_PER_SECOND / 10)); + assert!(m.info.video.as_ref().unwrap().has_alpha); + assert_eq!(pixel_at(&s, id, 0, 12), [0, 0, 0, 255]); + assert_eq!(pixel_at(&s, id, 250, 12), [80, 0, 0, 255], "the third frame plays"); + assert_eq!(pixel_at(&s, id, 250, 2)[3], 0, "transparent pixels stay transparent, not black"); + + let proj = dir.join("anim.fcproj"); + s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": proj.to_string_lossy()})).unwrap(); + assert_eq!(t.project.item(id).unwrap().as_media().unwrap().info.kind, MediaKind::Movie); + assert_eq!(pixel_at(&t, id, 350, 12), [120, 0, 0, 255], "frames reopen through the media pool"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/audio_effects_tests.rs b/crates/engine/src/audio_effects_tests.rs index b85463e4c..e3992640a 100644 --- a/crates/engine/src/audio_effects_tests.rs +++ b/crates/engine/src/audio_effects_tests.rs @@ -111,3 +111,19 @@ fn shared_names_resolve_by_track_kind() { let it = s.active_sequence().unwrap().find_item(ClipId(v)).unwrap().1.clone(); assert!(it.effects.iter().any(|e| e.effect == "invert")); } + +/// #696: applying an audio effect to the video side of an A/V pair lands on the linked audio. +#[test] +fn audio_effect_on_video_clip_applies_to_linked_audio() { + let mut s = demo(); + let seq = s.active_sequence().unwrap(); + let v = seq.video_tracks[0].items[0].clone(); + let a = seq.audio_tracks[0].items.iter().find(|i| i.link == v.link).expect("linked audio").clone(); + let n0 = a.effects.len(); + s.execute("effects.apply", json!({"clips": [v.id.0], "effect": "parametric_eq"})).unwrap(); + let a = s.active_sequence().unwrap().find_item(a.id).unwrap().1.clone(); + let v = s.active_sequence().unwrap().find_item(v.id).unwrap().1.clone(); + assert_eq!(a.effects.len(), n0 + 1); + assert_eq!(a.effects.last().unwrap().effect, "parametric_eq"); + assert!(v.effects.iter().all(|e| e.effect != "parametric_eq"), "EQ stays off the picture clip"); +} diff --git a/crates/engine/src/audio_placement_tests.rs b/crates/engine/src/audio_placement_tests.rs index f765e055c..f23a932b1 100644 --- a/crates/engine/src/audio_placement_tests.rs +++ b/crates/engine/src/audio_placement_tests.rs @@ -168,6 +168,32 @@ fn missing_audio_tracks_are_cloned_from_the_destination() { assert_eq!(again, ids); } +/// #823: a container with two PCM tracks imports both streams, and overwrite places linked clips. +#[test] +fn import_keeps_every_audio_stream_and_places_linked_clips() { + use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap(); + for (rate, value) in [(48_000u32, 8192i16), (24_000, -16384)] { + let entry = SampleEntry::pcm(PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 1, sample_rate: rate as f64 }); + let track = mux.add_track(TrackConfig::new(entry, rate)).unwrap(); + let data: Vec = (0..rate / 10).flat_map(|_| value.to_le_bytes()).collect(); + mux.write_sample(track, WriteSample { data: &data, duration: rate / 10, composition_offset: 0, is_sync: true }).unwrap(); + } + let bytes = mux.finish().unwrap().into_inner(); + let mut s = Session::default(); + let item = crate::commands::import_bytes(&mut s, "/two-audio.mov", bytes.into(), None).unwrap(); + let streams = s.project.item(item).unwrap().as_media().unwrap().info.audio_streams.len(); + assert_eq!(streams, 2); + s.execute("file.newSequence", json!({"name": "S", "fromItem": item.0, "video": 1, "audio": 1})).unwrap(); + let seq = s.state.active_sequence.unwrap(); + let q = s.project.sequence(seq).unwrap(); + assert_eq!(q.audio_tracks.len(), 2); + let (a, b) = (&q.audio_tracks[0].items[0], &q.audio_tracks[1].items[0]); + assert_eq!((a.audio_stream, b.audio_stream), (0, 1)); + assert_eq!(a.link, b.link); + assert!(a.link.is_some()); +} + #[test] fn hostile_track_counts_are_an_error_not_a_crash() { let s = demo(); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index f5e8e809a..8af0fef11 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -572,15 +572,16 @@ pub fn scan_candidates(data_dir: &Path, skip: Option<&Path>) -> Vec(&b).ok()); - match meta { - Some(meta) if dir.join(SNAPSHOT).is_file() => { - out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta }); - } - _ => { - let _ = fs::remove_dir_all(&dir); - } + let snap = dir.join(SNAPSHOT); + if !snap.is_file() { + // Empty leftover dirs (clean exit, or a crash before the first snapshot). + let _ = fs::remove_dir_all(&dir); + continue; } + // Keep the snapshot even when session.json is missing or unreadable: the project + // is still recoverable. Meta fields fall back to defaults. + let meta = fs::read(dir.join(META)).ok().and_then(|b| serde_json::from_slice::(&b).ok()).unwrap_or_default(); + out.push(RecoveryCandidate { id: e.file_name().to_string_lossy().into_owned(), dir, meta }); } out.sort_by(|a, b| b.meta.saved_unix.cmp(&a.meta.saved_unix).then(b.id.cmp(&a.id))); out diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index 5ac8281e9..44569295c 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -176,6 +176,28 @@ fn auto_save_ring_rotates_and_can_save_the_project() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn scan_keeps_snapshot_when_session_metadata_is_unreadable() { + // #894: a stale session with a snapshot must not be deleted because session.json is missing + // or corrupt — the recovered project would vanish from the list and the file would be gone. + let d = temp_dir("recover-nometa"); + let root = autosave::recovery_root(&d); + let sess = root.join("123-1"); + std::fs::create_dir_all(&sess).unwrap(); + let snap = filmcraft_format::encode(&filmcraft_project::Project::default(), false); + std::fs::write(sess.join("snapshot.fcproj"), snap).unwrap(); + std::fs::write(sess.join("session.json"), b"not json {").unwrap(); + let found = autosave::scan_candidates(&d, None); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, "123-1"); + assert!(sess.join("snapshot.fcproj").is_file(), "snapshot must survive the scan"); + std::fs::remove_file(sess.join("session.json")).unwrap(); + let found = autosave::scan_candidates(&d, None); + assert_eq!(found.len(), 1, "missing metadata still lists the snapshot"); + assert!(sess.join("snapshot.fcproj").is_file()); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn portable_marker_keeps_data_next_to_the_executable() { // #139: a portable install must not write to %APPDATA%. diff --git a/crates/engine/src/avi_import_tests.rs b/crates/engine/src/avi_import_tests.rs new file mode 100644 index 000000000..43d8a3018 --- /dev/null +++ b/crates/engine/src/avi_import_tests.rs @@ -0,0 +1,58 @@ +//! #598: AVI files import (ffmpeg-made, skipped without it): the Project item describes the +//! streams, a sequence made from it plays picture and sound, and the Media Browser's streaming +//! probe opens it without reading it whole. + +use serde_json::json; + +use crate::Session; +use filmcraft_project::ItemId; + +fn fixture() -> Option { + let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("avi import")?; + let path = filmcraft_testkit::fixtures_dir("engine/avi").join("h264_mp3.avi"); + filmcraft_testkit::fixtures::generate(&path, |tmp| { + std::process::Command::new(&ffmpeg) + .args([ + "-y", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=160x120:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + ]) + .args(["-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p", "-c:a", "libmp3lame", "-f", "avi"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +#[test] +fn avi_imports_plays_and_probes() { + let Some(path) = fixture() else { return }; + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(r["errors"].as_array().is_none_or(|e| e.is_empty()), "{r}"); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let info = s.project.item(item).unwrap().as_media().unwrap().info.clone(); + assert_eq!(info.container, "AVI"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, "H.264")); + assert_eq!(info.audio_streams.first().map(|a| a.codec.as_str()), Some("MP3")); + assert!((info.duration.seconds() - 2.0).abs() < 0.1, "{}", info.duration.seconds()); + // a sequence from the clip has its picture and sound + s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap(); + let q = s.active_sequence().unwrap().clone(); + assert!(!q.video_tracks[0].items.is_empty() && !q.audio_tracks[0].items.is_empty()); + let src = s.source(item).unwrap(); + assert!(src.video_frame(filmcraft_media::FrameRequest::full(filmcraft_time::Tick(filmcraft_time::TICKS_PER_SECOND))).is_ok()); + assert!(src.audio(48_000, 4_800, 48_000).unwrap().channels[0].iter().any(|v| v.abs() > 0.05)); + // the Media Browser's probe streams it (index now, frames on demand) + let probed = s.media.probe_file(&path.to_string_lossy(), &*s.services).unwrap(); + assert_eq!(probed.info().container, "AVI"); +} diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index ec4804a3d..8a85b9eee 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -1715,8 +1715,8 @@ fn replace_with(s: &mut Session, p: &Value, how: Replace) -> Result { } let Some((_, it)) = q.find_item_mut(*c) else { continue }; let src_in = match how { - // the Source monitor frame lines up with the sequence playhead - Replace::MatchFrame => src_ph - Tick(((ph - it.start).0 as f64 * it.speed.abs()).round() as i64), + // the Source monitor frame lines up with the sequence playhead (reverse-aware) + Replace::MatchFrame => src_ph - (it.moving_source_time_at(ph) - it.source_in), _ if keep_in => { let kept = keep_within.map(|(first, last)| it.source_in.clamp(first, last)).unwrap_or(it.source_in); if kept != it.source_in { diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 68eb470f1..262b1ec80 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -606,6 +606,32 @@ fn replace_with_clip_from_source_match_frame_and_bin() { assert!(s.execute("clip.replaceFromBin", json!({"item": music.0})).is_err()); } +/// Match Frame on a reversed clip maps the Source monitor frame through reverse playback, not as +/// if the clip still ran forward (source 50 at sequence 20 used to become source 69). +#[test] +fn replace_from_source_match_frame_on_a_reversed_clip() { + let mut s = demo(); + let rate = s.sequence_rate(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let forest = item_named(&s, "Misty_Forest.mp4"); + s.execute("file.newSequence", json!({"name": "MatchFrameReverse"})).unwrap(); + s.execute("timeline.place", json!({"item": forest.0, "time": 0})).unwrap(); + let c = s.active_sequence().unwrap().video_tracks[0].items[0].clone(); + s.execute("timeline.trim", json!({"clip": c.id.0, "edge": "out", "mode": "regular", "delta": (rate.tick_of(60) - c.duration).0})).unwrap(); + select(&mut s, &[c.id.0]); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true, "ripple": false})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!(rate.frame_at(got.duration), 60); + assert!(got.reverse); + s.execute("source.open", json!({"item": forest.0})).unwrap(); + s.execute("playhead.set", json!({"time": rate.tick_of(20).0})).unwrap(); + s.execute("source.setPlayhead", json!({"frame": 50})).unwrap(); + s.execute("clip.replaceFromSourceMatchFrame", json!({})).unwrap(); + let got = clip(&s, c.id.0); + assert_eq!(got.source_time_at(rate.tick_of(20)), rate.tick_of(50)); + assert_ne!(got.source_time_at(rate.tick_of(20)), rate.tick_of(69)); +} + /// Replace With Clip ▸ From Bin restarts the clip at the new item's In point (as Premiere does); /// `keepSourceIn` keeps the clip's place in the media instead. #[test] @@ -868,6 +894,29 @@ fn color_matte_color_changes_and_undoes() { assert!(s.execute("project.matteColor", json!({"item": matte, "color": "green"})).is_err()); } +/// #918: non-ripple Speed/Duration 200% halves a 100-frame clip; a 10-frame fade-out must +/// move from frames 90–100 to 40–50 (the new clip end), not stay past the clip. +#[test] +fn speed_duration_moves_fade_out_to_the_new_clip_end() { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name": "Speed fade", "video": 1, "audio": 0})).unwrap(); + let rate = s.sequence_rate(); + let seconds = rate.tick_of(100).seconds(); + let matte = s.execute("file.newColorMatte", json!({"color": "#ff0000", "seconds": seconds})).unwrap()["item"].as_u64().unwrap(); + let c = s.execute("timeline.place", json!({"item": matte, "track": "V1", "seconds": 0.0})).unwrap()["clips"][0].as_u64().unwrap(); + select(&mut s, &[c]); + s.execute("sequence.applyVideoTransition", json!({"edge": "out", "frames": 10})).unwrap(); + let before = s.active_sequence().unwrap().video_tracks[0].transitions[0].clone(); + assert_eq!((rate.frame_at(before.start), rate.frame_at(before.end())), (90, 100)); + s.execute("clip.speedDuration", json!({"speed": 200})).unwrap(); + let it = clip(&s, c); + let fade = &s.active_sequence().unwrap().video_tracks[0].transitions[0]; + assert_eq!((rate.frame_at(it.start), rate.frame_at(it.end())), (0, 50)); + assert_eq!((rate.frame_at(fade.start), rate.frame_at(fade.end())), (40, 50)); + assert_eq!(fade.from, Some(filmcraft_project::ClipId(c))); + assert_eq!(fade.to, None); +} + /// A damaged project whose subclip is its own parent (#66): `media_duration` followed the chain /// without a bound and overflowed the stack. It gives up after a few hops now. #[test] @@ -988,3 +1037,25 @@ fn keyboard_slip_keeps_linked_clips_in_sync_past_the_shorter_handle() { s.execute("timeline.slipLeft5", json!({})).unwrap(); assert_eq!((clip(&s, v).source_in, clip(&s, a).source_in), (Tick(fr.0 * 30), Tick(fr.0 * 35))); } + +#[test] +fn linked_rate_stretch_keeps_one_speed_when_a_neighbour_blocks_the_sound() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Stretch"})).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": "A", "seconds": 8, "fps": 25, "video": true, "audio": true})).unwrap(); + let a = r["item"].as_u64().unwrap(); + let r = s.execute("file.newOfflineFile", json!({"name": "B", "seconds": 2, "fps": 25, "video": true, "audio": true})).unwrap(); + let b = r["item"].as_u64().unwrap(); + let fr = s.sequence_rate().tick_of(1); + let r = s.execute("timeline.place", json!({"item": a, "track": "V1", "audioTrack": "A1", "time": 0, "duration": fr.0 * 60})).unwrap(); + let (v, snd) = (r["clips"][0].as_u64().unwrap(), r["clips"][1].as_u64().unwrap()); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + s.execute("timeline.place", json!({"item": b, "track": "A1", "time": fr.0 * 70, "duration": fr.0 * 10})).unwrap(); + s.execute("sequence.linkedSelection", json!({"on": true})).unwrap(); + let r = s.execute("timeline.rateStretch", json!({"clip": v, "edge": "out", "deltaFrames": 20})).unwrap(); + let (pic, sound) = (clip(&s, v), clip(&s, snd)); + assert_eq!((pic.duration, sound.duration), (Tick(fr.0 * 70), Tick(fr.0 * 70))); + assert!((pic.speed - sound.speed).abs() < 1e-12); + assert!((r["speed"].as_f64().unwrap() - 60.0 / 70.0).abs() < 1e-9); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fab7a0bf7..5f46ecc6b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -185,6 +185,18 @@ pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option< pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A marker's `color` param: absent (or null) is `None`; anything but a label name is an error +/// that lists the accepted names, instead of silently becoming Green (#857). +pub(crate) fn marker_color_p(p: &Value, cmd: &str) -> Result> { + let v = match p.get("color") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + v.as_str().and_then(Label::from_name).map(Some).ok_or_else(|| { + let names: Vec<&str> = Label::ALL.iter().map(|l| l.name()).collect(); + bad(cmd, format!("unknown marker color {v}; use one of: {}", names.join(", "))) + }) +} pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } @@ -221,6 +233,12 @@ pub(crate) fn checked_u32_p(p: &Value, key: &str, cmd: &str) -> Result Option { let rate = s.sequence_rate(); + let df = s.active_sequence().map(|q| q.settings.drop_frame).unwrap_or(false); + time_p_at(p, prefix, rate, df, s.playhead()) +} + +/// Same formats as [`time_p`], against an explicit rate (Source monitor, remix duration, …). +pub(crate) fn time_p_at(p: &Value, prefix: &str, rate: FrameRate, drop_frame: bool, current: Tick) -> Option { let k = |n: &str| if prefix.is_empty() { n.to_string() } else { format!("{prefix}{}{}", n[..1].to_uppercase(), &n[1..]) }; if let Some(t) = p.get(k("time")).and_then(Value::as_i64) { return Some(Tick(t)); @@ -232,13 +250,28 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { return Some(Tick::from_seconds_f64(sec)); } if let Some(tc) = p.get(k("timecode")).and_then(Value::as_str) { - let df = s.active_sequence().map(|q| q.settings.drop_frame).unwrap_or(false); - let cur = rate.frame_at(s.playhead()); - return parse_timecode(tc, rate, df, cur).ok().map(|f| rate.tick_of(f)); + return parse_timecode(tc, rate, drop_frame, rate.frame_at(current)).ok().map(|f| rate.tick_of(f)); } None } +/// In/Out / split time on the Source monitor: explicit `time`/`frame`/`seconds`/`timecode`, else the Source playhead. +pub(crate) fn source_mark_time(s: &Session, p: &Value, item: ItemId) -> Tick { + let view = crate::clip_ops::source_view(s, item); + let rate = view.as_ref().map(|v| v.rate).unwrap_or_else(|| s.project.item(item).map(|i| i.frame_rate()).unwrap_or_else(|| s.sequence_rate())); + let df = s.project.sequence(item).map(|q| q.settings.drop_frame).unwrap_or(false); + time_p_at(p, "", rate, df, s.state.source_playhead).unwrap_or(s.state.source_playhead) +} + +/// Sequence time from params. Times before 0 are unreachable in the GUI and cut off on export. +pub(crate) fn sequence_time_p(s: &Session, p: &Value, prefix: &str, cmd: &str) -> Result> { + let Some(t) = time_p(s, p, prefix) else { return Ok(None) }; + if t < Tick::ZERO { + return Err(bad(cmd, "time must not be negative")); + } + Ok(Some(t)) +} + pub(crate) fn clip_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ClipId) } @@ -397,20 +430,104 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { /// most constrained clip (media end or start) still moves, and no linked partner slips further. pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>, deltas: &[(ClipId, Tick)]) -> Result<()> { let mut scale = 1.0_f64; + let mut any = false; for (c, d) in deltas { if d.0 == 0 { continue; } - let x = edit::slip(&mut q.clone(), *c, *d, ctx)?; - scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64); + match edit::slip(&mut q.clone(), *c, *d, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(x) => { + any = true; + scale = scale.min(x.0.abs() as f64 / d.0.abs() as f64); + } + } + } + if !any && deltas.iter().any(|(_, d)| d.0 != 0) { + return Err(edit::EditError::Locked.into()); } for (c, d) in deltas { let dd = if scale < 1.0 { Tick((d.0 as f64 * scale).round() as i64) } else { *d }; - edit::slip(q, *c, dd, ctx)?; + match edit::slip(q, *c, dd, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(_) => {} + } } Ok(()) } +/// Clips an audio effect should land on: audio ids as given, plus the linked audio of any video +/// clip in the list (so applying Parametric EQ to an A/V pair hits the sound). +pub fn audio_effect_targets(s: &Session, clips: &[ClipId]) -> Vec { + let Some(seq) = s.active_sequence() else { return clips.to_vec() }; + let mut out = Vec::new(); + for c in clips { + let Some((tid, it)) = seq.find_item(*c) else { continue }; + let kind = seq.track(tid).map(|t| t.kind); + if kind == Some(TrackKind::Audio) { + if !out.contains(c) { + out.push(*c); + } + continue; + } + if kind != Some(TrackKind::Video) { + continue; + } + let Some(link) = it.link else { continue }; + for t in &seq.audio_tracks { + for i in &t.items { + if i.link == Some(link) && !out.contains(&i.id) { + out.push(i.id); + } + } + } + } + out +} + +/// Adjacent roll pairs that follow `left`/`right` when linked selection is on: the given cut, plus +/// through-edits between the same pieces' linked partners at that same time (Premiere linked +/// selection). Split edits (audio cut at another time) and unlinked clips are not added. +pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: ClipId, follow_links: bool) -> Vec<(ClipId, ClipId)> { + let mut pairs = vec![(left, right)]; + if !follow_links { + return pairs; + } + let Some((_, l)) = seq.find_item(left) else { + return pairs; + }; + let Some((_, r)) = seq.find_item(right) else { + return pairs; + }; + let cut = l.end(); + let left_link = l.link; + let right_link = r.link; + if left_link.is_none() && right_link.is_none() { + return pairs; + } + for tr in seq.all_tracks() { + if tr.locked { + continue; + } + for w in tr.items.windows(2) { + if w[0].id == left && w[1].id == right { + continue; + } + if w[0].end() != w[1].start || w[0].end() != cut { + continue; + } + let left_ok = left_link.is_some_and(|g| w[0].link == Some(g)); + let right_ok = right_link.is_some_and(|g| w[1].link == Some(g)); + if left_ok && right_ok { + pairs.push((w[0].id, w[1].id)); + } + } + } + pairs +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -432,12 +549,22 @@ pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { /// clamped by each one's media and neighbours. fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Edge, mode: TrimMode, delta: Tick, ctx: &edit::EditCtx) -> Result { let mut d = delta; + let mut any = false; for c in clips { - let x = edit::clamp_trim(q, *c, edge, mode, d, ctx)?; - if x.abs() < d.abs() { - d = x; + match edit::clamp_trim(q, *c, edge, mode, d, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(x) => { + any = true; + if x.abs() < d.abs() { + d = x; + } + } } } + if !any { + return Err(edit::EditError::Locked.into()); + } Ok(d) } @@ -664,6 +791,119 @@ pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize, Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default()) } +/// `timeline.place` for one item (`item`). +fn place_one(s: &mut Session, p: &Value) -> Result { + if crate::sequence_tools::wants_new_track(p) { + return crate::sequence_tools::place_on_new_tracks(s, p); + } + let stream_flag = |key: &str| match p.get(key) { + None => Ok(true), + Some(Value::Bool(v)) => Ok(*v), + _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), + }; + let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); + if !video && !audio { + return Err(bad("timeline.place", "enable video, audio, or both")); + } + let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; + let at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead()); + let tg = s.targeting(); + let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; + let (has_video, has_audio) = s.project.source_streams(item); + let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); + // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that + // names an audio track as `track` asks for the sound alone, on that track. + let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { + (Some(t), Some(_)) if is_audio(t) => { + return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); + } + (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), + (Some(t), None) if is_audio(t) && !has_audio => { + return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); + } + (Some(t), None) if is_audio(t) => (None, Some(t)), + (Some(_), None) if !has_video => { + return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); + } + (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), + }; + let v = v.filter(|_| video); + let a = a.filter(|_| audio); + if p.get("video").is_some() && video && has_video && v.is_none() { + return Err(bad("timeline.place", "no video destination: add or enable a video track")); + } + if p.get("audio").is_some() && audio && has_audio && a.is_none() { + return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); + } + let full = full_duration(s, pi); + let (mi, mo) = match &pi.kind { + ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), + ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), + _ => (None, None), + }; + let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); + let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); + let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; + Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) +} + +/// Most items one `timeline.place` with `items` takes: a larger list is refused, not cut. +const MAX_PLACE_ITEMS: usize = 10_000; + +/// `timeline.place` for `items`: the first at the given time, each next one where the previous one +/// ends, all as one undo step. `track`, `audioTrack`, `insert`, `video` and `audio` apply to every +/// item; `sourceIn` and `duration` name one item's range, so they need `item`. +fn place_items(s: &mut Session, p: &Value, items: &Value) -> Result { + let ids: Vec = items + .as_array() + .ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))? + .iter() + .map(|v| v.as_u64().map(ItemId).ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))) + .collect::>()?; + if p.get("item").is_some() || p.get("sourceIn").is_some() || p.get("duration").is_some() { + return Err(bad("timeline.place", "give `item` or `items`, and no `sourceIn` or `duration` with `items`")); + } + if ids.is_empty() || ids.len() > MAX_PLACE_ITEMS { + return Err(bad("timeline.place", format!("`items` needs 1 to {MAX_PLACE_ITEMS} item ids"))); + } + let mut at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead()); + s.edit_group("Place Clips", |s| { + let mut placed = Vec::new(); + // "new" adds a track for the first item only; the rest follow it on that track + let mut base = p.clone(); + for item in ids { + let mut one = base.clone(); + if let Some(o) = one.as_object_mut() { + o.remove("items"); + for k in ["time", "frame", "seconds"] { + o.remove(k); + } + o.insert("item".into(), json!(item.0)); + o.insert("time".into(), json!(at.0)); + } + let r = place_one(s, &one)?; + let clips: Vec = r["clips"].as_array().map(|a| a.iter().filter_map(|c| c.as_u64().map(ClipId)).collect()).unwrap_or_default(); + if crate::sequence_tools::wants_new_track(&base) + && let Some(q) = s.active_sequence() + { + for (key, audio) in [("track", false), ("audioTrack", true)] { + if base.get(key).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")) + && let Some(t) = + clips.iter().find_map(|c| q.find_item(*c).map(|(t, _)| t).filter(|t| q.audio_tracks.iter().any(|x| x.id == *t) == audio)) + { + base[key] = json!(t.0); + } + } + } + if let Some(end) = s.active_sequence().and_then(|q| clips.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()) { + at = end; + } + placed.push(json!(clips.iter().map(|c| c.0).collect::>())); + } + Ok(json!({"clips": placed.iter().flat_map(|c| c.as_array().cloned().unwrap_or_default()).collect::>(), "placed": placed})) + }) +} + /// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source). pub(crate) fn place_item( s: &mut Session, @@ -681,8 +921,9 @@ pub(crate) fn place_item( if s.state.sequences_as_clips && pi.as_sequence().is_some_and(|q| q.multicam.is_none()) { return crate::sequence_tools::place_sequence_clips(s, item, range, at, vdest, adest, insert, label); } - let has_v = pi.has_video() && vdest.is_some(); - let has_a = pi.has_audio() && adest.is_some(); + let (video, audio) = s.project.source_streams(item); + let has_v = video && vdest.is_some(); + let has_a = audio && adest.is_some(); if !has_v && !has_a { return Err(EngineError::Other("no destination track for this media (check source patching)".into())); } @@ -724,7 +965,7 @@ pub(crate) fn place_item( let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?; // Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below let clips: Vec> = - p.item(item).and_then(|i| i.as_media()).and_then(|m| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default(); + p.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default(); let streams = p.resolve_media(item).map_or(1, |(_, m, _)| m.info.audio_streams.len()); let specs = audio_placement_specs(&clips, streams); let first = p.sequence(seq_id).and_then(|q| q.audio_tracks.iter().position(|t| t.id == adest)).unwrap_or(0); @@ -864,6 +1105,9 @@ fn build() -> Vec { s.state.active_sequence = Some(seq); s.state.open_sequences = vec![seq]; s.state.playheads.insert(seq, FrameRate::FPS_23_976.tick_of(4 * 24 + 6)); + if s.path.take().is_some() { + s.previews.reset_temp(); + } s.revision += 1; s.saved_revision = s.revision; s.events.push(crate::Event::ProjectChanged { revision: s.revision }); @@ -875,11 +1119,22 @@ fn build() -> Vec { "Sequence…", ["File", "New"], Some("Cmd+N"), - r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#, + r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?,"fromItems":[itemId]?}"#, always, - |s, p| { + |s, p| s.edit_group("New Sequence", |s| { + // `fromItems` is `fromItem` for a list: the first sets the sequence, the rest follow it back to back + let mut from_ids: Vec = match p.get("fromItems") { + None => Vec::new(), + Some(l) => l + .as_array() + .and_then(|a| a.iter().map(|v| v.as_u64().map(ItemId)).collect()) + .ok_or_else(|| bad("file.newSequence", "`fromItems` must be a list of item ids"))?, + }; + if let Some(one) = item_p(p, "fromItem") { + from_ids.insert(0, one); + } let mut st = SequenceSettings::default(); - if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) { + if let Some(from) = from_ids.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media()) { st = default_seq_settings_for(from); } if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? { @@ -929,14 +1184,19 @@ fn build() -> Vec { } Ok(id) })?; - if let Some(from) = item_p(p, "fromItem") { + if let Some((&from, rest)) = from_ids.split_first() { let dur = s.project.item(from).map(|i| i.duration()).unwrap_or_default(); let tg = s.targeting(); - place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + let ids = + place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + if !rest.is_empty() { + let end = s.active_sequence().and_then(|q| ids.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()).unwrap_or_default(); + place_items(s, &json!({"time": end.0}), &json!(rest.iter().map(|i| i.0).collect::>()))?; + } } s.events.push(crate::Event::OpenSequence(id)); Ok(json!({"sequence": id.0})) - } + }) ), cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str,"parent":binId?}"#, always, |s, p| { let n = str_p(p, "name").unwrap_or("New Bin").to_string(); @@ -1285,11 +1545,11 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"exrCompression":"none|rle|zips|zip"?,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff|aac|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"exrCompression":"none|rle|zips|zip"?,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), - query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(crate::Job::to_json).collect()))), + query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(|j| crate::export_tools::job_json(s, j)).collect()))), query!("perf.stats", "Performance Statistics", "{}", |s, _| Ok(crate::perf::stats(s))), cmd!("jobs.cancel", "Cancel Job", [], None, r#"{"job":id}"#, always, |s, p| { let id = u64_p(p, "job").ok_or_else(|| bad("jobs.cancel", "need `job`"))?; @@ -1462,7 +1722,12 @@ fn build() -> Vec { } } for group in &groups { - edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?; + let unlocked: Vec = + group.iter().copied().filter(|c| q.find_item(*c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect(); + if unlocked.is_empty() { + return Err(edit::EditError::Locked.into()); + } + edit::set_speed_group(q, &unlocked, speed, reverse, ripple, ctx)?; } for c in &sel { if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) { @@ -1740,7 +2005,7 @@ fn build() -> Vec { "Apply Audio Transition", ["Sequence"], Some("Cmd+Shift+D"), - r#"{"clip":id?,"effect":str?,"frames":i64?}"#, + r#"{"clip":id?,"effect":"constant_power|Constant Power|…"?,"frames":i64?,"edge":"in"|"out"?,"params":{param:value}?,"reverse":bool?}"#, has_seq, |s, p| apply_transition(s, p, TrackKind::Audio) ), @@ -1897,8 +2162,24 @@ fn build() -> Vec { Ok(json!({"item": item.0})) }), // ================= Markers ================= - cmd!("markers.markIn", "Mark In", ["Markers"], Some("I"), r#"{"time":ticks?,"target":"program|source"}"#, always, |s, p| mark(s, p, true)), - cmd!("markers.markOut", "Mark Out", ["Markers"], Some("O"), r#"{"time":ticks?,"target":"program|source"}"#, always, |s, p| mark(s, p, false)), + cmd!( + "markers.markIn", + "Mark In", + ["Markers"], + Some("I"), + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?,"target":"program|source"}"#, + always, + |s, p| mark(s, p, true) + ), + cmd!( + "markers.markOut", + "Mark Out", + ["Markers"], + Some("O"), + r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?,"target":"program|source"}"#, + always, + |s, p| mark(s, p, false) + ), cmd!("markers.markClip", "Mark Clip", ["Markers"], Some("X"), "{}", has_seq, |s, _| { let t = s.playhead(); let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -1999,10 +2280,10 @@ fn build() -> Vec { return Ok(v); } let (_seq, ph) = marker_list_mut(s)?; - let t = time_p(s, p, "").unwrap_or(ph); + let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.add")?.unwrap_or(Label::Green); let dur = p.get("durationFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)).unwrap_or_default(); let id = s.edit_sequence("Add Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); @@ -2078,6 +2359,7 @@ fn build() -> Vec { } let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?; let rate = s.sequence_rate(); + let color = marker_color_p(p, "markers.edit")?; let p = p.clone(); s.edit_sequence("Edit Marker", |q, _, _| { let m = q.markers.iter_mut().find(|m| m.id == id).ok_or_else(|| bad("markers.edit", "no such marker"))?; @@ -2087,7 +2369,7 @@ fn build() -> Vec { if let Some(c) = str_p(&p, "comment") { m.comment = c.into(); } - if let Some(c) = str_p(&p, "color").and_then(Label::from_name) { + if let Some(c) = color { m.color = c; } if let Some(f) = p.get("durationFrames").and_then(Value::as_i64) { @@ -2184,60 +2466,11 @@ fn build() -> Vec { "Place Clip", [], None, - r#"{"item":id,"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, + r#"{"item":id|"items":[id] (back to back, one undo step),"track":"V1"|id|"A1" (sound only)|"new" (a video track after the last)?,"audioTrack":"A1"|id|"new"?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, has_seq, - |s, p| { - if crate::sequence_tools::wants_new_track(p) { - return crate::sequence_tools::place_on_new_tracks(s, p); - } - let stream_flag = |key: &str| match p.get(key) { - None => Ok(true), - Some(Value::Bool(v)) => Ok(*v), - _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), - }; - let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); - if !video && !audio { - return Err(bad("timeline.place", "enable video, audio, or both")); - } - let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); - let tg = s.targeting(); - let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; - let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); - // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that - // names an audio track as `track` asks for the sound alone, on that track. - let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { - (Some(t), Some(_)) if is_audio(t) => { - return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); - } - (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), - (Some(t), None) if is_audio(t) && !pi.has_audio() => { - return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); - } - (Some(t), None) if is_audio(t) => (None, Some(t)), - (Some(_), None) if !pi.has_video() => { - return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); - } - (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), - }; - let v = v.filter(|_| video); - let a = a.filter(|_| audio); - if p.get("video").is_some() && video && pi.has_video() && v.is_none() { - return Err(bad("timeline.place", "no video destination: add or enable a video track")); - } - if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { - return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); - } - let full = full_duration(s, pi); - let (mi, mo) = match &pi.kind { - ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), - ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), - _ => (None, None), - }; - let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); - let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); - let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; - Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) + |s, p| match p.get("items") { + Some(items) => place_items(s, p, items), + None => place_one(s, p), } ), cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { @@ -2279,9 +2512,14 @@ fn build() -> Vec { "Move Clips", [], None, - r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, + r#"{"moves":[{"clip":id,"track":id|"V2"|"new" (after the last of its kind),"time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, has_seq, - move_clips + |s, p| { + if crate::sequence_tools::move_wants_new_track(p) { + return crate::sequence_tools::move_to_new_tracks(s, p); + } + move_clips(s, p) + } ), cmd!( "timeline.trim", @@ -2302,6 +2540,9 @@ fn build() -> Vec { .unwrap_or_default(); let clips = with_links(s, &[c]); let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| { + if q.find_item(c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| tr.locked)) { + return Err(edit::EditError::Locked.into()); + } let before = q.find_item(c).map(|(_, i)| i.range()); // clamp across all linked partners, then apply the common delta let mut dd = clamp_trim_linked(q, &clips, edge, mode, d, ctx)?; @@ -2309,6 +2550,10 @@ fn build() -> Vec { // the clip and its linked partners ripple as one edit let mut group = vec![c]; group.extend(clips.iter().copied().filter(|o| *o != c)); + group.retain(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))); + if group.is_empty() { + return Err(edit::EditError::Locked.into()); + } dd = edit::ripple_trim_group(q, &group, edge, dd, ctx)?; if st.ripple_sequence_markers && let Some(r) = before @@ -2322,7 +2567,11 @@ fn build() -> Vec { } } else { for c in &clips { - edit::trim(q, *c, edge, mode, dd, ctx)?; + match edit::trim(q, *c, edge, mode, dd, ctx) { + Err(edit::EditError::Locked) => continue, + Err(e) => return Err(e.into()), + Ok(_) => {} + } } } Ok(dd) @@ -2401,7 +2650,7 @@ fn build() -> Vec { s, &json!({"side": "next", "ripple": false}) )), - cmd!("timeline.roll", "Rolling Edit", [], None, r#"{"left":id,"right":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { + cmd!("timeline.roll", "Rolling Edit", [], None, r#"{"left":id,"right":id,"delta":ticks|"deltaFrames":i64,"followLinks"?:bool}"#, has_seq, |s, p| { let (l, r) = ( clip_p(p, "left").ok_or_else(|| bad("timeline.roll", "need `left`"))?, clip_p(p, "right").ok_or_else(|| bad("timeline.roll", "need `right`"))?, @@ -2412,8 +2661,27 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?; - Ok(json!({"delta": x.0})) + // `followLinks: false` rolls only this pair: Trim mode already lists linked partners as edit points of their own + let follow = s.state.linked_selection && p.get("followLinks").and_then(Value::as_bool).unwrap_or(true); + let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { + let pairs = linked_roll_pairs(q, l, r, follow); + let linked = pairs.len() > 1; + let mut d = d; + for &(pl, pr) in &pairs { + let mut probe = q.clone(); + let x = edit::roll(&mut probe, pl, pr, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for &(pl, pr) in &pairs { + edit::roll(q, pl, pr, d, ctx)?; + } + } + Ok((d, pairs.len(), linked)) + })?; + Ok(json!({"delta": x.0, "pairs": n, "linked": linked})) }), cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?; @@ -2441,7 +2709,14 @@ fn build() -> Vec { .unwrap_or_default(); let clips = with_links(s, &[c]); // clamp across all linked partners, then slide them by the common delta - s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?; + s.edit_sequence("Slide", |q, ctx, _| { + let unlocked: Vec = + clips.iter().copied().filter(|id| q.find_item(*id).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| !tr.locked))).collect(); + if unlocked.is_empty() { + return Err(edit::EditError::Locked.into()); + } + Ok(edit::slide_items(q, &unlocked, d, ctx)?) + })?; Ok(Value::Null) }), cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| { @@ -2454,13 +2729,7 @@ fn build() -> Vec { .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); let clips = with_links(s, &[c]); - let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| { - let mut sp = 1.0; - for c in &clips { - sp = edit::rate_stretch(q, *c, edge, d, ctx)?; - } - Ok(sp) - })?; + let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| Ok(edit::rate_stretch_items(q, &clips, edge, d, ctx)?))?; Ok(json!({"speed": sp})) }), cmd!("timeline.razor", "Razor", [], None, r#"{"time":ticks,"track":"V1"|id?,"clip":id?}"#, has_seq, |s, p| { @@ -2558,6 +2827,12 @@ fn build() -> Vec { return Err(bad("effects.apply", "select clips first")); } let is_audio = def.kind == filmcraft_project::EffectKind::Audio; + // Audio effects belong on audio clips. A video clip in an A/V pair is retargeted to + // its linked audio so double-click / drop on the picture still applies Parametric EQ. + let clips = if is_audio { audio_effect_targets(s, &clips) } else { clips }; + if clips.is_empty() { + return Err(bad("effects.apply", "audio effects need an audio clip (or a video clip with linked audio)")); + } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); s.edit_sequence(&label, |q, _, _| { @@ -2583,6 +2858,16 @@ fn build() -> Vec { cmd!("effects.remove", "Remove Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.remove", "need `clip`"))?; let idx = u64_p(p, "index").ok_or_else(|| bad("effects.remove", "need `index`"))? as usize; + // the selected effect: where it is, and its selection once the effect at `idx` is gone + let effects = s.active_sequence().and_then(|q| q.find_item(c)).map(|(_, it)| it.effects.clone()).unwrap_or_default(); + let sel = s.state.selected_effect.clone().filter(|e| e.clip == c).and_then(|e| e.index(&effects)); + let after = sel.filter(|i| *i != idx).and_then(|i| { + let mut rest = effects; + if idx < rest.len() { + rest.remove(idx); + } + crate::EffectSel::of(c, &rest, if i > idx { i - 1 } else { i }) + }); s.edit_sequence("Remove Effect", |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; if it.effects.get(idx).and_then(|e| e.def()).is_some_and(|d| d.intrinsic) { @@ -2593,14 +2878,27 @@ fn build() -> Vec { } Ok(()) })?; + if sel.is_some() { + s.state.selected_effect = after; + } Ok(Value::Null) }), + CommandSpec { + id: "effects.select", + label: "Select Effect", + menu: &[], + shortcut: None, + params: r#"{"clip":id,"effect":index|id}|{"none":true}"#, + enabled: has_seq, + run: select_effect, + journal: false, + }, cmd!( "effects.setParam", "Set Effect Parameter", [], None, - r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool?,"begin":bool?}"##, + r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path|"option name" (a choice takes its index or its name),"time":ticks? (where the keyframe goes when the parameter is animated, kept there even outside the clip; default: the playhead, pinned to the clip; a parameter that is not animated gets a new static value whatever the time),"keyframe":bool? (true: write a keyframe at `time` and animate the parameter),"merge":bool|key?,"begin":bool?}"##, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?; @@ -2608,17 +2906,23 @@ fn build() -> Vec { let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?; let eff = p.get("effect").cloned().unwrap_or(json!("motion")); let keyframe = bool_p(p, "keyframe").unwrap_or(false); + // an explicit `time` outside the clip keeps its keyframe there, before the start as after + // the end, so a ramp across a cut keeps its slope (#1055); the playhead is pinned to the clip + let at = time_p(s, p, ""); let ph = s.playhead(); - let tl = time_p(s, p, "").unwrap_or(ph); let pq = p.clone(); - // `merge`: a drag of one parameter is one undo step (#201); `begin` starts a new one - let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setParam:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null))); + // `merge`: a drag of one parameter is one undo step (#201), and a key makes one of a + // drag that changes several (the Program monitor's handles); `begin` starts a new one + let merge = match p.get("merge") { + Some(Value::String(key)) => Some(format!("setParam:{}:{key}", c.0)), + _ => bool_p(p, "merge").unwrap_or(false).then(|| format!("setParam:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null))), + }; if bool_p(p, "begin").unwrap_or(false) { s.history.merge_key = None; } let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| { let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?; - let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start))); + let mt = it.source_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)))); let e = match &eff { Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize), Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid), @@ -2820,20 +3124,48 @@ fn build() -> Vec { } items.sort_by_key(|i| i.0); items.dedup(); + let mut used = std::collections::HashSet::new(); + for it in s.project.sequences() { + if let Some(q) = it.as_sequence() { + for t in q.all_tracks() { + used.extend(t.items.iter().map(|x| x.item)); + } + } + } + let mut gone = Vec::new(); + let mut offlined = Vec::new(); + for i in items { + let keep = used.contains(&i) + && s.project.item(i).and_then(filmcraft_project::ProjectItem::as_media).is_some_and(|m| matches!(m.media, MediaRef::File { .. })); + if keep { + offlined.push(i); + } else { + gone.push(i); + } + } s.edit("Clear", |pr, st| { + for i in &offlined { + if let Some(m) = pr.item_mut(*i).and_then(filmcraft_project::ProjectItem::as_media_mut) { + m.offline = true; + } + } for b in &bins { pr.root.remove_bin(*b); } - for i in &items { + for i in &offlined { + if pr.root.parent_of(*i).is_none() { + pr.root.children.push(filmcraft_project::BinEntry::Item(*i)); + } + } + for i in &gone { pr.items.remove(i); pr.root.remove_item(*i); } - // remove track items that referenced deleted media let seq_ids: Vec = pr.sequences().map(|q| q.id).collect(); for sid in seq_ids { if let Some(q) = pr.sequence_mut(sid) { for t in q.all_tracks_mut() { - t.items.retain(|x| !items.contains(&x.item)); + t.items.retain(|x| !gone.contains(&x.item)); filmcraft_edit::remove_orphan_transitions(t); } } @@ -2841,11 +3173,12 @@ fn build() -> Vec { st.project_selection.clear(); Ok(()) })?; - for i in &items { + for i in gone.iter().chain(&offlined) { s.media.remove(*i); } + crate::relink::refresh(s); s.fix_state(); - Ok(json!({"items": items.len(), "bins": bins.len()})) + Ok(json!({"items": gone.len(), "bins": bins.len(), "offline": offlined.len()})) }), cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id|binId]?,"bin":binId|null}"#, always, |s, p| { let items: Vec = match p.get("items").and_then(Value::as_array) { @@ -3088,7 +3421,7 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result { let target_source = str_p(p, "target") == Some("source"); if target_source { let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?; - let t = p.get("time").and_then(Value::as_i64).map(Tick).unwrap_or(s.state.source_playhead); + let t = source_mark_time(s, p, item); let key = if is_in { "in" } else { "out" }; return s.execute("project.setMarks", json!({"item": item.0, key: t.0})); } @@ -3464,6 +3797,28 @@ fn transition_ids_p(v: &Value) -> Option> { } } +/// `effects.select`: the effect selected in Effect Controls (`effect`: index, or the id of the +/// clip's first effect of that kind). It replaces the mask selection. +fn select_effect(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effects.select"; + if bool_p(p, "none") == Some(true) { + s.state.selected_effect = None; + return Ok(Value::Null); + } + let clip = clip_p(p, "clip").ok_or_else(|| bad(CMD, "need `clip` (or `none`)"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = q.find_item(clip).ok_or_else(|| bad(CMD, "no such clip"))?; + let effect = match p.get("effect") { + Some(Value::Number(n)) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < it.effects.len()), + Some(Value::String(id)) => it.effects.iter().position(|e| &e.effect == id), + _ => None, + } + .ok_or_else(|| bad(CMD, "no such effect on clip"))?; + s.state.selected_effect = crate::EffectSel::of(clip, &it.effects, effect); + s.state.selected_mask = None; + Ok(Value::Null) +} + /// `timeline.select {"transitions": […]}`: select transitions (clicking one in the Timeline). It /// replaces the clip, edit point and caption selections, as a clip selection replaces it. fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) -> Result { diff --git a/crates/engine/src/explicit_targets_tests.rs b/crates/engine/src/explicit_targets_tests.rs index c3df07e18..2abbcee12 100644 --- a/crates/engine/src/explicit_targets_tests.rs +++ b/crates/engine/src/explicit_targets_tests.rs @@ -234,15 +234,15 @@ fn project_delete_takes_explicit_bins_and_items_without_a_selection() { assert_eq!(disabled(s.execute("project.delete", json!({}))), "select an item in the Project panel"); // an empty bin, by its id alone let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); // a bin with a clip in it, and an item, each by id alone let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.item(ocean).is_none()); let dunes = item_named(&s, "Desert_Dunes.mp4"); let r = s.execute("project.delete", json!({"items": [dunes.0]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 0})); + assert_eq!(r, json!({"items": 1, "bins": 0, "offline": 0})); assert_eq!(s.project.items.len(), before - 2); assert!(s.state.project_selection.is_empty(), "the selection is left as it was"); assert!(!s.is_enabled("project.delete"), "menu enablement still follows the selection"); diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index febffb6a4..d5f1883ec 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -163,10 +163,11 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let Some(j) = probe(&out) else { continue }; let fmt_name = j["format"]["format_name"].as_str().unwrap(); match fmt.as_str() { - "wav" | "aiff" => { + "wav" | "aiff" | "flac" => { let a = stream(&j, "audio").unwrap(); let bits = settings["audio"]["bits"].as_u64().unwrap(); let codec = match (fmt.as_str(), bits) { + ("flac", _) => "flac", ("wav", 24) => "pcm_s24le", ("wav", _) => "pcm_s16le", (_, 24) => "pcm_s24be", @@ -682,3 +683,26 @@ fn default_export_dir_never_needs_a_real_filesystem() { let s = Session { path: Some("/projects/show/edit.fcproj".into()), ..Default::default() }; assert_eq!(crate::export_tools::default_export_dir(&s), std::path::PathBuf::from("/projects/show")); } + +#[test] +fn unknown_settings_keys_are_errors_not_ignored() { + // A flat parameter put inside `settings` used to be dropped without a word: the export ran + // without loudness normalization. A misspelt nested key was dropped the same way. + let s = demo(); + let err = crate::export_tools::settings_from_params(&s, &json!({"format": "h264", "settings": {"loudnessLufs": -14}}), "file.exportMedia").unwrap_err(); + assert!(err.to_string().contains("`loudnessLufs`"), "{err}"); + let err = crate::export_tools::settings_from_params(&s, &json!({"settings": {"effects": {"loudness": {"targetLoudness": -14}}}}), "file.exportMedia") + .unwrap_err(); + assert!(err.to_string().contains("`effects.loudness.targetLoudness`"), "{err}"); + // Both spellings of a snake_case field are known (#531), and null sets nothing. + for key in ["target_lufs", "targetLufs"] { + let (_, ok) = crate::export_tools::settings_from_params( + &s, + &json!({"settings": {"effects": {"loudness": {"enabled": true, key: -14}}, "captionSidecar": null}}), + "file.exportMedia", + ) + .unwrap(); + assert!(ok.effects.loudness.enabled); + assert_eq!(ok.effects.loudness.target_lufs, -14.0, "{key}"); + } +} diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index e0bee60fd..1d316aa5b 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -159,6 +159,26 @@ fn merge(base: &mut Value, patch: &Value) { } } +/// The first key of `patch` (as a dotted path) that the resolved settings `known` don't have, +/// matched as [`merge`] matches them (the camelCase spelling of a snake_case field counts). +/// A null value is accepted anywhere: it sets nothing. +fn unknown_key(known: &Value, patch: &Value, prefix: &str) -> Option { + let (Value::Object(k), Value::Object(p)) = (known, patch) else { return None }; + for (key, v) in p { + let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") }; + match k.get(key).or_else(|| k.get(&camel_to_snake(key))) { + None if !v.is_null() => return Some(path), + Some(kv) => { + if let Some(found) = unknown_key(kv, v, &path) { + return Some(found); + } + } + None => {} + } + } + None +} + /// A frame rate from a number of frames per second (29.97 → 30000/1001) or "num/den". pub fn parse_rate(v: &Value) -> Option { if let Some(s) = v.as_str() { @@ -191,6 +211,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option let mut v = serde_json::to_value(&settings).map_err(|e| EngineError::Other(e.to_string()))?; merge(&mut v, patch); settings = serde_json::from_value(v).map_err(|e| bad(cmd, format!("settings: {e}")))?; + // serde ignores keys it doesn't know, so a misspelt or misplaced key (a flat parameter + // such as `loudnessLufs` put inside `settings`) would silently do nothing. + let known = serde_json::to_value(&settings).map_err(|e| EngineError::Other(e.to_string()))?; + if let Some(path) = unknown_key(&known, patch, "") { + return Err(bad(cmd, format!("settings: unknown key `{path}` (see export.presets.get for the settings' keys)"))); + } } // flat overrides (the original file.exportMedia parameters and a few common ones) if let Some(f) = str_p(p, "format") { @@ -260,6 +286,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option if let Some(v) = str_p(p, "apvProfile") { settings.apv_profile = v.to_string(); } + if let Some(v) = str_p(p, "audioCodec") { + settings.audio.codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "audioCodec: auto | aac | pcm | flac"))?; + } + if let Some(v) = u64_p(p, "flacLevel") { + settings.audio.flac_level = v.min(8) as u8; + } if let Some(v) = str_p(p, "mxfVideoCodec") { settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?; } @@ -443,8 +475,9 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set let id = s.jobs.iter().map(|j| j.id).max().unwrap_or(0) + 1; let job = crate::Job { id, label, progress: Default::default(), result: Default::default() }; // Export always renders full-resolution media, whatever the proxy toggle says. - let provider = s.media.full_res_provider(project.clone(), s.services.clone()); + let provider = s.media.full_res_provider(project.clone(), s.services.clone()).recording(); let (prog, res) = (job.progress.clone(), job.result.clone()); + let offline = s.export_offline.entry(id).or_default().clone(); if cfg!(target_arch = "wasm32") && !wait && filmcraft_export::stepped(format) { // No threads: the host advances the export between UI frames (`Session::pump_jobs`). let mut exporter = filmcraft_export::Exporter::new(project, seq, &settings, &prog).map_err(|e| EngineError::Other(e.to_string()))?; @@ -455,6 +488,7 @@ pub fn spawn_export(s: &mut Session, project: Arc, seq: ItemId, mut set } let run = move || { let r = filmcraft_export::export(&project, seq, &settings, &provider, &prog).map_err(|e| e.to_string()); + *offline.lock().unwrap_or_else(|x| x.into_inner()) = provider.offline_used(); if let Err(e) = &r { *prog.error.lock().unwrap_or_else(|x| x.into_inner()) = Some(e.clone()); prog.finished.store(true, Ordering::Relaxed); @@ -517,6 +551,7 @@ pub fn export_media(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other(format!("export failed: {e}"))); } out["result"] = v["result"].clone(); + out["offlineMedia"] = offline_media_json(s, id); } Ok(out) } @@ -571,6 +606,22 @@ impl ExportQueue { } } +/// A `jobs.list` entry: [`crate::Job::to_json`], plus `offlineMedia` for an export job, so callers +/// that poll an export (the bridge, MCP long exports) can report it like `file.exportMedia` does. +pub(crate) fn job_json(s: &Session, j: &crate::Job) -> Value { + let mut v = j.to_json(); + if s.export_offline.contains_key(&j.id) { + v["offlineMedia"] = offline_media_json(s, j.id); + } + v +} + +/// The offline items export job `job` drew the slate for, as JSON (`[]` while it runs or when none). +fn offline_media_json(s: &Session, job: u64) -> Value { + let list = s.export_offline.get(&job).map(|o| o.lock().unwrap_or_else(|e| e.into_inner()).clone()).unwrap_or_default(); + serde_json::to_value(list).unwrap_or_else(|_| json!([])) +} + fn item_json(s: &Session, it: &QueueItem) -> Value { let job = it.job.and_then(|id| s.jobs.iter().find(|j| j.id == id)); let (progress, status_text) = match job { @@ -591,6 +642,7 @@ fn item_json(s: &Session, it: &QueueItem) -> Value { "statusText": status_text, "job": it.job, "error": it.error, + "offlineMedia": it.job.map_or_else(|| json!([]), |j| offline_media_json(s, j)), }) } diff --git a/crates/engine/src/file_tests.rs b/crates/engine/src/file_tests.rs index 00f2ea078..f474b268c 100644 --- a/crates/engine/src/file_tests.rs +++ b/crates/engine/src/file_tests.rs @@ -93,3 +93,25 @@ fn project_is_named_after_its_file() { assert_eq!(s.project.name, "Apollo 11 - Tranquility"); let _ = std::fs::remove_dir_all(&d); } + +/// Opening the demo after a saved project must not keep that path: Save would overwrite it. +#[test] +fn open_demo_project_clears_saved_path_so_save_does_not_overwrite() { + let d = temp_dir("demo-path"); + let original = d.join("original.fcproj").to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Original"})).unwrap(); + s.execute("file.newBin", json!({"name": "ORIGINAL"})).unwrap(); + s.execute("file.saveAs", json!({"path": original})).unwrap(); + s.execute("file.openDemoProject", json!({})).unwrap(); + assert!(s.path.is_none(), "demo is unsaved, like New Project"); + let err = s.execute("file.save", json!({})).unwrap_err().to_string(); + assert!(err.contains("no path") || err.contains("Save As"), "{err}"); + let mut reopen = Session::default(); + reopen.execute("file.open", json!({"path": original})).unwrap(); + assert!( + reopen.project.root.children.iter().any(|e| matches!(e, filmcraft_project::BinEntry::Bin(b) if b.name == "ORIGINAL")), + "original.fcproj still has the ORIGINAL bin" + ); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index 9989be581..ea6dc3ad8 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -138,6 +138,71 @@ fn import_image_sequences_setting_detects_a_numbered_still() { let _ = std::fs::remove_dir_all(&dir); } +/// A CinemaDNG frame: 24 × 16 RGGB, 12-bit lossless JPEG behind a preview, a flat grey at `level` +/// (white balanced by the as-shot neutral), 23.976 fps, time code 01:00:00:00 + `frame`. +fn cinemadng_frame(level: f64, frame: u8) -> Vec { + use filmcraft_dng::tiff::tags; + use filmcraft_dng::writer::{Compression, Options, Value, write}; + let (w, h) = (24, 16); + let neutral = [0.6, 1.0, 0.7]; + let px: Vec = + (0..w * h).map(|i| (level * [neutral[0], neutral[1], neutral[1], neutral[2]][(i / w % 2) * 2 + i % 2] * 4000.0 + 95.0).round() as u16).collect(); + let o = Options { + width: w, + height: h, + bits: 12, + preview: true, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + raw_tags: vec![(tags::BLACK_LEVEL, Value::Short(vec![95])), (tags::WHITE_LEVEL, Value::Short(vec![4095]))], + main_tags: vec![ + (tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral)), + (tags::FRAME_RATE, Value::SRational(vec![(24_000, 1001)])), + (tags::TIME_CODES, Value::Byte(vec![frame, 0, 0, 0x01, 0, 0, 0, 0])), + ], + ..Default::default() + }; + write(&px, &o) +} + +#[test] +fn import_cinemadng_folder_as_one_clip() { + let dir = tmp_dir("cinemadng-import"); + let mut s = demo(); + s.execute("prefs.set", json!({"key": "media.indeterminateTimebase", "value": "25"})).unwrap(); + let paths: Vec = (0..4u8) + .map(|i| { + let p = dir.join(format!("A001_C002_0101AB_{:06}.dng", i)); + std::fs::write(&p, cinemadng_frame(0.1 + 0.2 * f64::from(i), i)).unwrap(); + p.to_string_lossy().to_string() + }) + .collect(); + let r = s.execute("file.import", json!({"paths": [paths[0]], "imageSequence": true})).unwrap(); + let id = item_of(&r); + assert_eq!(r["imageSequences"][0]["frames"], 4); + let m = s.project.item(id).unwrap().as_media().unwrap().clone(); + assert_eq!(m.info.kind, MediaKind::ImageSequence); + assert_eq!(m.info.frame_rate(), FrameRate::FPS_23_976, "the CinemaDNG FrameRate tag, not the timebase setting"); + assert_eq!(m.info.duration, FrameRate::FPS_23_976.tick_of(4)); + assert_eq!(m.info.start_timecode, Some(3600 * 24), "01:00:00:00 at 24 nominal"); + let v = m.info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height), (24, 16)); + let src = s.media.source_for(&s.project, id, &*s.services).unwrap(); + for i in 0..4 { + let f = src.video_frame(FrameRequest::full(FrameRate::FPS_23_976.tick_of(i))).unwrap(); + let filmcraft_frame::PixelData::RgbaF32(px) = &f.data else { panic!("linear float frames") }; + let want = 0.1 + 0.2 * i as f32; + for k in 0..3 { + assert!((px[(8 * 24 + 8) * 4 + k] - want).abs() < 2e-3, "frame {i} channel {k}: {}", px[(8 * 24 + 8) * 4 + k]); + } + } + // one frame on its own is a still + let r = s.execute("file.import", json!({"paths": [paths[3]]})).unwrap(); + let still = s.project.item(item_of(&r)).unwrap().as_media().unwrap(); + assert_eq!(still.info.kind, MediaKind::Still); + assert_eq!(still.info.video.as_ref().unwrap().codec, "DNG"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn image_sequence_survives_save_and_reopen() { let dir = tmp_dir("imgseq-reopen"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..f04ee8163 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -192,6 +192,10 @@ impl Services for FsServices { } } +/// A point in the undo history (its top step), from [`Session::undo_mark`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UndoMark(Option); + /// Undo history of whole-project snapshots. #[derive(Clone, Default)] pub struct History { @@ -230,6 +234,29 @@ pub struct GapSelection { pub range: TimeRange, } +/// An effect of a clip (`effects.select`): its id and which of the clip's effects with that id it +/// is (0 = the first). Not its index, which applying an effect shifts (standard effects go first). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffectSel { + pub clip: ClipId, + pub effect: String, + pub instance: usize, +} + +impl EffectSel { + /// The selection of `effects[index]` of `clip`. + pub fn of(clip: ClipId, effects: &[filmcraft_project::EffectInstance], index: usize) -> Option { + let id = &effects.get(index)?.effect; + let instance = effects.iter().take(index).filter(|e| &e.effect == id).count(); + Some(Self { clip, effect: id.clone(), instance }) + } + + /// Where the selected effect is in the clip's `effects`. + pub fn index(&self, effects: &[filmcraft_project::EffectInstance]) -> Option { + effects.iter().enumerate().filter(|(_, e)| e.effect == self.effect).nth(self.instance).map(|(i, _)| i) + } +} + /// Editing state that commands depend on (not project data, but headless-relevant). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -291,6 +318,11 @@ pub struct EditorState { /// The mask being edited on the Program monitor (Effect Controls selection). #[serde(default)] pub selected_mask: Option, + /// The effect selected in Effect Controls (its header or one of its properties): Motion and + /// Transform show their handles on the Program monitor. Selecting a mask clears it, and + /// selecting an effect clears the mask selection. + #[serde(default)] + pub selected_effect: Option, /// Multi-Camera Audio Follows Video: switching a multi-camera clip's angle switches its linked /// audio clips too. #[serde(default)] @@ -357,6 +389,9 @@ pub struct Session { pub journal: Vec<(String, Value)>, /// Background jobs (exports, render previews…). pub jobs: Vec, + /// Export job → the offline items it drew the slate for, filled when the export finishes + /// (#1054; `offlineMedia` in `file.exportMedia` and queue results). + pub export_offline: std::collections::HashMap>>>, /// User preferences (`prefs.*` commands) and where they persist (None = not persisted). pub prefs: autosave::Preferences, pub prefs_path: Option, @@ -476,6 +511,7 @@ impl Session { events: Vec::new(), journal: Vec::new(), jobs: Vec::new(), + export_offline: Default::default(), prefs: Default::default(), prefs_path: None, persistence: None, @@ -833,6 +869,77 @@ impl Session { Ok(r) } + /// Run several undoable edits as one undo step labelled `label`. If `f` fails, every edit it made + /// is rolled back and the history is as before. + pub fn edit_group(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let (project, state, undo, redo, limit) = + (self.project.clone(), self.state.clone(), self.history.undo.len(), self.history.redo.clone(), self.history.limit); + // keep every step the group makes, however many, so the first one can be found afterwards + self.history.limit = usize::MAX; + // a panic inside the group must not leave the history unlimited: restore, then let it go on + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))); + self.history.limit = limit; + let first = self.history.undo.get(undo).map(|e| e.1.clone()); + let rolled_back = |s: &mut Session| { + s.history.undo.truncate(undo); + s.history.redo = redo.clone(); + s.history.merge_key = None; + s.project = project.clone(); + s.state = state.clone(); + s.bump(); + }; + match (r, first) { + (Ok(Ok(v)), Some(first)) => { + self.history.undo.truncate(undo); + self.history.undo.push((label.to_string(), first)); + if self.history.undo.len() > limit { + self.history.undo.remove(0); + } + self.history.merge_key = None; + Ok(v) + } + (Ok(Ok(v)), None) => Ok(v), + (Ok(Err(e)), _) => { + rolled_back(self); + Err(e) + } + (Err(panic), _) => { + rolled_back(self); + std::panic::resume_unwind(panic) + } + } + } + + /// Where the undo history stands now, for [`Session::revert_to`]. + pub fn undo_mark(&self) -> UndoMark { + UndoMark(self.history.undo.last().map(|u| Arc::as_ptr(&u.1) as usize)) + } + + /// Take back every edit made since `mark` (a gesture cancelled with Escape, #580), merged or + /// not: the project returns to how it was at `mark`, and neither undo nor redo keeps them. + /// Nothing happens when `mark` has left the history. Returns how many steps were taken back. + pub fn revert_to(&mut self, mark: UndoMark) -> usize { + if let UndoMark(Some(p)) = mark + && !self.history.undo.iter().any(|u| Arc::as_ptr(&u.1) as usize == p) + { + return 0; + } + let mut earliest = None; + let mut n = 0; + while self.undo_mark() != mark { + let Some((_, prev)) = self.history.undo.pop() else { break }; + earliest = Some(prev); + n += 1; + } + if let Some(prev) = earliest { + self.project = prev; + self.history.merge_key = None; + self.fix_state(); + self.bump(); + } + n + } + /// Edit the active sequence with the edit-algebra context. pub fn edit_sequence(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result) -> Result { self.edit_sequence_as(label, None, f) @@ -902,6 +1009,11 @@ impl Session { self.state.selected_mask = None; } } + if let Some(e) = &self.state.selected_effect + && self.active_sequence().and_then(|q| q.find_item(e.clip)).and_then(|(_, it)| e.index(&it.effects)).is_none() + { + self.state.selected_effect = None; + } if let Some(s) = self.state.active_sequence && p.sequence(s).is_none() { @@ -1024,11 +1136,22 @@ impl Session { /// [`Session::render_program_at`], with the reason when nothing can be rendered. pub fn try_render_program_at(&self, scale: f32, t: Tick) -> Result { + self.render_program_with(scale, t, &self.media.provider(self.project.clone(), self.services.clone())) + } + + /// [`Session::try_render_program_at`], also naming the offline items the frame drew the slate + /// for (#1054). + pub fn try_render_program_with_offline_at(&self, scale: f32, t: Tick) -> Result<(filmcraft_render::Image, Vec)> { + let provider = self.media.provider(self.project.clone(), self.services.clone()).recording(); + let img = self.render_program_with(scale, t, &provider)?; + Ok((img, provider.offline_used())) + } + + fn render_program_with(&self, scale: f32, t: Tick, provider: &dyn filmcraft_render::SourceProvider) -> Result { let seq = self.renderable_sequence(scale)?; let t = self.sequence_rate().snap(t.max(Tick::ZERO)); - let provider = self.media.provider(self.project.clone(), self.services.clone()); let opts = filmcraft_render::RenderOptions { scale, captions: true, ..Default::default() }; - Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, &provider)) + Ok(filmcraft_render::render_sequence(&self.project, seq, t, opts, provider)) } /// The active sequence, if a frame of it at `scale` is within the image size limits. @@ -1086,12 +1209,16 @@ const MAX_SUBCLIP_CHAIN: usize = 16; #[cfg(test)] mod aaf_omf_tests; #[cfg(test)] +mod animated_image_tests; +#[cfg(test)] mod audio_effects_tests; #[cfg(test)] mod audio_placement_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] +mod avi_import_tests; +#[cfg(test)] mod clip_ops_tests; #[cfg(test)] mod color_tests; @@ -1114,6 +1241,8 @@ mod keyboard_tests; #[cfg(test)] mod linked_speed_tests; #[cfg(test)] +mod locked_clip_edits_tests; +#[cfg(test)] mod masks_tests; #[cfg(test)] mod media_browser_tests; @@ -1140,6 +1269,8 @@ mod par_tests; #[cfg(test)] mod paste_media_tests; #[cfg(test)] +mod place_items_tests; +#[cfg(test)] mod presets_tests; #[cfg(test)] mod previews_tests; @@ -1156,6 +1287,8 @@ mod remix_tests; #[cfg(test)] mod ripple_delete_tests; #[cfg(test)] +mod roll_linked_tests; +#[cfg(test)] mod scopes_tests; #[cfg(test)] mod sequence_inspect_tests; diff --git a/crates/engine/src/locked_clip_edits_tests.rs b/crates/engine/src/locked_clip_edits_tests.rs new file mode 100644 index 000000000..67817a4dd --- /dev/null +++ b/crates/engine/src/locked_clip_edits_tests.rs @@ -0,0 +1,84 @@ +//! #911: a locked track refuses timing and source-range edits the way a locked destination +//! refuses `timeline.move`. + +use super::*; +use filmcraft_project::ClipId; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Lock check"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap(); + let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": 0, "duration": 1_016_064_000_000i64})).unwrap(); + let clip = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + (s, clip) +} + +fn v1_clip(s: &Session, id: u64) -> filmcraft_project::TrackItem { + s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.clone() +} + +/// Place a 100-frame clip, lock V1, trim Out by −20 frames: the command errors and duration stays 100. +#[test] +fn locked_track_refuses_trim_out_and_keeps_duration() { + let (mut s, clip) = fixture(); + let before = v1_clip(&s, clip); + assert_eq!(s.sequence_rate().frame_at(before.duration), 100); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let err = s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap_err(); + assert!(err.to_string().contains("locked"), "{err}"); + let after = v1_clip(&s, clip); + assert_eq!(after.duration, before.duration); + assert_eq!(after.start, before.start); + assert_eq!(after.source_in, before.source_in); + assert_eq!(after.speed, before.speed); +} + +#[test] +fn unlocked_track_still_trims_out() { + let (mut s, clip) = fixture(); + s.execute("timeline.trim", json!({"clip": clip, "edge": "out", "mode": "regular", "deltaFrames": -20})).unwrap(); + assert_eq!(s.sequence_rate().frame_at(v1_clip(&s, clip).duration), 80); +} + +#[test] +fn locked_track_refuses_move_on_the_same_track() { + let (mut s, clip) = fixture(); + let before = v1_clip(&s, clip); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let err = s.execute("timeline.move", json!({"moves": [{"clip": clip, "track": "V1", "time": 1_016_064_000_000i64}], "linked": false})).unwrap_err(); + assert!(err.to_string().contains("locked"), "{err}"); + assert_eq!(v1_clip(&s, clip).duration, before.duration); +} + +#[test] +fn locked_track_refuses_roll_slip_slide_speed() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Lock check"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Sequence", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + s.execute("file.newOfflineFile", json!({"name": "Media", "seconds": 4, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})).unwrap(); + let item = s.project.items.values().find(|i| i.as_media().is_some()).map(|i| i.id.0).unwrap(); + let frame = 10_160_640_000i64; + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0, "sourceIn": frame * 10, "duration": frame * 60})).unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": frame * 60, "sourceIn": 0, "duration": frame * 100})).unwrap(); + let (left, right) = { + let q = s.active_sequence().unwrap(); + (q.video_tracks[0].items[0].id.0, q.video_tracks[0].items[1].id.0) + }; + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let snap = s.project.clone(); + for cmd in [ + json!({"id": "timeline.roll", "p": {"left": left, "right": right, "deltaFrames": 10}}), + json!({"id": "timeline.slip", "p": {"clip": left, "deltaFrames": -10}}), + json!({"id": "timeline.slide", "p": {"clip": left, "deltaFrames": 10}}), + json!({"id": "timeline.rateStretch", "p": {"clip": left, "edge": "out", "deltaFrames": 20}}), + json!({"id": "clip.speedDuration", "p": {"clips": [left], "speed": 200}}), + json!({"id": "timeline.trim", "p": {"clip": left, "edge": "in", "mode": "regular", "deltaFrames": 20}}), + ] { + let err = s.execute(cmd["id"].as_str().unwrap(), cmd["p"].clone()).unwrap_err(); + assert!(err.to_string().contains("locked"), "{}: {err}", cmd["id"]); + assert_eq!(*s.project, *snap, "{} must leave the sequence unchanged", cmd["id"]); + } +} diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index ffa5a5a16..7dedd9c35 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec { /// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices /// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points. pub fn path_from_json(v: &Value) -> Option { - if let Ok(p) = serde_json::from_value::(v.clone()) { - return Some(p); - } + // The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` / + // `masks.add` form) but silently drops the unknown `in` / `out` tangents. + vertex_path(v).or_else(|| serde_json::from_value::(v.clone()).ok()) +} + +fn vertex_path(v: &Value) -> Option { let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?; let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true); let mut vertices = Vec::new(); @@ -244,6 +247,7 @@ fn add(s: &mut Session, p: &Value) -> Result { e.masks.push(Mask::new(name.clone(), path)); let mi = e.masks.len() - 1; st.selected_mask = Some(MaskSel { clip, effect: ei, mask: mi }); + st.selected_effect = None; Ok((ei, mi, name)) })?; Ok(json!({"clip": clip.0, "effect": ei, "mask": mi, "name": name})) @@ -508,6 +512,7 @@ fn select(s: &mut Session, p: &Value) -> Result { return Err(bad("masks.select", format!("no mask {mi}"))); } s.state.selected_mask = Some(MaskSel { clip, effect: ei, mask: mi }); + s.state.selected_effect = None; Ok(Value::Null) } diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 1fc069a84..c25cb4680 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() assert_eq!(v.len(), 6); assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); } + +/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so +/// a pen mask's dragged-out curves were committed as corners; #853). +#[test] +fn path_json_round_trip_keeps_tangents() { + use filmcraft_geom::Vec2; + use filmcraft_project::mask::{MaskPath, MaskVertex}; + let path = MaskPath { + vertices: vec![ + MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) }, + MaskVertex::corner(Vec2::new(80.0, 20.0)), + MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) }, + ], + closed: true, + }; + assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone())); + // the serialized project form still parses + assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path)); +} diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index ac849710c..8b4c4d95c 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -12,7 +12,7 @@ //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the //! same picture at lower resolution. Export asks for [`MediaPool::full_res_provider`]. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -32,12 +32,25 @@ pub struct OfflineStatus { pub error: String, } +/// An offline item an export or a rendered frame drew the slate for (#1054). `status` is the +/// word `media.status` uses: `missing`, `offline` (made offline) or `unreadable`. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct OfflineMedia { + pub item: u64, + pub name: String, + pub path: String, + pub status: &'static str, + pub error: String, +} + pub struct MediaPool { /// item → (reference key, source) sources: RwLock>, proxies: RwLock>, offline: Arc>>, use_proxies: AtomicBool, + /// Per item: video frames that failed in a row in the monitors (see [`Self::monitor_source`]). + failures: Arc>>, /// Openers tried before the built-in ones (MP4/MOV + codecs register here). pub openers: RwLock>, } @@ -50,13 +63,14 @@ impl Default for MediaPool { proxies: RwLock::new(HashMap::new()), offline: Arc::new(RwLock::new(HashMap::new())), use_proxies: AtomicBool::new(false), + failures: Arc::default(), openers: RwLock::new(filmcraft_codecs::openers()), } } } /// Largest file [`MediaPool::probe_file`] reads whole when no streaming reader takes it (WAV, -/// MP3 and other byte-opened formats, or an unsupported container such as AVI). +/// MP3 and other byte-opened formats, or an unsupported container). pub const PROBE_WHOLE_FILE_MAX: u64 = 64 * 1024 * 1024; /// Cache key of a media clip's full-resolution reference ("" matches anything: sources inserted @@ -183,6 +197,19 @@ impl MediaPool { self.use_proxies.store(on, Ordering::Relaxed); } + /// `src` (item `item`) as the monitors show it: once [`DAMAGED_AFTER`] of its video frames in a + /// row fail to decode or read (a damaged file), its failing frames show the unreadable slate + /// instead of nothing, until a frame decodes again (#626, follow-up to #762). Only for preview: + /// exports and other renders use the pool's sources directly, so they never contain the slate. + pub fn monitor_source(&self, item: ItemId, src: SharedSource) -> SharedSource { + Arc::new(DamagedAsSlate { + slate: SlateSource::new(src.info().clone(), &src.info().name, OfflineReason::Unreadable), + inner: src, + item, + failures: self.failures.clone(), + }) + } + /// Why an item last rendered offline (None = online or not opened yet). pub fn offline_status(&self, item: ItemId) -> Option { self.offline.read().unwrap_or_else(|e| e.into_inner()).get(&item).cloned() @@ -316,12 +343,12 @@ impl MediaPool { /// A render-side provider bound to a project snapshot (proxies when enabled). pub fn provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: false } + PoolProvider { pool: self.clone(), project, services, full_res: false, used: None } } /// A provider that always reads full-resolution media (export). pub fn full_res_provider(self: &Arc, project: Arc, services: Arc) -> PoolProvider { - PoolProvider { pool: self.clone(), project, services, full_res: true } + PoolProvider { pool: self.clone(), project, services, full_res: true, used: None } } } @@ -332,10 +359,45 @@ pub struct PoolProvider { pub services: Arc, /// Ignore proxies (export always renders full resolution). pub full_res: bool, + /// Items asked for so far, when recording ([`PoolProvider::recording`]). + used: Option>>, +} + +impl PoolProvider { + /// This provider, remembering the items it is asked for so [`PoolProvider::offline_used`] can + /// tell which of them rendered offline. + pub fn recording(mut self) -> Self { + self.used = Some(Mutex::new(BTreeSet::new())); + self + } + + /// The offline items among those asked for since [`PoolProvider::recording`], once each, in + /// item order (subclips count as their media). Empty when not recording. + pub fn offline_used(&self) -> Vec { + let Some(used) = &self.used else { return Vec::new() }; + let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let media: BTreeSet = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect(); + media + .into_iter() + .filter_map(|id| { + let st = self.pool.offline_status(id)?; + let status = match st.reason { + OfflineReason::Missing => "missing", + OfflineReason::MadeOffline => "offline", + OfflineReason::Unreadable => "unreadable", + }; + let name = self.project.item(id).map(|i| i.name.clone()).unwrap_or_default(); + Some(OfflineMedia { item: id.0, name, path: st.path, status, error: st.error }) + }) + .collect() + } } impl filmcraft_render::SourceProvider for PoolProvider { fn source(&self, item: ItemId) -> Option { + if let Some(used) = &self.used { + used.lock().unwrap_or_else(|e| e.into_inner()).insert(item); + } if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) } } } @@ -419,6 +481,54 @@ impl MediaSource for UnsupportedAsSlate { } } +/// Consecutive failed video frames after which a monitor treats a source as damaged. A single +/// failure (one bad slice, a read hiccup) and the decoder's retry of it stay as they were; three +/// in a row (an eighth of a second at 24 fps) mean the file itself is damaged. +pub const DAMAGED_AFTER: u32 = 3; + +/// See [`MediaPool::monitor_source`]. Counts `Decode` / `Io` failures per item; a successful frame +/// resets the count. Data still on its way (the web build's pending reads, see +/// [`filmcraft_media::pending`]) neither counts nor resets, and cancellation, absent video and +/// unsupported frames (already the slate, #762) pass through. +struct DamagedAsSlate { + inner: SharedSource, + slate: SlateSource, + item: ItemId, + failures: Arc>>, +} + +impl MediaSource for DamagedAsSlate { + fn info(&self) -> &MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + let r = self.inner.video_frame(req); + let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + match r { + Ok(f) => { + failures.remove(&self.item); + Ok(f) + } + Err(MediaError::Decode(_) | MediaError::Io(_)) if !filmcraft_media::pending::is_set() => { + let n = failures.entry(self.item).or_insert(0); + *n = n.saturating_add(1); + if *n >= DAMAGED_AFTER { + drop(failures); + return self.slate.video_frame(req); + } + r + } + r => r, + } + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio(start, frames, sample_rate) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio_stream(stream, start, frames, sample_rate) + } +} + /// A proxy standing in for its full-resolution media: reports the full-resolution info and hands /// out the proxy's (smaller) frames. A proxy whose aspect ratio differs from the original is /// resampled to the original's aspect so it lines up exactly. @@ -592,6 +702,123 @@ mod tests { assert!(pool.offline_status(item).is_none()); } + /// A source whose video decodes (`OK`) or fails as its mode says, switchable at run time. + struct Flaky(MediaInfo, std::sync::atomic::AtomicU8); + const OK: u8 = 0; + const DAMAGED: u8 = 1; + const IO: u8 = 2; + const LOADING: u8 = 3; + const CANCELLED: u8 = 4; + const NO_VIDEO: u8 = 5; + impl Flaky { + fn set(&self, mode: u8) { + self.1.store(mode, Ordering::Relaxed); + } + } + impl MediaSource for Flaky { + fn info(&self) -> &MediaInfo { + &self.0 + } + fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result> { + Err(match self.1.load(Ordering::Relaxed) { + OK => return Ok(Arc::new(VideoFrame::rgba8(2, 2, vec![7; 16]))), + DAMAGED => MediaError::Decode("damaged slice".into()), + IO => MediaError::Io("short read".into()), + LOADING => { + // as the web build's WebCodecs / Blob sources do + filmcraft_media::pending::mark(); + MediaError::Decode("still decoding".into()) + } + CANCELLED => MediaError::Cancelled, + _ => MediaError::NoStream("video"), + }) + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(AudioBuffer::silence(sample_rate, 1, frames)) + } + } + + fn flaky() -> (MediaPool, ItemId, Arc) { + let info = GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.0; 4] }, 16, 8, Default::default(), filmcraft_time::Tick(100)) + .info() + .clone(); + (MediaPool::default(), ItemId(1), Arc::new(Flaky(info, Default::default()))) + } + + fn frame(src: &SharedSource) -> filmcraft_media::Result> { + src.video_frame(FrameRequest { time: filmcraft_time::Tick(0), scale: 1.0 }) + } + + fn is_slate(r: &filmcraft_media::Result>, name: &str) -> bool { + r.as_ref().is_ok_and(|f| f.to_rgba8() == filmcraft_render::offline::slate_rgba8(16, 8, name, OfflineReason::Unreadable)) + } + + /// #626 follow-up to #762: a damaged file (frames failing again and again) shows the + /// unreadable slate in the monitors; one failure does not, and a decoded frame clears it. + #[test] + fn monitors_show_the_slate_for_persistently_failing_frames_only() { + let (pool, item, src) = flaky(); + let name = src.info().name.clone(); + let mon = pool.monitor_source(item, src.clone()); + for mode in [DAMAGED, IO] { + src.set(mode); + for _ in 1..DAMAGED_AFTER { + assert!(frame(&mon).is_err(), "a transient failure stays as it was"); + } + assert!(is_slate(&frame(&mon), &name), "persistent failure shows the slate"); + assert!(is_slate(&frame(&mon), &name)); + // recovery clears it, and one failure after that is transient again + src.set(OK); + assert_eq!(frame(&mon).unwrap().to_rgba8(), vec![7; 16]); + src.set(DAMAGED); + assert!(frame(&mon).is_err()); + src.set(OK); + assert!(frame(&mon).is_ok()); + } + // cancellation and absent video never become the slate + for mode in [CANCELLED, NO_VIDEO] { + src.set(mode); + for _ in 0..DAMAGED_AFTER * 2 { + assert!(frame(&mon).is_err()); + } + } + } + + /// Pictures still on their way (the web build's pending reads) never count as damage. + #[test] + fn still_decoding_frames_never_show_the_slate() { + let (pool, item, src) = flaky(); + let mon = pool.monitor_source(item, src.clone()); + src.set(LOADING); + for _ in 0..DAMAGED_AFTER * 4 { + assert!(frame(&mon).is_err()); + assert!(filmcraft_media::pending::take()); + } + // and they do not interrupt a run of real failures either way: a following damaged frame + // is still only the first + src.set(DAMAGED); + assert!(frame(&mon).is_err()); + } + + /// Exports read the pool's own sources: a damaged file never burns the slate into them, + /// however often the monitors have shown it. + #[test] + fn export_sources_never_show_the_damaged_slate() { + let (pool, item, src) = flaky(); + let name = src.info().name.clone(); + pool.insert(item, src.clone()); + let mon = pool.monitor_source(item, pool.cached(item).unwrap()); + src.set(DAMAGED); + for _ in 0..DAMAGED_AFTER { + let _ = frame(&mon); + } + assert!(is_slate(&frame(&mon), &name)); + let export = pool.cached(item).unwrap(); + for _ in 0..DAMAGED_AFTER * 2 { + assert!(matches!(frame(&export), Err(MediaError::Decode(_)))); + } + } + /// #626 end to end: an H.264 profile FilmCraft does not decode yet (4:4:4) imports and shows /// the unreadable slate with the decoder's reason, instead of a black picture. #[test] diff --git a/crates/engine/src/mixer_tests.rs b/crates/engine/src/mixer_tests.rs index faf9243ae..4e218d113 100644 --- a/crates/engine/src/mixer_tests.rs +++ b/crates/engine/src/mixer_tests.rs @@ -520,3 +520,11 @@ fn surround_sequence_settings_and_panner_lanes() { assert!(s.execute("file.newSequence", json!({"mix": "7.1"})).is_err()); s.execute("file.newSequence", json!({"trackType": "Standard"})).unwrap(); } + +#[test] +fn audio_transition_schema_lists_edge_params_and_reverse() { + let params = find_command("sequence.applyAudioTransition").unwrap().params; + for key in ["\"edge\"", "\"params\"", "\"reverse\"", "constant_power"] { + assert!(params.contains(key), "schema `{params}` is missing {key}"); + } +} diff --git a/crates/engine/src/place_items_tests.rs b/crates/engine/src/place_items_tests.rs new file mode 100644 index 000000000..57dcd41fe --- /dev/null +++ b/crates/engine/src/place_items_tests.rs @@ -0,0 +1,144 @@ +//! `timeline.place` with `items` and `file.newSequence` with `fromItems`: several items back to +//! back, as one undo step. + +use filmcraft_project::ItemId; +use serde_json::json; + +use crate::Session; + +/// A session on an empty sequence, and the ids of three media items with picture and sound. +fn three() -> (Session, Vec) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let items: Vec = s.project.items.values().filter(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).take(3).collect(); + assert!(items.len() >= 2, "the demo project has media to place"); + s.execute("file.newSequence", json!({"name": "Back to back", "video": 2, "audio": 2})).unwrap(); + (s, items) +} + +fn ends(s: &Session) -> Vec<(i64, i64)> { + let q = s.active_sequence().unwrap(); + q.video_tracks[0].items.iter().map(|i| (i.start.0, i.end().0)).collect() +} + +/// Dragging several selected items placed them one by one, each one an undo step. +#[test] +fn items_land_back_to_back_in_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": items.iter().map(|i| i.0).collect::>(), "time": 0})).unwrap(); + assert_eq!(r["placed"].as_array().unwrap().len(), items.len()); + let spans = ends(&s); + assert_eq!(spans.len(), items.len()); + assert_eq!(spans[0].0, 0); + for w in spans.windows(2) { + assert_eq!(w[0].1, w[1].0, "each item starts where the one before ends"); + } + assert_eq!(s.history.undo.len(), steps + 1, "one undo step for all of them"); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Place Clips")); + s.undo(); + assert!(ends(&s).is_empty(), "one undo takes them all off"); + s.redo(); + assert_eq!(ends(&s), spans); +} + +/// One bad item in the list leaves the sequence and the history as they were. +#[test] +fn a_failing_item_places_nothing() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let list = vec![items[0].0, u64::MAX - 1]; + assert!(s.execute("timeline.place", json!({"items": list, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); + assert_eq!(s.history.undo.len(), steps); +} + +/// `items` is checked: not a list, empty, mixed with `item`, or with one item's range. +#[test] +fn items_are_checked() { + let (mut s, items) = three(); + let one = items[0].0; + for p in [ + json!({"items": one}), + json!({"items": []}), + json!({"items": ["x"]}), + json!({"items": [one], "item": one}), + json!({"items": [one], "duration": 5}), + json!({"items": [one], "sourceIn": 5}), + ] { + assert!(s.execute("timeline.place", p.clone()).is_err(), "{p}"); + } + assert!(ends(&s).is_empty()); +} + +/// A new sequence from several items is one undo step: the sequence and its clips go together. +#[test] +fn new_sequence_from_items_is_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let sequences = s.project.sequences().count(); + s.execute("file.newSequence", json!({"fromItems": items.iter().map(|i| i.0).collect::>()})).unwrap(); + assert_eq!(ends(&s).len(), items.len()); + assert_eq!(s.history.undo.len(), steps + 1); + s.undo(); + assert_eq!(s.project.sequences().count(), sequences); + assert!(s.execute("file.newSequence", json!({"fromItems": 3})).is_err()); +} + +/// Times at the edge of the number range are refused or placed, never a panic, and a refusal +/// leaves the sequence and the history as they were. +#[test] +fn hostile_times_never_panic_and_leave_nothing_half_placed() { + let (mut s, items) = three(); + let ids: Vec = items.iter().map(|i| i.0).collect(); + for t in [i64::MAX, i64::MAX - 5, i64::MIN, -1] { + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": ids, "time": t})); + if r.is_err() { + assert_eq!(s.history.undo.len(), steps, "{t}"); + assert!(ends(&s).is_empty(), "{t}"); + } else { + assert_eq!(s.history.undo.len(), steps + 1, "{t}"); + s.undo(); + } + } + // zero-length and many items: capped, not unbounded + let many = vec![items[0].0; 10_001]; + assert!(s.execute("timeline.place", json!({"items": many, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); +} + +/// `"track":"new"` with `items` adds one track, and the whole list goes on it, in one undo step. +#[test] +fn items_on_a_new_track_share_that_one_track() { + let (mut s, items) = three(); + let tracks = s.active_sequence().unwrap().video_tracks.len(); + let steps = s.history.undo.len(); + let list: Vec = items.iter().map(|i| i.0).collect(); + s.execute("timeline.place", json!({"items": list, "track": "new", "time": 0})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks.len(), tracks + 1, "one new video track, not one per item"); + assert_eq!(q.video_tracks[tracks].items.len(), items.len()); + assert_eq!(s.history.undo.len(), steps + 1); + s.undo(); + assert_eq!(s.active_sequence().unwrap().video_tracks.len(), tracks, "undo removes the track too"); +} + +/// A panic inside a group leaves the project, the history and its limit as they were. +#[test] +fn a_panic_in_a_group_rolls_back_and_keeps_the_history_limit() { + let (mut s, items) = three(); + let (steps, limit) = (s.history.undo.len(), s.history.limit); + let before = s.project.to_json(); + let one = items[0]; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = s.edit_group::<()>("Boom", |s| { + s.execute("timeline.place", json!({"item": one.0, "time": 0}))?; + panic!("inside the group"); + }); + })); + assert!(outcome.is_err(), "the panic goes on"); + assert_eq!(s.history.limit, limit, "the history is not left unlimited"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(s.project.to_json(), before); +} diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index a0e2d181a..bcda38356 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -14,6 +14,7 @@ //! | `file.projectSettings.general` / `.scratchDisks` | File ▸ Project Settings ▸ General… / Scratch Disks… | //! | `edit.find` / `edit.findNext` | Edit ▸ Find… (⌘F) / Find Next | //! | `edit.editOriginal` | Edit ▸ Edit Original (⌘E): the frontend opens the files | +//! | `file.reveal` | Project panel ▸ Reveal in Finder / Show in Explorer: the frontend reveals the media file | //! | `clip.editOffline` | Clip ▸ Edit Offline… | //! | `clip.sourceSettings` | Clip ▸ Source Settings… | //! | `clip.restoreCaptionsFromSource` | Clip ▸ Restore Captions from Source Clip (always disabled: no embedded caption reader) | @@ -163,6 +164,8 @@ pub(crate) fn commands() -> Vec { spec("edit.find", "Find…", &["Edit"], Some("Cmd+F"), FIND_PARAMS, always, find), spec("edit.findNext", "Find Next", &["Edit"], None, "{}", has_find, find_next), spec("edit.editOriginal", "Edit Original", &["Edit"], Some("Cmd+E"), r#"{"items":[id]?}"#, has_original, edit_original), + // Project panel context menu (not in the File menu): reveal the clip's media file. + query("file.reveal", "Reveal in Finder", &[], None, r#"{"items":[id]?}"#, has_file_selection, edit_original), // ---------------- Clip ---------------- spec( "clip.editOffline", @@ -1171,7 +1174,8 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { let d = rate.tick_of(rate.frame_at(r.duration + Tick(fd.0 / 2)).max(1)); plan.push((*id, TimeRange::new(r.start, d))); } - // destinations + // destinations. `cut_overlap[k]` is the overlap between clip k and k+1 (placement-clamped). + let mut cut_overlap: Vec = Vec::new(); let starts: Vec = if placement == "unnumberedMarkers" { let mut m: Vec = q.markers.iter().filter(|m| m.name.is_empty() && m.kind == MarkerKind::Comment).map(|m| rate.snap(m.start)).collect(); m.sort(); @@ -1189,6 +1193,7 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { if k > 0 { let prev = plan[k - 1].1.duration; let ov = overlap.min(Tick(prev.0 / 2)).min(Tick(r.duration.0 / 2)); + cut_overlap.push(ov); at -= rate.snap(ov); } v.push(at); @@ -1239,20 +1244,23 @@ fn automate_to_sequence(s: &mut Session, p: &Value) -> Result { let mut transitions = 0; if placement == "sequentially" && (vt || at) { let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let default_frames = s.project.settings.default_transition_duration_frames; let mut edges = Vec::new(); - for (_, ids) in placed.iter().skip(1) { + for ((_, ids), ov) in placed.iter().skip(1).zip(cut_overlap.iter().chain(std::iter::repeat(&overlap))) { + let frames = if *ov > Tick::ZERO { rate.frame_at(*ov) } else { default_frames }; for c in ids { let Some((tid, _)) = q.find_item(*c) else { continue }; let kind = q.track(tid).map(|t| t.kind); - if (kind == Some(TrackKind::Video) && vt) || (kind == Some(TrackKind::Audio) && at) { - edges.push((*c, kind == Some(TrackKind::Video))); - } + let cmd = match kind { + Some(TrackKind::Video) if vt => "sequence.applyVideoTransition", + Some(TrackKind::Audio) if at => "sequence.applyAudioTransition", + _ => continue, + }; + edges.push((cmd, *c, frames)); } } - let frames = if overlap > Tick::ZERO { rate.frame_at(overlap) } else { s.project.settings.default_transition_duration_frames }; - for (c, video) in edges { - let id = if video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" }; - if s.execute(id, json!({"clip": c.0, "edge": "in", "frames": frames})).is_ok() { + for (cmd, c, frames) in edges { + if s.execute(cmd, json!({"clip": c.0, "edge": "in", "frames": frames})).is_ok() { transitions += 1; } } @@ -1279,7 +1287,7 @@ pub fn system_report() -> Value { "family": std::env::consts::FAMILY, "arch": std::env::consts::ARCH, "cpuThreads": cpus, - "decoders": ["H.264 (AVC)", "H.265 (HEVC) Main / Main 10", "VP9", "AV1 Main", "Apple ProRes", "Avid DNxHD / DNxHR", "Motion JPEG", "AAC-LC", "Opus", "PCM"], + "decoders": ["H.264 (AVC)", "H.265 (HEVC) Main / Main 10", "VP8", "VP9", "AV1 Main", "Apple ProRes", "Avid DNxHD / DNxHR", "Motion JPEG", "AAC-LC", "Opus", "PCM"], "containers": ["MP4 / MOV (ISO BMFF)", "Matroska / WebM", "WAV", "PNG / JPEG stills"], "exportFormats": formats, "speechToText": crate::transcript::speech_available(), diff --git a/crates/engine/src/project_tools_tests.rs b/crates/engine/src/project_tools_tests.rs index e00af7326..90b1148ee 100644 --- a/crates/engine/src/project_tools_tests.rs +++ b/crates/engine/src/project_tools_tests.rs @@ -361,6 +361,18 @@ fn edit_original_update_metadata_and_clip_items() { assert!(std::fs::read_to_string(&side).unwrap().contains("someone else")); } +#[test] +fn file_reveal_enabled_for_imported_clip() { + let dir = tmp_dir("reveal"); + let path = dir.join("take.mp4"); + make_movie(&path, DemoScene::Aurora, 64, 36, 8); + let (mut s, items, _) = session_with(&[&path]); + assert!(!s.is_enabled("file.reveal"), "disabled with no Project panel selection"); + s.execute("project.select", json!({"items": [items[0].0]})).unwrap(); + assert!(s.is_enabled("file.reveal")); + assert_eq!(s.execute("file.reveal", json!({})).unwrap()["paths"], json!([path.to_string_lossy()])); +} + #[test] fn edit_offline_sets_the_log_fields() { let mut s = demo(); @@ -445,6 +457,32 @@ fn automate_to_sequence_places_clips_with_overlap_and_transitions() { assert!(!s.is_enabled("clip.automateToSequence")); } +/// Issue #916: overlap is clamped to half of each clip for placement, but the Cross Dissolve +/// used the unclamped `overlapFrames` and started before the first clip. +#[test] +fn automate_to_sequence_clamps_transition_to_overlap() { + let mut s = demo(); + s.execute("file.newSequence", json!({"name": "Auto", "fps": 23.976})).unwrap(); + let rate = s.sequence_rate(); + let names = ["Misty_Forest.mp4", "Ocean_Sunset.mp4"]; + let ids: Vec = names.iter().map(|n| item_named(&s, n)).collect(); + for id in &ids { + s.execute("project.setMarks", json!({"item": id.0, "in": 0, "out": rate.tick_of(19).0})).unwrap(); + } + s.execute("project.select", json!({"items": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.execute("clip.automateToSequence", json!({"ordering": "selection", "method": "overwrite", "overlapFrames": 30})).unwrap(); + let q = s.active_sequence().unwrap(); + let v = &q.video_tracks[0]; + assert_eq!(v.items.len(), 2); + assert_eq!(v.items[0].start, Tick::ZERO); + assert_eq!(v.items[1].start, rate.tick_of(10), "overlap clamped to half of a 20-frame clip"); + assert_eq!(v.transitions.len(), 1); + let t = &v.transitions[0]; + assert_eq!(t.duration, rate.tick_of(10), "dissolve matches clamped overlap, not 30"); + assert!(t.start >= v.items[0].start, "dissolve must not start before the first clip: {t:?}"); + assert!(t.end() <= v.items[1].end(), "dissolve must not end after the second clip: {t:?}"); +} + #[test] fn system_report_lists_the_build() { let s = &mut Session::default(); @@ -469,13 +507,13 @@ fn clear_deletes_a_bin_with_everything_in_it() { select(&mut s); // an empty bin, by its id: gone, nothing else touched let r = s.execute("project.delete", json!({"items": [empty]})).unwrap(); - assert_eq!(r, json!({"items": 0, "bins": 1})); + assert_eq!(r, json!({"items": 0, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(empty)).is_none()); assert_eq!(s.project.items.len(), before); // a bin holding a sub-bin holding a clip: all three go select(&mut s); let r = s.execute("project.delete", json!({"items": [outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert!(s.project.root.find_bin(filmcraft_project::BinId(outer)).is_none()); assert!(s.project.root.find_bin(filmcraft_project::BinId(inner)).is_none()); assert!(s.project.item(ocean).is_none()); @@ -487,7 +525,7 @@ fn clear_deletes_a_bin_with_everything_in_it() { // the clip, its bin and the bin around it all named: each counted once select(&mut s); let r = s.execute("project.delete", json!({"items": [ocean.0, inner, outer]})).unwrap(); - assert_eq!(r, json!({"items": 1, "bins": 1})); + assert_eq!(r, json!({"items": 1, "bins": 1, "offline": 0})); assert_eq!(s.project.items.len(), before - 1); s.execute("edit.undo", json!({})).unwrap(); // the project's own top bin is never cleared @@ -496,3 +534,31 @@ fn clear_deletes_a_bin_with_everything_in_it() { s.execute("project.delete", json!({"items": [root]})).unwrap(); assert_eq!(s.project.items.len(), before); } + +#[test] +fn deleting_media_used_on_the_timeline_makes_it_offline() { + let mut s = Session::default(); + s.execute("file.newProject", json!({"name": "Offline"})).unwrap(); + s.execute("file.newSequence", json!({"name": "Seq", "fps": 25, "video": 1, "audio": 0, "width": 16, "height": 16})).unwrap(); + let item = s + .execute("file.newOfflineFile", json!({"name": "Clip", "seconds": 2, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})) + .unwrap()["item"] + .as_u64() + .unwrap(); + s.execute("timeline.place", json!({"item": item, "track": "V1", "time": 0})).unwrap(); + let clips_before = s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(); + let r = s.execute("project.delete", json!({"items": [item]})).unwrap(); + assert_eq!(r["offline"], 1); + assert_eq!(r["items"], 0); + assert!(s.project.item(ItemId(item)).unwrap().as_media().unwrap().offline); + assert_eq!(s.project.sequences().next().unwrap().as_sequence().unwrap().video_tracks[0].items.len(), clips_before); + let unused = s + .execute("file.newOfflineFile", json!({"name": "Spare", "seconds": 1, "fps": 25, "video": true, "audio": false, "width": 16, "height": 16})) + .unwrap()["item"] + .as_u64() + .unwrap(); + let r = s.execute("project.delete", json!({"items": [unused]})).unwrap(); + assert_eq!(r["items"], 1); + assert_eq!(r["offline"], 0); + assert!(s.project.item(ItemId(unused)).is_none()); +} diff --git a/crates/engine/src/relink_tests.rs b/crates/engine/src/relink_tests.rs index b6475c0d4..d25dda91e 100644 --- a/crates/engine/src/relink_tests.rs +++ b/crates/engine/src/relink_tests.rs @@ -201,3 +201,72 @@ fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() { assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into())); assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None); } + +/// #1054: an export (or a rendered frame) that draws the offline slate names the offline items it +/// used, with their status, so a headless caller can tell placeholder frames from real ones. Media +/// outside the exported range is not reported. +#[test] +fn export_names_the_offline_media_it_rendered() { + let root = tmp_dir("relink-export-offline"); + let (path, items) = saved_project(&root); + let out = |name: &str| root.join(name).to_string_lossy().into_owned(); + let mut s = open(&path); + let r = s + .execute("file.exportMedia", json!({"path": out("on.png"), "format": "png", "range": "custom", "startSeconds": 0, "endSeconds": 0.05, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + std::fs::remove_file(root.join("Media/a.mov")).unwrap(); + let mut s = open(&path); + // b.mov (frames 12..24) is still there + let r = s + .execute("file.exportMedia", json!({"path": out("b.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!(r["offlineMedia"], json!([]), "{r}"); + let r = s.execute("file.exportMedia", json!({"path": out("all.wav"), "format": "wav", "wait": true})).unwrap(); + let off = r["offlineMedia"].as_array().unwrap(); + assert_eq!(off.len(), 1, "{r}"); + assert_eq!(off[0]["item"], json!(items[0].0)); + assert_eq!(off[0]["name"], json!("a.mov")); + assert_eq!(off[0]["status"], json!("missing")); + assert!(off[0]["path"].as_str().unwrap().ends_with("a.mov"), "{r}"); + // made offline on purpose + s.execute("media.makeOffline", json!({"items": [items[1].0]})).unwrap(); + let r = s + .execute("file.exportMedia", json!({"path": out("b2.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "wait": true})) + .unwrap(); + assert_eq!( + r["offlineMedia"].as_array().map(|a| a.iter().map(|o| (o["item"].clone(), o["status"].clone())).collect::>()), + Some(vec![(json!(items[1].0), json!("offline"))]), + "{r}" + ); + // the queue reports it per item + let r = s + .execute( + "export.queue.add", + json!({"path": out("q.png"), "format": "png", "range": "custom", "startSeconds": 0.6, "endSeconds": 0.65, "start": true, "wait": true}), + ) + .unwrap(); + assert_eq!(r["items"][0]["offlineMedia"][0]["status"], json!("offline"), "{r}"); + // one rendered frame + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(3)).unwrap(); + assert_eq!(off.iter().map(|o| (o.item, o.status)).collect::>(), [(items[0].0, "missing")]); + let (_, off) = s.try_render_program_with_offline_at(0.5, s.sequence_rate().tick_of(30)).unwrap(); + assert!(off.is_empty(), "nothing on the timeline there: {off:?}"); + let _ = std::fs::remove_dir_all(&root); +} + +/// #1054: `jobs.list` carries an export job's `offlineMedia`, for callers that poll the job (the +/// bridge and MCP long exports) instead of waiting in `file.exportMedia`. +#[test] +fn jobs_list_reports_an_exports_offline_media() { + let root = tmp_dir("relink-jobs-offline"); + let (path, items) = saved_project(&root); + std::fs::remove_file(root.join("Media/b.mov")).unwrap(); + let mut s = open(&path); + let out = root.join("all.wav").to_string_lossy().into_owned(); + let job = s.execute("file.exportMedia", json!({"path": out, "format": "wav", "wait": true})).unwrap()["job"].clone(); + let jobs = s.execute("jobs.list", json!({})).unwrap(); + let j = jobs.as_array().unwrap().iter().find(|j| j["id"] == job).unwrap(); + assert_eq!(j["offlineMedia"][0]["item"], json!(items[1].0), "{j}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/roll_linked_tests.rs b/crates/engine/src/roll_linked_tests.rs new file mode 100644 index 000000000..dd3f2e4b2 --- /dev/null +++ b/crates/engine/src/roll_linked_tests.rs @@ -0,0 +1,86 @@ +//! #855: a rolling edit on a linked A/V cut must move the sound cut with the picture. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +fn source_in(s: &Session, c: ClipId) -> Tick { + s.active_sequence().unwrap().find_item(c).unwrap().1.source_in +} + +/// Two consecutive 48-frame A/V clips (picture on V1, linked sound on A1). +fn two_clips(s: &mut Session) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for n in 0..2 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +/// #855: rolling the V1 cut +12 frames used to leave A1, so export played the second clip's audio +/// under the first clip's picture. +#[test] +fn roll_moves_linked_audio_cut() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + assert!(s.state.linked_selection); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(true), "{r}"); + assert_eq!(r["pairs"], json!(2), "{r}"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(60)), "linked sound must roll with the picture"); + assert_eq!(range(&s, a1), (f(60), f(96))); +} + +/// Linked selection off (or unlinked clips) rolls only the given pair and says so. +#[test] +fn roll_without_linked_selection_leaves_audio() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(false), "{r}: clips were not followed as a linked pair"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(48)), "unlinked A1 cut stays"); + assert_eq!(range(&s, a1), (f(48), f(96))); +} + +/// #526/#558: reversed linked clips still keep the media on the unmoved timeline edges. +#[test] +fn roll_linked_reversed_clips_keeps_media_mapping() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("timeline.select", json!({"clips": [v0.0, a0.0, v1.0, a1.0]})).unwrap(); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let left_in = source_in(&s, v0); + let right_in = source_in(&s, v1); + s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, a0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a1), (f(60), f(96))); + // reversed left: source_in moves so the clip's In still shows the same media + assert_eq!(source_in(&s, v0), left_in - f(12)); + assert_eq!(source_in(&s, a0), left_in - f(12)); + // reversed right: source_in stays so the clip's Out still shows the same media + assert_eq!(source_in(&s, v1), right_in); + assert_eq!(source_in(&s, a1), right_in); +} diff --git a/crates/engine/src/scene_detect.rs b/crates/engine/src/scene_detect.rs index 59dd68736..3a46ae2a8 100644 --- a/crates/engine/src/scene_detect.rs +++ b/crates/engine/src/scene_detect.rs @@ -9,8 +9,10 @@ //! //! - **Apply a cut at each detected cut point**: the clip (and its linked audio) is cut at the //! first frame of each new shot; -//! - **Create a subclip for each cut**: one subclip per shot, in a new bin " Scenes"; -//! - **Generate clip markers**: a marker on the clip's master clip (media time) at each cut. +//! - **Create a bin of subclips from each detected cut point**: one subclip per shot, in a new +//! bin " Scenes"; +//! - **Create a clip marker at each detected cut point**: a marker on the clip's master clip +//! (media time) at each cut. use std::sync::{Arc, Mutex}; diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c81..e8966758d 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, marker_color_p, sequence_time_p, str_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -483,11 +483,11 @@ fn show_active_only(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- fn add_flash_cue(s: &mut Session, p: &Value) -> Result { - let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead())); + let t = s.sequence_rate().snap(sequence_time_p(s, p, "", "markers.addFlashCue")?.unwrap_or(s.playhead())); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.addFlashCue")?.unwrap_or(Label::Green); let dur = u64_p(p, "durationFrames").map(|f| s.sequence_rate().tick_of(f as i64)).unwrap_or(Tick::ZERO); let id = s.edit_sequence("Add Flash Cue Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..f871f0118 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,9 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{ + CommandSpec, always, bad, bool_p, clips_p, has_seq, marker_color_p, sequence_time_p, source_mark_time, str_p, time_p, track_p, with_links, +}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -64,7 +66,7 @@ pub(crate) fn splice(v: &mut Vec) { } } -const SPLIT: &str = r#"{"time":ticks?,"target":"program|source"}"#; +const SPLIT: &str = r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?,"target":"program|source"}"#; fn groups() -> Vec<(&'static str, Vec)> { vec![ @@ -206,10 +208,10 @@ fn groups() -> Vec<(&'static str, Vec)> { r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#, has_seq, |s, p| { - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); - let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; + let id = add_marker(s, "markers.addChapter", "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; Ok(json!({"marker": id.0})) }, ), @@ -735,11 +737,12 @@ pub(crate) fn wants_new_track(p: &Value) -> bool { pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.place"; let item = crate::commands::item_p(p, "item").ok_or_else(|| bad(cmd, "need `item`"))?; - let pi = s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?; + s.project.item(item).ok_or_else(|| bad(cmd, "no such item"))?; + let (video, audio) = s.project.source_streams(item); let is_new = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")); let enabled = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); - let new_v = is_new("track") && pi.has_video() && enabled("video"); - let new_a = is_new("audioTrack") && pi.has_audio() && enabled("audio"); + let new_v = is_new("track") && video && enabled("video"); + let new_a = is_new("audioTrack") && audio && enabled("audio"); if !new_v && !new_a { return Err(bad(cmd, "no new track to add: the item has no picture or sound for it")); } @@ -771,6 +774,65 @@ pub(crate) fn place_on_new_tracks(s: &mut Session, p: &Value) -> Result { } } +/// `timeline.move` names `"new"` as the `track` of a move. +pub(crate) fn move_wants_new_track(p: &Value) -> bool { + p.get("moves").and_then(Value::as_array).is_some_and(|m| m.iter().any(is_new_move)) +} + +/// One move of `timeline.move` with `"track":"new"`. +fn is_new_move(m: &Value) -> bool { + m.get("track").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("new")) +} + +/// `timeline.move` with `"track":"new"` on some moves (#1104: a clip dragged into the empty space +/// above the top video track or below the last audio track): one track is added after the last one +/// of each kind the `"new"` clips come from, they all land on it, and the track and the move are +/// one undo step. When the move fails, the new tracks go away again. +pub(crate) fn move_to_new_tracks(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.move"; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (mut new_v, mut new_a) = (false, false); + for m in p.get("moves").and_then(Value::as_array).into_iter().flatten().filter(|m| is_new_move(m)) { + let clip = crate::commands::clip_p(m, "clip").ok_or_else(|| bad(cmd, "need `clip` in each move"))?; + let (track, _) = q.find_item(clip).ok_or_else(|| bad(cmd, "no such clip"))?; + if q.video_tracks.iter().any(|t| t.id == track) { + new_v = true; + } else { + new_a = true; + } + } + let added = add_tracks(s, &json!({"video": u64::from(new_v), "audio": u64::from(new_a)}))?; + let mark = s.history.undo.len(); + let (video, audio) = (added.get("video").and_then(|v| v.get(0)).cloned(), added.get("audio").and_then(|v| v.get(0)).cloned()); + let mut moves = p.get("moves").and_then(Value::as_array).cloned().unwrap_or_default(); + for m in moves.iter_mut().filter(|m| is_new_move(m)) { + let on_video = crate::commands::clip_p(m, "clip") + .and_then(|c| s.active_sequence().and_then(|q| q.find_item(c).map(|(t, _)| q.video_tracks.iter().any(|v| v.id == t)))) + .unwrap_or(true); + m["track"] = if on_video { video.clone() } else { audio.clone() }.unwrap_or(Value::Null); + } + let mut q = p.clone(); + q["moves"] = Value::Array(moves); + let run = crate::commands::find(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?.run; + match run(s, &q) { + Ok(v) => { + // the new tracks and the move are one undo step + if mark >= 1 + && s.history.undo.len() == mark + 1 + && let (Some((label, _)), Some((_, before))) = (s.history.undo.pop(), s.history.undo.pop()) + { + s.history.undo.push((label, before)); + } + Ok(v) + } + Err(e) => { + s.undo(); + s.history.redo.pop(); + Err(e) + } + } +} + fn delete_tracks(s: &mut Session, p: &Value) -> Result { let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; let q = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -895,7 +957,7 @@ fn mark_split(s: &mut Session, p: &Value, side: Side) -> Result { let label = format!("Mark Split {}", side.label()); if str_p(p, "target") == Some("source") { let item = s.state.source_item.ok_or_else(|| EngineError::Other("no clip in the Source monitor".into()))?; - let t = p.get("time").and_then(Value::as_i64).map(Tick).unwrap_or(s.state.source_playhead); + let t = source_mark_time(s, p, item); let split = s.edit(&label, |pr, _| { let it = pr.item_mut(item).ok_or_else(|| EngineError::Other("no such item".into()))?; side.set(&mut it.split, t); @@ -962,9 +1024,9 @@ pub fn ripple_markers(markers: &mut Vec, from: Tick, shift: Tick) { markers.sort_by_key(|m| m.start); } -fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { +fn add_marker(s: &mut Session, cmd: &str, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, cmd)?.unwrap_or(Label::Green); s.edit_sequence(label, |q, ctx, _| { let id = MarkerId(ctx.alloc()); q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind, color }); @@ -977,7 +1039,7 @@ fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind /// `duration` say otherwise) at the playhead. fn add_range_marker(s: &mut Session, p: &Value) -> Result { let rate = s.sequence_rate(); - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addRange")?.unwrap_or(s.playhead()); let dur = p .get("duration") .and_then(Value::as_i64) @@ -988,7 +1050,7 @@ fn add_range_marker(s: &mut Session, p: &Value) -> Result { return Err(bad("markers.addRange", "the duration must be positive")); } let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRange", "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } @@ -998,7 +1060,7 @@ fn add_range_marker_in_out(s: &mut Session, p: &Value) -> Result { let (Some(i), Some(o)) = (q.mark_in, q.mark_out) else { return Err(EngineError::Other("mark an In and an Out point first".into())) }; let dur = o + q.settings.frame_rate.frame_duration() - i; let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRangeInOut", "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index d275f9b3e..8f1ae2d4d 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -448,6 +448,26 @@ fn source_split_points_make_l_and_j_cuts() { assert!(s.project.item(ItemId(bars)).unwrap().split.audio_in.is_none()); } +/// #920: Source Mark In/Out and Mark Split honour `frame` / `seconds` / `timecode`, not only `time`. +#[test] +fn source_mark_in_out_and_split_honour_frame_seconds_and_timecode() { + let (mut s, bars) = fresh(); + s.execute("source.open", json!({"item": bars})).unwrap(); + s.execute("source.setPlayhead", json!({"frame": 0})).unwrap(); + let r = s.project.item(ItemId(bars)).unwrap().frame_rate(); + assert_eq!(s.state.source_playhead, Tick::ZERO); + s.execute("markers.markIn", json!({"target": "source", "frame": 24})).unwrap(); + let m = s.project.item(ItemId(bars)).unwrap().as_media().unwrap(); + assert_eq!(m.mark_in, Some(r.tick_of(24)), "frame:24 marks source frame 24, not the playhead"); + s.execute("markers.markOut", json!({"target": "source", "seconds": 2.0})).unwrap(); + let m = s.project.item(ItemId(bars)).unwrap().as_media().unwrap(); + assert_eq!(m.mark_out, Some(Tick::from_seconds_f64(2.0))); + s.execute("markers.markSplitVideoIn", json!({"target": "source", "timecode": "00:00:01:00"})).unwrap(); + assert_eq!(s.project.item(ItemId(bars)).unwrap().split.video_in, Some(r.tick_of(r.as_f64().round() as i64))); + s.execute("markers.markSplitAudioOut", json!({"target": "source", "frame": 48})).unwrap(); + assert_eq!(s.project.item(ItemId(bars)).unwrap().split.audio_out, Some(r.tick_of(48))); +} + #[test] fn range_and_chapter_markers() { let mut s = demo(); @@ -476,6 +496,27 @@ fn range_and_chapter_markers() { assert!(s.active_sequence().unwrap().markers.is_empty()); } +#[test] +fn unknown_marker_colours_are_rejected_not_stored_as_green() { + // #857: `markers.add {color:"Red"}` used to succeed and store Green + let mut s = demo(); + let before = s.project.to_json(); + for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] { + for color in [json!("Red"), json!("NotAColor"), json!(3)] { + let err = s.execute(cmd, json!({"frame": 10, "color": color})).unwrap_err().to_string(); + assert!(err.contains("Rose"), "{cmd}: the error lists the accepted names: {err}"); + assert_eq!(s.project.to_json(), before, "{cmd} {color} must not add a marker"); + } + } + let id = s.execute("markers.add", json!({"frame": 10, "color": "rose"})).unwrap()["marker"].as_u64().unwrap(); + let colour = |s: &Session| s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().color; + assert_eq!(colour(&s), Label::Rose); + assert!(s.execute("markers.edit", json!({"marker": id, "color": "Red"})).is_err()); + assert_eq!(colour(&s), Label::Rose, "a bad colour leaves the marker unchanged"); + s.execute("markers.edit", json!({"marker": id, "color": "Teal"})).unwrap(); + assert_eq!(colour(&s), Label::Teal); +} + #[test] fn ripple_sequence_markers_follow_ripple_edits() { let mut s = demo(); @@ -607,3 +648,26 @@ fn place_on_new_tracks_adds_them_in_one_undo_step() { assert_eq!(*s.active_sequence().unwrap(), before); assert_eq!(s.history.undo.len(), undo); } + +/// #1104: a clip dragged into the empty space past the last track moves onto a new track (with +/// `"new"`), one undo step; a move that fails leaves no new track behind. +#[test] +fn move_to_new_track_adds_it_in_one_undo_step() { + let (mut s, item) = fresh(); + let clips = place(&mut s, item, "V1", 0, 0, 10); + let before = s.active_sequence().unwrap().clone(); + let nv = before.video_tracks.len(); + let undo = s.history.undo.len(); + s.execute("timeline.move", json!({"moves": [{"clip": clips[0], "track": "new", "time": 0}], "linked": false})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.video_tracks.len(), nv + 1); + assert_eq!(q.video_tracks[nv].items.iter().map(|i| i.id.0).collect::>(), [clips[0]], "the clip is on the new top video track"); + assert!(q.video_tracks[0].items.is_empty()); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active_sequence().unwrap(), before); + + assert!(s.execute("timeline.move", json!({"moves": [{"clip": 999_999, "track": "new", "time": 0}]})).is_err()); + assert_eq!(*s.active_sequence().unwrap(), before); + assert_eq!(s.history.undo.len(), undo); +} diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index e6f91d785..1fb709160 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2; #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`, - /// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the + /// `zh-cn`, `de`, `ru`, `fr`, `id`, or `system` (the default: the operating system's preferred language when the /// interface has it, otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. @@ -326,11 +326,17 @@ impl Default for LabelDefaults { pub struct LabelPrefs { pub colors: BTreeMap, pub defaults: LabelDefaults, + /// Label Color Presets: the preset the names and colours were last taken from. + pub preset: String, } impl Default for LabelPrefs { fn default() -> Self { - Self { colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(), defaults: LabelDefaults::default() } + Self { + colors: Label::ALL.iter().map(|l| (label_id(*l), default_label_color(*l))).collect(), + defaults: LabelDefaults::default(), + preset: DEFAULT_LABEL_PRESET.into(), + } } } @@ -386,6 +392,226 @@ impl LabelPrefs { c.name = c.name.chars().take(64).collect(); } self.colors.retain(|k, _| Label::ALL.iter().any(|l| label_id(*l) == *k)); + self.preset = self.preset.chars().take(LABEL_NAME_MAX).collect(); + } +} + +/// The label colour preset FilmCraft ships with: the labels' own names and colours. +pub const DEFAULT_LABEL_PRESET: &str = "Default"; +/// The folder of label colour presets, beside `preferences.json`. +pub const LABEL_PRESETS_DIR: &str = "Label Presets"; +/// Largest label preset file read; a real one is under 2 KiB. +const LABEL_PRESET_MAX_BYTES: u64 = 64 * 1024; +/// Longest preset or label name, in characters. +const LABEL_NAME_MAX: usize = 64; +/// Most files looked at in the presets folder. +const LABEL_PRESETS_MAX: usize = 500; + +/// A label colour preset (Settings ▸ Labels ▸ Label Color Presets), shared between editors as a +/// small JSON file: its name and the 16 labels' names and colours, in [`Label::ALL`] order. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LabelPreset { + pub name: String, + pub labels: Vec, +} + +/// One label of a [`LabelPreset`]. A file must give both fields. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PresetLabel { + pub name: String, + /// `#rrggbb`. + pub color: String, +} + +/// The built-in presets after [`DEFAULT_LABEL_PRESET`]: (name, 16 × (label name, colour)) in +/// [`Label::ALL`] order. "Roles" puts the label defaults (Video, Movie, Audio, Still, Sequence, +/// Dynamic Link, Bin / Captions on labels 0, 1, 2, 3, 5, 6, 7) on matching roles. +const BUILTIN_LABEL_PRESETS: [(&str, [(&str, &str); 16]); 2] = [ + ( + "Bright", + [ + ("Red", "#e3342f"), + ("Orange", "#ec7a24"), + ("Amber", "#f0a82a"), + ("Yellow", "#e8d422"), + ("Lime", "#9ed62a"), + ("Green", "#36c24a"), + ("Mint", "#2fd69a"), + ("Cyan", "#24c9dc"), + ("Sky", "#2fa0ec"), + ("Blue", "#3d6ff0"), + ("Indigo", "#5b5cf0"), + ("Violet", "#8452ec"), + ("Purple", "#ac46e0"), + ("Magenta", "#dc3ccc"), + ("Pink", "#ec4f8e"), + ("Gray", "#8c8c8c"), + ], + ), + ( + "Roles", + [ + ("B-Roll", "#5b8fd4"), + ("Footage", "#36a9c9"), + ("Dialogue", "#4cb87a"), + ("Graphics", "#e8913a"), + ("Interview", "#d9c040"), + ("Sequence", "#7f9e3c"), + ("Visual Effects", "#c95bc4"), + ("Bin", "#8a94a6"), + ("Titles", "#e86f5c"), + ("Music", "#8c6fe0"), + ("Sound Effects", "#e0609a"), + ("Voice-Over", "#5fd1b5"), + ("Captions", "#c8b48a"), + ("Ambience", "#a8754f"), + ("Color Grade", "#d64545"), + ("Stock", "#b5d454"), + ], + ), +]; + +/// Whether `name` is a built-in preset's (any case): built-ins can't be deleted or replaced. +pub fn is_builtin_label_preset(name: &str) -> bool { + name.eq_ignore_ascii_case(DEFAULT_LABEL_PRESET) || BUILTIN_LABEL_PRESETS.iter().any(|(n, _)| name.eq_ignore_ascii_case(n)) +} + +impl LabelPreset { + /// The built-in preset ([`DEFAULT_LABEL_PRESET`]). + pub fn builtin() -> Self { + Self::from_prefs(DEFAULT_LABEL_PRESET, &LabelPrefs::default()) + } + + /// Every built-in preset, [`DEFAULT_LABEL_PRESET`] first. + pub fn builtins() -> Vec { + let more = BUILTIN_LABEL_PRESETS.iter().map(|(name, labels)| Self { + name: (*name).into(), + labels: labels.iter().map(|(n, c)| PresetLabel { name: (*n).into(), color: (*c).into() }).collect(), + }); + std::iter::once(Self::builtin()).chain(more).collect() + } + + /// `labels`' names and colours as a preset called `name`. + pub fn from_prefs(name: &str, labels: &LabelPrefs) -> Self { + let entry = |l: Label| labels.colors.get(&label_id(l)).cloned().unwrap_or_else(|| default_label_color(l)); + Self { name: name.into(), labels: Label::ALL.iter().map(|l| entry(*l)).map(|c| PresetLabel { name: c.name, color: c.color }).collect() } + } + + /// Whether `labels` has exactly this preset's names and colours. + pub fn matches(&self, labels: &LabelPrefs) -> bool { + Self::from_prefs(&self.name, labels) == *self + } + + /// Give `labels` this preset's names and colours (the label defaults stay) and select it. + pub fn apply(&self, labels: &mut LabelPrefs) { + for (l, p) in Label::ALL.iter().zip(&self.labels) { + labels.colors.insert(label_id(*l), LabelColor { name: p.name.clone(), color: p.color.clone() }); + } + labels.preset = self.name.clone(); + } + + /// Check a preset file: at most 64 KiB of JSON with a name and exactly 16 labels, each with a + /// name (trimmed, at most 64 characters) and a `#rrggbb` colour. + pub fn parse(bytes: &[u8]) -> std::result::Result { + if bytes.len() as u64 > LABEL_PRESET_MAX_BYTES { + return Err("the file is too large to be a label color preset".into()); + } + let mut p: Self = serde_json::from_slice(bytes).map_err(|e| format!("not a label color preset: {e}"))?; + p.name = p.name.trim().to_string(); + if p.name.is_empty() || p.name.chars().count() > LABEL_NAME_MAX { + return Err(format!("the preset needs a name of 1 to {LABEL_NAME_MAX} characters")); + } + if p.labels.len() != Label::ALL.len() { + return Err(format!("a label color preset has {} labels; this file has {}", Label::ALL.len(), p.labels.len())); + } + for (i, l) in p.labels.iter_mut().enumerate() { + l.name = l.name.trim().to_string(); + if l.name.chars().count() > LABEL_NAME_MAX { + return Err(format!("label {}: the name is longer than {LABEL_NAME_MAX} characters", i + 1)); + } + let rgb = l.color.strip_prefix('#').and(parse_hex(&l.color)).ok_or_else(|| format!("label {}: the color is not like #2f6bdf", i + 1))?; + l.color = hex(rgb); + } + Ok(p) + } + + /// Read and check a preset file ([`LabelPreset::parse`]). + pub fn read(path: &Path) -> std::result::Result { + use std::io::Read as _; + let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut bytes = Vec::new(); + file.take(LABEL_PRESET_MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("{}: {e}", path.display()))?; + Self::parse(&bytes) + } + + pub fn write(&self, path: &Path) -> std::result::Result<(), String> { + let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?; + filmcraft_format::atomic_write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) + } + + /// Save `labels`' names and colours to `path` as a preset named after the file. + pub fn export(path: &Path, labels: &LabelPrefs) -> std::result::Result { + let stem = path.file_stem().map(|s| s.to_string_lossy().trim().chars().take(LABEL_NAME_MAX).collect::()).unwrap_or_default(); + let p = Self::from_prefs(if stem.is_empty() { "Labels" } else { &stem }, labels); + p.write(path)?; + Ok(p) + } +} + +impl Session { + /// The label colour presets folder (None without a data directory, e.g. tests and the web). + pub fn label_presets_dir(&self) -> Option { + self.prefs_path.as_deref().and_then(Path::parent).map(|d| d.join(LABEL_PRESETS_DIR)) + } + + /// The built-in presets, then the presets in the folder by name with the file each is in. + /// Files that fail [`LabelPreset::parse`] and names already taken are left out. + pub fn label_presets(&self) -> Vec<(LabelPreset, Option)> { + let mut found: Vec<(LabelPreset, Option)> = self + .label_presets_dir() + .and_then(|d| std::fs::read_dir(d).ok()) + .into_iter() + .flatten() + .flatten() + .take(LABEL_PRESETS_MAX) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"))) + .filter_map(|p| LabelPreset::read(&p).ok().map(|preset| (preset, Some(p)))) + .collect(); + found.sort_by_key(|(p, _)| p.name.to_lowercase()); + let mut out: Vec<(LabelPreset, Option)> = LabelPreset::builtins().into_iter().map(|p| (p, None)).collect(); + for f in found { + if !out.iter().any(|(p, _)| p.name.eq_ignore_ascii_case(&f.0.name)) { + out.push(f); + } + } + out + } + + /// Copy the preset file at `path` into the presets folder, replacing a preset of the same + /// name, and return it. One named like a built-in preset becomes " (imported)". + pub fn import_label_preset(&self, path: &Path) -> std::result::Result { + let mut p = LabelPreset::read(path)?; + if is_builtin_label_preset(&p.name) { + p.name = format!("{} (imported)", p.name); + } + let dir = self.label_presets_dir().ok_or("there is no presets folder in this session")?; + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for (old, file) in self.label_presets() { + if let Some(file) = file.filter(|_| old.name.eq_ignore_ascii_case(&p.name)) { + std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display()))?; + } + } + let safe: String = p.name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect(); + p.write(&dir.join(format!("{}.json", safe.trim())))?; + Ok(p) + } + + /// Delete a preset from the presets folder (built-in presets can't be deleted). + pub fn delete_label_preset(&self, name: &str) -> std::result::Result<(), String> { + let (_, file) = self.label_presets().into_iter().find(|(p, _)| p.name == name).ok_or_else(|| format!("there is no label color preset “{name}”"))?; + let file = file.ok_or("built-in presets can't be deleted")?; + std::fs::remove_file(&file).map_err(|e| format!("{}: {e}", file.display())) } } @@ -777,6 +1003,7 @@ static CATEGORIES: &[Category] = &[ ("de", "Deutsch"), ("ru", "Русский"), ("fr", "Français"), + ("id", "Bahasa Indonesia"), ]), true, ), @@ -967,6 +1194,7 @@ static CATEGORIES: &[Category] = &[ f("labels.defaults.captions", "Captions", Kind::Label, false), ], ), + Row::Custom("labelPresets"), ], }, Category { @@ -1107,7 +1335,7 @@ static CATEGORIES: &[Category] = &[ b("timeline.playAfterRendering", "Play after rendering previews", true), b("timeline.clipMismatchWarning", "Show Clip Mismatch Warning dialog", false), b("timeline.matchFrameSetsIn", "Match frame sets in point", false), - b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", false), + b("timeline.restoreOpenSequences", "Restore open sequences when opening projects", true), b("timeline.addTracksAutomatically", "Add tracks automatically when editing source clips onto the Timeline", false), b("timeline.dropImportToTimeline", "Place files dropped onto the Timeline directly on the Timeline", true), ], @@ -1555,10 +1783,41 @@ fn cache_clean(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": files, "bytes": bytes, "location": dir.to_string_lossy()})) } +fn presets_json(s: &Session) -> Value { + let presets: Vec = s.label_presets().into_iter().map(|(p, file)| json!({"name": p.name, "builtin": file.is_none(), "labels": p.labels})).collect(); + json!({"dir": s.label_presets_dir(), "selected": s.prefs.labels.preset, "presets": presets}) +} + +fn label_presets(s: &mut Session, _: &Value) -> Result { + Ok(presets_json(s)) +} + +fn import_label_preset(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("labels.importPreset", "need `path`"))?; + let preset = s.import_label_preset(Path::new(path)).map_err(|e| bad("labels.importPreset", e))?; + Ok(json!({"name": preset.name, "presets": presets_json(s)})) +} + +fn export_label_preset(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("labels.exportPreset", "need `path`"))?; + let preset = LabelPreset::export(Path::new(path), &s.prefs.labels).map_err(|e| bad("labels.exportPreset", e))?; + Ok(json!({"name": preset.name, "path": path})) +} + +fn delete_label_preset(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "name").ok_or_else(|| bad("labels.deletePreset", "need `name`"))?; + s.delete_label_preset(name).map_err(|e| bad("labels.deletePreset", e))?; + Ok(presets_json(s)) +} + pub fn commands() -> Vec { vec![ spec("prefs.schema", "Settings Schema", r#"{"category":str?}"#, schema, false), spec("mediaCache.info", "Media Cache Info", "{}", cache_info, false), spec("mediaCache.clean", "Delete Media Cache Files", r#"{"all":bool?}"#, cache_clean, true), + spec("labels.presets", "Label Color Presets", "{}", label_presets, false), + spec("labels.importPreset", "Import Label Color Preset", r#"{"path":str}"#, import_label_preset, true), + spec("labels.exportPreset", "Export Label Color Preset", r#"{"path":str}"#, export_label_preset, true), + spec("labels.deletePreset", "Delete Label Color Preset", r#"{"name":str}"#, delete_label_preset, true), ] } diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 6df9e2462..34262638a 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -38,6 +38,12 @@ fn interface_language_persists_and_rejects_hostile_values() { assert_eq!(Preferences::load(&path).general.interface_language, "de"); set(&mut s, "general.interfaceLanguage", json!("fr")); assert_eq!(Preferences::load(&path).general.interface_language, "fr"); + // Indonesian is stored as `id`, not a locale tag or the legacy `in` code + for value in [json!("id-ID"), json!("in"), json!("ID")] { + assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); + } + set(&mut s, "general.interfaceLanguage", json!("id")); + assert_eq!(Preferences::load(&path).general.interface_language, "id"); // back to following the operating system (#218) set(&mut s, "general.interfaceLanguage", json!("system")); assert_eq!(Preferences::load(&path).general.interface_language, "system"); @@ -256,6 +262,10 @@ fn schema_command_describes_every_category() { assert_eq!(auto["wired"], true); assert!(auto["kind"]["choices"].as_array().unwrap().iter().any(|c| c["value"] == "smoothScroll")); + // `file.open` restores the saved tabs and views with this on (#928) + let restore = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.restoreOpenSequences").unwrap(); + assert_eq!((restore["wired"].clone(), restore["value"].clone()), (json!(true), json!(true))); + // This option is stored, but Insert/Overwrite does not yet implement focus transfer. let focus = tl["fields"].as_array().unwrap().iter().find(|f| f["key"] == "timeline.focusTimelineOnEdit").unwrap(); assert_eq!(focus["kind"], json!({"type": "bool"})); @@ -675,3 +685,145 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() { } assert_eq!(seen, ["auto", "light", "dark", "auto"]); } + +fn preset_json(name: &str, n: usize, color: &str) -> Vec { + let labels: Vec = (0..n).map(|i| json!({"name": format!(" Label {i} "), "color": color})).collect(); + serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap() +} + +/// Label color preset files are checked strictly: size, exactly 16 labels, names trimmed and +/// capped, colours `#rrggbb`. A bad file is an error, never a panic or a partial preset. +#[test] +fn label_presets_reject_bad_files() { + use settings::LabelPreset; + let p = LabelPreset::parse(&preset_json(" Team ", 16, "#A0B0C0")).unwrap(); + assert_eq!(p.name, "Team"); + assert_eq!((p.labels[0].name.as_str(), p.labels[0].color.as_str()), ("Label 0", "#a0b0c0")); + let builtin = LabelPreset::builtin(); + assert_eq!(builtin.name, "Default"); + assert_eq!(builtin.labels.len(), 16); + assert!(builtin.matches(&settings::LabelPrefs::default()), "the built-in preset is today's labels"); + assert_eq!(LabelPreset::parse(&serde_json::to_vec(&builtin).unwrap()).unwrap(), builtin); + + let long = "x".repeat(65); + let mut too_big = preset_json("Big", 16, "#000000"); + too_big.extend(std::iter::repeat_n(b' ', 64 * 1024)); + for (why, bytes) in [ + ("15 labels", preset_json("A", 15, "#000000")), + ("17 labels", preset_json("A", 17, "#000000")), + ("no #", preset_json("A", 16, "000000")), + ("not hex", preset_json("A", 16, "#12345g")), + ("a colour name", preset_json("A", 16, "red")), + ("short", preset_json("A", 16, "#fff")), + ("empty name", preset_json(" ", 16, "#000000")), + ("long name", preset_json(&long, 16, "#000000")), + ( + "long label", + serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": long, "color": "#000000"})).collect::>()})).unwrap(), + ), + ("no colour", serde_json::to_vec(&json!({"name": "A", "labels": (0..16).map(|_| json!({"name": "a"})).collect::>()})).unwrap()), + ("not an object", b"[1,2,3]".to_vec()), + ("too big", too_big), + ("not utf-8", vec![0xff, 0xfe, 0x00]), + ] { + assert!(LabelPreset::parse(&bytes).is_err(), "{why} should be refused"); + } + // every truncation of a good file is refused cleanly + let good = preset_json("Team", 16, "#102030"); + for n in 0..good.len() { + assert!(LabelPreset::parse(&good[..n]).is_err()); + } +} + +/// Import copies a preset into `Label Presets` beside the preferences, export writes the current +/// labels named after the file, and delete removes user presets only. A bad file changes nothing. +#[test] +fn label_presets_import_export_and_delete() { + let dir = tmp_dir("label-presets"); + let mut s = Session { prefs_path: Some(dir.join("preferences.json")), ..Session::default() }; + let names = |s: &mut Session| -> Vec { + let v = s.execute("labels.presets", json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() + }; + assert_eq!(names(&mut s), ["Default", "Bright", "Roles"]); + let src = dir.join("from someone.json"); + std::fs::write(&src, preset_json("Team", 16, "#102030")).unwrap(); + let r = s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap(); + assert_eq!(r["name"], "Team"); + assert!(dir.join("Label Presets").join("Team.json").is_file()); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "Team"]); + + // a bad file: an error, and the folder is as it was + let bad = dir.join("bad.json"); + std::fs::write(&bad, preset_json("Bad", 3, "#102030")).unwrap(); + assert!(s.execute("labels.importPreset", json!({"path": bad.to_string_lossy()})).is_err()); + std::fs::write(dir.join("Label Presets").join("junk.json"), b"{not json").unwrap(); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "Team"], "unreadable files in the folder are skipped"); + + // a preset named like a built-in one keeps both + std::fs::write(&src, preset_json("default", 16, "#405060")).unwrap(); + assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "default (imported)"); + std::fs::write(&src, preset_json("ROLES", 16, "#405060")).unwrap(); + assert_eq!(s.execute("labels.importPreset", json!({"path": src.to_string_lossy()})).unwrap()["name"], "ROLES (imported)"); + + // export writes the applied labels, named after the file, and imports back + set(&mut s, "labels.colors.rose.color", json!("#123456")); + let out = dir.join("Our Labels.json"); + assert_eq!(s.execute("labels.exportPreset", json!({"path": out.to_string_lossy()})).unwrap()["name"], "Our Labels"); + let back = settings::LabelPreset::read(&out).unwrap(); + assert!(back.matches(&s.prefs.labels)); + + for builtin in ["Default", "Bright", "Roles"] { + assert!(s.execute("labels.deletePreset", json!({"name": builtin})).is_err(), "{builtin} stays"); + } + s.execute("labels.deletePreset", json!({"name": "Team"})).unwrap(); + assert_eq!(names(&mut s), ["Default", "Bright", "Roles", "default (imported)", "ROLES (imported)"]); + assert!(s.execute("labels.deletePreset", json!({"name": "Team"})).is_err()); +} + +/// The built-in presets: Default (the labels as shipped), Bright and Roles, each with 16 distinct +/// names and valid colours (Bright's and Roles' clearly different); Roles puts the label defaults +/// on matching roles. +#[test] +fn builtin_label_presets_are_complete_and_distinct() { + use settings::LabelPreset; + let all = LabelPreset::builtins(); + assert_eq!(all.iter().map(|p| p.name.as_str()).collect::>(), ["Default", "Bright", "Roles"]); + for p in &all { + assert!(settings::is_builtin_label_preset(&p.name)); + assert_eq!(&LabelPreset::parse(&serde_json::to_vec(p).unwrap()).unwrap(), p, "{} is a valid preset file", p.name); + let mut names: Vec<&str> = p.labels.iter().map(|l| l.name.as_str()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), 16, "{}: names are unique", p.name); + let rgb: Vec<[u8; 3]> = p.labels.iter().map(|l| settings::parse_hex(&l.color).unwrap()).collect(); + if p.name == "Default" { + continue; // the colours labels have always had + } + for (i, a) in rgb.iter().enumerate() { + for b in &rgb[i + 1..] { + let d: f32 = a.iter().zip(b).map(|(x, y)| (f32::from(*x) - f32::from(*y)).powi(2)).sum::().sqrt(); + assert!(d > 30.0, "{}: {a:?} and {b:?} are too close", p.name); + } + } + } + let roles = &all[2]; + let mut labels = settings::LabelPrefs::default(); + roles.apply(&mut labels); + assert!(roles.matches(&labels)); + let d = labels.defaults.clone(); + let named = [ + (d.video, "B-Roll"), + (d.movie, "Footage"), + (d.audio, "Dialogue"), + (d.still, "Graphics"), + (d.sequence, "Sequence"), + (d.dynamic_link, "Visual Effects"), + (d.bin, "Bin"), + (d.captions, "Bin"), + ]; + for (label, role) in named { + assert_eq!(labels.name(label), role); + } + assert!(!settings::is_builtin_label_preset("Team")); +} diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 0c9e47718..b24362989 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -737,7 +737,7 @@ impl Shortcuts { let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; let before = self.snapshot(); if !add { - self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context())); } let mut reassigned = Vec::new(); if !keep_conflicts { @@ -768,9 +768,19 @@ impl Shortcuts { }; let before = self.snapshot(); let n0 = self.bindings.len(); + let twins = shortcut_twins(command); self.bindings.retain(|b| { - let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); - !hit + let cmd = same_shortcut_command(&b.command, command); + let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)); + // File ▸ Close and Close Sequence share Cmd+W (app + Timeline). The editor's Clear + // always sends panel Application; treating that as every context for this pair is + // what actually frees the chord (#773). + let panel_ok = match &panel { + None => true, + Some(p) if p.is_none() && twins => true, + Some(p) => &b.panel == p, + }; + !(cmd && key_ok && panel_ok) }); let n = n0 - self.bindings.len(); if n > 0 { @@ -909,12 +919,12 @@ impl Shortcuts { // ---------------------------------------------------------------- queries pub fn for_command(&self, id: &str) -> Vec<&Binding> { - self.bindings.iter().filter(|b| b.command == id).collect() + self.bindings.iter().filter(|b| same_shortcut_command(&b.command, id)).collect() } /// The shortcut menus show: the first application-wide one. pub fn primary(&self, id: &str) -> Option { - self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + self.bindings.iter().find(|b| same_shortcut_command(&b.command, id) && b.panel.is_none()).map(|b| b.keys.clone()) } /// The command a chord runs with `panel` focused (panel shortcuts first). @@ -998,6 +1008,15 @@ fn panel_opt(p: &str) -> Option<&str> { if p.is_empty() { None } else { Some(p) } } +/// `file.close` is File ▸ Close: the same action as `sequence.close` (Close Sequence). +fn shortcut_twins(id: &str) -> bool { + id == "file.close" || id == "sequence.close" +} + +fn same_shortcut_command(bound: &str, target: &str) -> bool { + bound == target || (shortcut_twins(bound) && shortcut_twins(target)) +} + fn check_panel(panel: Option<&str>) -> std::result::Result, String> { match panel { None => Ok(None), diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 9ffd35f4d..c589086d8 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -113,6 +113,26 @@ fn assign_reassigns_conflicts_and_undoes() { assert!(s.shortcuts.for_command("edit.undo").is_empty()); } +#[test] +fn clear_close_sequence_also_clears_file_close() { + // #773: File ▸ Close (`file.close`, Cmd+W) aliases Close Sequence (`sequence.close`). + // The editor's Clear sends panel Application against `sequence.close`, which had only a + // Timeline binding — Cmd+W stayed on `file.close`. + let mut s = Session::default(); + assert_eq!(s.shortcuts.primary("file.close").as_deref(), Some("Cmd+W")); + assert_eq!(s.shortcuts.primary("sequence.close").as_deref(), Some("Cmd+W")); + assert!(s.shortcuts.for_command("sequence.close").iter().any(|b| b.keys == "Cmd+W")); + let r = s.execute("shortcuts.clear", json!({"command": "sequence.close", "panel": "Application"})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 1, "{r}"); + assert!(s.shortcuts.for_command("sequence.close").is_empty(), "{:?}", s.shortcuts.for_command("sequence.close")); + assert!(s.shortcuts.primary("file.close").is_none()); + let w = Chord::parse("Cmd+W").unwrap(); + assert!(s.shortcuts.resolve(&w, None, Platform::current()).is_none()); + assert!(s.shortcuts.resolve(&w, Some("Timeline"), Platform::current()).is_none()); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+W", "add": true})).unwrap(); + assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit"); +} + #[test] fn panel_shortcuts_override_application_ones_with_focus() { let s = Session::default(); diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index 2e7ec7455..af97205c5 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -274,7 +274,7 @@ fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() } fn color(p: &Value, id: &str) -> Result> { - text(p, "color", id)?.map(|t| Label::from_name(t).ok_or_else(|| bad(id, "unknown marker color"))).transpose() + crate::commands::marker_color_p(p, id) } fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result { let Some(v) = p.get("durationFrames") else { return Ok(Tick::ZERO) }; diff --git a/crates/engine/src/source_placement_tests.rs b/crates/engine/src/source_placement_tests.rs index 0fb56556c..8e85f7f2f 100644 --- a/crates/engine/src/source_placement_tests.rs +++ b/crates/engine/src/source_placement_tests.rs @@ -1,5 +1,5 @@ use super::*; -use filmcraft_project::ItemId; +use filmcraft_project::{ItemId, ItemKind}; use filmcraft_time::{Tick, TimeRange}; use serde_json::json; @@ -83,6 +83,107 @@ fn audio_only_source_drops_only_sound() { assert_eq!(s.active_sequence().unwrap().audio_tracks[1].items.len(), 1); } +fn subclip_session(video: bool, audio: bool) -> (Session, ItemId) { + let mut s = Session::default(); + s.execute("file.newSequence", json!({"name":"Subclip test","fps":24})).unwrap(); + let parent = + s.execute("file.newOfflineFile", json!({"name":"Parent","video":video,"audio":audio,"fps":24,"seconds":10})).unwrap()["item"].as_u64().unwrap(); + let sub = s.execute("clip.makeSubclip", json!({"item":parent,"startFrame":10,"endFrame":60})).unwrap()["item"].as_u64().unwrap(); + (s, ItemId(sub)) +} + +fn assert_subclip_placement(s: &Session, item: ItemId, expected: (usize, usize)) { + assert_eq!(counts(s), expected); + let q = s.active_sequence().unwrap(); + let ItemKind::Subclip { range, .. } = &s.project.item(item).unwrap().kind else { panic!("expected a subclip") }; + for clip in q.all_tracks().flat_map(|t| t.items.iter()) { + assert_eq!(clip.item, item); + assert_eq!(clip.source_in, range.start); + assert_eq!(clip.duration, q.settings.frame_rate.snap_nearest(range.duration)); + } + if expected == (1, 1) { + let video = &q.video_tracks[0].items[0]; + let audio = &q.audio_tracks[0].items[0]; + assert!(video.link.is_some()); + assert_eq!(video.link, audio.link); + } +} + +#[test] +fn subclip_placement_keeps_parent_streams_range_and_history() { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + let before = s.project.to_json(); + s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0,"video":true,"audio":true})).unwrap(); + assert_subclip_placement(&s, sub, expected); + let placed = s.project.to_json(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), placed); + s.project = std::sync::Arc::new(Project::from_json(&placed).unwrap()); + assert_subclip_placement(&s, sub, expected); + } +} + +#[test] +fn subclip_audio_can_be_placed_on_an_audio_track() { + for video in [false, true] { + let (mut s, sub) = subclip_session(video, true); + s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).unwrap(); + assert_subclip_placement(&s, sub, (0, 1)); + } + let (mut s, sub) = subclip_session(true, false); + let before = s.project.to_json(); + assert!(s.execute("timeline.place", json!({"item":sub.0,"track":"A1","time":0,"video":false,"audio":true})).is_err()); + assert_eq!(s.project.to_json(), before); +} + +#[test] +fn source_insert_and_overwrite_keep_subclip_sound() { + for command in ["source.insert", "source.overwrite"] { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + s.execute("source.open", json!({"item":sub.0})).unwrap(); + s.execute(command, json!({})).unwrap(); + assert_subclip_placement(&s, sub, expected); + } + } +} + +#[test] +fn subclip_placement_can_add_tracks_for_parent_streams() { + for (video, audio, expected) in [(true, true, (1, 1)), (false, true, (0, 1)), (true, false, (1, 0))] { + let (mut s, sub) = subclip_session(video, audio); + let before = s.project.to_json(); + s.execute("timeline.place", json!({"item":sub.0,"track":"new","audioTrack":"new","time":0})).unwrap(); + assert_eq!(counts(&s), expected); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.to_json(), before); + } +} + +#[test] +fn subclip_placement_keeps_parent_channel_map_and_all_audio_streams() { + let (mut s, sub) = subclip_session(true, true); + let parent = s.project.resolve_media(sub).unwrap().0; + let m = std::sync::Arc::make_mut(&mut s.project).item_mut(parent).unwrap().as_media_mut().unwrap(); + m.info.audio_streams.push(m.info.audio_streams[0].clone()); + m.interpret.audio_channels = Some(filmcraft_project::AudioChannelMap { format: filmcraft_project::AudioChannels::Mono, clips: vec![vec![1], vec![0]] }); + s.execute("timeline.place", json!({"item":sub.0,"track":"V1","audioTrack":"A1","time":0})).unwrap(); + assert_eq!(counts(&s), (1, 3)); + let q = s.active_sequence().unwrap(); + let link = q.video_tracks[0].items[0].link; + assert!(link.is_some()); + for (index, (stream, channels)) in [(0, vec![1]), (0, vec![0]), (1, vec![])].into_iter().enumerate() { + let clip = &q.audio_tracks[index].items[0]; + assert_eq!(clip.item, sub); + assert_eq!(clip.audio_stream, stream); + assert_eq!(clip.source_channels, channels); + assert_eq!(clip.link, link); + } +} + #[test] fn malformed_source_spans_are_bounded() { let s = setup(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d7..fe3e35133 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -381,6 +381,59 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() { assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2); } +/// A keyframe set with a `time` outside the clip stays at that time (#1055), before the start as +/// after the end: a ramp that spans a cut shows its true slice, and a clip-end keyframe is not +/// overwritten by a later one. Only the playhead fallback is pinned to the clip. +#[test] +fn set_param_keeps_a_keyframe_outside_the_clip_at_its_time() { + let mut s = demo(); + let it = s.active_sequence().unwrap().video_tracks[0].items[1].clone(); + let (c, start, end) = (it.id.0, it.start, it.end()); + let opacity = |s: &Session| { + let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(c)).unwrap().1; + let p = &it.effect("opacity").unwrap().params["opacity"]; + (p.keyframes.iter().map(|k| (k.time, k.value.as_f64().unwrap())).collect::>(), it.clone()) + }; + let set = |value: f64, time: Tick| json!({"clip": c, "effect": "opacity", "param": "opacity", "value": value, "time": time.0, "keyframe": true}); + let len = end - start; + // Case A: a keyframe exactly at the clip end, then one past it, gives three keyframes + s.execute("effects.setParam", set(100.0, start)).unwrap(); + s.execute("effects.setParam", set(100.0, end)).unwrap(); + let r = s.execute("effects.setParam", set(20.0, end + len)).unwrap(); + assert_eq!(r["keyframes"], 3, "{r}"); + let (keys, it) = opacity(&s); + assert_eq!(keys, vec![(it.source_time_at(start), 100.0), (it.source_time_at(end), 100.0), (it.source_time_at(end + len), 20.0)]); + // Case B: a ramp past the end is not compressed; halfway through the clip is a quarter of the way down + s.undo(); + s.undo(); + let r = s.execute("effects.setParam", set(0.0, end + len)).unwrap(); + assert_eq!(r["keyframes"], 2, "{r}"); + let (_, it) = opacity(&s); + let p = it.effect("opacity").unwrap().params["opacity"].clone(); + let mid = start + Tick(len.0 / 2); + assert!((p.f64_at(it.source_time_at(mid)) - 75.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(mid))); + // before the start the same holds: the keyframe keeps its time and the ramp its slope + s.undo(); + s.undo(); + s.execute("effects.setParam", set(0.0, start - len)).unwrap(); + s.execute("effects.setParam", set(100.0, end)).unwrap(); + let (keys, it) = opacity(&s); + assert_eq!(keys, vec![(it.source_time_at(start - len), 0.0), (it.source_time_at(end), 100.0)]); + let p = it.effect("opacity").unwrap().params["opacity"].clone(); + assert!((p.f64_at(it.source_time_at(start)) - 50.0).abs() < 0.5, "{}", p.f64_at(it.source_time_at(start))); + // hostile times saturate instead of overflowing + for t in [i64::MIN, i64::MAX] { + s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 50.0, "time": t})).unwrap(); + } + // with no `time`, a playhead past the clip still lands on its last frame + s.undo(); + s.undo(); + s.execute("playhead.set", json!({"time": (end + len).0})).unwrap(); + s.execute("effects.setParam", json!({"clip": c, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + let (keys, it) = opacity(&s); + assert!(keys.contains(&(it.source_time_at(end - Tick(1)), 40.0)), "{keys:?}"); +} + /// A choice parameter takes its option name as well as its index, as transition parameters do /// (`effects.list {"detail": true}` lists the names). #[test] @@ -817,6 +870,81 @@ fn dragging_an_effect_parameter_is_one_undo_step() { assert_eq!(opacity(&s), start, "and then the whole first one"); } +/// #639: a drag that changes several parameters (the anchor point and the position) shares one +/// undo step when its changes carry the same `merge` key. +#[test] +fn a_merge_key_folds_several_parameters_into_one_undo_step() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let motion = |s: &Session| { + let it = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.clone(); + let e = it.effect("motion").unwrap().clone(); + (e.params["anchor"].value.clone(), e.params["position"].value.clone()) + }; + let start = motion(&s); + let undo = s.history.undo.len(); + for (i, d) in [10.0, 20.0, 30.0].into_iter().enumerate() { + for param in ["anchor", "position"] { + let p = json!({"clip": id, "effect": "motion", "param": param, "value": [500.0 + d, 300.0 + d], "merge": "programTransform", "begin": i == 0 && param == "anchor"}); + s.execute("effects.setParam", p).unwrap(); + } + } + assert_eq!(s.history.undo.len(), undo + 1); + assert_eq!(motion(&s).1, filmcraft_project::ParamValue::Vec2(filmcraft_geom::Vec2::new(530.0, 330.0))); + s.undo(); + assert_eq!(motion(&s), start); +} + +/// #639: `effects.select` names the effect whose handles the Program monitor shows. It and the +/// mask selection replace each other; undo or removal that takes the effect away clears it. +#[test] +fn selecting_an_effect_replaces_the_mask_selection() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let motion_sel = Some(EffectSel { clip: ClipId(id), effect: "motion".into(), instance: 0 }); + s.execute("effects.select", json!({"clip": id, "effect": "motion"})).unwrap(); + assert_eq!(s.state.selected_effect, motion_sel); + // applying an effect puts it first; Motion stays the selected effect + s.execute("effects.apply", json!({"clips": [id], "effect": "gaussian_blur"})).unwrap(); + assert_eq!(s.state.selected_effect, motion_sel); + s.execute("effects.remove", json!({"clip": id, "index": 0})).unwrap(); + assert_eq!(s.state.selected_effect, motion_sel, "and when one before it is removed"); + let motion = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.effects.iter().position(|e| e.effect == "motion").unwrap(); + s.execute("masks.add", json!({"clip": id, "shape": "ellipse"})).unwrap(); + assert!(s.state.selected_effect.is_none(), "a new mask is selected instead"); + s.execute("effects.select", json!({"clip": id, "effect": motion})).unwrap(); + assert!(s.state.selected_mask.is_none()); + s.execute("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0})).unwrap(); + assert!(s.state.selected_effect.is_none()); + s.execute("effects.select", json!({"none": true})).unwrap(); + assert!(s.state.selected_effect.is_none()); + // an applied effect that undo takes away, and one removed + s.execute("effects.apply", json!({"clips": [id], "effect": "transform"})).unwrap(); + s.execute("effects.select", json!({"clip": id, "effect": "transform"})).unwrap(); + assert!(s.state.selected_effect.is_some()); + s.undo(); + assert!(s.state.selected_effect.is_none(), "selection follows undo"); + s.redo(); + s.execute("effects.select", json!({"clip": id, "effect": "transform"})).unwrap(); + let effects = s.active_sequence().unwrap().find_item(ClipId(id)).unwrap().1.effects.clone(); + let index = s.state.selected_effect.as_ref().unwrap().index(&effects).unwrap(); + s.execute("effects.remove", json!({"clip": id, "index": index})).unwrap(); + assert!(s.state.selected_effect.is_none()); + // hostile parameters are errors, not panics + for p in [ + json!({}), + json!({"clip": id}), + json!({"clip": id, "effect": 9999}), + json!({"clip": id, "effect": -1}), + json!({"clip": id, "effect": u64::MAX}), + json!({"clip": id, "effect": "no_such_effect"}), + json!({"clip": u64::MAX, "effect": 0}), + json!({"clip": "x", "effect": 0}), + ] { + assert!(s.execute("effects.select", p.clone()).is_err(), "{p}"); + } +} + /// The demo's first video transition between two clips: (id, start, duration, cut). fn demo_crossing(s: &Session) -> (u64, Tick, Tick, Tick) { let q = s.active_sequence().unwrap(); @@ -958,3 +1086,78 @@ fn enable_flips_each_selected_clip() { s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } + +/// #580: Escape takes back what a gesture changed, merged or not: the project returns to the mark, +/// neither undo nor redo keeps the changes, and older steps stay. +#[test] +fn reverting_to_a_mark_takes_back_a_gesture_without_redo() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let opacity = |s: &Session| { + let q = s.active_sequence().unwrap(); + let it = q.find_item(ClipId(id)).unwrap().1; + it.effects.iter().find(|e| e.effect == "opacity").unwrap().params["opacity"].value.as_f64().unwrap() + }; + let set = |s: &mut Session, v: f64, merge: bool, begin: bool| { + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": v, "merge": merge, "begin": begin})).unwrap(); + }; + set(&mut s, 80.0, false, false); // an earlier edit, kept + let (start, undo, mark) = (opacity(&s), s.history.undo.len(), s.undo_mark()); + set(&mut s, 90.0, true, true); // a merged drag… + set(&mut s, 70.0, true, false); + set(&mut s, 60.0, false, false); // …and changes made one step each + set(&mut s, 50.0, false, false); + assert_eq!(s.revert_to(mark), 3); + assert_eq!(opacity(&s), start, "the value at the mark"); + assert_eq!(s.history.undo.len(), undo, "the earlier step stays"); + assert!(s.history.redo.is_empty(), "nothing to redo"); + assert_eq!(s.revert_to(mark), 0, "nothing left to take back"); + s.undo(); + assert_eq!(opacity(&s), 100.0, "undo still takes back the earlier edit"); +} + +/// A mark no longer in the history does nothing; an older mark still takes back what followed it. +#[test] +fn reverting_to_a_lost_mark_does_nothing() { + let mut s = demo_unlocked(); + let id = v1(&s)[0].0; + let (base, start) = (s.history.undo.len(), s.undo_mark()); + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": 40.0})).unwrap(); + let mark = s.undo_mark(); + s.undo(); + s.execute("effects.setParam", json!({"clip": id, "effect": "opacity", "param": "opacity", "value": 30.0})).unwrap(); + assert_eq!(s.revert_to(mark), 0, "the marked step was undone and replaced"); + assert_eq!(s.revert_to(start), 1); + assert_eq!(s.history.undo.len(), base); +} + +/// Times before sequence start are unreachable in the GUI and cut off on export. +#[test] +fn negative_place_and_marker_times_are_refused() { + let mut s = demo(); + let item = s.active_sequence().unwrap().video_tracks[0].items[0].item; + let before = s.project.clone(); + let n_markers = s.active_sequence().unwrap().markers.len(); + let n_clips: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + for (cmd, params) in [ + ("timeline.place", json!({"item": item.0, "frame": -12})), + ("timeline.place", json!({"item": item.0, "seconds": -1.0})), + ("timeline.place", json!({"item": item.0, "time": -1})), + ("markers.add", json!({"frame": -10})), + ("markers.add", json!({"seconds": -0.5})), + ("markers.add", json!({"time": i64::MIN})), + ("markers.addChapter", json!({"frame": -1})), + ("markers.addRange", json!({"time": -1, "durationFrames": 10})), + ("markers.addFlashCue", json!({"frame": -3})), + ] { + let e = s.execute(cmd, params.clone()).expect_err(&format!("{cmd} {params}")); + assert!(matches!(&e, EngineError::BadParams { cmd: c, msg } if c == cmd && msg.contains("negative")), "{cmd} {params}: {e:?}"); + assert_eq!(*s.project, *before); + } + assert_eq!(s.active_sequence().unwrap().markers.len(), n_markers); + let n_clips_after: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + assert_eq!(n_clips_after, n_clips); + s.execute("timeline.place", json!({"item": item.0, "frame": 0})).unwrap(); + s.execute("markers.add", json!({"frame": 0, "name": "start"})).unwrap(); + assert!(s.active_sequence().unwrap().markers.iter().any(|m| m.start == Tick::ZERO && m.name == "start")); +} diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index 6f176b814..565edd55b 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -100,6 +100,23 @@ fn a_linked_partner_on_a_locked_track_stays_and_the_listed_clip_still_moves() { assert_eq!(*s.project, *before); } +/// Moving a clip off a locked track used to leave it there and place another item with the same +/// clip id on the destination (#917). The source is locked, so the move is refused. +#[test] +fn a_clip_on_a_locked_track_cannot_move_to_another_track() { + let mut s = demo(); + let (v_track, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + let before = s.project.clone(); + let e = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": v.start.0}], "linked": false})).unwrap_err(); + assert!(matches!(&e, EngineError::Edit(filmcraft_edit::EditError::Locked)), "{e:?}"); + assert_eq!(*s.project, *before); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(v.id).map(|(t, _)| t), Some(v_track)); + assert_eq!(q.video_tracks[1].items.len(), 0); + q.check().unwrap(); +} + /// A partner that would land where it already is does not move: re-placing it would drop the /// transitions at its edges and list it in `moved` for nothing. Moving only the picture to /// another track at the same time leaves the sound as it was. diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index 951a873c8..f9bb63ec3 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -171,7 +171,9 @@ 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 n0 = s.history.undo.len(); let applied = trim_all_raw(s, &|_, _| Some(delta))?; + crate::clip_ops::collapse_history(s, n0, "Trim by Frames"); let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -208,7 +210,7 @@ fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Op let r = match ep.kind { TrimKind::Roll => { let Some((l, r)) = roll_pair(seq, ep) else { continue }; - s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0}))? + s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0, "followLinks": false}))? } k => s.execute( "timeline.trim", @@ -230,7 +232,9 @@ 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 n0 = s.history.undo.len(); let applied = trim_all_raw(s, &|seq, e| edit_time(seq, e).map(|t| ph - t))?; + crate::clip_ops::collapse_history(s, n0, "Extend Edit to Playhead"); let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); add_shift(s, first); Ok(json!({"applied": applied})) @@ -343,6 +347,7 @@ pub fn monitor_info(s: &Session) -> Value { /// default video/audio transition at every selected edit point. pub fn apply_default_transitions(s: &mut Session) -> Result { let pts = s.state.edit_points.clone(); + let n0 = s.history.undo.len(); let mut ids = Vec::new(); for ep in &pts { let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; @@ -356,6 +361,7 @@ pub fn apply_default_transitions(s: &mut Session) -> Result { if ids.is_empty() { return Err(EngineError::Other("no transition could be applied at the selected edit points".into())); } + crate::clip_ops::collapse_history(s, n0, "Apply Default Transitions"); Ok(json!({"transitions": ids})) } diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs index b003e48dd..cb3d32ccd 100644 --- a/crates/engine/src/trim_tests.rs +++ b/crates/engine/src/trim_tests.rs @@ -237,6 +237,34 @@ fn extend_to_playhead_moves_each_unequal_out_point_to_the_playhead() { assert_eq!(q.find_item(b).unwrap().1.end(), ph); } +/// #914: Trim by Frames on two selected edit points is one undo (and redo) step. +#[test] +fn multipoint_trim_by_frames_is_one_undo_step() { + let mut s = demo(); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let c = v1(&s); + let (a, b) = (c[0], c[1]); + 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(); + let undo0 = s.history.undo.len(); + let d = s.sequence_rate().tick_of(-1); + s.execute("trim.nudge", json!({"frames": -1})).unwrap(); + let q = s.active_sequence().unwrap(); + let a1 = q.find_item(ClipId(a.0)).unwrap().1.end(); + let b1 = q.find_item(ClipId(b.0)).unwrap().1.end(); + assert_eq!(a1, a.2 + d); + assert_eq!(b1, b.2 + d); + assert_eq!(s.history.undo.len(), undo0 + 1, "one undo step for both clips"); + s.execute("edit.undo", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(ClipId(a.0)).unwrap().1.end(), a.2); + assert_eq!(q.find_item(ClipId(b.0)).unwrap().1.end(), b.2); + s.execute("edit.redo", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + assert_eq!(q.find_item(ClipId(a.0)).unwrap().1.end(), a1); + assert_eq!(q.find_item(ClipId(b.0)).unwrap().1.end(), b1); +} + /// #653: the delta the Timeline previews while an edge is dragged is the one `timeline.trim` /// applies on release: dragging the last clip's Out edge far past the end of its media stops at /// the media's end instead of following the pointer. diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index d058e4e60..3732c7fb3 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -25,6 +25,7 @@ filmcraft-dnx = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-mxf = { workspace = true } filmcraft-aac = { workspace = true } +filmcraft-flac = { workspace = true } filmcraft-h264enc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/export/src/audio_out.rs b/crates/export/src/audio_out.rs index 5fd2b90c0..21ab56a89 100644 --- a/crates/export/src/audio_out.rs +++ b/crates/export/src/audio_out.rs @@ -1,6 +1,6 @@ //! The export audio path: the sequence mix at the output sample rate, folded to the output //! channel count, with Export ▸ Effects ▸ Loudness Normalization (a BS.1770-4 measuring pass over -//! the export range, then a linear gain and a look-ahead true-peak limiter). +//! the export range, then bounded gain refinement through a look-ahead true-peak limiter). //! //! The limiter's look-ahead delays its output; [`AudioOut`] mixes that many samples ahead (silence //! past the end of the range) and drops the first ones, so the exported audio stays aligned with @@ -20,6 +20,7 @@ use crate::{ExportError, ExportSettings, Result}; #[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)] pub struct LoudnessReport { pub measured_lufs: f64, + /// Applied gain before the limiter, including bounded loudness correction. pub gain_db: f64, } @@ -84,11 +85,62 @@ impl AudioOut { } let measured = meter.integrated(); let gain_db = filmcraft_audio_dsp::normalize_gain_db(measured, ln.target_lufs); - self.gain = 10f64.powf(gain_db / 20.0) as f32; - self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db }); + // Limiting changes integrated loudness. Measure the actual limited signal and + // refine its INPUT gain; the peak ceiling remains in force on every pass. + // Four trials and at most 9 dB of extra drive bound both work and compression. + let start = self.out; + let mut trial_gain = gain_db; + let mut best_gain = gain_db; + let mut best_error = f64::INFINITY; + let mut previous = None; + if measured.is_finite() && ln.target_lufs.is_finite() { + for _ in 0..4 { + self.rewind(start, trial_gain); + let mut limited = filmcraft_audio_dsp::LoudnessMeter::new(self.sr as f64, self.channels); + while self.out < self.end { + if cancel() { + self.rewind(start, best_gain); + return Err(ExportError::Cancelled); + } + let until = self.out.saturating_add(self.sr as i64).min(self.end); + if let Some(buf) = self.pull(until, sources) { + let refs: Vec<&[f32]> = buf.iter().map(Vec::as_slice).collect(); + limited.process(&refs); + } + } + let achieved = limited.integrated(); + if !achieved.is_finite() { + break; + } + let error = ln.target_lufs - achieved; + if error.abs() < best_error { + best_error = error.abs(); + best_gain = trial_gain; + } + // Stop on convergence or a ceiling-limited plateau. An impossible target + // must not cause unbounded passes or ever bypass the limiter. + if error.abs() <= 0.1 || previous.is_some_and(|last: f64| (achieved - last).abs() < 0.02) { + break; + } + previous = Some(achieved); + trial_gain += error.clamp(-3.0, 3.0); + } + } + self.rewind(start, best_gain); + self.loudness = Some(LoudnessReport { measured_lufs: measured, gain_db: best_gain }); Ok(()) } + /// Restore the output cursor and limiter history between measuring passes and delivery. + fn rewind(&mut self, start: i64, gain_db: f64) { + self.out = start; + self.next_in = start; + self.gain = 10f64.powf(gain_db / 20.0) as f32; + if let Some(limiter) = self.limiter.as_mut() { + limiter.reset(); + } + } + /// Mix `n` samples from `pos`, folded to the output channels (silence past the range end). /// Stereo output of a 5.1 Mix is the ITU-R BS.775 downmix; 5.1 output of a stereo Mix places it /// on L and R; 5.1 is in L, R, C, LFE, Ls, Rs order. diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 1154f99ff..5c26ea529 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -260,6 +260,7 @@ impl Exporter { audio_factories().read().unwrap_or_else(|e| e.into_inner()).iter().find_map(|f| f(settings.format, a.sr, a.channels as u32, &settings)); Some(found.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??) } + (Some(a), AudioCodec::Flac) => Some(Box::new(crate::FlacAudio::new(a.sr, a.channels as u32, &settings)?)), _ => None, }; let measured = !settings.effects.loudness.enabled || audio.is_none(); @@ -487,11 +488,12 @@ impl Exporter { self.write_video(tail)?; self.write_audio(mixed)?; if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) { - let fs = a.frame_size(); let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?; - for au in a.flush()? { - mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true }) - .map_err(|e| ExportError::Io(e.to_string()))?; + for (au, duration) in a.flush_timed()? { + mux.write_sample(at, WriteSample { data: &au, duration, composition_offset: 0, is_sync: true }).map_err(|e| ExportError::Io(e.to_string()))?; + } + if let Some(entry) = a.final_sample_entry() { + mux.set_sample_entry(at, entry).map_err(|e| ExportError::Io(e.to_string()))?; } } let (bytes, extra_files) = match self.mxf.take() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index d1a986fa8..66905c991 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -121,6 +121,9 @@ pub enum Format { /// the path ends in `.aac`. #[serde(rename = "aac", alias = "Aac")] Aac, + /// FLAC (RFC 9639): lossless compressed audio. + #[serde(rename = "flac", alias = "Flac")] + Flac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -154,6 +157,7 @@ impl Format { "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, + "flac" | "fla" => Format::Flac, _ => return None, }) } @@ -178,6 +182,7 @@ impl Format { Format::Wav => "wav", Format::Aiff => "aiff", Format::Aac => "aac", + Format::Flac => "flac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -197,6 +202,7 @@ impl Format { Format::Wav => "wav", Format::Aiff => "aif", Format::Aac => "m4a", + Format::Flac => "flac", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -220,6 +226,7 @@ impl Format { Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", Format::Aac => "AAC Audio", + Format::Flac => "FLAC", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } @@ -246,9 +253,9 @@ impl Format { pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } - /// Sound without picture: Waveform Audio, AIFF and AAC Audio. + /// Sound without picture: Waveform Audio, AIFF, AAC Audio and FLAC. pub fn is_audio_only(self) -> bool { - matches!(self, Format::Wav | Format::Aiff | Format::Aac) + matches!(self, Format::Wav | Format::Aiff | Format::Aac | Format::Flac) } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { @@ -262,7 +269,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 20] = [ + pub const ALL: [Format; 21] = [ Format::H264, Format::Hevc, Format::Av1, @@ -281,6 +288,7 @@ impl Format { Format::Wav, Format::Aiff, Format::Aac, + Format::Flac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -410,6 +418,12 @@ impl std::fmt::Debug for OutputSink { } } +/// How an audio-only export codes its samples. +enum AudioWriter { + Pcm(pcm::PcmContainer), + Flac(Box), +} + /// An export's output file: on disk, or in memory for an [`OutputSink`]. enum Out { File(std::io::BufWriter), @@ -738,6 +752,17 @@ pub trait AudioEncoder: Send { fn priming(&self) -> u32; /// Samples per access unit (1024 for AAC). fn frame_size(&self) -> u32; + /// [`AudioEncoder::flush`] with each access unit's duration, for codecs whose last unit is + /// shorter than [`AudioEncoder::frame_size`] (FLAC); by default every unit is a full frame. + fn flush_timed(&mut self) -> Result, u32)>> { + let n = self.frame_size(); + Ok(self.flush()?.into_iter().map(|au| (au, n)).collect()) + } + /// The sample entry once everything is encoded, when it differs from the first one (the FLAC + /// STREAMINFO learns its frame sizes and MD5 at the end). + fn final_sample_entry(&self) -> Option { + None + } } pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option>>; @@ -1080,6 +1105,53 @@ impl AudioEncoder for AacEncoder { } } +/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit. +pub(crate) struct FlacAudio { + enc: filmcraft_flac::Encoder, + bits: u16, + /// Samples of the frame `flush` returns (shorter than a full block). + tail: u32, +} + +impl FlacAudio { + pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result { + let bits = if s.audio.bits >= 24 { 24 } else { 16 }; + let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?; + let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8); + cfg.level = s.audio.flac_level.min(8); + let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?; + Ok(FlacAudio { enc, bits, tail: 0 }) + } +} + +impl AudioEncoder for FlacAudio { + fn sample_entry(&self) -> SampleEntry { + SampleEntry::flac(&self.enc.streaminfo()) + } + fn encode(&mut self, planar: &[Vec]) -> Result>> { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}"))) + } + fn flush(&mut self) -> Result>> { + Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect()) + } + fn flush_timed(&mut self) -> Result, u32)>> { + self.tail = self.enc.pending_samples() as u32; + let last = self.enc.finish(); + Ok(if last.is_empty() { Vec::new() } else { vec![(last, self.tail)] }) + } + fn priming(&self) -> u32 { + 0 + } + fn frame_size(&self) -> u32 { + 4096 + } + fn final_sample_entry(&self) -> Option { + Some(SampleEntry::flac(&self.enc.streaminfo())) + } +} + fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option>> { // AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so // 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused @@ -1676,6 +1748,19 @@ pub fn frame_span(rate: FrameRate, range: TimeRange) -> (i64, i64) { (f0, f1.max(f0)) } +/// The delay of the `i`th GIF frame (from the start of the export) in centiseconds, the unit GIF +/// stores. Each frame runs from its rounded start time to the next frame's, so the delays add up +/// to the exact duration (24 fps: 4, 4, 5, 4, 4, 4 cs …) instead of every frame truncating +/// 4.17 cs to 4 cs and the animation playing 4 % fast (#854). +fn gif_delay_cs(rate: FrameRate, i: i64) -> u16 { + let num = i128::from(rate.num.max(1)); + let den = i128::from(rate.den.max(0)); + // start of frame `k` in centiseconds, rounded half up + let at = |k: i64| i128::from(k.max(0)).saturating_mul(200).saturating_mul(den).saturating_add(num) / num.saturating_mul(2); + let cs = at(i.saturating_add(1)).saturating_sub(at(i)); + u16::try_from(cs.clamp(0, i128::from(u16::MAX))).unwrap_or(u16::MAX) +} + /// An in-memory writer that stays reachable after the encoder that owns a clone is dropped. #[derive(Clone, Default)] struct SharedBuf(Arc>>); @@ -1764,13 +1849,24 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, - Format::Wav | Format::Aiff => { + Format::Wav | Format::Aiff | Format::Flac => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); - let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff }; let frames = a.remaining(); // the sizes are known up front: refuse an AIFF that cannot hold them before measuring - let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?; + let mut writer = match settings.format { + Format::Flac => { + let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 }); + cfg.level = settings.audio.flac_level.min(8); + AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?)) + } + Format::Wav => AudioWriter::Pcm(pcm::PcmContainer::Wav), + _ => AudioWriter::Pcm(pcm::PcmContainer::Aiff), + }; + let head = match &writer { + AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?, + AudioWriter::Flac(enc) => enc.header(), + }; // one second per chunk: memory stays flat however long the range is let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { @@ -1788,13 +1884,30 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so return Err(ExportError::Cancelled); } buf.clear(); - pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf); + match &mut writer { + AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf), + AudioWriter::Flac(enc) => { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?; + } + } out.write_all(&buf).map_err(io)?; if !settings.part_of_batch { progress.done.fetch_add(1, Ordering::Relaxed); } } - out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?; + match &mut writer { + AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?, + AudioWriter::Flac(enc) => { + out.write_all(&enc.finish()).map_err(io)?; + // the frame sizes and MD5 are known now: fill them into STREAMINFO + use std::io::{Seek, SeekFrom}; + out.seek(SeekFrom::Start(filmcraft_flac::STREAMINFO_OFFSET)).map_err(io)?; + out.write_all(&enc.streaminfo()).map_err(io)?; + out.seek(SeekFrom::End(0)).map_err(io)?; + } + } let n = out.finish(settings)?; if settings.part_of_batch { progress.done.store(1, Ordering::Relaxed); @@ -1819,7 +1932,6 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } else { None }; - let delay = image::Delay::from_numer_denom_ms((1000 * pipe.rate.den) as u32, pipe.rate.num as u32); let mut f = f0; while f < f1 { if cancelled() { @@ -1828,8 +1940,9 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect(); - for rgba in frames { + for (fi, rgba) in (f..end).zip(frames) { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?; } } else { diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 03b88a74d..7601886f0 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -13,7 +13,7 @@ pub enum PcmContainer { } /// Quantise one sample to a signed integer of `bits` (16 or 24). -fn quantise(s: f32, bits: u16) -> i32 { +pub(crate) fn quantise(s: f32, bits: u16) -> i32 { let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 }; (s.clamp(-1.0, 1.0) * max).round() as i32 } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 6e6b9ddd8..58f60d6ef 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -41,7 +41,7 @@ fn h264(size: Option<(u32, u32)>, kbps: u32, max_kbps: u32) -> ExportSettings { bitrate_kbps: kbps, max_bitrate_kbps: Some(max_kbps), bitrate_mode: BitrateMode::Vbr1Pass, - audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() }, ..Default::default() } } @@ -53,7 +53,7 @@ fn adaptive(bpp: f32) -> ExportSettings { fn mov(format: Format, profile: &str) -> ExportSettings { let mut s = ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24 }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24, ..Default::default() }, render_at_max_depth: true, ..Default::default() }; @@ -82,7 +82,7 @@ fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings { fn audio_only(format: Format, bits: u16) -> ExportSettings { ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits, ..Default::default() }, ..Default::default() } } @@ -181,10 +181,11 @@ pub fn builtin_presets() -> Vec { "MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps", ExportSettings { format: Format::Aac, - audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() }, ..Default::default() }, ), + p("FLAC 48 kHz 24-bit", "Audio Only", "FLAC, stereo 48 kHz 24-bit, lossless", audio_only(Format::Flac, 24)), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index dee3da932..a59f481e1 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -200,7 +200,8 @@ impl ExrCompression { } } -/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF, FLAC in FLAC. +/// MP4 and QuickTime can also carry FLAC; MXF is always PCM. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -208,6 +209,8 @@ pub enum AudioCodec { Auto, Aac, Pcm, + /// Lossless compression (the FLAC format). + Flac, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -220,13 +223,15 @@ pub struct AudioSettings { pub channels: u32, /// AAC bitrate. pub bitrate_kbps: u32, - /// PCM sample size: 16 or 24. + /// PCM and FLAC sample size: 16 or 24. pub bits: u16, + /// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless. + pub flac_level: u8, } impl Default for AudioSettings { fn default() -> Self { - AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16 } + AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 } } } @@ -471,8 +476,11 @@ impl ExportSettings { /// The audio codec actually used. pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { + (Format::Flac, _) => AudioCodec::Flac, (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, (Format::Aac, _) => AudioCodec::Aac, + // MP4 and QuickTime both carry FLAC (`fLaC` + `dfLa`) + (_, AudioCodec::Flac) => AudioCodec::Flac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -526,7 +534,8 @@ impl ExportSettings { } Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, - Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, + Format::BmpSequence => px * 3.0 * 8.0 * fps, + Format::TiffSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, // 10-bit RGB in one 32-bit word per pixel Format::DpxSequence => px * 4.0 * 8.0 * fps, @@ -536,12 +545,15 @@ impl ExportSettings { px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps * k } Format::Gif => px * 0.6 * 8.0 * fps, - Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, + Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; let audio_bps = if self.has_audio() || self.format.is_audio_only() { + let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }; match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, - _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, + // typical of music and dialogue; noise compresses less, silence far more + AudioCodec::Flac => pcm * 0.6, + _ => pcm, } } else { 0.0 @@ -637,6 +649,12 @@ impl ExportSettings { }; match self.audio_codec() { AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate), + AudioCodec::Flac => format!( + "FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", + if self.audio.bits >= 24 { 24 } else { 16 }, + self.audio.flac_level.min(8), + r.sample_rate + ), _ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate), } } else { diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 53aeaa772..b2ada9813 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -437,6 +437,126 @@ fn aac_export_rejects_unsupported_explicit_sample_rates() { } } +#[test] +fn flac_is_lossless_against_wav() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { channels: 2, bits: 24, ..Default::default() }; + let (wav, flac) = (dir.path("a.wav"), dir.path("a.flac")); + let w = ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }; + export(&p, seq, &w, &m, &Progress::default()).unwrap(); + let f = ExportSettings { format: Format::Flac, path: flac.clone(), range, audio, ..Default::default() }; + let prog = Progress::default(); + let r = export(&p, seq, &f, &m, &prog).unwrap(); + assert_eq!(r.frames, 72_000, "1.5 s at 48 kHz"); + // one progress step per second of audio + assert_eq!((prog.total.load(Ordering::Relaxed), prog.done.load(Ordering::Relaxed)), (2, 2)); + let (wb, fb) = (std::fs::read(&wav).unwrap(), std::fs::read(&flac).unwrap()); + assert_eq!(&fb[..4], b"fLaC"); + assert!(fb.len() * 2 < wb.len(), "a tone compresses: FLAC {} bytes, WAV {}", fb.len(), wb.len()); + // STREAMINFO was filled in at the end: total samples + let si = &fb[8..42]; + assert_eq!((u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes(si[14..18].try_into().unwrap())), 72_000); + + // the same samples through FilmCraft's own readers + let decode = |name: &str, bytes: Vec| filmcraft_codecs::open_bytes(name, bytes.into()).unwrap().audio(0, 72_000, 48_000).unwrap(); + let (a, b) = (decode("a.wav", wb), decode("a.flac", fb)); + assert_eq!(a.channels.len(), 2); + for (ca, cb) in a.channels.iter().zip(&b.channels) { + assert_eq!(ca.len(), cb.len()); + let worst = ca.iter().zip(cb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "differs by {worst}"); + assert!(ca.iter().any(|v| v.abs() > 0.1), "the tone is there"); + } + // and bit for bit through ffmpeg + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac export") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-i", path, "-f", "s32le", "-c:a", "pcm_s32le", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + let (a, b) = (raw(&wav), raw(&flac)); + assert_eq!(a.len(), 72_000 * 2 * 4); + assert!(a == b, "FLAC must decode to the WAV's samples exactly"); + } + if let Some(j) = ffprobe_json(&["-show_streams"], &flac) { + let st = &j["streams"][0]; + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["bits_per_raw_sample"].as_str()), (Some("flac"), Some("48000"), Some("24"))); + } +} + +#[test] +fn flac_audio_in_mp4_and_mov() { + let (p, seq, m) = matte([0.2, 0.4, 0.6, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac-in"); + // 1.5 s at 48 kHz: 17 full FLAC frames of 4096 samples and a last one of 2368 + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { codec: AudioCodec::Flac, bits: 24, ..Default::default() }; + let wav = dir.path("ref.wav"); + export(&p, seq, &ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }, &m, &Progress::default()) + .unwrap(); + for (format, mux, name) in + [(Format::H264, Multiplexer::Mp4, "a.mp4"), (Format::H264, Multiplexer::Mov, "b.mov"), (Format::ProRes, Multiplexer::Mp4, "c.mov")] + { + let path = dir.path(name); + let s = ExportSettings { format, multiplexer: mux, path: path.clone(), range, audio: audio.clone(), ..Default::default() }; + assert_eq!(s.audio_codec(), AudioCodec::Flac, "{name}"); + assert!(s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio.starts_with("FLAC (lossless) 24-bit"), "{name}"); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + // `dfLa` was rewritten at the end: STREAMINFO with frame sizes, total samples and the MD5 + let at = bytes.windows(4).position(|w| w == b"dfLa").unwrap() + 4 + 4 + 4; + let si = &bytes[at..at + 34]; + assert_eq!(bytes[at - 4], 0x80, "{name}: STREAMINFO is the last metadata block"); + assert!(si[4..10].iter().any(|&b| b != 0), "{name}: frame sizes"); + assert_eq!(u32::from_be_bytes(si[14..18].try_into().unwrap()), 72_000, "{name}: total samples"); + assert!(si[18..34].iter().any(|&b| b != 0), "{name}: MD5"); + // FilmCraft's own importer reads the FLAC track back, sample-accurately long + let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap(); + let a = src.audio(0, 72_000, 48_000).unwrap(); + assert!(a.channels.iter().all(|c| c.len() == 72_000) && a.channels[0].iter().any(|v| v.abs() > 0.1), "{name}"); + if let Some(j) = ffprobe_json(&["-show_streams", "-count_packets"], &path) { + let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone(); + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["channels"].as_u64()), (Some("flac"), Some("48000"), Some(2)), "{name}"); + assert_eq!(st["nb_read_packets"].as_str(), Some("18"), "{name}: one FLAC frame per packet"); + assert_eq!(st["duration_ts"].as_u64(), Some(72_000), "{name}: the short last frame is timed exactly"); + } + // the audio decodes to the WAV export's samples bit for bit + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac in containers") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i", path, "-map", "0:a", "-f", "s32le", "-c:a", "pcm_s32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + assert!(raw(&path) == raw(&wav), "{name}: FLAC must decode to the WAV's samples exactly"); + } + } + // MXF stays PCM whatever is asked for + let mxf = ExportSettings { format: Format::MxfOp1a, audio: audio.clone(), ..Default::default() }; + assert_eq!(mxf.audio_codec(), AudioCodec::Pcm); +} + +#[test] +fn flac_settings_round_trip_and_summary() { + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "flac"})).unwrap(); + assert_eq!((s.format, s.audio_codec(), s.audio.flac_level, s.has_video()), (Format::Flac, AudioCodec::Flac, 5, false)); + assert_eq!((s.extension(), Format::from_name("FLAC"), Format::Flac.id()), ("flac", Some(Format::Flac), "flac")); + // settings saved before FLAC read the default level + let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap(); + assert_eq!(old.flac_level, 5); + let sum = ExportSettings { format: Format::Flac, ..Default::default() }.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(sum.audio.starts_with("FLAC (lossless) 16-bit, level 5, 48000 Hz"), "{}", sum.audio); + assert_eq!(sum.video, "No video"); + // estimated below PCM + let pcm = ExportSettings { format: Format::Wav, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + let flac = ExportSettings { format: Format::Flac, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(flac < pcm, "{flac} < {pcm}"); +} + #[test] fn invalid_export_allocations_are_rejected_before_rendering() { let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None); @@ -553,7 +673,7 @@ fn loudness_normalization_hits_target_and_true_peak() { assert!((i - target).abs() < 0.5, "target {target}: measured {i:.2} LUFS"); assert!(tp <= -0.9, "true peak {tp:.2} dBTP"); let rep = prog.loudness.lock().unwrap().unwrap(); - assert!((rep.measured_lufs - (target - rep.gain_db)).abs() < 1e-6); + assert!((rep.measured_lufs + rep.gain_db - target).abs() < 0.1, "unlimited tone needs no correction"); } // a target the peaks cannot reach: the limiter holds the true-peak ceiling let path = dir.path("hot.wav"); @@ -833,7 +953,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 20); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 21); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); @@ -896,6 +1016,39 @@ fn extreme_bitrates_do_not_overflow_encoder_setup() { } } +#[test] +fn tiff_size_estimate_tracks_alpha_and_real_output() { + let (project, sequence, sources) = matte([0.0, 0.0, 1.0, 0.5], 320, 180, None); + let rate = FrameRate::FPS_24; + let duration = rate.tick_of(1); + for alpha in [false, true] { + let dir = Scratch::new(&format!("tiff-size-{alpha}")); + let mut settings = ExportSettings { format: Format::TiffSequence, path: dir.path("frame.tif"), alpha, ..Default::default() }; + settings.range = Some(TimeRange::new(Tick::ZERO, duration)); + settings.validate().unwrap(); + + let expected = 320_u64 * 180 * if alpha { 4 } else { 3 }; + let summary = settings.summary(320, 180, rate, 48_000, duration); + assert_eq!(summary.estimated_bytes, expected, "alpha={alpha}"); + assert_eq!(settings.estimate_bytes(320, 180, rate, 48_000, duration), expected); + let result = export(&project, sequence, &settings, &sources, &Progress::default()).unwrap(); + assert_eq!(result.frames, 1); + let paths = files_in(&dir.0); + assert_eq!(paths.len(), 1, "{paths:?}"); + let actual_bytes = std::fs::metadata(dir.0.join(&paths[0])).unwrap().len(); + assert!(actual_bytes.abs_diff(summary.estimated_bytes) < 1024, "alpha={alpha}: estimate={} actual={actual_bytes}", summary.estimated_bytes); + } + + // Other uncompressed formats already have the correct channel policy. + for format in [Format::BmpSequence, Format::TgaSequence] { + let rgb = ExportSettings { format, alpha: false, ..Default::default() }; + let rgba = ExportSettings { format, alpha: true, ..Default::default() }; + assert_eq!(rgb.estimate_bytes(320, 180, rate, 48_000, duration), 320 * 180 * 3); + let expected = if format == Format::BmpSequence { 3 } else { 4 }; + assert_eq!(rgba.estimate_bytes(320, 180, rate, 48_000, duration), 320 * 180 * expected); + } +} + #[test] fn gpu_rendering_is_off_by_default() { assert_eq!(ExportSettings::default().gpu_rendering, crate::GpuRendering::Off); @@ -905,3 +1058,80 @@ fn gpu_rendering_is_off_by_default() { let auto: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "gpuRendering": "auto"})).unwrap(); assert_eq!(auto.gpu_rendering, crate::GpuRendering::Auto); } + +/// Brief large peaks over a quiet bed exercise loudness lost during limiter release. +struct CrestSource(filmcraft_media::SharedSource, Arc); +impl MediaSource for CrestSource { + fn info(&self) -> &filmcraft_media::MediaInfo { + self.0.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + self.0.video_frame(req) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.1.fetch_add(1, Ordering::Relaxed); + let samples: Vec = (0..frames) + .map(|i| { + let at = start + i as i64; + let phase = at.rem_euclid(sample_rate as i64 / 4); + let amplitude = if phase < sample_rate as i64 / 500 { 0.9 } else { 0.08 }; + amplitude * (std::f32::consts::TAU * 1000.0 * at as f32 / sample_rate as f32).sin() + }) + .collect(); + Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![samples.clone(), samples] }) + } +} + +#[test] +fn loudness_normalization_corrects_limiter_loss_and_bounds_impossible_targets() { + use std::cell::Cell; + let (p, seq, mut sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for source in sources.0.values_mut() { + *source = Arc::new(CrestSource(source.clone(), reads.clone())); + } + let range = TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND)); + let mut settings = ExportSettings::default(); + settings.effects.loudness = LoudnessNormalization { enabled: true, target_lufs: -14.0, true_peak_dbtp: -2.0 }; + for target in [-14.0, 2.0] { + settings.effects.loudness.target_lufs = target; + let mut audio = audio_out::AudioOut::new(p.clone(), seq, &settings, range).unwrap(); + let chunks = Cell::new(0); + audio + .measure(&settings, &sources, &|| { + chunks.set(chunks.get() + 1); + false + }) + .unwrap(); + let report = audio.loudness.unwrap(); + let result = audio.rest(&sources).unwrap(); + let (integrated, peak) = lufs(&result, 48_000); + eprintln!("target {target}: output {integrated:.3} LUFS, {peak:.3} dBTP, gain {:.3}, chunks {}", report.gain_db, chunks.get()); + assert!(chunks.get() <= 5, "one raw pass plus at most four limited trials"); + assert_eq!(result[0].len(), 48_000, "measuring must rewind output and limiter latency"); + assert!(peak <= -1.8, "limiting must remain enabled: {peak}"); + if target == -14.0 { + assert!((integrated - target).abs() < 0.15, "achieved {integrated}"); + assert!(report.gain_db > target - report.measured_lufs + 0.3, "fixture must require limiter correction"); + } else { + assert!(integrated < target - 1.0, "impossible target sacrifices loudness, not peak ceiling"); + } + } + // Cancel after raw measurement and one limited trial, before further refinement. + // Record source reads at cancellation to prove no further mixing occurs. + let mut audio = audio_out::AudioOut::new(p, seq, &settings, range).unwrap(); + let chunks = Cell::new(0); + let reads_at_cancel = Cell::new(None); + let cancel = || { + chunks.set(chunks.get() + 1); + if chunks.get() > 2 { + reads_at_cancel.set(Some(reads.load(Ordering::Relaxed))); + true + } else { + false + } + }; + assert!(matches!(audio.measure(&settings, &sources, &cancel), Err(ExportError::Cancelled))); + assert_eq!(chunks.get(), 3); + assert_eq!(reads_at_cancel.get(), Some(reads.load(Ordering::Relaxed))); +} diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 3b0d6bca5..9417a7fda 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -89,6 +89,31 @@ fn wav_png_gif() { assert!(std::path::Path::new(&tmp("seq005.png")).exists()); } +/// GIF delays are whole centiseconds (#854): at 24 fps they alternate 4/4/5 cs so a second of +/// frames lasts 1000 ms, not 24 × 40 ms = 960 ms. +#[test] +fn gif_frame_delays_add_up_to_the_duration() { + let d: Vec = (0..6).map(|i| gif_delay_cs(FrameRate::FPS_24, i)).collect(); + assert_eq!(d, [4, 4, 5, 4, 4, 4]); + let ntsc: u64 = (0..30).map(|i| u64::from(gif_delay_cs(FrameRate::FPS_29_97, i))).sum(); + assert_eq!(ntsc, 100, "30 frames at 29.97 fps last 1.001 s"); + // hostile rates neither panic nor overflow + assert_eq!(gif_delay_cs(FrameRate { num: 0, den: 0 }, 3), 0); + let _ = gif_delay_cs(FrameRate { num: 1, den: i64::MAX }, i64::MAX); + + let (p, seq, m) = project(); + let path = tmp("timing.gif"); + let mut s = ExportSettings { format: Format::Gif, path: path.clone(), scale: 0.25, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(24))); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + use image::AnimationDecoder; + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(std::fs::read(&path).unwrap())).unwrap(); + let frames = dec.into_frames().collect_frames().unwrap(); + assert_eq!(frames.len(), 24); + let ms: u32 = frames.iter().map(|f| f.delay().numer_denom_ms()).map(|(n, d)| n / d.max(1)).sum(); + assert_eq!(ms, 1000); +} + #[test] fn prores_export_roundtrip() { let (p, seq, m) = project(); diff --git a/crates/flac/Cargo.toml b/crates/flac/Cargo.toml new file mode 100644 index 000000000..8d6d05095 --- /dev/null +++ b/crates/flac/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-flac" +description = "Clean-room FLAC encoder (RFC 9639)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/flac/README.md b/crates/flac/README.md new file mode 100644 index 000000000..3909ab793 --- /dev/null +++ b/crates/flac/README.md @@ -0,0 +1,62 @@ +# filmcraft-flac + +A clean-room FLAC encoder (Free Lossless Audio Codec, [RFC 9639](https://www.rfc-editor.org/rfc/rfc9639)), +written from the RFC. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, has no +`unsafe`, and builds for `wasm32-unknown-unknown`. + +No libFLAC, FFmpeg or other FLAC implementation was read or ported. ffmpeg is used only as an +external **test oracle** (decoding our streams). Decoding FLAC in FilmCraft is still done by the +symphonia bootstrap decoder in `filmcraft-codecs`. + +## API + +```rust +use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; + +let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24) +cfg.level = 8; // 0 fastest … 8 smallest; 5 by default +let mut enc = Encoder::new(cfg)?; +let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor) +file.extend(enc.encode(&[&left, &right])?); // planar i32 samples, any chunk size → whole frames +file.extend(enc.finish()); // the last, shorter frame +// frame sizes, total samples and MD5 are only known now: patch STREAMINFO (or seek back in a file) +let at = STREAMINFO_OFFSET as usize; +file[at..at + 34].copy_from_slice(&enc.streaminfo()); + +// MP4 / QuickTime (`fLaC` + `dfLa`): one frame per sample, the STREAMINFO body in `dfLa` +let frames: Vec> = enc.encode_frames(&[&left, &right])?; // each a full block +let last_len = enc.pending_samples(); // duration of the frame finish() writes +``` + +## What it writes + +| Part | | +|---|---| +| Metadata | STREAMINFO (block size, frame sizes, rate, channels, sample size, total samples, MD5 of the audio) and a VORBIS_COMMENT with the vendor string | +| Frames | fixed block size (4096 by default), frame numbers, sample rate and size codes, CRC-8 header and CRC-16 frame checksums | +| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest | +| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback | +| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe | +| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest | + +Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples, +seek tables. + +| Level | LPC order | Rice partition order | Stereo decorrelation | +|---|---|---|---| +| 0 | fixed only | ≤ 3 | no | +| 1–2 | fixed only | ≤ 3 | yes | +| 3 | ≤ 6 | ≤ 4 | no | +| 4 | ≤ 8 | ≤ 4 | yes | +| 5 (default) | ≤ 8 | ≤ 5 | yes | +| 6 | ≤ 8 | ≤ 6 | yes | +| 7–8 | ≤ 12 | ≤ 6 | yes | + +## Tests + +`cargo test -p filmcraft-flac`: CRC check values, the RFC 1321 MD5 suite, frame-number coding, +predictor round trips, and `tests/ffmpeg_oracle.rs`, which decodes every stream with ffmpeg and +requires every sample back exactly, no decoder errors, and the STREAMINFO MD5 equal to ffmpeg's +hash of the decoded audio. It covers music-like, sweep, noise, full-scale square, silence and click +signals; 8/16/20/24-bit; mono, stereo and 5.1; common and odd sample rates; block sizes 16 to 65535; +and every level. Skipped (with a `SKIPPED` line) without ffmpeg. diff --git a/crates/flac/src/crc.rs b/crates/flac/src/crc.rs new file mode 100644 index 000000000..0cc4869b5 --- /dev/null +++ b/crates/flac/src/crc.rs @@ -0,0 +1,57 @@ +//! The two frame checksums of RFC 9639 §9.1.8 and §9.3: CRC-8 (polynomial x⁸+x²+x+1) over the +//! frame header and CRC-16 (x¹⁶+x¹⁵+x²+1) over the whole frame, both MSB-first with a zero start. + +const fn table8() -> [u8; 256] { + let mut t = [0u8; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u8; + let mut b = 0; + while b < 8 { + c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +const fn table16() -> [u16; 256] { + let mut t = [0u16; 256]; + let mut i = 0; + while i < 256 { + let mut c = (i as u16) << 8; + let mut b = 0; + while b < 8 { + c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +static T8: [u8; 256] = table8(); +static T16: [u16; 256] = table16(); + +pub fn crc8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |c, &b| T8[usize::from(c ^ b)]) +} + +pub fn crc16(data: &[u8]) -> u16 { + data.iter().fold(0u16, |c, &b| (c << 8) ^ T16[usize::from((c >> 8) as u8 ^ b)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_values() { + // the standard check value of each polynomial over "123456789" + assert_eq!(crc8(b"123456789"), 0xF4); + assert_eq!(crc16(b"123456789"), 0xFEE8); + } +} diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs new file mode 100644 index 000000000..6ccd8a50f --- /dev/null +++ b/crates/flac/src/frame.rs @@ -0,0 +1,203 @@ +//! Writing one frame (RFC 9639 §9): header with CRC-8, one subframe per channel, zero padding to a +//! byte, CRC-16. + +use filmcraft_bitstream::BitWriter; + +use crate::crc::{crc8, crc16}; +use crate::predict::{Residual, Subframe}; + +/// Inter-channel decorrelation of a stereo frame (§9.1.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stereo { + Independent, + /// Left, then side (left − right). + LeftSide, + /// Side, then right. + SideRight, + /// Mid ((left + right) >> 1), then side. + MidSide, +} + +/// Frame header block size bits: 4096 has its own code, others are stored after the header. +fn block_size_code(n: u16) -> (u8, Option<(u32, u32)>) { + match n { + 4096 => (0b1100, None), + 1..=256 => (0b0110, Some((u32::from(n - 1), 8))), + _ => (0b0111, Some((u32::from(n.saturating_sub(1)), 16))), + } +} + +/// Frame header sample rate code; 0 refers to STREAMINFO. +fn sample_rate_code(rate: u32) -> u8 { + match rate { + 88_200 => 1, + 176_400 => 2, + 192_000 => 3, + 8_000 => 4, + 16_000 => 5, + 22_050 => 6, + 24_000 => 7, + 32_000 => 8, + 44_100 => 9, + 48_000 => 10, + 96_000 => 11, + _ => 0, + } +} + +/// Frame header sample size code; 0 refers to STREAMINFO. +fn sample_size_code(bps: u8) -> u8 { + match bps { + 8 => 1, + 12 => 2, + 16 => 4, + 20 => 5, + 24 => 6, + _ => 0, + } +} + +/// The frame number in the UTF-8-like coding of §9.1.5 (up to 36 bits). +fn coded_number(v: u64, out: &mut Vec) { + if v < 0x80 { + out.push(v as u8); + return; + } + let tail = match v { + 0..0x800 => 1, + 0x800..0x1_0000 => 2, + 0x1_0000..0x20_0000 => 3, + 0x20_0000..0x400_0000 => 4, + 0x400_0000..0x8000_0000 => 5, + _ => 6, + }; + let lead_mask: u8 = !(0xFFu8 >> (tail + 1)); + out.push(lead_mask | (v >> (6 * tail)) as u8 & (0x7F >> (tail + 1))); + for i in (0..tail).rev() { + out.push(0x80 | ((v >> (6 * i)) & 0x3F) as u8); + } +} + +pub struct FrameSpec<'a> { + pub number: u64, + pub sample_rate: u32, + pub bps: u8, + pub stereo: Option, + /// The signals as coded (mid/side already formed), with their subframes. + pub channels: &'a [(&'a [i32], Subframe, u8)], +} + +fn write_residual(w: &mut BitWriter, residual: &Residual, r: &[i64], block: usize, order: usize) { + let wide = residual.wide(); + w.write_bits(u32::from(wide), 2); + w.write_bits(u32::from(residual.partition_order), 4); + let parts = 1usize << residual.partition_order; + let len = block / parts; + let mut start = 0usize; + for (p, &k) in residual.params.iter().enumerate() { + w.write_bits(u32::from(k), if wide { 5 } else { 4 }); + let n = if p == 0 { len.saturating_sub(order) } else { len }; + for &v in r.get(start..start + n).unwrap_or_default() { + let u = if v >= 0 { (v as u64) << 1 } else { ((-v - 1) as u64) << 1 | 1 }; + let mut q = u >> k; + while q >= 32 { + w.write_bits(0, 32); + q -= 32; + } + w.write_bits(1, q as u32 + 1); + w.write_bits((u & ((1u64 << k) - 1)) as u32, u32::from(k)); + } + start += n; + } +} + +fn write_subframe(w: &mut BitWriter, x: &[i32], sub: &Subframe, bps: u8) { + let b = u32::from(bps); + let sample = |w: &mut BitWriter, v: i32| w.write_bits(v as u32, b); + w.write_bit(false); + match sub { + Subframe::Constant(v) => { + w.write_bits(0, 6); + w.write_bit(false); + sample(w, *v); + } + Subframe::Verbatim => { + w.write_bits(1, 6); + w.write_bit(false); + x.iter().for_each(|&v| sample(w, v)); + } + Subframe::Fixed { order, residual } => { + w.write_bits(0b001000 | u32::from(*order), 6); + w.write_bit(false); + let o = usize::from(*order); + x.iter().take(o).for_each(|&v| sample(w, v)); + let r = crate::predict::fixed_residual(x, o).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + Subframe::Lpc { coefs, precision, shift, residual } => { + let o = coefs.len(); + w.write_bits(0b100000 | (o as u32 - 1), 6); + w.write_bit(false); + x.iter().take(o).for_each(|&v| sample(w, v)); + w.write_bits(u32::from(*precision) - 1, 4); + w.write_bits(u32::from(*shift), 5); + coefs.iter().for_each(|&c| w.write_bits(c as u32, u32::from(*precision))); + let r = crate::predict::lpc_residual(x, coefs, *shift).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + } +} + +/// One complete frame. +pub fn write(spec: &FrameSpec) -> Vec { + let block = spec.channels.first().map_or(0, |c| c.0.len()); + let (bs_code, bs_extra) = block_size_code(u16::try_from(block).unwrap_or(u16::MAX)); + let channel_code = match spec.stereo { + Some(Stereo::LeftSide) => 8, + Some(Stereo::SideRight) => 9, + Some(Stereo::MidSide) => 10, + _ => spec.channels.len().saturating_sub(1) as u8, + }; + let mut h = vec![0xFF, 0xF8, bs_code << 4 | sample_rate_code(spec.sample_rate), channel_code << 4 | sample_size_code(spec.bps) << 1]; + coded_number(spec.number, &mut h); + if let Some((v, n)) = bs_extra { + if n == 8 { + h.push(v as u8); + } else { + h.extend_from_slice(&(v as u16).to_be_bytes()); + } + } + h.push(crc8(&h)); + let mut w = BitWriter::new(); + w.write_bytes(&h); + for (x, sub, bps) in spec.channels { + write_subframe(&mut w, x, sub, *bps); + } + let mut out = w.finish(); + let crc = crc16(&out); + out.extend_from_slice(&crc.to_be_bytes()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coded_numbers() { + let code = |v| { + let mut o = Vec::new(); + coded_number(v, &mut o); + o + }; + assert_eq!(code(0), [0]); + assert_eq!(code(0x7F), [0x7F]); + assert_eq!(code(0x80), [0xC2, 0x80]); + assert_eq!(code(0x7FF), [0xDF, 0xBF]); + assert_eq!(code(0x800), [0xE0, 0xA0, 0x80]); + assert_eq!(code(0xFFFF), [0xEF, 0xBF, 0xBF]); + assert_eq!(code(0x1_0000), [0xF0, 0x90, 0x80, 0x80]); + assert_eq!(code(0x7FFF_FFFF), [0xFD, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + assert_eq!(code(0xF_FFFF_FFFF), [0xFE, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + } +} diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs new file mode 100644 index 000000000..5e278365b --- /dev/null +++ b/crates/flac/src/lib.rs @@ -0,0 +1,247 @@ +//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec). +//! +//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the +//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of +//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed +//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes; +//! stereo frames also try left/side, side/right and mid/side. +//! - The STREAMINFO sizes and MD5 are only known at the end: write the header first, then overwrite +//! [`STREAMINFO_OFFSET`] with [`Encoder::streaminfo`] once [`Encoder::finish`] has run (or keep +//! the placeholder, whose zeros mean "unknown" and decode fine). +//! +//! Lossless at every compression level; the level (0–8) only trades encoding time for size. +//! +//! Layer L0: depends only on `filmcraft-bitstream` (+ `thiserror`); no `unsafe`; builds for +//! `wasm32-unknown-unknown`. +//! +//! ``` +//! use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; +//! +//! let left: Vec = (0..48_000).map(|i| ((i as f64 * 0.05).sin() * 20_000.0) as i32).collect(); +//! let right = left.clone(); +//! let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16))?; +//! let mut file = enc.header(); +//! file.extend(enc.encode(&[&left, &right])?); +//! file.extend(enc.finish()); +//! let at = STREAMINFO_OFFSET as usize; +//! file[at..at + 34].copy_from_slice(&enc.streaminfo()); +//! assert_eq!(&file[..4], b"fLaC"); +//! assert!(file.len() < 48_000 * 2 * 2 / 2); // well under half of 16-bit PCM +//! # Ok::<(), filmcraft_flac::Error>(()) +//! ``` + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod crc; +mod frame; +mod md5; +mod predict; + +use frame::{FrameSpec, Stereo}; +use predict::{Effort, Subframe}; + +/// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`]. +pub const STREAMINFO_OFFSET: u64 = 8; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid configuration: {0}")] + InvalidConfig(&'static str), + #[error("expected {expected} channels, got {got}")] + Channels { expected: usize, got: usize }, + #[error("channels of unequal length")] + UnequalChannels, + #[error("sample {0} does not fit the configured sample size")] + SampleRange(i32), +} + +#[derive(Clone, Debug)] +pub struct EncoderConfig { + /// 1 to 1 048 575 Hz. + pub sample_rate: u32, + /// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1). + pub channels: u8, + /// 8–24. + pub bits_per_sample: u8, + /// Samples per channel per frame, 16–65535 (4096 by default). + pub block_size: u16, + /// 0 (fastest) to 8 (smallest); 5 by default. + pub level: u8, +} + +impl EncoderConfig { + pub fn new(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Self { + EncoderConfig { sample_rate, channels, bits_per_sample, block_size: 4096, level: 5 } + } +} + +pub struct Encoder { + cfg: EncoderConfig, + effort: Effort, + /// Samples waiting for a full block, per channel. + pending: Vec>, + frame_number: u64, + total_samples: u64, + min_frame: u32, + max_frame: u32, + md5: md5::Md5, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + if !(1..=8).contains(&cfg.channels) { + return Err(Error::InvalidConfig("1 to 8 channels")); + } + if !(8..=24).contains(&cfg.bits_per_sample) { + return Err(Error::InvalidConfig("8 to 24 bits per sample")); + } + if !(1..=1_048_575).contains(&cfg.sample_rate) { + return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz")); + } + if cfg.block_size < 16 { + return Err(Error::InvalidConfig("block size 16 to 65535")); + } + let effort = Effort::level(cfg.level); + Ok(Encoder { + pending: vec![Vec::new(); usize::from(cfg.channels)], + cfg, + effort, + frame_number: 0, + total_samples: 0, + min_frame: 0, + max_frame: 0, + md5: md5::Md5::default(), + }) + } + + /// `fLaC`, the STREAMINFO block (sizes and MD5 unknown yet: see [`Encoder::streaminfo`]) and a + /// VORBIS_COMMENT block with the vendor string. + pub fn header(&self) -> Vec { + let mut v = b"fLaC".to_vec(); + v.extend_from_slice(&[0, 0, 0, 34]); + v.extend_from_slice(&self.streaminfo_with(0, 0, 0, [0; 16])); + let vendor = concat!("filmcraft-flac ", env!("CARGO_PKG_VERSION")); + let len = 4 + vendor.len() + 4; + // last metadata block, type 4 (VORBIS_COMMENT), little-endian lengths inside + v.push(0x84); + v.extend_from_slice(&(len as u32).to_be_bytes()[1..]); + v.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + v.extend_from_slice(vendor.as_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v + } + + /// The final STREAMINFO body: frame sizes, total samples and MD5 of what has been encoded. + pub fn streaminfo(&self) -> [u8; 34] { + self.streaminfo_with(self.min_frame, self.max_frame, self.total_samples, self.md5.digest()) + } + + fn streaminfo_with(&self, min_frame: u32, max_frame: u32, total: u64, md5: [u8; 16]) -> [u8; 34] { + let mut w = filmcraft_bitstream::BitWriter::new(); + let bs = u32::from(self.cfg.block_size); + w.write_bits(bs, 16); + w.write_bits(bs, 16); + w.write_bits(min_frame.min(0xFF_FFFF), 24); + w.write_bits(max_frame.min(0xFF_FFFF), 24); + w.write_bits(self.cfg.sample_rate, 20); + w.write_bits(u32::from(self.cfg.channels) - 1, 3); + w.write_bits(u32::from(self.cfg.bits_per_sample) - 1, 5); + // 36 bits; 0 means unknown, and a count that does not fit is left unknown + let total = if total >> 36 == 0 { total } else { 0 }; + w.write_bits((total >> 32) as u32, 4); + w.write_bits(total as u32, 32); + w.write_bytes(&md5); + let mut out = [0u8; 34]; + out.copy_from_slice(w.bytes().get(..34).unwrap_or(&[0; 34])); + out + } + + /// Add samples (one slice per channel, equal lengths); returns the frames completed. + pub fn encode(&mut self, planar: &[&[i32]]) -> Result, Error> { + Ok(self.encode_frames(planar)?.concat()) + } + + /// [`Encoder::encode`] with each completed frame on its own (one MP4 / QuickTime sample each). + pub fn encode_frames(&mut self, planar: &[&[i32]]) -> Result>, Error> { + if planar.len() != self.pending.len() { + return Err(Error::Channels { expected: self.pending.len(), got: planar.len() }); + } + let n = planar.first().map_or(0, |c| c.len()); + if planar.iter().any(|c| c.len() != n) { + return Err(Error::UnequalChannels); + } + let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1; + if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) { + return Err(Error::SampleRange(bad)); + } + for (p, c) in self.pending.iter_mut().zip(planar) { + p.extend_from_slice(c); + } + let bs = usize::from(self.cfg.block_size); + let mut out = Vec::new(); + while self.pending.first().is_some_and(|p| p.len() >= bs) { + let block: Vec> = self.pending.iter_mut().map(|p| p.drain(..bs).collect()).collect(); + out.push(self.frame(&block)); + } + Ok(out) + } + + /// Samples per channel waiting for a full block: the length of the frame [`Encoder::finish`] + /// would write. + pub fn pending_samples(&self) -> usize { + self.pending.first().map_or(0, Vec::len) + } + + /// Encode what is left as a final, shorter frame. + pub fn finish(&mut self) -> Vec { + if self.pending.first().is_none_or(Vec::is_empty) { + return Vec::new(); + } + let block: Vec> = self.pending.iter_mut().map(std::mem::take).collect(); + self.frame(&block) + } + + fn frame(&mut self, block: &[Vec]) -> Vec { + let n = block.first().map_or(0, Vec::len); + let bps = self.cfg.bits_per_sample; + // the MD5 of the unencoded audio: interleaved, little-endian, whole bytes per sample + let bytes = usize::from(bps.div_ceil(8)); + let mut raw = Vec::with_capacity(n * block.len() * bytes); + for i in 0..n { + for c in block { + raw.extend_from_slice(c.get(i).copied().unwrap_or(0).to_le_bytes().get(..bytes).unwrap_or_default()); + } + } + self.md5.update(&raw); + + let effort = self.effort; + let code = |x: &[i32], b: u8| predict::choose(x, b, effort); + let data = match block { + [l, r] if effort.stereo => { + let side: Vec = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); + let mid: Vec = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect(); + let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps)); + let options = + [(Stereo::Independent, cl.1 + cr.1), (Stereo::LeftSide, cl.1 + cs.1), (Stereo::SideRight, cs.1 + cr.1), (Stereo::MidSide, cm.1 + cs.1)]; + let best = options.iter().min_by_key(|o| o.1).map_or(Stereo::Independent, |o| o.0); + let channels: Vec<(&[i32], Subframe, u8)> = match best { + Stereo::Independent => vec![(l, cl.0, bps), (r, cr.0, bps)], + Stereo::LeftSide => vec![(l, cl.0, bps), (&side, cs.0, bps + 1)], + Stereo::SideRight => vec![(&side, cs.0, bps + 1), (r, cr.0, bps)], + Stereo::MidSide => vec![(&mid, cm.0, bps), (&side, cs.0, bps + 1)], + }; + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: Some(best), channels: &channels }) + } + _ => { + let channels: Vec<(&[i32], Subframe, u8)> = block.iter().map(|c| (c.as_slice(), code(c, bps).0, bps)).collect(); + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: None, channels: &channels }) + } + }; + self.frame_number += 1; + self.total_samples += n as u64; + let size = u32::try_from(data.len()).unwrap_or(u32::MAX); + self.min_frame = if self.min_frame == 0 { size } else { self.min_frame.min(size) }; + self.max_frame = self.max_frame.max(size); + data + } +} diff --git a/crates/flac/src/md5.rs b/crates/flac/src/md5.rs new file mode 100644 index 000000000..ca99c9e32 --- /dev/null +++ b/crates/flac/src/md5.rs @@ -0,0 +1,118 @@ +//! MD5 (RFC 1321), for the STREAMINFO signature of the unencoded audio (RFC 9639 §8.2): every +//! sample, interleaved, little-endian, in the fewest whole bytes that hold the sample size. + +const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, + 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// `floor(abs(sin(i + 1)) × 2³²)`, RFC 1321 §3.4. +const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, + 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, + 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, + 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +#[derive(Clone)] +pub struct Md5 { + state: [u32; 4], + block: [u8; 64], + filled: usize, + len: u64, +} + +impl Default for Md5 { + fn default() -> Self { + Md5 { state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], block: [0; 64], filled: 0, len: 0 } + } +} + +impl Md5 { + pub fn update(&mut self, mut data: &[u8]) { + self.len = self.len.wrapping_add(data.len() as u64); + while !data.is_empty() { + let take = (64 - self.filled).min(data.len()); + let (head, rest) = data.split_at(take); + if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) { + dst.copy_from_slice(head); + } + self.filled += take; + data = rest; + if self.filled == 64 { + let block = self.block; + self.compress(&block); + self.filled = 0; + } + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut m = [0u32; 16]; + for (w, c) in m.iter_mut().zip(block.as_chunks::<4>().0) { + *w = u32::from_le_bytes(*c); + } + let [mut a, mut b, mut c, mut d] = self.state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + for (s, v) in self.state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + + /// The digest so far (the state is not consumed: more data may follow). + pub fn digest(&self) -> [u8; 16] { + let mut h = self.clone(); + let bits = h.len.wrapping_mul(8); + h.update(&[0x80]); + while h.filled != 56 { + h.update(&[0]); + } + h.update(&bits.to_le_bytes()); + let mut out = [0u8; 16]; + for (o, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(h.state) { + o.copy_from_slice(&s.to_le_bytes()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(d: [u8; 16]) -> String { + d.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn rfc_1321_test_suite() { + let md5 = |s: &[u8]| { + let mut m = Md5::default(); + m.update(s); + hex(m.digest()) + }; + assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(md5(b"message digest"), "f96b697d7cb7938d525a2f31aaf161d0"); + assert_eq!(md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "57edf4a22be3c955ac49da2e2107b67a"); + // fed in pieces across block boundaries + let mut m = Md5::default(); + for c in b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".chunks(7) { + m.update(c); + } + assert_eq!(hex(m.digest()), "57edf4a22be3c955ac49da2e2107b67a"); + } +} diff --git a/crates/flac/src/predict.rs b/crates/flac/src/predict.rs new file mode 100644 index 000000000..f5c681dee --- /dev/null +++ b/crates/flac/src/predict.rs @@ -0,0 +1,327 @@ +//! Subframe prediction (RFC 9639 §9.2.5–9.2.6) and residual coding (§9.2.7): the fixed polynomial +//! predictors, linear prediction from the windowed autocorrelation (Levinson–Durbin), and +//! partitioned Rice codes. Everything here only measures and chooses; [`crate::frame`] writes. + +/// Residuals larger than this are not Rice coded (a 5-bit parameter tops out at 30). +const MAX_RESIDUAL: i64 = (1 << 30) - 1; + +/// A residual coded with partitioned Rice codes. +#[derive(Clone, Debug, PartialEq)] +pub struct Residual { + /// Rice parameter per partition; `1 << order` partitions. + pub params: Vec, + pub partition_order: u8, + /// Total bits of the residual section, coding method included. + pub bits: u64, +} + +impl Residual { + /// The 5-bit parameter method is needed when any parameter passes 14 (15 is the escape code). + pub fn wide(&self) -> bool { + self.params.iter().any(|&k| k > 14) + } +} + +/// How one channel of one block is coded. +#[derive(Clone, Debug, PartialEq)] +pub enum Subframe { + Constant(i32), + Verbatim, + Fixed { order: u8, residual: Residual }, + Lpc { coefs: Vec, precision: u8, shift: u8, residual: Residual }, +} + +/// Folded residual: 0, -1, 1, -2, 2 … → 0, 1, 2, 3, 4 …. +fn fold(r: i64) -> u64 { + if r >= 0 { (r as u64) << 1 } else { ((-r - 1) as u64) << 1 | 1 } +} + +/// Bits of `n` folded values summing to `sum` with Rice parameter `k`: unary quotient, stop bit, +/// `k` low bits. The quotient total is approximated by `sum >> k`, exact up to the per-value +/// carries; [`rice_bits_exact`] gives the true cost for the chosen parameter. +fn rice_estimate(sum: u64, n: u64, k: u32) -> u64 { + (sum >> k) + n * (u64::from(k) + 1) +} + +fn rice_bits_exact(u: &[u64], k: u32) -> u64 { + u.iter().map(|&v| (v >> k) + u64::from(k) + 1).sum() +} + +/// The best parameter for a partition of folded values. +fn best_param(u: &[u64]) -> (u8, u64) { + let n = u.len() as u64; + let sum: u64 = u.iter().sum(); + if n == 0 { + return (0, 0); + } + let mean = sum / n; + let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() }; + let mut best = (0u32, u64::MAX); + for k in guess.saturating_sub(1)..=(guess + 1).min(30) { + let b = rice_estimate(sum, n, k); + if b < best.1 { + best = (k, b); + } + } + (best.0 as u8, rice_bits_exact(u, best.0)) +} + +/// Choose the partition order and parameters for the residual of a block of `block` samples whose +/// first `order` samples are warm-up (`u` holds the folded residual after them). +fn code_residual(u: &[u64], block: usize, order: usize, max_partition_order: u8) -> Residual { + let mut best: Option = None; + for po in 0..=max_partition_order { + let parts = 1usize << po; + if !block.is_multiple_of(parts) { + break; + } + let len = block / parts; + if len <= order { + break; + } + let mut params = Vec::with_capacity(parts); + let mut bits = 0u64; + let mut start = 0usize; + for p in 0..parts { + let n = if p == 0 { len - order } else { len }; + let Some(chunk) = u.get(start..start + n) else { break }; + let (k, b) = best_param(chunk); + params.push(k); + bits += b; + start += n; + } + let wide = params.iter().any(|&k| k > 14); + bits += 2 + 4 + parts as u64 * if wide { 5 } else { 4 }; + if best.as_ref().is_none_or(|b| bits < b.bits) { + best = Some(Residual { params, partition_order: po, bits }); + } + } + best.unwrap_or(Residual { params: vec![0], partition_order: 0, bits: u64::MAX }) +} + +/// The residual of fixed predictor `order` (0–4), or None when it does not fit. +pub fn fixed_residual(x: &[i32], order: usize) -> Option> { + let x: Vec = x.iter().map(|&v| i64::from(v)).collect(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let s = |j: usize| x.get(i - j).copied().unwrap_or(0); + let r = match order { + 0 => s(0), + 1 => s(0) - s(1), + 2 => s(0) - 2 * s(1) + s(2), + 3 => s(0) - 3 * s(1) + 3 * s(2) - s(3), + _ => s(0) - 4 * s(1) + 6 * s(2) - 4 * s(3) + s(4), + }; + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// The residual of quantised linear prediction, or None when it does not fit. +pub fn lpc_residual(x: &[i32], coefs: &[i32], shift: u8) -> Option> { + let order = coefs.len(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let hist = x.get(i - order..i)?; + // coefs[0] weighs the previous sample + let pred: i64 = coefs.iter().zip(hist.iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + let r = i64::from(*x.get(i)?) - (pred >> shift); + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// Tukey(0.5) window: flat in the middle half, raised-cosine tapers. +fn window(n: usize) -> Vec { + let taper = (n / 4).max(1); + (0..n) + .map(|i| { + let edge = i.min(n - 1 - i); + if edge >= taper { 1.0 } else { 0.5 * (1.0 - (std::f64::consts::PI * edge as f64 / taper as f64).cos()) } + }) + .collect() +} + +/// Linear prediction coefficients of every order 1..=`max` (Levinson–Durbin on the windowed +/// autocorrelation); empty for a silent or degenerate block. +fn lpc_orders(x: &[i32], max: usize) -> Vec> { + let n = x.len(); + if n <= max { + return Vec::new(); + } + let w = window(n); + let s: Vec = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect(); + let r: Vec = (0..=max).map(|lag| s.iter().zip(s.iter().skip(lag)).map(|(a, b)| a * b).sum()).collect(); + let Some(&r0) = r.first() else { return Vec::new() }; + if r0 <= 0.0 { + return Vec::new(); + } + let mut err = r0 * (1.0 + 1e-9); + let mut a: Vec = Vec::new(); + let mut all = Vec::with_capacity(max); + for i in 0..max { + let ri = r.get(i + 1).copied().unwrap_or(0.0); + let acc: f64 = a.iter().enumerate().map(|(j, &aj)| aj * r.get(i - j).copied().unwrap_or(0.0)).sum(); + let k = (ri - acc) / err; + if !k.is_finite() { + break; + } + let prev = a.clone(); + a.push(k); + for j in 0..i { + if let (Some(aj), Some(&p)) = (a.get_mut(j), prev.get(i - 1 - j)) { + *aj -= k * p; + } + } + err *= 1.0 - k * k; + all.push(a.clone()); + if err <= 0.0 { + break; + } + } + all +} + +/// Quantise coefficients to `precision`-bit integers with a right shift (0–15), carrying the +/// rounding error forward so the quantised filter stays close to the real one. +fn quantise(coefs: &[f64], precision: u8) -> Option<(Vec, u8)> { + let cmax = coefs.iter().fold(0.0f64, |m, c| m.max(c.abs())); + if !(cmax.is_finite() && cmax > 0.0) { + return None; + } + let lim = (1i64 << (precision - 1)) - 1; + // the largest shift that keeps the biggest coefficient inside the precision + let log = cmax.log2().floor() as i32 + 1; + let shift = (i32::from(precision) - 1 - log).clamp(0, 15); + let scale = f64::from(1u32 << shift); + let mut carry = 0.0; + let mut q = Vec::with_capacity(coefs.len()); + for &c in coefs { + carry += c * scale; + let v = (carry.round() as i64).clamp(-lim - 1, lim); + carry -= v as f64; + q.push(v as i32); + } + Some((q, shift as u8)) +} + +/// Encoding effort, from the compression level. +#[derive(Clone, Copy, Debug)] +pub struct Effort { + pub max_lpc_order: usize, + pub max_partition_order: u8, + pub stereo: bool, +} + +impl Effort { + pub fn level(level: u8) -> Effort { + let (max_lpc_order, max_partition_order, stereo) = match level { + 0 => (0, 3, false), + 1 | 2 => (0, 3, true), + 3 => (6, 4, false), + 4 => (8, 4, true), + 5 => (8, 5, true), + 6 => (8, 6, true), + _ => (12, 6, true), + }; + Effort { max_lpc_order, max_partition_order, stereo } + } +} + +/// The cheapest coding of one channel of `bps`-bit samples, and its size in bits. +pub fn choose(x: &[i32], bps: u8, effort: Effort) -> (Subframe, u64) { + let n = x.len(); + let b = u64::from(bps); + let head = 8; // zero bit, type, wasted-bits flag + let first = x.first().copied().unwrap_or(0); + if x.iter().all(|&v| v == first) { + return (Subframe::Constant(first), head + b); + } + let mut best = (Subframe::Verbatim, head + n as u64 * b); + let mut consider = |sub: Subframe, bits: u64| { + if bits < best.1 { + best = (sub, bits); + } + }; + let folded = |r: &[i64]| r.iter().map(|&v| fold(v)).collect::>(); + for order in 0..=4usize.min(n.saturating_sub(1)) { + let Some(r) = fixed_residual(x, order) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * b + residual.bits; + consider(Subframe::Fixed { order: order as u8, residual }, bits); + } + // 15-bit coefficients, fewer for short blocks (their side information weighs more) + let precision: u8 = if n <= 1152 { 12 } else { 15 }; + for coefs in lpc_orders(x, effort.max_lpc_order) { + let Some((q, shift)) = quantise(&coefs, precision) else { continue }; + let order = q.len(); + let Some(r) = lpc_residual(x, &q, shift) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * (b + u64::from(precision)) + 4 + 5 + residual.bits; + consider(Subframe::Lpc { coefs: q, precision, shift, residual }, bits); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folding() { + assert_eq!([0, -1, 1, -2, 2].map(fold), [0, 1, 2, 3, 4]); + } + + #[test] + fn predictable_signals_beat_verbatim() { + // a ramp is exactly predicted by the order-2 fixed predictor + let ramp: Vec = (0..4096).map(|i| i * 3 - 6000).collect(); + let (sub, bits) = choose(&ramp, 16, Effort::level(5)); + assert!(matches!(sub, Subframe::Fixed { order: 2, .. } | Subframe::Lpc { .. }), "{sub:?}"); + assert!(bits < 4096 * 2, "{bits}"); + // a sine is well predicted by LPC + let sine: Vec = (0..4096).map(|i| ((i as f64 * 0.05).sin() * 20000.0) as i32).collect(); + let (_, bits) = choose(&sine, 16, Effort::level(5)); + assert!(bits < 4096 * 6, "{bits}"); + assert_eq!(choose(&[7; 100], 16, Effort::level(5)), (Subframe::Constant(7), 24)); + } + + #[test] + fn lpc_residual_inverts() { + let x: Vec = (0..500).map(|i| ((i as f64 * 0.1).sin() * 1000.0) as i32).collect(); + let coefs = lpc_orders(&x, 4).pop().unwrap(); + let (q, shift) = quantise(&coefs, 15).unwrap(); + let r = lpc_residual(&x, &q, shift).unwrap(); + // rebuild the signal from the warm-up and the residual, as a decoder would + let mut y: Vec = x[..q.len()].to_vec(); + for (i, &res) in r.iter().enumerate() { + let p: i64 = q.iter().zip(y[i..i + q.len()].iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + y.push((res + (p >> shift)) as i32); + } + assert_eq!(y, x); + } + + #[test] + fn noise_and_extremes_do_not_panic() { + let mut seed = 1u32; + let noise: Vec = (0..4096) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as i32 - (1 << 23) + }) + .collect(); + let (_, bits) = choose(&noise, 24, Effort::level(8)); + assert!(bits <= 8 + 4096 * 24); + let square: Vec = (0..4096).map(|i| if i % 2 == 0 { (1 << 23) - 1 } else { -(1 << 23) }).collect(); + choose(&square, 24, Effort::level(8)); + choose(&[], 16, Effort::level(5)); + choose(&[1], 16, Effort::level(5)); + choose(&[1, 2], 16, Effort::level(8)); + } +} diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs new file mode 100644 index 000000000..80a0c7538 --- /dev/null +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -0,0 +1,181 @@ +//! ffmpeg as an external decoder oracle (skipped when absent): every stream must decode without an +//! error to exactly the samples that went in, with the STREAMINFO MD5 matching ffmpeg's hash of the +//! decoded audio. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_flac::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET}; + +fn ffmpeg() -> Option { + filmcraft_testkit::ffmpeg_or_skip("flac oracle") +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise, +/// a full-scale square, silence and a lone click. +fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { + let max = f64::from((1i32 << (bps - 1)) - 1); + let mut r = Rng(seed | 1); + (0..n) + .map(|i| { + let t = i as f64 / 48_000.0; + let v = match kind { + "music" => 0.5 * (2.0 * std::f64::consts::PI * 220.0 * t).sin() + 0.3 * (2.0 * std::f64::consts::PI * 1375.0 * t).sin() + 0.05 * r.next(), + "sweep" => (2.0 * std::f64::consts::PI * (40.0 + 4000.0 * t) * t).sin() * 0.9, + "noise" => r.next(), + "square" => { + if (i / 37) % 2 == 0 { + 1.0 + } else { + -1.0 + } + } + "click" => f64::from(u8::from(i == n / 2)), + _ => 0.0, + }; + ((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32) + }) + .collect() +} + +fn encode(planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Result, Error> { + let mut enc = Encoder::new(cfg)?; + let mut file = enc.header(); + let n = planar.first().map_or(0, Vec::len); + let mut at = 0; + while at < n { + let end = (at + chunk).min(n); + let parts: Vec<&[i32]> = planar.iter().map(|c| &c[at..end]).collect(); + file.extend(enc.encode(&parts)?); + at = end; + } + file.extend(enc.finish()); + let o = STREAMINFO_OFFSET as usize; + file[o..o + 34].copy_from_slice(&enc.streaminfo()); + Ok(file) +} + +fn run(ffmpeg: &Path, args: &[&str], input: &Path) -> (Vec, String) { + let out = Command::new(ffmpeg).args(["-v", "error", "-i"]).arg(input).args(args).output().unwrap(); + assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr)); + (out.stdout, String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// Encode, decode with ffmpeg, compare every sample and the MD5; returns the file size. +fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Option { + let ffmpeg = ffmpeg()?; + let bps = cfg.bits_per_sample; + let rate = cfg.sample_rate; + let file = encode(planar, cfg, chunk).unwrap(); + let path = filmcraft_testkit::fixtures_dir("flac").join(format!("{name}.flac")); + std::fs::write(&path, &file).unwrap(); + + // samples: s32 holds each one shifted to the top bits + let (raw, err) = run(&ffmpeg, &["-f", "s32le", "-c:a", "pcm_s32le", "-"], &path); + assert!(err.is_empty(), "{name}: decoder errors: {err}"); + let decoded: Vec = raw.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes(*b) >> (32 - bps)).collect(); + let n = planar[0].len(); + assert_eq!(decoded.len(), n * planar.len(), "{name}: sample count"); + for (i, frame) in decoded.chunks_exact(planar.len()).enumerate() { + for (c, &v) in frame.iter().enumerate() { + assert_eq!(v, planar[c][i], "{name}: channel {c} sample {i}"); + } + } + + // STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC + // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16 + // and 24 bit compare + let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34]; + let pcm = match bps { + 8 => Some("s8"), + 16 => Some("s16le"), + 24 => Some("s24le"), + _ => None, + }; + if let Some(pcm) = pcm { + let (hash, _) = run(&ffmpeg, &["-c:a", &format!("pcm_{pcm}"), "-f", "hash", "-hash", "md5", "-"], &path); + let hash = String::from_utf8_lossy(&hash); + let ours: String = si[18..34].iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hash.trim().strip_prefix("MD5=").unwrap_or(""), ours, "{name}: MD5"); + } + let total = (u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes([si[14], si[15], si[16], si[17]])); + assert_eq!(total, n as u64, "{name}: total samples"); + let si_rate = (u32::from(si[10]) << 12) | (u32::from(si[11]) << 4) | (u32::from(si[12]) >> 4); + assert_eq!(si_rate, rate); + Some(file.len()) +} + +#[test] +fn every_signal_round_trips_exactly() { + for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] { + for bps in [16u8, 24] { + let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)]; + if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() { + return; + } + } + } +} + +#[test] +fn channel_layouts_rates_and_sizes() { + let cases: [(&str, u8, u8, u32, usize); 6] = [ + ("mono-8bit", 1, 8, 8_000, 3_001), + ("mono-24-44k1", 1, 24, 44_100, 9_999), + ("stereo-20bit", 2, 20, 96_000, 10_000), + ("5.1-24", 6, 24, 48_000, 12_345), + ("odd-rate", 2, 16, 37_800, 5_000), + ("tiny", 2, 16, 48_000, 1), + ]; + for (name, ch, bps, rate, n) in cases { + let planar: Vec> = (0..ch).map(|c| signal(if c == 3 { "silence" } else { "music" }, n, bps, u64::from(c) + 7)).collect(); + if round_trip(name, &planar, EncoderConfig::new(rate, ch, bps), 1000).is_none() { + return; + } + } +} + +#[test] +fn levels_and_block_sizes_trade_time_for_size() { + let planar = vec![signal("music", 48_000, 16, 11), signal("sweep", 48_000, 16, 12)]; + let mut sizes = Vec::new(); + for level in 0..=8 { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 16) }; + let Some(size) = round_trip(&format!("level{level}"), &planar, cfg, 48_000) else { return }; + sizes.push(size); + } + // the effortful levels are never larger than the fastest, and all beat PCM + assert!(sizes[8] <= sizes[0] && sizes[5] <= sizes[0], "{sizes:?}"); + assert!(sizes.iter().all(|&s| s < 48_000 * 4), "{sizes:?}"); + for bs in [16u16, 192, 1152, 4608, 65_535] { + let cfg = EncoderConfig { block_size: bs, ..EncoderConfig::new(48_000, 2, 16) }; + round_trip(&format!("block{bs}"), &planar, cfg, 777); + } +} + +#[test] +fn bad_input_is_an_error_not_a_panic() { + assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err()); + assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err()); + assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err()); + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap(); + assert_eq!(enc.encode(&[&[0, 1]]), Err(Error::Channels { expected: 2, got: 1 })); + assert_eq!(enc.encode(&[&[0, 1], &[0]]), Err(Error::UnequalChannels)); + assert_eq!(enc.encode(&[&[40_000], &[0]]), Err(Error::SampleRange(40_000))); + assert_eq!(enc.encode(&[&[-32_769], &[0]]), Err(Error::SampleRange(-32_769))); + // nothing encoded: no frames, and a header that still says "unknown" + assert!(enc.finish().is_empty()); + assert_eq!(&enc.header()[..4], b"fLaC"); +} diff --git a/crates/gpu/src/blend_tests.rs b/crates/gpu/src/blend_tests.rs index 40b575229..a21c5a2b7 100644 --- a/crates/gpu/src/blend_tests.rs +++ b/crates/gpu/src/blend_tests.rs @@ -162,14 +162,15 @@ fn gpu_blend_modes_match_cpu() { layers: vec![ // opaque YUV on the left, a partially transparent ramp on the right; the top-right // corner stays empty (destination alpha 0) - PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.35, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: base.clone(), matrix: Affine::translate(200.0, 40.0), opacity: 0.9, blend: Blend::Normal, fx: None, input: None }, PlanLayer { frame: top.clone(), matrix: Affine::motion(Vec2::new(170.0, 95.0), Vec2::new(1.1, 1.1), 17.0, Vec2::new(80.0, 60.0)), opacity: 0.8, blend: mode, fx: None, + input: None, }, PlanLayer { frame: demo.clone(), @@ -177,6 +178,7 @@ fn gpu_blend_modes_match_cpu() { opacity: 0.6, blend: mode, fx: None, + input: None, }, ], }; @@ -235,8 +237,8 @@ fn gpu_blend_edge_cases_match_cpu() { width: n, height: n, layers: vec![ - PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None }, + PlanLayer { frame: back.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: src.clone(), matrix: Affine::IDENTITY, opacity, blend: mode, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan); @@ -276,8 +278,8 @@ fn gpu_dissolve_pattern_is_exact() { width: w, height: h, layers: vec![ - PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None }, + PlanLayer { frame: red.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: white.clone(), matrix: Affine::scale(w as f64, h as f64), opacity: op, blend: Blend::Dissolve, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan).over_black_rgba8(); @@ -303,7 +305,7 @@ fn normal_fast_path_and_blend_layers_off_output() { }; let mut c = GpuCompositor::new(&dev, &q); let grey = Arc::new(VideoFrame::rgba_f32(4, 4, vec![0.2; 64])); - let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None }; + let layer = |m: Affine, blend: Blend| PlanLayer { frame: grey.clone(), matrix: m, opacity: 1.0, blend, fx: None, input: None }; let normal = FramePlan::Layers { width: 32, height: 16, layers: vec![layer(Affine::scale(8.0, 4.0), Blend::Normal)] }; c.composite(&normal); assert!(c.backdrop.is_none()); @@ -329,7 +331,14 @@ fn normal_fast_path_and_blend_layers_off_output() { #[test] fn quad_bounds_clamps_and_handles_non_finite() { - let l = |m: Affine| PlanLayer { frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), matrix: m, opacity: 1.0, blend: Blend::Screen, fx: None }; + let l = |m: Affine| PlanLayer { + frame: Arc::new(VideoFrame::rgba_f32(10, 10, vec![0.0; 400])), + matrix: m, + opacity: 1.0, + blend: Blend::Screen, + fx: None, + input: None, + }; assert_eq!(quad_bounds(&l(Affine::translate(5.0, 5.0)), 100, 100), Some((4, 4, 12, 12))); assert_eq!(quad_bounds(&l(Affine::translate(-5.0, 95.0)), 100, 100), Some((0, 94, 6, 6))); assert_eq!(quad_bounds(&l(Affine::translate(f64::NAN, 0.0)), 100, 100), Some((0, 0, 100, 100))); diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl index c2eb10440..9e6372d00 100644 --- a/crates/gpu/src/composite.wgsl +++ b/crates/gpu/src/composite.wgsl @@ -15,6 +15,13 @@ struct U { p1: vec4, // y_off y_scale c_off c_scale (code units) p2: vec4, // kr kb code_scale footprint p3: vec4, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused + // Colour management of HDR / wide-gamut Y'CbCr (`filmcraft_color::InputTransform`, #965): + q0: vec4, // on, curve (0 sdr, 1 linear, 2 pq, 3 hlg), HLG OOTF scale (0: none), reference white cd/m² + q1: vec4, // HLG OOTF luma weights, gamut matrix on + q2: vec4, // gamut matrix row 0, tone mapping on + q3: vec4, // gamut matrix row 1, tone-mapping source peak cd/m² + q4: vec4, // gamut matrix row 2, tone-mapping target peak cd/m² + q5: vec4, // desaturation luma weights, desaturation on }; @group(0) @binding(0) var u: U; @@ -85,6 +92,82 @@ fn to_linear(v: vec3) -> vec3 { return srgb_to_linear(clamp(v, vec3(0.0), vec3(1.0))); } +// ---- colour management (`filmcraft_color::transform`), for `u.q0.x` = 1 + +// SMPTE ST 2084 on a 0..1 scale (1 = 10 000 cd/m²) and back. +fn pq1(e: f32) -> f32 { + let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875; + let p = pow(max(e, 0.0), 1.0 / m2); + return pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0 / m1); +} +fn pq1_inv(y: f32) -> f32 { + let m1 = 0.1593017578125; let m2 = 78.84375; let c1 = 0.8359375; let c2 = 18.8515625; let c3 = 18.6875; + let p = pow(clamp(y, 0.0, 1.0), m1); + return pow((c1 + c2 * p) / (1.0 + c3 * p), m2); +} + +// ITU-R BT.2390 EETF (`bt2390_eetf`), source peak s to target peak d, in cd/m². +fn eetf(nits: f32, s: f32, d: f32) -> f32 { + if d >= s || nits <= 0.0 { return clamp(nits, 0.0, max(s, d)); } + let smax = pq1_inv(s / 10000.0); + let e1 = min(pq1_inv(nits / 10000.0) / smax, 1.0); + let max_lum = pq1_inv(d / 10000.0) / smax; + let ks = 1.5 * max_lum - 0.5; + var e2 = e1; + if e1 >= ks { + let t = (e1 - ks) / (1.0 - ks); + let t2 = t * t; let t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + (t3 - 2.0 * t2 + t) * (1.0 - ks) + (-2.0 * t3 + 3.0 * t2) * max_lum; + } + return pq1(e2 * smax) * 10000.0; +} + +// Signal → working linear: `InputTransform::convert` (curve, HLG OOTF, gamut matrix, tone +// mapping, desaturation into the working gamut). The CPU's decode tables clamp the signal to 0..1. +fn managed(rgb: vec3) -> vec3 { + let v = clamp(rgb, vec3(0.0), vec3(1.0)); + let curve = u32(u.q0.y); + let white = u.q0.w; + var c = v; + if curve == 0u { c = srgb_to_linear(v); } + if curve == 2u { c = vec3(pq1(v.r), pq1(v.g), pq1(v.b)) * (10000.0 / white); } + if curve == 3u { c = hlg_inv(v); } + if u.q0.z > 0.0 { + let ys = max(dot(u.q1.xyz, c), 0.0); + var g = 0.0; + if ys > 0.0 { g = u.q0.z * pow(ys, 0.2); } + c = c * g; + } + if u.q1.w > 0.5 { + c = vec3(dot(u.q2.xyz, c), dot(u.q3.xyz, c), dot(u.q4.xyz, c)); + } + if u.q2.w > 0.5 { + let m = max(max(c.r, c.g), c.b); + if m > 0.0 { + let nits = m * white; + var k = 1.0; + if nits >= u.q3.w { + k = u.q4.w / white / m; + } else if nits > 1e-6 { + k = eetf(nits, u.q3.w, u.q4.w) / nits; + } + c = c * k; + } + } + if u.q5.w > 0.5 { + let mn = min(min(c.r, c.g), c.b); + if mn < 0.0 { + let y = dot(u.q5.xyz, c); + if y <= 0.0 { + c = vec3(0.0); + } else { + c = max(y + (c - y) * (y / (y - mn)), vec3(0.0)); + } + } + } + return c; +} + // One linear premultiplied sample at source position p. fn sample(p: vec2) -> vec4 { let kind = u32(u.p0.y); @@ -113,7 +196,9 @@ fn sample(p: vec2) -> vec4 { if u.p3.y > 0.0 { a = clamp(load4(tex3, p).r * u.p3.y, 0.0, 1.0); } - return vec4(to_linear(vec3(R, G, B)) * a, a); + var lin = to_linear(vec3(R, G, B)); + if u.q0.x > 0.5 { lin = managed(vec3(R, G, B)); } + return vec4(lin * a, a); } // The layer's premultiplied linear colour at this pixel, opacity applied. diff --git a/crates/gpu/src/fx.wgsl b/crates/gpu/src/fx.wgsl index 1fe152f83..7d97231f3 100644 --- a/crates/gpu/src/fx.wgsl +++ b/crates/gpu/src/fx.wgsl @@ -383,7 +383,12 @@ fn color_op(op: u32, c: vec3) -> vec3 { case OP_EXTRACT: { let lo = u.p0.x; let hi = u.p0.y; let soft = u.p0.z; let l = linear_to_srgb1(max(luma709(c), 0.0)); - let inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0)); + // Hard cutoffs avoid both division by zero and an artificial narrow fade + // that amplifies transfer-function rounding relative to the CPU reference. + var inside = select(0.0, 1.0, l > lo && l < hi); + if soft > 0.0 { + inside = min(clamp((l - lo) / soft, 0.0, 1.0), clamp((hi - l) / soft, 0.0, 1.0)); + } return vec3(select(inside, 1.0 - inside, u.i1.x != 0u)); } case OP_INVERT: { diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 6cd1a1d9b..2298bdadc 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -126,8 +126,14 @@ fn cases() -> Vec<(&'static str, Vec<(&'static str, ParamValue)>, bool)> { ("gamma_correction", vec![("gamma", fl(-3.0))], false), ("levels", vec![("in_black", fl(20.0)), ("in_white", fl(230.0)), ("out_black", fl(10.0)), ("out_white", fl(240.0)), ("gamma", fl(150.0))], false), ("levels", vec![("in_black", fl(200.0)), ("in_white", fl(0.0)), ("gamma", fl(5.0)), ("out_white", fl(-50.0))], true), + ("extract", vec![], true), + ("extract", vec![("invert", ParamValue::Bool(true))], true), ("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(30.0))], true), ("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(true))], true), + ("extract", vec![("black", fl(90.0)), ("white", fl(160.0)), ("softness", fl(1.0))], true), + ("extract", vec![("black", fl(40.0)), ("white", fl(200.0)), ("softness", fl(100.0))], true), + ("extract", vec![("black", fl(160.0)), ("white", fl(90.0))], true), + ("extract", vec![("black", fl(90.0)), ("white", fl(90.0))], true), ("invert", vec![("blend", fl(30.0))], false), ("invert", vec![("channel", ParamValue::Choice(1))], false), ("invert", vec![("channel", ParamValue::Choice(2)), ("blend", fl(70.0))], false), @@ -329,6 +335,34 @@ fn gpu_effects_match_cpu_exactly() { assert!(failures.is_empty(), "GPU effects differ from the CPU:\n{}", failures.join("\n")); } +#[test] +fn gpu_extract_zero_softness_has_no_fractional_edge() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let (w, h) = (67, 41); + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + let mut masks = Vec::new(); + for invert in [false, true] { + let e = effect("extract", &[("black", fl(90.0)), ("white", fl(160.0)), ("invert", ParamValue::Bool(invert))]); + let op = FxOp::eval(&e, &cx(Tick::ZERO, 1.0), w as usize, h as usize).unwrap(); + let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).unwrap(); + for (src, out) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) { + assert_eq!(out[3], src[3], "Extract must preserve alpha"); + assert!(out[0] == 0.0 || out[0] == out[3], "zero softness produced a fractional edge: {out:?}"); + assert_eq!(out[0], out[1]); + assert_eq!(out[0], out[2]); + } + masks.push(gpu); + } + for ((normal, inverted), src) in masks[0].as_chunks::<4>().0.iter().zip(masks[1].as_chunks::<4>().0.iter()).zip(px.as_chunks::<4>().0.iter()) { + assert_eq!(normal[0] + inverted[0], src[3], "inversion must complement the premultiplied mask"); + } +} + /// Chains run in order (and Unsharp keeps its original while blurring, between other blurs). #[test] fn gpu_effect_chains_match_cpu() { @@ -578,6 +612,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 1.0, blend: Blend::Normal, fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 2, ops: ops(&chain_c, 320, 180, 0.5) })), + input: None, }, PlanLayer { frame: ramp.clone(), @@ -585,6 +620,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 0.8, blend: mode, fx: Some(Arc::new(LayerFx { size: (160, 120), decimation: 1, ops: ops(&chain_a, 160, 120, 1.0) })), + input: None, }, PlanLayer::new(ramp.clone(), Affine::translate(10.0, 20.0), 0.5, Blend::Normal), PlanLayer { @@ -593,6 +629,7 @@ fn gpu_effect_layers_match_cpu_plan() { opacity: 0.7, blend: mode, fx: Some(Arc::new(LayerFx { size: (320, 180), decimation: 1, ops: ops(&chain_b, 320, 180, 1.0) })), + input: None, }, ], }; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index d57c53e5b..6daf61bfe 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -586,10 +586,11 @@ impl GpuCompositor { chroma: up.chroma, size: (f.width, f.height), color: f.color, + input: None, }) } - fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; 28] { + fn uniforms(src: &SrcInfo, m: &filmcraft_geom::Affine, opacity: f32, blend: Blend, out: (u32, u32)) -> [f32; UNIFORM_FLOATS] { let (kr, kb) = src.color.matrix.kr_kb(); let bits_scale = src.code_levels / 256.0; // 2^(bits - 8), independent of texture encoding let (yo, ys, co, cs) = match (src.color.range, src.kind) { @@ -609,7 +610,31 @@ impl GpuCompositor { let footprint = (1.0 / sx.min(sy).max(1e-6)) as f32; let taps = if footprint > 1.25 { footprint.ceil().min(4.0) } else { 1.0 }; let _ = Matrix::Bt709; - [ + // colour management (q0..q5), stage by stage as `InputTransform::apply` + let q = src.input.map_or([0.0; 24], |p| { + let curve = match p.curve { + filmcraft_color::GpuCurve::Sdr => 0.0, + filmcraft_color::GpuCurve::Linear => 1.0, + filmcraft_color::GpuCurve::Pq => 2.0, + filmcraft_color::GpuCurve::Hlg => 3.0, + }; + let (hl, hk) = p.hlg.unwrap_or(([0.0; 3], 0.0)); + let mx = p.matrix.unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + let (sp, dp) = p.tone.unwrap_or((0.0, 0.0)); + let dl = p.desaturate.unwrap_or([0.0; 3]); + let on = |b: bool| if b { 1.0 } else { 0.0 }; + #[rustfmt::skip] + let q = [ + 1.0, curve, hk, filmcraft_color::REFERENCE_WHITE_NITS as f32, + hl[0], hl[1], hl[2], on(p.matrix.is_some()), + mx[0][0], mx[0][1], mx[0][2], on(p.tone.is_some()), + mx[1][0], mx[1][1], mx[1][2], sp, + mx[2][0], mx[2][1], mx[2][2], dp, + dl[0], dl[1], dl[2], on(p.desaturate.is_some()), + ]; + q + }); + let base = [ m.a as f32, m.b as f32, m.c as f32, @@ -638,10 +663,14 @@ impl GpuCompositor { src.alpha, 0.0, 0.0, - ] + ]; + let mut u = [0.0; UNIFORM_FLOATS]; + u[..28].copy_from_slice(&base); + u[28..].copy_from_slice(&q); + u } - fn uniform_buffer(&self, u: &[f32; 28]) -> wgpu::Buffer { + fn uniform_buffer(&self, u: &[f32; UNIFORM_FLOATS]) -> wgpu::Buffer { let bytes: Vec = u.iter().flat_map(|v| v.to_le_bytes()).collect(); let buf = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("layer-u"), @@ -679,7 +708,8 @@ impl GpuCompositor { }, None => VideoFrame::rgba_f32(img.w as u32, img.h as u32, img.px.clone()), }; - owned = [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }]; + owned = + [PlanLayer { frame: Arc::new(frame), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }]; (img.w as u32, img.h as u32, &owned[..]) } FramePlan::Composite { width, height, steps } => return self.composite_steps(*width, *height, steps, prep), @@ -736,7 +766,10 @@ impl GpuCompositor { chroma: (1, 1), size: (l.frame.width, l.frame.height), color: l.frame.color, + input: None, }); + // HDR / wide-gamut Y'CbCr: the planner's input transform, in the shader (#965) + let src = SrcInfo { input: l.input.as_ref().filter(|_| src.kind == 2).and_then(|t| t.gpu_params()), ..src }; let views = self.uploads.get(k).map_or_else(|| std::array::from_fn(|_| self.dummy.clone()), |up| up.views.clone()); let job = match &l.fx { Some(lfx) => { @@ -768,7 +801,8 @@ impl GpuCompositor { // what the layer draws: the effect result (linear premultiplied RGBA) or the frame let (u, tex) = match &job { Some(j) => { - let s = SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color }; + let s = + SrcInfo { kind: 1, code_scale: 1.0, code_levels: 1.0, alpha: 0.0, chroma: l.size(), size: l.size(), color: l.frame.color, input: None }; (Self::uniforms(&s, &l.matrix, l.opacity, l.blend, (w, h)), [j.result.clone(), self.dummy.clone(), self.dummy.clone(), self.dummy.clone()]) } None => { @@ -1108,8 +1142,13 @@ struct SrcInfo { chroma: (u32, u32), size: (u32, u32), color: filmcraft_color::ColorInfo, + /// Colour management beyond the default decode (a Y'CbCr layer's [`PlanLayer::input`]). + input: Option, } +/// Floats in a layer's uniform block (`U` in `composite.wgsl`: 13 vec4). +const UNIFORM_FLOATS: usize = 52; + /// A render pass drawing into the accumulator. fn accum_pass<'e>(enc: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, load: wgpu::LoadOp) -> wgpu::RenderPass<'e> { enc.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index b7807aa63..034333ee3 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -23,6 +23,7 @@ fn yuv_code_range_uniforms() { chroma: (1, 1), size: (1, 1), color: filmcraft_color::ColorInfo { range, ..filmcraft_color::ColorInfo::REC709 }, + input: None, }; let u = GpuCompositor::uniforms(&src, &Affine::IDENTITY, 1.0, Blend::Normal, (1, 1)); // Full: 0..2^bits-1, neutral 2^(bits-1). Limited: BT.709 8-bit codes * 2^(bits-8). @@ -122,13 +123,14 @@ fn gpu_matches_cpu_plan() { width: w, height: h, layers: vec![ - PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv_frame(640, 360), matrix: Affine::scale(0.5, 0.5), opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, PlanLayer { frame: rgba, matrix: Affine::motion(Vec2::new(200.0, 100.0), Vec2::new(0.4, 0.4), 12.0, Vec2::new(160.0, 90.0)), opacity: 0.7, blend: Blend::Normal, fx: None, + input: None, }, ], }; @@ -287,8 +289,8 @@ fn prepared_upload_matches_inline_conversion() { width: w as usize, height: h as usize, layers: vec![ - PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None }, + PlanLayer { frame: yuv16, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: rgbaf, matrix: Affine::scale(0.5, 0.5), opacity: 0.8, blend: Blend::Normal, fx: None, input: None }, ], }; let image = FramePlan::Image(filmcraft_render::Image { w: w as usize, h: h as usize, px: f32_layer }); @@ -317,8 +319,11 @@ fn upload_cache_keeps_buffers_alive() { let mut c = GpuCompositor::new(&dev, &q); let px = Arc::new(vec![200u8; 16 * 8 * 4]); let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() }); - let plan = - FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }] }; + let plan = FramePlan::Layers { + width: 16, + height: 8, + layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }], + }; c.composite(&plan); drop(plan); assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer"); @@ -467,8 +472,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { width: w, height: h, layers: vec![ - PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }, - PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None }, + PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }, + PlanLayer { frame: yuv_alpha_frame(320, 180, bits), matrix: Affine::IDENTITY, opacity: 0.9, blend: Blend::Normal, fx: None, input: None }, ], }; let cpu = execute_cpu(&plan).over_black_rgba8(); @@ -484,7 +489,7 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() { let bg = execute_cpu(&FramePlan::Layers { width: w, height: h, - layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None }], + layers: vec![PlanLayer { frame: background.clone(), matrix: Affine::IDENTITY, opacity: 1.0, blend: Blend::Normal, fx: None, input: None }], }) .over_black_rgba8(); let (l, r, b0) = (at(&gpu, 1), at(&gpu, w - 2), at(&bg, 1)); @@ -500,3 +505,50 @@ fn fast_half_float_conversion_matches_the_reference_for_every_value() { assert!(fast.to_bits() == slow.to_bits() || (fast.is_nan() && slow.is_nan()), "{h:#06x}: {fast} vs {slow}"); } } + +/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence (#965): the shader's colour management +/// (curve, HLG OOTF, BT.2020 → 709 matrix, BT.2390 tone mapping, desaturation) matches the CPU +/// running the same `InputTransform`. +#[test] +fn hdr_input_gpu_matches_cpu() { + use filmcraft_color::{ColorInfo, ColorPipeline, ColorSpace, InputTransform, Matrix, Primaries, Transfer}; + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + // a 10-bit luma ramp (to the top code) under neutral, saturated and extreme chroma, 4:4:4 + let (w, h) = (256u32, 6u32); + let chroma = [(512, 512), (300, 760), (760, 300), (64, 512), (960, 960), (512, 64)]; + let y: Vec = (0..h).flat_map(|_| (0..w).map(|x| (64 + x * 876 / 255) as u16)).collect(); + let cb: Vec = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].0, w as usize)).collect(); + let cr: Vec = (0..h).flat_map(|r| std::iter::repeat_n(chroma[r as usize].1, w as usize)).collect(); + let data = PixelData::Yuv16 { planes: [Arc::new(y), Arc::new(cb), Arc::new(cr)], chroma: Chroma::C444, bits: 10, alpha: None }; + let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited }; + let cases = [ + ("HLG", hdr(Transfer::Hlg), None), + ("PQ", hdr(Transfer::Pq), None), + ("PQ 4000 cd/m²", hdr(Transfer::Pq), Some(4000.0)), + ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 }, None), + ]; + for (name, color, peak) in cases { + let frame = Arc::new(VideoFrame { width: w, height: h, data: data.clone(), color, par: (1, 1), pts: Default::default() }); + let cs = ColorSpace::from_info(&color); + let t = Arc::new(InputTransform::new(cs, color.range, &ColorPipeline::REC709, peak)); + assert!(t.gpu_params().is_some(), "{name}"); + let layer = PlanLayer { input: Some(t), ..PlanLayer::new(frame.clone(), Affine::IDENTITY, 1.0, Blend::Normal) }; + let plan = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![layer] }; + let cpu = execute_cpu(&plan).over_black_rgba8(); + c.composite(&plan); + let (_, _, gpu) = c.read_output().expect("readback"); + let error = cpu.iter().zip(&gpu).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + // and the stage does something: the default decode of the same frame is far off + let plain = FramePlan::Layers { width: w as usize, height: h as usize, layers: vec![PlanLayer::new(frame, Affine::IDENTITY, 1.0, Blend::Normal)] }; + c.composite(&plain); + let (_, _, unmanaged) = c.read_output().expect("readback"); + let apart = cpu.iter().zip(&unmanaged).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + eprintln!("{name}: max error {error}, unmanaged differs by up to {apart}"); + assert!(error <= 2, "{name}: GPU differs from the CPU by up to {error} codes"); + assert!(apart > 20, "{name}: colour management changed nothing ({apart})"); + } +} diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/common/mod.rs index ab75ea00e..e100c3289 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/common/mod.rs @@ -4,6 +4,11 @@ use filmcraft_h264::{Decoder, Picture}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{Mutex, PoisonError}; + +// Tests share fixture names within each binary. Serialize the existence check, +// generation and publication so a late producer cannot replace a file in use. +static FIXTURE_GENERATION: Mutex<()> = Mutex::new(()); /// One encoder configuration of the fixture matrix. pub struct Fixture { @@ -334,6 +339,7 @@ fn run(cmd: &mut Command) -> bool { /// Returns None (with a message) when ffmpeg is unavailable. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?; + let _generation = FIXTURE_GENERATION.lock().unwrap_or_else(PoisonError::into_inner); let dir = fixtures_dir(); let h264 = dir.join(format!("{}.h264", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/conformance.rs index 154bb8e95..c06694b79 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/conformance.rs @@ -148,3 +148,50 @@ fn generate_fixtures() { filmcraft_testkit::fixtures::generate_and_report(&format!("h264/{}", f.name), &outs, || common::ensure(f)); } } + +/// Concurrent consumers must publish one complete fixture without replacing an open file. +#[test] +fn concurrent_fixture_publication_is_safe() { + if filmcraft_testkit::ffmpeg_or_skip("concurrent_fixture_publication").is_none() { + return; + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let name = Box::leak(format!("concurrent_fixture_{}_{stamp}", std::process::id()).into_boxed_str()); + let fixture = + common::Fixture { name, source: "testsrc2", width: 64, height: 48, frames: 2, filter: "", args: &["-preset", "ultrafast", "-x264-params", "keyint=1"] }; + let open_published = |path: &std::path::Path| { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Like the external decoder, allow reads but deny replacement while open. + options.share_mode(1); + } + options.open(path).unwrap() + }; + let start = std::sync::Barrier::new(4); + let results = std::thread::scope(|scope| { + let jobs: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + start.wait(); + let paths = common::ensure(&fixture).expect("software fixture must generate"); + // Keep both published files open until every producer has finished. + let files = (open_published(&paths.0), open_published(&paths.1)); + (paths, files) + }) + }) + .collect(); + jobs.into_iter().map(|j| j.join().expect("fixture producer panicked")).collect::>() + }); + let paths = results[0].0.clone(); + for (got, _) in &results { + assert_eq!(got, &paths); + } + assert!(!std::fs::read(&paths.0).unwrap().is_empty()); + assert_eq!(std::fs::metadata(&paths.1).unwrap().len(), 2 * (64 * 48 + 2 * 32 * 24)); + drop(results); + std::fs::remove_file(paths.0).unwrap(); + std::fs::remove_file(paths.1).unwrap(); +} diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 48f500666..e58088a82 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -581,19 +581,24 @@ fn output_meta(sps: &Sps, pts: i64, key: bool, draft: bool) -> OutputMeta { fn make_picture(o: &Output) -> Picture { let (cx, cy, cw, ch) = o.meta.crop; let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize); - let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch)); let (ccw, cch) = (cw.div_ceil(2), ch.div_ceil(2)); let bd = o.meta.bit_depth; - let conv = |p: Vec| if bd <= 8 { Plane::U8(p.into_iter().map(|v| v as u8).collect()) } else { Plane::U16(p) }; + let (y, u, v) = if bd <= 8 { + let (y, u, v) = o.frame.copy_cropped_u8((cx, cy, cw, ch)); + (Plane::U8(y), Plane::U8(u), Plane::U8(v)) + } else { + let (y, u, v) = o.frame.copy_cropped((cx, cy, cw, ch)); + (Plane::U16(y), Plane::U16(u), Plane::U16(v)) + }; Picture { width: cw as u32, height: ch as u32, chroma_width: ccw as u32, chroma_height: cch as u32, bit_depth: bd, - y: conv(y), - u: conv(u), - v: conv(v), + y, + u, + v, y_stride: cw, uv_stride: ccw, pts: o.meta.pts, diff --git a/crates/hevc/src/filter.rs b/crates/hevc/src/filter.rs index 479e26978..8fc9e2e86 100644 --- a/crates/hevc/src/filter.rs +++ b/crates/hevc/src/filter.rs @@ -51,8 +51,9 @@ fn tc_beta(q: i32, bs: i32, tc_off: i32, beta_off: i32, bd: u32) -> (i32, i32) { ((TC_TABLE[qt as usize] as i32) << (bd - 8), (BETA_TABLE[qb as usize] as i32) << (bd - 8)) } -/// Boundary strength (8.7.2.4) between 4x4 blocks p and q. -fn boundary_strength(pic: &PicState, pi: usize, qi: usize, tu_edge: bool) -> i32 { +/// Boundary strength (8.7.2.4) between 4x4 blocks p and q, in slices `ps` and `qs` (indices into +/// `pic.slices`, from [`edge_params`]). +fn boundary_strength(pic: &PicState, pi: usize, qi: usize, tu_edge: bool, ps: u32, qs: u32) -> i32 { let (p, q) = (&pic.blk[pi], &pic.blk[qi]); if (p.flags | q.flags) & F_INTRA != 0 { return 2; @@ -61,20 +62,22 @@ fn boundary_strength(pic: &PicState, pi: usize, qi: usize, tu_edge: bool) -> i32 return 1; } let (mp, mq) = (&pic.mvf[pi], &pic.mvf[qi]); - motion_bs(pic, pi, mp, qi, mq) + // Same motion in the same slice (same reference lists): same pictures, no motion difference. + if mp == mq && ps == qs { + return 0; + } + motion_bs(pic, ps, mp, qs, mq) } -fn ref_id(pic: &PicState, blk: usize, f: &MvField, l: usize) -> u32 { - let (x, y) = ((blk % pic.w4) * 4, (blk / pic.w4) * 4); - let ctb = (y >> pic.log2_ctb) * pic.wctb + (x >> pic.log2_ctb); - let s = pic.ctb_slice[ctb]; +/// The picture referenced by list `l` of motion `f` in slice `s`. +fn ref_id(pic: &PicState, s: u32, f: &MvField, l: usize) -> u32 { if s == u32::MAX { return u32::MAX; } - pic.slices[s as usize].ref_ids[l].get(f.ref_idx[l] as usize).copied().unwrap_or(u32::MAX - 1) + pic.slices.get(s as usize).and_then(|sl| sl.ref_ids[l].get(f.ref_idx[l] as usize)).copied().unwrap_or(u32::MAX - 1) } -fn motion_bs(pic: &PicState, pi: usize, p: &MvField, qi: usize, q: &MvField) -> i32 { +fn motion_bs(pic: &PicState, ps: u32, p: &MvField, qs: u32, q: &MvField) -> i32 { let np = p.pred(0) as u32 + p.pred(1) as u32; let nq = q.pred(0) as u32 + q.pred(1) as u32; if np != nq { @@ -84,13 +87,13 @@ fn motion_bs(pic: &PicState, pi: usize, p: &MvField, qi: usize, q: &MvField) -> if np == 1 { let lp = if p.pred(0) { 0 } else { 1 }; let lq = if q.pred(0) { 0 } else { 1 }; - if ref_id(pic, pi, p, lp) != ref_id(pic, qi, q, lq) { + if ref_id(pic, ps, p, lp) != ref_id(pic, qs, q, lq) { return 1; } return far(p.mv[lp], q.mv[lq]) as i32; } - let (p0, p1) = (ref_id(pic, pi, p, 0), ref_id(pic, pi, p, 1)); - let (q0, q1) = (ref_id(pic, qi, q, 0), ref_id(pic, qi, q, 1)); + let (p0, p1) = (ref_id(pic, ps, p, 0), ref_id(pic, ps, p, 1)); + let (q0, q1) = (ref_id(pic, qs, q, 0), ref_id(pic, qs, q, 1)); if !((p0 == q0 && p1 == q1) || (p0 == q1 && p1 == q0)) { return 1; } @@ -102,8 +105,8 @@ fn motion_bs(pic: &PicState, pi: usize, p: &MvField, qi: usize, q: &MvField) -> } /// Whether the edge between blocks p and q (q = current block, owning the edge) is filtered, and the -/// slice-level parameters (tc offset, beta offset) of q's slice. -fn edge_params(pic: &PicState, px: usize, py: usize, qx: usize, qy: usize) -> Option<(i32, i32)> { +/// slice-level parameters (tc offset, beta offset) of q's slice, then the slices of p and q. +fn edge_params(pic: &PicState, px: usize, py: usize, qx: usize, qy: usize) -> Option<(i32, i32, u32, u32)> { let s = pic.log2_ctb; let pc = (py >> s) * pic.wctb + (px >> s); let qc = (qy >> s) * pic.wctb + (qx >> s); @@ -124,7 +127,7 @@ fn edge_params(pic: &PicState, px: usize, py: usize, qx: usize, qy: usize) -> Op return None; } } - Some((sq.tc_offset, sq.beta_offset)) + Some((sq.tc_offset, sq.beta_offset, ps, qs)) } /// Deblock CTB row `r`: vertical edges of the row, then horizontal edges whose q side is in the row. @@ -148,8 +151,8 @@ fn deblock_row(pic: &mut PicState, r: usize) { continue; } let (x, y) = (bx * 4, by * 4); - let Some((tc_off, beta_off)) = edge_params(pic, x - 1, y, x, y) else { continue }; - let bs = boundary_strength(pic, qi - 1, qi, e & E_TU_V != 0); + let Some((tc_off, beta_off, ps, qs)) = edge_params(pic, x - 1, y, x, y) else { continue }; + let bs = boundary_strength(pic, qi - 1, qi, e & E_TU_V != 0, ps, qs); bs_v[(by - y0 / 4) * w4 + bx] = bs as i8; if bs == 0 { continue; @@ -170,7 +173,7 @@ fn deblock_row(pic: &mut PicState, r: usize) { } let (x, y) = (bx * 4, by * 4); let qi = by * w4 + bx; - let Some((tc_off, _)) = edge_params(pic, x - 1, y, x, y) else { continue }; + let Some((tc_off, ..)) = edge_params(pic, x - 1, y, x, y) else { continue }; let (p, q) = (pic.blk[qi - 1], pic.blk[qi]); let (nfp, nfq) = (p.flags & F_NOFILTER != 0, q.flags & F_NOFILTER != 0); for (c, off) in [(1, cb_off), (2, cr_off)] { @@ -191,9 +194,9 @@ fn deblock_row(pic: &mut PicState, r: usize) { continue; } let (x, y) = (bx * 4, by * 4); - let Some((tc_off, beta_off)) = edge_params(pic, x, y - 1, x, y) else { continue }; + let Some((tc_off, beta_off, ps, qs)) = edge_params(pic, x, y - 1, x, y) else { continue }; let pi = qi - w4; - let bs = boundary_strength(pic, pi, qi, e & E_TU_H != 0); + let bs = boundary_strength(pic, pi, qi, e & E_TU_H != 0, ps, qs); bs_h[(by - y0 / 4) * w4 + bx] = bs as i8; if bs == 0 { continue; @@ -212,7 +215,7 @@ fn deblock_row(pic: &mut PicState, r: usize) { } let (x, y) = (bx * 4, by * 4); let qi = by * w4 + bx; - let Some((tc_off, _)) = edge_params(pic, x, y - 1, x, y) else { continue }; + let Some((tc_off, ..)) = edge_params(pic, x, y - 1, x, y) else { continue }; let (p, q) = (pic.blk[qi - w4], pic.blk[qi]); let (nfp, nfq) = (p.flags & F_NOFILTER != 0, q.flags & F_NOFILTER != 0); for (c, off) in [(1, cb_off), (2, cr_off)] { @@ -227,46 +230,88 @@ fn deblock_row(pic: &mut PicState, r: usize) { /// Filter one 4-sample luma edge segment. `o` = offset of q0 of the first line, `step` = distance /// between p/q samples across the edge, `along` = distance between lines. +/// +/// The 4 x 8 samples (p3..p0, q0..q3 of each line) are read once into an array, filtered there +/// and written back, a whole line at a time for vertical edges (`step` = 1) and a whole row of +/// the 4 lines at a time for horizontal ones (`along` = 1): no per-sample index arithmetic or +/// bounds checks in the filter itself. #[allow(clippy::too_many_arguments)] fn filter_luma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta: i32, nfp: bool, nfq: bool, bd: u32) { - let px = |s: &[u16], k: usize, i: usize| s[o + k * along - (i + 1) * step] as i32; - let qx = |s: &[u16], k: usize, i: usize| s[o + k * along + i * step] as i32; - let dp0 = (px(s, 0, 2) - 2 * px(s, 0, 1) + px(s, 0, 0)).abs(); - let dp3 = (px(s, 3, 2) - 2 * px(s, 3, 1) + px(s, 3, 0)).abs(); - let dq0 = (qx(s, 0, 2) - 2 * qx(s, 0, 1) + qx(s, 0, 0)).abs(); - let dq3 = (qx(s, 3, 2) - 2 * qx(s, 3, 1) + qx(s, 3, 0)).abs(); + // l[k][i]: line k, i = 0..4 -> p3, p2, p1, p0 and i = 4..8 -> q0, q1, q2, q3 + let mut l = [[0i32; 8]; 4]; + let Some(start) = o.checked_sub(4 * step) else { return }; + if step == 1 { + for (k, line) in l.iter_mut().enumerate() { + let Some(src) = s.get(start + k * along..).and_then(|v| v.first_chunk::<8>()) else { return }; + for (d, &v) in line.iter_mut().zip(src) { + *d = v as i32; + } + } + } else { + for i in 0..8 { + let Some(src) = s.get(start + i * step..).and_then(|v| v.first_chunk::<4>()) else { return }; + for (k, &v) in src.iter().enumerate() { + l[k][i] = v as i32; + } + } + } + if !filter_luma_lines(&mut l, tc, beta, nfp, nfq, bd) { + return; + } + if step == 1 { + for (k, line) in l.iter().enumerate() { + let Some(dst) = s.get_mut(start + k * along..).and_then(|v| v.first_chunk_mut::<8>()) else { return }; + for (d, &v) in dst.iter_mut().zip(line) { + *d = v as u16; + } + } + } else { + for i in 1..7 { + let Some(dst) = s.get_mut(start + i * step..).and_then(|v| v.first_chunk_mut::<4>()) else { return }; + for (k, d) in dst.iter_mut().enumerate() { + *d = l[k][i] as u16; + } + } + } +} + +/// The luma edge filter (8.7.2.5.3 decisions, 8.7.2.5.7 filtering) on the lines of +/// [`filter_luma`]; false when the edge is left unfiltered (d >= beta). +fn filter_luma_lines(l: &mut [[i32; 8]; 4], tc: i32, beta: i32, nfp: bool, nfq: bool, bd: u32) -> bool { + let px = |l: &[[i32; 8]; 4], k: usize, i: usize| l[k][3 - i]; + let qx = |l: &[[i32; 8]; 4], k: usize, i: usize| l[k][4 + i]; + let dp0 = (px(l, 0, 2) - 2 * px(l, 0, 1) + px(l, 0, 0)).abs(); + let dp3 = (px(l, 3, 2) - 2 * px(l, 3, 1) + px(l, 3, 0)).abs(); + let dq0 = (qx(l, 0, 2) - 2 * qx(l, 0, 1) + qx(l, 0, 0)).abs(); + let dq3 = (qx(l, 3, 2) - 2 * qx(l, 3, 1) + qx(l, 3, 0)).abs(); let (dpq0, dpq3) = (dp0 + dq0, dp3 + dq3); let (dp, dq) = (dp0 + dp3, dq0 + dq3); let d = dpq0 + dpq3; if d >= beta { - return; + return false; } - let dsam = |s: &[u16], k: usize, dpq: i32| -> bool { + let dsam = |l: &[[i32; 8]; 4], k: usize, dpq: i32| -> bool { dpq < (beta >> 2) - && (px(s, k, 3) - px(s, k, 0)).abs() + (qx(s, k, 0) - qx(s, k, 3)).abs() < (beta >> 3) - && (px(s, k, 0) - qx(s, k, 0)).abs() < ((5 * tc + 1) >> 1) + && (px(l, k, 3) - px(l, k, 0)).abs() + (qx(l, k, 0) - qx(l, k, 3)).abs() < (beta >> 3) + && (px(l, k, 0) - qx(l, k, 0)).abs() < ((5 * tc + 1) >> 1) }; - let strong = dsam(s, 0, 2 * dpq0) && dsam(s, 3, 2 * dpq3); + let strong = dsam(l, 0, 2 * dpq0) && dsam(l, 3, 2 * dpq3); let dep = dp < ((beta + (beta >> 1)) >> 3); let deq = dq < ((beta + (beta >> 1)) >> 3); let max = (1i32 << bd) - 1; - for k in 0..4 { - let base = o + k * along; - let p = |i: usize| s[base - (i + 1) * step] as i32; - let q = |i: usize| s[base + i * step] as i32; - let (p0, p1, p2, p3) = (p(0), p(1), p(2), p(3)); - let (q0, q1, q2, q3) = (q(0), q(1), q(2), q(3)); + for line in l.iter_mut() { + let [p3, p2, p1, p0, q0, q1, q2, q3] = *line; if strong { let t2 = 2 * tc; if !nfp { - s[base - step] = ((p2 + 2 * p1 + 2 * p0 + 2 * q0 + q1 + 4) >> 3).clamp(p0 - t2, p0 + t2) as u16; - s[base - 2 * step] = ((p2 + p1 + p0 + q0 + 2) >> 2).clamp(p1 - t2, p1 + t2) as u16; - s[base - 3 * step] = ((2 * p3 + 3 * p2 + p1 + p0 + q0 + 4) >> 3).clamp(p2 - t2, p2 + t2) as u16; + line[3] = ((p2 + 2 * p1 + 2 * p0 + 2 * q0 + q1 + 4) >> 3).clamp(p0 - t2, p0 + t2); + line[2] = ((p2 + p1 + p0 + q0 + 2) >> 2).clamp(p1 - t2, p1 + t2); + line[1] = ((2 * p3 + 3 * p2 + p1 + p0 + q0 + 4) >> 3).clamp(p2 - t2, p2 + t2); } if !nfq { - s[base] = ((p1 + 2 * p0 + 2 * q0 + 2 * q1 + q2 + 4) >> 3).clamp(q0 - t2, q0 + t2) as u16; - s[base + step] = ((p0 + q0 + q1 + q2 + 2) >> 2).clamp(q1 - t2, q1 + t2) as u16; - s[base + 2 * step] = ((p0 + q0 + q1 + 3 * q2 + 2 * q3 + 4) >> 3).clamp(q2 - t2, q2 + t2) as u16; + line[4] = ((p1 + 2 * p0 + 2 * q0 + 2 * q1 + q2 + 4) >> 3).clamp(q0 - t2, q0 + t2); + line[5] = ((p0 + q0 + q1 + q2 + 2) >> 2).clamp(q1 - t2, q1 + t2); + line[6] = ((p0 + q0 + q1 + 3 * q2 + 2 * q3 + 4) >> 3).clamp(q2 - t2, q2 + t2); } } else { let mut delta = (9 * (q0 - p0) - 3 * (q1 - p1) + 8) >> 4; @@ -275,21 +320,22 @@ fn filter_luma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta } delta = delta.clamp(-tc, tc); if !nfp { - s[base - step] = (p0 + delta).clamp(0, max) as u16; + line[3] = (p0 + delta).clamp(0, max); } if !nfq { - s[base] = (q0 - delta).clamp(0, max) as u16; + line[4] = (q0 - delta).clamp(0, max); } if dep && !nfp { let dp = ((((p2 + p0 + 1) >> 1) - p1 + delta) >> 1).clamp(-(tc >> 1), tc >> 1); - s[base - 2 * step] = (p1 + dp).clamp(0, max) as u16; + line[2] = (p1 + dp).clamp(0, max); } if deq && !nfq { let dq = ((((q2 + q0 + 1) >> 1) - q1 - delta) >> 1).clamp(-(tc >> 1), tc >> 1); - s[base + step] = (q1 + dq).clamp(0, max) as u16; + line[5] = (q1 + dq).clamp(0, max); } } } + true } /// Filter one 4-sample chroma edge segment. @@ -596,3 +642,110 @@ fn publish_row(pic: &mut PicState, r: usize) { let row = FrameRow { y: oy.into(), cb: ocb.into(), cr: ocr.into(), col: col.into() }; pic.frame.publish(r, row); } + +#[cfg(test)] +mod luma_filter_tests { + use super::*; + + /// The luma edge filter as it was before reading the samples into an array (indexing the + /// picture sample by sample), as the reference. + #[allow(clippy::too_many_arguments)] + fn reference(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta: i32, nfp: bool, nfq: bool, bd: u32) { + let px = |s: &[u16], k: usize, i: usize| s[o + k * along - (i + 1) * step] as i32; + let qx = |s: &[u16], k: usize, i: usize| s[o + k * along + i * step] as i32; + let dp0 = (px(s, 0, 2) - 2 * px(s, 0, 1) + px(s, 0, 0)).abs(); + let dp3 = (px(s, 3, 2) - 2 * px(s, 3, 1) + px(s, 3, 0)).abs(); + let dq0 = (qx(s, 0, 2) - 2 * qx(s, 0, 1) + qx(s, 0, 0)).abs(); + let dq3 = (qx(s, 3, 2) - 2 * qx(s, 3, 1) + qx(s, 3, 0)).abs(); + let (dpq0, dpq3) = (dp0 + dq0, dp3 + dq3); + let (dp, dq) = (dp0 + dp3, dq0 + dq3); + let d = dpq0 + dpq3; + if d >= beta { + return; + } + let dsam = |s: &[u16], k: usize, dpq: i32| -> bool { + dpq < (beta >> 2) + && (px(s, k, 3) - px(s, k, 0)).abs() + (qx(s, k, 0) - qx(s, k, 3)).abs() < (beta >> 3) + && (px(s, k, 0) - qx(s, k, 0)).abs() < ((5 * tc + 1) >> 1) + }; + let strong = dsam(s, 0, 2 * dpq0) && dsam(s, 3, 2 * dpq3); + let dep = dp < ((beta + (beta >> 1)) >> 3); + let deq = dq < ((beta + (beta >> 1)) >> 3); + let max = (1i32 << bd) - 1; + for k in 0..4 { + let base = o + k * along; + let p = |i: usize| s[base - (i + 1) * step] as i32; + let q = |i: usize| s[base + i * step] as i32; + let (p0, p1, p2, p3) = (p(0), p(1), p(2), p(3)); + let (q0, q1, q2, q3) = (q(0), q(1), q(2), q(3)); + if strong { + let t2 = 2 * tc; + if !nfp { + s[base - step] = ((p2 + 2 * p1 + 2 * p0 + 2 * q0 + q1 + 4) >> 3).clamp(p0 - t2, p0 + t2) as u16; + s[base - 2 * step] = ((p2 + p1 + p0 + q0 + 2) >> 2).clamp(p1 - t2, p1 + t2) as u16; + s[base - 3 * step] = ((2 * p3 + 3 * p2 + p1 + p0 + q0 + 4) >> 3).clamp(p2 - t2, p2 + t2) as u16; + } + if !nfq { + s[base] = ((p1 + 2 * p0 + 2 * q0 + 2 * q1 + q2 + 4) >> 3).clamp(q0 - t2, q0 + t2) as u16; + s[base + step] = ((p0 + q0 + q1 + q2 + 2) >> 2).clamp(q1 - t2, q1 + t2) as u16; + s[base + 2 * step] = ((p0 + q0 + q1 + 3 * q2 + 2 * q3 + 4) >> 3).clamp(q2 - t2, q2 + t2) as u16; + } + } else { + let mut delta = (9 * (q0 - p0) - 3 * (q1 - p1) + 8) >> 4; + if delta.abs() >= tc * 10 { + continue; + } + delta = delta.clamp(-tc, tc); + if !nfp { + s[base - step] = (p0 + delta).clamp(0, max) as u16; + } + if !nfq { + s[base] = (q0 - delta).clamp(0, max) as u16; + } + if dep && !nfp { + let dp = ((((p2 + p0 + 1) >> 1) - p1 + delta) >> 1).clamp(-(tc >> 1), tc >> 1); + s[base - 2 * step] = (p1 + dp).clamp(0, max) as u16; + } + if deq && !nfq { + let dq = ((((q2 + q0 + 1) >> 1) - q1 - delta) >> 1).clamp(-(tc >> 1), tc >> 1); + s[base + step] = (q1 + dq).clamp(0, max) as u16; + } + } + } + } + + /// Same samples out of the gathered-block filter as out of the reference, for vertical and + /// horizontal edges, strong and weak filtering, both bit depths and the no-filter flags. + #[test] + fn gathered_luma_filter_matches_the_per_sample_filter() { + let mut rng = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + rng + }; + let w = 16usize; + let mut filtered = 0; + for case in 0..4000 { + let bd = if case % 2 == 0 { 8 } else { 10 }; + let max = (1u64 << bd) - 1; + // smooth blocks (strong filtering) to noisy ones (no filtering) + let base = next() % (max + 1); + let spread = [0u64, 2, 8, 64, max][case % 5]; + let plane: Vec = (0..w * w).map(|_| (base + next() % (spread + 1)).min(max) as u16).collect(); + let tc = (next() % 40) as i32; + let beta = (next() % 120) as i32; + let (nfp, nfq) = (next() % 4 == 0, next() % 4 == 0); + for vertical in [true, false] { + let (o, step, along) = if vertical { (4 * w + 8, 1, w) } else { (8 * w + 4, w, 1) }; + let (mut a, mut b) = (plane.clone(), plane.clone()); + filter_luma(&mut a, o, step, along, tc, beta, nfp, nfq, bd); + reference(&mut b, o, step, along, tc, beta, nfp, nfq, bd); + assert_eq!(a, b, "case {case} vertical {vertical}"); + filtered += (a != plane) as usize; + } + } + assert!(filtered > 1000, "only {filtered} edges filtered"); + } +} diff --git a/crates/hevc/src/inter.rs b/crates/hevc/src/inter.rs index b5feeb41f..3632a60ff 100644 --- a/crates/hevc/src/inter.rs +++ b/crates/hevc/src/inter.rs @@ -40,9 +40,44 @@ fn filt_w(src: &[i16], step: usize, f: &[i8; T], } } -/// [`filt_w`] for a run of `w` outputs (prediction block widths are fixed sizes). +/// [`filt_w`] in 16-bit lanes, for a first pass over 8-bit samples (`shift` = 0): the filtered +/// value is then at most 88 * 255 and at least -24 * 255 (the positive / negative taps of any +/// luma or chroma filter, 8.5.3.3.3.1 / 8.5.3.3.3.2), so it fits in an `i16`, and wrapping sums +/// are exact whenever the final value fits. Twice the lanes of [`filt_w`] per vector. #[inline(always)] -fn filt(src: &[i16], step: usize, f: &[i8; T], shift: u32, out: &mut [i16], w: usize) { +fn filt16_w(src: &[i16], step: usize, f: &[i8; T], out: &mut [i16]) { + let mut acc = [0i16; W]; + for k in 0..T { + let c = f[k] as i16; + let Some(p) = src.get(k * step..).and_then(|s| s.first_chunk::()) else { + return; + }; + for j in 0..W { + acc[j] = acc[j].wrapping_add(c.wrapping_mul(p[j])); + } + } + if let Some(o) = out.first_chunk_mut::() { + *o = acc; + } +} + +/// [`filt_w`] for a run of `w` outputs (prediction block widths are fixed sizes); in 16-bit lanes +/// ([`filt16_w`]) when `narrow` (a first pass over 8-bit samples). +#[inline(always)] +fn filt(src: &[i16], step: usize, f: &[i8; T], shift: u32, out: &mut [i16], w: usize, narrow: bool) { + if narrow { + match w { + 4 => return filt16_w::(src, step, f, out), + 8 => return filt16_w::(src, step, f, out), + 12 => return filt16_w::(src, step, f, out), + 16 => return filt16_w::(src, step, f, out), + 24 => return filt16_w::(src, step, f, out), + 32 => return filt16_w::(src, step, f, out), + 48 => return filt16_w::(src, step, f, out), + 64 => return filt16_w::(src, step, f, out), + _ => {} + } + } match w { 2 => filt_w::(src, step, f, shift, out), 4 => filt_w::(src, step, f, shift, out), @@ -80,6 +115,8 @@ fn interpolate( out: &mut [i16], ) { let m = T / 2 - 1; + // 8-bit samples: first passes in 16-bit lanes (see filt16_w) + let narrow = shift1 == 0 && shift3 == 6; if fx == 0 && fy == 0 { for r in 0..h { for (o, &s) in out[r * w..r * w + w].iter_mut().zip(&win[(r + m) * WIN + m..]) { @@ -88,18 +125,18 @@ fn interpolate( } } else if fy == 0 { for r in 0..h { - filt(&win[(r + m) * WIN..], 1, hf, shift1, &mut out[r * w..], w); + filt(&win[(r + m) * WIN..], 1, hf, shift1, &mut out[r * w..], w, narrow); } } else if fx == 0 { for r in 0..h { - filt(&win[r * WIN + m..], WIN, vf, shift1, &mut out[r * w..], w); + filt(&win[r * WIN + m..], WIN, vf, shift1, &mut out[r * w..], w, narrow); } } else { for r in 0..h + T - 1 { - filt(&win[r * WIN..], 1, hf, shift1, &mut tmp[r * WIN..], w); + filt(&win[r * WIN..], 1, hf, shift1, &mut tmp[r * WIN..], w, narrow); } for r in 0..h { - filt(&tmp[r * WIN..], WIN, vf, 6, &mut out[r * w..], w); + filt(&tmp[r * WIN..], WIN, vf, 6, &mut out[r * w..], w, false); } } } @@ -132,27 +169,72 @@ pub fn mc_chroma(f: &Frame, c: usize, x: i32, y: i32, mv: [i16; 2], w: usize, h: interpolate::<4>(&sc.win, &mut sc.tmp, fx, fy, &CHROMA_FILTER[fx], &CHROMA_FILTER[fy], w, h, shift1, shift3, out); } +/// Calls `$f::(args)` with `W` the prediction block width `$w` as a constant (block widths are +/// fixed sizes; a constant width lets the row loops vectorise even for 4- and 8-sample rows), or +/// `$f::<0>` for any other width. +macro_rules! by_width { + ($w:expr, $f:ident($($a:expr),*)) => { + match $w { + 2 => $f::<2>($($a),*), + 4 => $f::<4>($($a),*), + 6 => $f::<6>($($a),*), + 8 => $f::<8>($($a),*), + 12 => $f::<12>($($a),*), + 16 => $f::<16>($($a),*), + 24 => $f::<24>($($a),*), + 32 => $f::<32>($($a),*), + 48 => $f::<48>($($a),*), + 64 => $f::<64>($($a),*), + _ => $f::<0>($($a),*), + } + }; +} + /// Default weighted prediction, one list (8-262). pub fn put_uni(p: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u16], ds: usize) { + by_width!(w, put_uni_w(p, w, h, bd, dst, ds)) +} + +/// [`put_uni`] for rows of `W` samples (`W` = 0: rows of `w`). +/// +/// In 16-bit lanes: prediction samples are 14-bit values (8.5.3.3.3: at most about +/-27000 for any +/// bit depth, as the reference samples are within `0..2^bd`), so `s + off` and the clipped result fit +/// in an `i16` and the sums are exact. SSE2 clips 16-bit lanes in one instruction each, 32-bit lanes +/// in four. +#[inline(always)] +fn put_uni_w(p: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u16], ds: usize) { + let w = if W > 0 { W } else { w }; let shift = 14 - bd; - let off = if shift > 0 { 1 << (shift - 1) } else { 0 }; - let max = (1i32 << bd) - 1; + let off: i16 = if shift > 0 { 1 << (shift - 1) } else { 0 }; + let max = ((1i32 << bd) - 1) as i16; // Rows as slices and min / max clipping (`clamp` asserts its bounds) so the loop vectorises. for (d, s) in dst.chunks_mut(ds).zip(p[..w * h].chunks(w)) { for (d, &s) in d[..w].iter_mut().zip(s) { - *d = ((s as i32 + off) >> shift).max(0).min(max) as u16; + *d = (s.wrapping_add(off) >> shift).max(0).min(max) as u16; } } } /// Default weighted prediction, bi-prediction (8-263). pub fn put_bi(p0: &[i16], p1: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u16], ds: usize) { - let shift = 15 - bd; - let off = 1 << (shift - 1); - let max = (1i32 << bd) - 1; + by_width!(w, put_bi_w(p0, p1, w, h, bd, dst, ds)) +} + +/// [`put_bi`] for rows of `W` samples (`W` = 0: rows of `w`). +/// +/// In 16-bit lanes, exactly: with `off = 2^(shift - 1)` even, `(a + b + off) >> shift` equals +/// `((a >> 1) + (b >> 1) + (a & b & 1) + off / 2) >> (shift - 1)` (the two dropped low bits only +/// carry when both are set), and `(a >> 1) + (b >> 1)` fits in an `i16` (see [`put_uni_w`]). +#[inline(always)] +fn put_bi_w(p0: &[i16], p1: &[i16], w: usize, h: usize, bd: u32, dst: &mut [u16], ds: usize) { + let w = if W > 0 { W } else { w }; + let shift = 15 - bd; // >= 3 (bd <= 12) + let half_off: i16 = 1 << (shift - 2); + let max = ((1i32 << bd) - 1) as i16; for ((d, a), b) in dst.chunks_mut(ds).zip(p0[..w * h].chunks(w)).zip(p1[..w * h].chunks(w)) { for ((d, &a), &b) in d[..w].iter_mut().zip(a).zip(b) { - *d = ((a as i32 + b as i32 + off) >> shift).max(0).min(max) as u16; + let v = (a >> 1).wrapping_add(b >> 1).wrapping_add(a & b & 1).wrapping_add(half_off) >> (shift - 1); + *d = v.max(0).min(max) as u16; } } } @@ -180,3 +262,143 @@ pub fn put_weighted_bi(p0: &[i16], p1: &[i16], w: usize, h: usize, bd: u32, log2 } } } + +#[cfg(test)] +mod tests { + use super::*; + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn range(&mut self, lo: i32, hi: i32) -> i32 { + lo + (self.next() % (hi - lo + 1) as u64) as i32 + } + } + + const WIDTHS: [usize; 11] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64, 3]; + + /// The 16-bit-lane `put_uni` / `put_bi` give exactly the 32-bit formulas of 8-262 / 8-263 over + /// the whole range of 14-bit prediction samples, for every block width and bit depth. + #[test] + fn default_weighted_prediction_matches_the_32_bit_formulas() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for bd in [8u32, 10, 12] { + let max = (1i32 << bd) - 1; + for &w in &WIDTHS { + let h = 3; + let sample = |rng: &mut Rng| -> i16 { + match rng.next() % 6 { + 0 => -10_240, + 1 => 26_624, + 2 => rng.range(-1, 1) as i16, + _ => rng.range(-10_240, 26_624) as i16, + } + }; + let p0: Vec = (0..w * h).map(|_| sample(&mut rng)).collect(); + let p1: Vec = (0..w * h).map(|_| sample(&mut rng)).collect(); + let ds = w + 5; + let (mut uni, mut bi) = (vec![0u16; ds * h], vec![0u16; ds * h]); + put_uni(&p0, w, h, bd, &mut uni, ds); + put_bi(&p0, &p1, w, h, bd, &mut bi, ds); + for r in 0..h { + for c in 0..w { + let (a, b) = (p0[r * w + c] as i32, p1[r * w + c] as i32); + let s1 = 14 - bd; + let o1 = if s1 > 0 { 1 << (s1 - 1) } else { 0 }; + assert_eq!(uni[r * ds + c] as i32, ((a + o1) >> s1).clamp(0, max), "uni bd {bd} w {w} a {a}"); + let s2 = 15 - bd; + assert_eq!(bi[r * ds + c] as i32, ((a + b + (1 << (s2 - 1))) >> s2).clamp(0, max), "bi bd {bd} w {w} a {a} b {b}"); + } + } + } + } + } + + /// First passes over 8-bit samples in 16-bit lanes equal the 32-bit sums, including the + /// extreme sample patterns of every filter. + #[test] + fn narrow_first_pass_matches_the_32_bit_filter() { + let mut rng = Rng(0x2545_f491_4f6c_dd1d); + fn check(f: &[i8; T], src: &[i16], step: usize, w: usize) { + let (mut a, mut b) = ([0i16; 64], [0i16; 64]); + filt(src, step, f, 0, &mut a, w, true); + filt(src, step, f, 0, &mut b, w, false); + assert_eq!(a[..w], b[..w], "filter {f:?} step {step} w {w}"); + } + for &w in &WIDTHS { + for step in [1usize, WIN] { + let len = w + 7 * step + 8; + let mut patterns: Vec> = (0..20).map(|_| (0..len).map(|_| rng.range(0, 255) as i16).collect()).collect(); + patterns.push(vec![255; len]); + patterns.push(vec![0; len]); + for f in LUMA_FILTER.iter() { + // 255 under positive taps, 0 under negative ones (maximum), and the reverse + for sign in [1, -1] { + let mut v = vec![0i16; len]; + for (k, &c) in f.iter().enumerate() { + for j in 0..w { + v[j + k * step] = if (c as i32) * sign > 0 { 255 } else { 0 }; + } + } + check(f, &v, step, w); + } + for p in &patterns { + check(f, p, step, w); + } + } + for f in CHROMA_FILTER.iter() { + for p in &patterns { + check(f, p, step, w); + } + } + } + } + } + + fn frame(rng: &mut Rng, w: usize, h: usize, bd: u32) -> Frame { + let like = Frame::new(0, 0, w, h, 4, bd, bd); + let max = (1i32 << bd) - 1; + let y: Vec = (0..w * h).map(|_| rng.range(0, max) as u16).collect(); + let c: Vec = (0..w * h / 4).map(|_| rng.range(0, max) as u16).collect(); + let c2: Vec = (0..w * h / 4).map(|_| rng.range(0, max) as u16).collect(); + Frame::from_planes(1, 0, &like, &y, &c, &c2) + } + + /// The full-sample uni-prediction shortcut (`Frame::copy_block`) gives what interpolation and + /// 8-262 give, inside the picture and across every edge. + #[test] + fn full_sample_copy_matches_interpolation() { + let mut rng = Rng(0x0123_4567_89ab_cdef); + let mut sc = McScratch::default(); + for bd in [8u32, 10] { + let f = frame(&mut rng, 48, 40, bd); + for _ in 0..300 { + let (w, h) = ([4usize, 8, 12, 16, 24, 32][rng.range(0, 5) as usize], [4usize, 8, 16][rng.range(0, 2) as usize]); + let (x, y) = (rng.range(-40, 80), rng.range(-40, 70)); + let mut pred = vec![0i16; w * h]; + let (mut a, mut b) = (vec![0u16; w * h], vec![0u16; w * h]); + inter_luma(&f, x, y, w, h, &mut pred, &mut sc); + put_uni(&pred, w, h, bd, &mut a, w); + f.copy_block(0, x, y, w, h, &mut b, w); + assert_eq!(a, b, "luma bd {bd} at ({x}, {y}) {w}x{h}"); + for c in 1..3 { + let (cw, ch) = (w / 2, h / 2); + mc_chroma(&f, c - 1, 0, 0, [(x * 8) as i16, (y * 8) as i16], cw, ch, &mut pred, &mut sc); + let (mut a, mut b) = (vec![0u16; cw * ch], vec![0u16; cw * ch]); + put_uni(&pred, cw, ch, bd, &mut a, cw); + f.copy_block(c, x, y, cw, ch, &mut b, cw); + assert_eq!(a, b, "chroma {c} bd {bd} at ({x}, {y}) {cw}x{ch}"); + } + } + } + } + + fn inter_luma(f: &Frame, x: i32, y: i32, w: usize, h: usize, out: &mut [i16], sc: &mut McScratch) { + mc_luma(f, 0, 0, [(x * 4) as i16, (y * 4) as i16], w, h, out, sc); + } +} diff --git a/crates/hevc/src/picture.rs b/crates/hevc/src/picture.rs index 55d2730fa..f1655687f 100644 --- a/crates/hevc/src/picture.rs +++ b/crates/hevc/src/picture.rs @@ -138,27 +138,70 @@ impl Frame { } } + /// Copy the w x h block at (x0, y0) of plane `c` (0 = luma, 1 = Cb, 2 = Cr) into `dst` + /// (stride `ds`), samples outside the picture replicated from its edges (8-228, 8-229). + #[allow(clippy::too_many_arguments)] + pub fn copy_block(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, dst: &mut [u16], ds: usize) { + let (pw, ph, sh) = if c == 0 { (self.width, self.height, self.log2_ctb) } else { (self.cwidth, self.cheight, self.log2_ctb - 1) }; + let inside = usize::try_from(x0).ok().filter(|&x| x.saturating_add(w) <= pw); + for (r, d) in dst.chunks_mut(ds).take(h).enumerate() { + let yy = y0.saturating_add(r as i32).min(ph as i32 - 1).max(0) as usize; + let row = self.row(yy >> sh); + let plane = match c { + 0 => &row.y, + 1 => &row.cb, + _ => &row.cr, + }; + let off = (yy & ((1 << sh) - 1)) * pw; + let Some(line) = plane.get(off..off + pw) else { return }; + let Some(d) = d.get_mut(..w) else { return }; + match inside.and_then(|x| line.get(x..x + w)) { + Some(s) => copy_run(s, d), + None => { + let max = (pw as i32 - 1).max(0); + for (i, d) in d.iter_mut().enumerate() { + *d = line.get(x0.saturating_add(i as i32).clamp(0, max) as usize).copied().unwrap_or(0); + } + } + } + } + } + /// Crop and copy the frame into planar buffers (waits for completion). pub fn copy_cropped(&self, crop: (usize, usize, usize, usize)) -> (Vec, Vec, Vec) { + let mut out = (Vec::new(), Vec::new(), Vec::new()); + self.copy_cropped_with(crop, &mut out, |o, s| o.extend_from_slice(s)); + out + } + + /// [`copy_cropped`](Self::copy_cropped) into 8-bit planes (samples of an 8-bit picture), + /// without an intermediate 16-bit copy. + pub fn copy_cropped_u8(&self, crop: (usize, usize, usize, usize)) -> (Vec, Vec, Vec) { + let mut out = (Vec::new(), Vec::new(), Vec::new()); + self.copy_cropped_with(crop, &mut out, |o, s| o.extend(s.iter().map(|&v| v as u8))); + out + } + + fn copy_cropped_with(&self, crop: (usize, usize, usize, usize), out: &mut (Vec, Vec, Vec), put: impl Fn(&mut Vec, &[u16])) { let (cx, cy, cw, ch) = crop; let ctb = 1usize << self.log2_ctb; - let mut y = Vec::with_capacity(cw * ch); + let (y, u, v) = out; + y.reserve_exact(cw * ch); for r in cy..cy + ch { let row = self.row(r / ctb); let o = (r % ctb) * self.width + cx; - y.extend_from_slice(&row.y[o..o + cw]); + put(y, &row.y[o..o + cw]); } let (ccx, ccy, ccw, cch) = (cx / 2, cy / 2, cw.div_ceil(2), ch.div_ceil(2)); let cctb = ctb / 2; - let mut u = Vec::with_capacity(ccw * cch); - let mut v = Vec::with_capacity(ccw * cch); + u.reserve_exact(ccw * cch); + v.reserve_exact(ccw * cch); for r in ccy..ccy + cch { let row = self.row(r / cctb); let o = (r % cctb) * self.cwidth + ccx; - u.extend_from_slice(&row.cb[o..o + ccw]); - v.extend_from_slice(&row.cr[o..o + ccw]); + put(u, &row.cb[o..o + ccw]); + put(v, &row.cr[o..o + ccw]); } - (y, u, v) } /// A frame with every row published from full-picture planes and no motion (used to replace missing @@ -188,13 +231,69 @@ impl Frame { } } +/// `dst.copy_from_slice(src)` for the run lengths of prediction blocks as fixed-size copies: a +/// libc `memcpy` call per short block line costs more than the copy. +#[inline(always)] +fn copy_run(src: &[u16], dst: &mut [u16]) { + #[inline(always)] + fn fixed(src: &[u16], dst: &mut [u16]) { + if let (Some(s), Some(d)) = (src.first_chunk::(), dst.first_chunk_mut::()) { + *d = *s; + } + } + match dst.len() { + 2 => fixed::<2>(src, dst), + 4 => fixed::<4>(src, dst), + 6 => fixed::<6>(src, dst), + 8 => fixed::<8>(src, dst), + 12 => fixed::<12>(src, dst), + 16 => fixed::<16>(src, dst), + 24 => fixed::<24>(src, dst), + 32 => fixed::<32>(src, dst), + 48 => fixed::<48>(src, dst), + 64 => fixed::<64>(src, dst), + n => { + if let Some(s) = src.get(..n) { + dst.copy_from_slice(s); + } + } + } +} + /// Copy `out.len()` samples of `line` starting at x0, replicating edge samples outside the line. #[inline(always)] fn copy_line(line: &[u16], x0: i32, out: &mut [i16]) { let w = out.len(); if x0 >= 0 && x0 as usize + w <= line.len() { - for (o, &s) in out.iter_mut().zip(&line[x0 as usize..x0 as usize + w]) { - *o = s as i16; + let src = &line[x0 as usize..x0 as usize + w]; + // fixed-size copies for the window widths (block width + 7 luma, + 3 chroma): see copy_run + #[inline(always)] + fn fixed(src: &[u16], out: &mut [i16]) { + if let (Some(s), Some(o)) = (src.first_chunk::(), out.first_chunk_mut::()) { + for j in 0..N { + o[j] = s[j] as i16; + } + } + } + match w { + 5 => fixed::<5>(src, out), + 7 => fixed::<7>(src, out), + 9 => fixed::<9>(src, out), + 11 => fixed::<11>(src, out), + 15 => fixed::<15>(src, out), + 19 => fixed::<19>(src, out), + 23 => fixed::<23>(src, out), + 27 => fixed::<27>(src, out), + 31 => fixed::<31>(src, out), + 35 => fixed::<35>(src, out), + 39 => fixed::<39>(src, out), + 55 => fixed::<55>(src, out), + 71 => fixed::<71>(src, out), + _ => { + for (o, &s) in out.iter_mut().zip(src) { + *o = s as i16; + } + } } } else { let max = line.len() as i32 - 1; diff --git a/crates/hevc/src/slicedec.rs b/crates/hevc/src/slicedec.rs index 949485648..12b9d4c36 100644 --- a/crates/hevc/src/slicedec.rs +++ b/crates/hevc/src/slicedec.rs @@ -1448,6 +1448,21 @@ impl<'a> SliceDecoder<'a> { let bi = f.pred(0) && f.pred(1); for c in 0..3 { let (x, y, bw, bh, bd) = if c == 0 { (xp, yp, w, h, self.pic.bd_y) } else { (xp / 2, yp / 2, w / 2, h / 2, self.pic.bd_c) }; + // Uni-prediction at a full-sample position without weighting: the prediction + // ((s << shift3) + off) >> shift3 (8-228, 8-262) is the reference sample itself. + if !bi && !weighted { + let l = if f.pred(0) { 0 } else { 1 }; + let frac = if c == 0 { 3 } else { 7 }; + let mv = f.mv[l]; + if f.pred(l) && mv[0] & frac == 0 && mv[1] & frac == 0 { + let sh = if c == 0 { 2 } else { 3 }; + let stride = if c == 0 { self.pic.width } else { self.pic.cwidth }; + let frame = &self.refs[l][f.ref_idx[l] as usize].frame; + let dst = &mut self.pic.planes[c][y as usize * stride + x as usize..]; + frame.copy_block(c, x + (mv[0] as i32 >> sh), y + (mv[1] as i32 >> sh), bw, bh, dst, stride); + continue; + } + } let mut lists = [false; 2]; for l in 0..2 { if !f.pred(l) { diff --git a/crates/interchange/src/fcp7.rs b/crates/interchange/src/fcp7.rs index d5de669d2..e0e9075d9 100644 --- a/crates/interchange/src/fcp7.rs +++ b/crates/interchange/src/fcp7.rs @@ -3,8 +3,9 @@ //! //! Supported: `project`/`bin`/`clip`/`sequence` structure, video and audio tracks (enabled, //! locked), `clipitem` (start/end/in/out, `pproTicksIn/Out` for tick-exact source times, enabled, -//! labels, markers, links), `file` (pathurl, rate, duration, timecode, media characteristics, -//! references by id), nested sequences (inline or by id), `generatoritem` (Slug, Color, …), +//! labels, markers, links), master-clip `` media markers, `file` (pathurl, rate, duration, +//! timecode, media characteristics, references by id), nested sequences (inline or by id), +//! `generatoritem` (Slug, Color, …), //! `transitionitem` (alignment, Cross Dissolve & friends), and the filters Basic Motion (scale, //! rotation, center, anchor, anti-flicker), Opacity, Time Remap (constant speed, reverse, freeze), //! Audio Levels and Audio Pan, all with keyframes. Other effects are written with our effect ids @@ -516,6 +517,14 @@ impl<'a, 'i> Imp<'a, 'i, '_> { && let Ok(ch) = u16::try_from(idx - 1) { ti.source_channels = vec![ch]; + // The two halves of a split stereo file play on their own side, as the stereo clip did (#371): + // a mono clip is centred (both sides at unity), so the halves would otherwise sum on both + // sides, +3 dB for uncorrelated channels, +6 dB for dual mono. An `audiopan` filter + // (read below) still overrides this. + let stereo = matches!(&self.b.p.item(item).map(|i| &i.kind), Some(ItemKind::Media(m)) if m.info.audio().is_some_and(|a| a.channels == 2)); + if stereo && ch <= 1 { + set_param(&mut ti, "panner", "balance", Param::new(ParamValue::Float(if ch == 0 { -100.0 } else { 100.0 }))); + } } if let Some(mode) = child_text(c, "compositemode") { match BLEND_XML.iter().find(|(n, _)| n.eq_ignore_ascii_case(mode)) { @@ -803,6 +812,7 @@ pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: }; let v = if opts.xmeml_version == 5 { "5" } else { "4" }; x.w.open("xmeml", &[("version", v)]); + x.write_master_clips(seq_id); x.write_sequence(seq_id, true); x.w.close(); Ok(x.w.finish()) @@ -951,6 +961,57 @@ impl Exp<'_, '_> { self.master_ids.entry(item).or_insert_with(|| format!("masterclip-{n}")).clone() } + /// Source-media (master clip) markers live on ``, not on timeline ``s. + fn write_master_clips(&mut self, seq_id: ItemId) { + let mut seen = HashSet::new(); + let mut items = Vec::new(); + self.collect_file_media(seq_id, &mut seen, &mut items); + for item in items { + let Some(m) = item_media(self.p, item) else { continue }; + if m.markers.is_empty() { + continue; + } + let rate = m + .info + .video + .as_ref() + .map(|v| m.interpret.frame_rate.unwrap_or(v.frame_rate)) + .unwrap_or_else(|| self.p.sequence(seq_id).map(|s| s.settings.frame_rate).unwrap_or_default()); + let name = self.p.item(item).map(|i| i.name.clone()).unwrap_or_default(); + let label = self.p.item(item).map(|i| i.label).unwrap_or(Label::Iris); + let duration = m.info.duration; + let markers = m.markers.clone(); + let mid = self.master_id(item); + self.w.open("clip", &[("id", &mid)]); + self.w.text("name", name); + self.w.text("duration", frames_round(rate, duration)); + write_rate(&mut self.w, rate); + self.w.open("labels", &[]); + self.w.text("label2", label.name()); + self.w.close(); + self.write_file(item, rate); + write_markers(&mut self.w, &markers, rate); + self.w.close(); + } + } + + fn collect_file_media(&self, seq: ItemId, seen: &mut HashSet, out: &mut Vec) { + if !seen.insert(seq) { + return; + } + let Some(s) = self.p.sequence(seq) else { return }; + for t in s.all_tracks() { + for c in &t.items { + let base = base_item(self.p, c.item); + match self.p.item(base).map(|i| &i.kind) { + Some(ItemKind::Sequence(_)) => self.collect_file_media(base, seen, out), + Some(ItemKind::Media(_)) if crate::common::generator_of(self.p, base).is_none() && !out.contains(&base) => out.push(base), + _ => {} + } + } + } + } + fn write_clip(&mut self, c: &TrackItem, kind: TrackKind, seq: &Sequence, xid: &str, links: &[(String, TrackKind, usize, usize)]) { let rate = seq.settings.frame_rate; if !on_frame(rate, c.start) || !on_frame(rate, c.duration) { @@ -1159,7 +1220,9 @@ impl Exp<'_, '_> { self.float_param(c, "volume", "level", "Level", 0.0, 3.98109, rate, move |v| db_to_gain(v + g), "level"); self.close_effect(); } - if param_modified(c, "panner", "balance") { + // A one-channel clip says where it is panned even when centred: without Audio Pan the + // importer pans the halves of a split stereo file to their sides (#371). + if param_modified(c, "panner", "balance") || c.source_channels.len() == 1 { self.open_effect("Audio Pan", "audiopan", "audiopan", "audiopan", "audio", true); self.float_param(c, "panner", "balance", "Pan", -1.0, 1.0, rate, |v| v / 100.0, "pan"); self.close_effect(); diff --git a/crates/interchange/src/fcpxml.rs b/crates/interchange/src/fcpxml.rs index faf6a77cf..e231fff5e 100644 --- a/crates/interchange/src/fcpxml.rs +++ b/crates/interchange/src/fcpxml.rs @@ -431,7 +431,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } self.adjustments(&mut ti, el, local_start, src_in, speed, ctx, kind); if !primary || kind == TrackKind::Audio { - ti.markers = self.markers(el, local_start, src_in, speed); + ti.markers = self.markers(el, local_start, src_in, speed, ctx.seq_rate); } let pref = match kind { TrackKind::Video => lane.max(0) as usize, @@ -453,7 +453,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { } if primary { // Markers on primary storyline elements are timeline markers. - for m in self.markers(el, local_start, Tick::ZERO, 1.0) { + for m in self.markers(el, local_start, Tick::ZERO, 1.0, ctx.seq_rate) { seq.markers.push(Marker { start: t0 + m.start, ..m }); } } @@ -475,8 +475,10 @@ impl<'a, 'i> Imp<'a, 'i, '_> { (t0 + dur, vclip) } - fn markers(&mut self, el: Node, local_start: Tick, src_in: Tick, speed: f64) -> Vec { + fn markers(&mut self, el: Node, local_start: Tick, src_in: Tick, speed: f64, rate: FrameRate) -> Vec { let mut out = Vec::new(); + // FCPXML has no zero-duration marker: a point is written as one frame at the sequence rate. + let frame = rate.frame_duration(); for m in elements(el).filter(|m| matches!(m.tag_name().name(), "marker" | "chapter-marker")) { let rel = attr_time(m, "start").unwrap_or(Tick::ZERO) - local_start; let start = if src_in == Tick::ZERO && speed == 1.0 { rel } else { src_in + scaled(rel, speed) }; @@ -484,7 +486,7 @@ impl<'a, 'i> Imp<'a, 'i, '_> { out.push(Marker { id: MarkerId(self.b.alloc()), start, - duration: if d.0 <= 1 { Tick::ZERO } else { d }, + duration: if d <= frame { Tick::ZERO } else { d }, name: m.attribute("value").unwrap_or("").to_string(), comment: m.attribute("note").unwrap_or("").to_string(), kind: if m.has_tag_name("chapter-marker") { MarkerKind::Chapter } else { MarkerKind::Comment }, diff --git a/crates/interchange/src/otio.rs b/crates/interchange/src/otio.rs index b4fabb31f..421bea985 100644 --- a/crates/interchange/src/otio.rs +++ b/crates/interchange/src/otio.rs @@ -273,9 +273,10 @@ impl Exp<'_, '_> { let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate))); self.stack.pop(); s["effects"] = json!(effects); - s["markers"] = json!(markers); s["enabled"] = json!(it.enabled); s["metadata"]["filmcraft"]["clip"] = meta["filmcraft"].clone(); + // Nested sequence markers stay on the Stack; clip instance markers live on the clip metadata. + s["metadata"]["filmcraft"]["clip"]["markers"] = json!(markers); return s; } let _ = generator_of(self.p, base); @@ -665,7 +666,10 @@ impl Imp<'_, '_> { let name = name_of(c); let mut ti = self.b.clip(item, kind, if name.is_empty() { "Nested Sequence" } else { &name }, pos, dur, src_in); let clip_meta = meta.and_then(|m| m.get("clip")).cloned(); - // Stack markers belong to the nested sequence, not the clip instance. + // Stack markers belong to the nested sequence; instance markers are on filmcraft.clip. + if let Some(ref cm) = clip_meta { + ti.markers = self.markers(cm); + } self.apply_clip_meta(&mut ti, c, clip_meta.as_ref()); Some(ti) } diff --git a/crates/interchange/tests/fcp7.rs b/crates/interchange/tests/fcp7.rs index 6b2a9414b..aad1d5f0e 100644 --- a/crates/interchange/tests/fcp7.rs +++ b/crates/interchange/tests/fcp7.rs @@ -3,7 +3,7 @@ mod support; -use filmcraft_interchange::{ExportOptions, Format, detect, export, import}; +use filmcraft_interchange::{ExportOptions, Format, Imported, detect, export, import}; use filmcraft_media::Generator; use filmcraft_project::{ItemKind, Keyframe, Label, Marker, MarkerId, MarkerKind, MediaRef, Param, ParamValue, Project, TrackKind, TransitionAlign}; use filmcraft_time::{FrameRate, Tick}; @@ -600,3 +600,66 @@ fn audio_trackindex_1_is_channel_0_only_when_the_file_is_split() { assert!(a1[1].source_channels.is_empty(), "{:?}", a1[1].source_channels); assert_eq!(seq.tracks(TrackKind::Audio)[1].items[0].source_channels, vec![1u16]); } + +/// A Premiere-style stereo file split onto two mono tracks (A1 = `trackindex` 1, A2 = 2). +fn split_stereo_doc(pan_a1: &str) -> String { + format!( + r#" + +S48{RATE} + +"# + ) +} + +fn balances(imp: &Imported) -> Vec { + let seq = imp.project.sequence(only_seq(imp)).unwrap(); + seq.tracks(TrackKind::Audio) + .iter() + .flat_map(|t| &t.items) + .map(|i| i.effect("panner").and_then(|e| e.param("balance")).and_then(|p| p.value.as_f64()).unwrap_or(0.0)) + .collect() +} + +/// #371: the halves of a split stereo file played centred, so both channels summed on both sides +/// (+3 dB for a stereo effect, +6 dB for dual-mono dialogue). They now play on their own side. +#[test] +fn split_stereo_halves_are_panned_to_their_side() { + let (imp, _) = import(split_stereo_doc("").as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![-100.0, 100.0]); +} + +/// An Audio Pan filter on a split half still wins. +#[test] +fn split_stereo_half_keeps_its_audio_pan() { + let pan = r#"Audio Panaudiopanaudiopanaudiopanaudio + panPan-110"#; + let (imp, _) = import(split_stereo_doc(pan).as_bytes(), Format::Fcp7Xml, None).unwrap(); + assert_eq!(balances(&imp), vec![0.0, 100.0]); +} + +/// A centred one-channel clip (Breakout to Mono) round-trips centred: export writes its Audio Pan. +#[test] +fn centred_mono_breakout_round_trips_centred() { + let r = FrameRate::FPS_23_976; + let mut p = Project::new("P"); + let a = media(&mut p, "/m/a.wav", false, true, r); + let s = sequence(&mut p, "t", r, false); + let left = clip(&mut p, s, TrackKind::Audio, 0, a, 0, 24, 0); + let right = clip(&mut p, s, TrackKind::Audio, 1, a, 0, 24, 0); + for (id, ch) in [(left, 0u16), (right, 1u16)] { + let (_, it) = p.sequence_mut(s).unwrap().find_item_mut(id).unwrap(); + it.source_channels = vec![ch]; + } + let (imp, xml, _) = roundtrip(&p, s, Format::Fcp7Xml, &ExportOptions::default()); + assert_eq!(balances(&imp), vec![0.0, 0.0], "{xml}"); +} diff --git a/crates/interchange/tests/fcpxml.rs b/crates/interchange/tests/fcpxml.rs index b5483450b..12892da57 100644 --- a/crates/interchange/tests/fcpxml.rs +++ b/crates/interchange/tests/fcpxml.rs @@ -152,7 +152,10 @@ fn golden_connected_clips() { assert!(s.audio_tracks.iter().flat_map(|t| &t.items).all(|i| i.item != boats.item)); // marker on a primary element is a timeline marker assert_eq!(s.markers.len(), 1); - assert_eq!((s.markers[0].name.as_str(), s.markers[0].comment.as_str(), s.markers[0].start), ("Seagull", "nice", r.tick_of(24))); + assert_eq!( + (s.markers[0].name.as_str(), s.markers[0].comment.as_str(), s.markers[0].start, s.markers[0].duration), + ("Seagull", "nice", r.tick_of(24), filmcraft_time::Tick::ZERO) + ); // event became a bin assert!(p.root.children.iter().any(|c| matches!(c, filmcraft_project::BinEntry::Bin(b) if b.name == "Day 1"))); assert!(!rep.has_warnings(), "{rep}"); diff --git a/crates/interchange/tests/media_marker_export.rs b/crates/interchange/tests/media_marker_export.rs new file mode 100644 index 000000000..752cc8b5d --- /dev/null +++ b/crates/interchange/tests/media_marker_export.rs @@ -0,0 +1,49 @@ +//! Source-media (master clip) markers must survive FCP7 XML export for xmeml 4 and 5. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format, export, import}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn project_with_media_marker(rate: FrameRate) -> (filmcraft_project::Project, filmcraft_project::ItemId) { + let mut p = filmcraft_project::Project::new("Markers"); + let a = media(&mut p, "/m/src.mov", true, true, rate); + if let Some(m) = p.item_mut(a).and_then(|i| i.as_media_mut()) { + m.markers.push(Marker { + id: MarkerId(1), + start: rate.tick_of(24), + duration: Tick::ZERO, + name: "media hit".into(), + comment: "on source".into(), + kind: MarkerKind::Comment, + color: Label::Green, + }); + } + let s = sequence(&mut p, "Cut", rate, false); + clip(&mut p, s, TrackKind::Video, 0, a, 0, 48, 0); + (p, s) +} + +#[test] +fn fcp7_xml_exports_master_clip_markers_on_xmeml_4_and_5() { + let rate = FrameRate::FPS_24; + let (p, s) = project_with_media_marker(rate); + for version in [4u32, 5] { + let opts = ExportOptions { xmeml_version: version, ..Default::default() }; + let (bytes, report) = export(&p, s, Format::Fcp7Xml, &opts).expect("export"); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!(text.contains(&format!("")), "{text}"); + assert!(text.contains("media hit"), "{text}"); + assert!(!report.has_warnings(), "export must not silently drop media markers: {report}"); + let (imp, irep) = import(&bytes, Format::Fcp7Xml, None).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert!(!irep.has_warnings(), "{irep}"); + let media = imp.project.items.values().find(|i| i.name == "src.mov").and_then(|i| i.as_media()).expect("media"); + assert_eq!(media.markers.len(), 1, "{text}"); + assert_eq!(media.markers[0].name, "media hit"); + assert_eq!(media.markers[0].comment, "on source"); + assert_eq!(media.markers[0].start, rate.tick_of(24)); + } +} diff --git a/crates/interchange/tests/nested_sequence_markers.rs b/crates/interchange/tests/nested_sequence_markers.rs new file mode 100644 index 000000000..9f69b755e --- /dev/null +++ b/crates/interchange/tests/nested_sequence_markers.rs @@ -0,0 +1,77 @@ +//! Nested sequence markers vs clip instance markers in OTIO (issue #901). +//! +//! FCP7 XML and FCPXML already keep these on the right objects. OTIO must not drop the nested +//! sequence's markers when the using clip has none, and must not put clip instance markers on the +//! nested sequence. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn nest_project(clip_has_marker: bool) -> (filmcraft_project::Project, filmcraft_project::ItemId, filmcraft_project::ItemId) { + let r = FrameRate::FPS_24; + let mut p = filmcraft_project::Project::new("P"); + let a = media(&mut p, "/m/a.mov", true, true, r); + let nest = sequence(&mut p, "Inner", r, false); + clip(&mut p, nest, TrackKind::Video, 0, a, 0, 48, 0); + { + let q = p.sequence_mut(nest).unwrap(); + q.markers.push(Marker { + id: MarkerId(1), + start: r.tick_of(12), + duration: Tick::ZERO, + name: "on nest".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Green, + }); + } + let s = sequence(&mut p, "Outer", r, false); + let nest_clip = clip(&mut p, s, TrackKind::Video, 0, nest, 0, 48, 0); + if clip_has_marker { + let q = p.sequence_mut(s).unwrap(); + let (_, it) = q.find_item_mut(nest_clip).unwrap(); + it.markers.push(Marker { + id: MarkerId(2), + start: r.tick_of(6), + duration: Tick::ZERO, + name: "on clip".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Yellow, + }); + } + (p, s, nest) +} + +fn names(ms: &[Marker]) -> Vec<&str> { + ms.iter().map(|m| m.name.as_str()).collect() +} + +#[test] +fn otio_keeps_nested_sequence_markers_when_clip_has_none() { + let (p, s, nest) = nest_project(false); + let (imp, text, rep) = roundtrip(&p, s, Format::Otio, &ExportOptions::default()); + assert!(!rep.has_warnings(), "{rep}"); + let outer = only_seq(&imp); + let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0]; + assert!(nest_clip.markers.is_empty(), "clip instance must stay marker-free\n{text}"); + let inner = nest_clip.item; + assert_eq!(imp.project.item(inner).unwrap().name, "Inner"); + assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}"); +} + +#[test] +fn otio_keeps_clip_instance_markers_off_the_nested_sequence() { + let (p, s, nest) = nest_project(true); + let (imp, text, _) = roundtrip(&p, s, Format::Otio, &ExportOptions::default()); + let outer = only_seq(&imp); + let nest_clip = &imp.project.sequence(outer).unwrap().video_tracks[0].items[0]; + assert_eq!(names(&nest_clip.markers), vec!["on clip"], "{text}"); + assert_eq!(nest_clip.markers[0].start, FrameRate::FPS_24.tick_of(6)); + let inner = nest_clip.item; + assert_eq!(names(&imp.project.sequence(inner).unwrap().markers), names(&p.sequence(nest).unwrap().markers), "{text}"); +} diff --git a/crates/interchange/tests/point_markers.rs b/crates/interchange/tests/point_markers.rs new file mode 100644 index 000000000..e0ffb5616 --- /dev/null +++ b/crates/interchange/tests/point_markers.rs @@ -0,0 +1,86 @@ +//! FCPXML point markers: a one-frame `` is a FilmCraft point (duration 0), not a range. + +mod support; + +use filmcraft_interchange::{ExportOptions, Format, import}; +use filmcraft_project::{Label, Marker, MarkerId, MarkerKind, Project, TrackKind}; +use filmcraft_time::{FrameRate, Tick}; +use support::*; + +fn fcpxml_with_markers(frame: &str, point_dur: &str, range_dur: &str) -> String { + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + +"# + ) +} + +#[test] +fn one_frame_fcpxml_marker_is_a_point() { + let r = FrameRate::FPS_24; + let xml = fcpxml_with_markers("1/24s", "1/24s", "2/24s"); + let (imp, _) = import(xml.as_bytes(), Format::Fcpxml, None).unwrap(); + let s = imp.project.sequence(only_seq(&imp)).unwrap(); + assert_eq!(s.markers.len(), 2); + assert_eq!((s.markers[0].name.as_str(), s.markers[0].start, s.markers[0].duration), ("Point", r.tick_of(24), Tick::ZERO)); + assert_eq!((s.markers[1].name.as_str(), s.markers[1].start, s.markers[1].duration), ("Range", r.tick_of(48), r.tick_of(2))); +} + +#[test] +fn point_roundtrips_through_the_one_frame_convention() { + let r = FrameRate::FPS_24; + let mut p = Project::new("Lib"); + let a = media(&mut p, "/m/a.mov", true, true, r); + let s = sequence(&mut p, "Cut", r, false); + clip(&mut p, s, TrackKind::Video, 0, a, 0, 240, 0); + { + let q = p.sequence_mut(s).unwrap(); + q.markers.push(Marker { + id: MarkerId(1), + start: r.tick_of(24), + duration: Tick::ZERO, + name: "Point".into(), + comment: String::new(), + kind: MarkerKind::Comment, + color: Label::Blue, + }); + q.markers.push(Marker { + id: MarkerId(2), + start: r.tick_of(48), + duration: r.tick_of(5), + name: "Range".into(), + comment: "span".into(), + kind: MarkerKind::Comment, + color: Label::Blue, + }); + } + let (imp, text, _) = roundtrip(&p, s, Format::Fcpxml, &ExportOptions::default()); + assert!(text.contains("duration=\"1/24s\""), "{text}"); + let qs = imp.project.sequence(only_seq(&imp)).unwrap(); + assert_eq!( + qs.markers.iter().map(|m| (m.name.as_str(), m.start, m.duration)).collect::>(), + vec![("Point", r.tick_of(24), Tick::ZERO), ("Range", r.tick_of(48), r.tick_of(5))] + ); +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index 6d2ff79b2..1fbcff9ce 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -569,11 +569,15 @@ impl AacConfig { let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } } - /// Channel count implied by the channel configuration (0 if unspecified). + /// Channel count implied by the channel configuration (ISO/IEC 14496-3 Table 1.19 with Amd 4: + /// 7 and 12 are 7.1 front / back, 11 is 6.1, 13 is 22.2, 14 is 7.1 with front heights), 0 if + /// unspecified (a PCE) or reserved. pub fn channels(&self) -> u32 { match self.channel_config { 1..=6 => self.channel_config as u32, - 7 => 8, + 11 => 7, + 7 | 12 | 14 => 8, + 13 => 24, _ => 0, } } @@ -1316,6 +1320,16 @@ impl SampleEntry { pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) } + /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. + pub fn flac(streaminfo: &[u8; 34]) -> Self { + let mut blocks = vec![0x80, 0, 0, 34]; + blocks.extend_from_slice(streaminfo); + let mut full = vec![0; 4]; + full.extend_from_slice(&blocks); + let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); + let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); + Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) + } /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). pub fn pcm(cfg: PcmConfig) -> Self { Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) @@ -1632,6 +1646,13 @@ mod tests { assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); } + #[test] + fn aac_channel_configuration_is_an_index() { + // 48 kHz AAC LC: configuration → channels (Table 1.19 with Amd 4; #922) + let channels = |c: u16| AacConfig::from_asc(((2 << 11) | (3 << 7) | (c << 3)).to_be_bytes().to_vec()).channels(); + assert_eq!([0, 1, 6, 7, 8, 11, 12, 13, 14, 15].map(channels), [0, 1, 6, 8, 0, 7, 8, 24, 8, 0]); + } + #[test] fn aac_esds_roundtrip() { let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index 0ea814779..880d49a2c 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -541,6 +541,14 @@ impl Mp4Writer { Ok(self.tracks.len() - 1) } + /// Replace a track's sample entry before the file is finished (the `moov` is written last), for + /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. + pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { + let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; + t.cfg.entry = entry; + Ok(()) + } + /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start /// frame number) is written at finish, spanning the video track's duration. pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 29c63d931..ad14cbb79 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -13,7 +13,13 @@ workspace = true filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } +filmcraft-dng = { workspace = true } rayon = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp"] } + +[dev-dependencies] +filmcraft-dng = { workspace = true, features = ["writer"] } +# APNG fixtures for the animated-image tests (the image crate does not encode APNG) +png = "0.18" diff --git a/crates/media/src/animated.rs b/crates/media/src/animated.rs new file mode 100644 index 000000000..e20188a14 --- /dev/null +++ b/crates/media/src/animated.rs @@ -0,0 +1,225 @@ +//! Animated images (APNG and animated GIF) as movie clips (#402). +//! +//! The still decoders read only the first picture of these files. [`open`] decodes every frame +//! instead, composited onto the full canvas by the `image` crate (APNG blend and dispose operations, +//! GIF disposal methods), and keeps them with their timing: +//! +//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG delays +//! under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; +//! - consecutive identical frames are merged (one picture, the sum of their delays); +//! - the clip is one pass through the animation (the file's loop count is not applied); +//! - transparency is kept: the source reports alpha when any frame has a pixel that is not fully +//! opaque. +//! +//! The file is hostile: decoding stops at [`MAX_FRAMES`] frames or [`MAX_DECODED_BYTES`] of +//! pictures (the clip then ends there), and a frame that fails to decode ends the animation at the +//! frames decoded before it. A file that turns out not to be animated (one frame, or no frame that +//! decodes) is left to the still decoders, which is what happened to every animated image before. + +use std::io::Cursor; +use std::sync::Arc; + +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use image::codecs::gif::GifDecoder; +use image::codecs::png::PngDecoder; +use image::{AnimationDecoder, Delay, Frames, ImageDecoder, Limits}; + +use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; + +/// Most frames kept from one file (frames past this are dropped; the clip ends there). +pub const MAX_FRAMES: usize = 10_000; +/// Most bytes of decoded RGBA pictures kept from one file (after merging identical frames). +pub const MAX_DECODED_BYTES: usize = 1 << 30; + +/// Animated image formats. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Apng, + Gif, +} + +impl Format { + fn sniff(b: &[u8]) -> Option { + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + Some(Format::Apng) + } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + Some(Format::Gif) + } else { + None + } + } + + fn codec(self) -> &'static str { + match self { + Format::Apng => "APNG", + Format::Gif => "GIF", + } + } + + /// A frame's delay in ticks, with browsers' minimum for zero and near-zero delays. + fn frame_ticks(self, delay: Delay) -> Tick { + let (numer, denom) = delay.numer_denom_ms(); + let (numer, denom) = (u128::from(numer), u128::from(denom.max(1))); + // whole milliseconds, rounded down, decide the minimum (GIF delays are 10 ms steps) + let ms = numer / denom; + let (numer, denom) = match self { + Format::Gif if ms <= 10 => (100, 1), + Format::Apng if ms < 10 => (10, 1), + _ => (numer, denom), + }; + let ticks = numer.saturating_mul(TICKS_PER_SECOND as u128) / denom.saturating_mul(1000); + Tick(i64::try_from(ticks).unwrap_or(i64::MAX).max(1)) + } +} + +/// An animation: its frames (full-canvas pictures) and when each one starts. +pub struct AnimatedSource { + info: MediaInfo, + frames: Vec>, + /// Media time each frame starts at; ascending, `starts[0]` is zero. + starts: Vec, +} + +/// Decode `bytes` as an animated image. `None` when it is not an APNG or GIF with at least two +/// frames that decode (the caller then opens it as a still). +pub fn open(name: &str, bytes: &[u8]) -> Option> { + let format = Format::sniff(bytes)?; + let frames = match format { + Format::Apng => { + let mut d = PngDecoder::new(Cursor::new(bytes)).ok()?; + if !d.is_apng().ok()? { + return None; + } + d.set_limits(Limits::default()).ok()?; + d.apng().ok()?.into_frames() + } + Format::Gif => { + let mut d = GifDecoder::new(Cursor::new(bytes)).ok()?; + d.set_limits(Limits::default()).ok()?; + d.into_frames() + } + }; + AnimatedSource::from_frames(name, format, frames, bytes.len() as u64).map(Ok) +} + +impl AnimatedSource { + fn from_frames(name: &str, format: Format, frames: Frames<'_>, file_size: u64) -> Option { + let mut pictures: Vec<(u32, u32, Vec)> = Vec::new(); + let mut durations: Vec = Vec::new(); + let mut decoded = 0usize; + let mut has_alpha = false; + for frame in frames.take(MAX_FRAMES) { + // a damaged frame ends the animation at the frames before it + let Ok(frame) = frame else { break }; + let ticks = format.frame_ticks(frame.delay()); + let buffer = frame.into_buffer(); + let (w, h) = buffer.dimensions(); + let rgba = buffer.into_raw(); + if let (Some(last), Some(d)) = (pictures.last(), durations.last_mut()) + && (last.0, last.1) == (w, h) + && last.2 == rgba + { + *d = Tick(d.0.saturating_add(ticks.0)); + continue; + } + decoded = decoded.saturating_add(rgba.len()); + if decoded > MAX_DECODED_BYTES { + break; + } + has_alpha = has_alpha || rgba.as_chunks::<4>().0.iter().any(|p| p[3] < 255); + pictures.push((w, h, rgba)); + durations.push(ticks); + } + if pictures.len() < 2 { + return None; + } + let (width, height) = pictures.first().map(|p| (p.0, p.1))?; + let mut starts = Vec::with_capacity(durations.len()); + let mut t = Tick::ZERO; + for d in &durations { + starts.push(t); + t = Tick(t.0.saturating_add(d.0)); + } + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::Movie, + duration: t, + video: Some(VideoStreamInfo { + width, + height, + frame_rate: rate_of(&durations), + par: (1, 1), + codec: format.codec().into(), + pixel_format: if has_alpha { "RGBA 8-bit".into() } else { "RGB 8-bit".into() }, + color: filmcraft_color::ColorInfo::SRGB_FULL, + has_alpha, + bitrate: None, + hdr: None, + }), + audio_streams: Vec::new(), + container: format.codec().into(), + start_timecode: None, + file_size: Some(file_size), + }; + let frames = pictures.into_iter().map(|(w, h, rgba)| Arc::new(VideoFrame::rgba8(w, h, rgba))).collect(); + Some(Self { info, frames, starts }) + } + + /// Number of distinct frames kept. + pub fn frame_count(&self) -> usize { + self.frames.len() + } + + /// Media time frame `i` starts at. + pub fn frame_start(&self, i: usize) -> Option { + self.starts.get(i).copied() + } + + /// The frame shown at media time `t` (the first before the start, the last after the end). + fn index_at(&self, t: Tick) -> usize { + self.starts.partition_point(|s| *s <= t).saturating_sub(1) + } +} + +/// The clip's frame rate: the rate of its most common frame duration, when that is a sensible video +/// rate (1 to 120 frames per second), else 30 fps. Frames are shown for their own durations +/// whatever this says; it sets the clip's timebase (e.g. a sequence made from the clip). +fn rate_of(durations: &[Tick]) -> FrameRate { + let mut sorted: Vec = durations.iter().map(|d| d.0).collect(); + sorted.sort_unstable(); + let mut best = (0usize, 0i64); + let mut i = 0; + while let Some(&v) = sorted.get(i) { + let run = sorted.get(i..).map_or(0, |rest| rest.iter().take_while(|&&x| x == v).count()).max(1); + if run > best.0 { + best = (run, v); + } + i = i.saturating_add(run); + } + let ticks = best.1; + if ticks <= 0 { + return FrameRate::FPS_30; + } + let rate = FrameRate::new(TICKS_PER_SECOND, ticks); + let fps = rate.as_f64(); + if (1.0..=120.0).contains(&fps) { rate } else { FrameRate::FPS_30 } +} + +impl MediaSource for AnimatedSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result> { + self.frames.get(self.index_at(req.time)).cloned().ok_or(MediaError::NoStream("video")) + } + + fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { + Err(MediaError::NoStream("audio")) + } +} + +#[cfg(test)] +#[path = "animated_tests.rs"] +mod tests; diff --git a/crates/media/src/animated_tests.rs b/crates/media/src/animated_tests.rs new file mode 100644 index 000000000..be77ca879 --- /dev/null +++ b/crates/media/src/animated_tests.rs @@ -0,0 +1,176 @@ +use super::*; +use crate::{FrameRequest, MediaKind, open_bytes}; + +const W: u32 = 4; +const H: u32 = 2; + +/// A 4×2 RGBA picture: the left half `rgb` with alpha `left_alpha`, the right half `rgb` opaque. +fn picture(rgb: [u8; 3], left_alpha: u8) -> Vec { + let mut px = Vec::new(); + for _y in 0..H { + for x in 0..W { + let a = if x < W / 2 { left_alpha } else { 255 }; + px.extend_from_slice(&[rgb[0], rgb[1], rgb[2], a]); + } + } + px +} + +/// An APNG whose frames are `frames` (RGBA, W×H) with delays `(numerator, denominator)` seconds. +fn apng(frames: &[(Vec, (u16, u16))]) -> Vec { + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, W, H); + e.set_color(png::ColorType::Rgba); + e.set_depth(png::BitDepth::Eight); + e.set_animated(frames.len() as u32, 0).unwrap(); + let mut w = e.write_header().unwrap(); + for (px, (num, den)) in frames { + w.set_frame_delay(*num, *den).unwrap(); + w.set_dispose_op(png::DisposeOp::None).unwrap(); + w.set_blend_op(png::BlendOp::Source).unwrap(); + w.write_image_data(px).unwrap(); + } + w.finish().unwrap(); + } + out +} + +/// A GIF whose frames are `frames` (RGBA, W×H) with delays in milliseconds. +fn gif(frames: &[(Vec, u32)]) -> Vec { + let mut out = Vec::new(); + { + let mut e = image::codecs::gif::GifEncoder::new(&mut out); + e.set_repeat(image::codecs::gif::Repeat::Infinite).unwrap(); + let frames = frames.iter().map(|(px, ms)| { + let img = image::RgbaImage::from_raw(W, H, px.clone()).unwrap(); + image::Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(*ms, 1)) + }); + e.encode_frames(frames).unwrap(); + } + out +} + +fn ms(n: i64) -> Tick { + Tick(n * TICKS_PER_SECOND / 1000) +} + +fn pixels(s: &dyn MediaSource, t: Tick) -> Vec { + match &s.video_frame(FrameRequest::full(t)).unwrap().data { + filmcraft_frame::PixelData::Rgba8(p) => p.to_vec(), + _ => panic!("not RGBA"), + } +} + +#[test] +fn apng_plays_every_frame_with_its_timing_and_keeps_transparency() { + let (red, green, blue) = (picture([255, 0, 0], 0), picture([0, 255, 0], 128), picture([0, 0, 255], 255)); + let bytes = apng(&[(red.clone(), (1, 10)), (green.clone(), (1, 5)), (blue.clone(), (1, 10))]); + let s = open_bytes("spin.png", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie, "an APNG is a clip, not a still (#402)"); + assert_eq!(info.duration, ms(400)); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (W, H, "APNG")); + assert!(v.has_alpha, "transparent pixels are reported"); + assert_eq!(pixels(&*s, Tick::ZERO), red, "fully transparent pixels keep alpha 0, not black"); + assert_eq!(pixels(&*s, ms(99)), red); + assert_eq!(pixels(&*s, ms(100)), green); + assert_eq!(pixels(&*s, ms(299)), green, "the second frame lasts its 200 ms"); + assert_eq!(pixels(&*s, ms(300)), blue); + assert_eq!(pixels(&*s, ms(10_000)), blue, "past the end: the last frame"); + assert_eq!(pixels(&*s, Tick(-5)), red, "before the start: the first frame"); +} + +#[test] +fn identical_frames_merge_into_one_longer_frame() { + let (a, b) = (picture([10, 20, 30], 255), picture([40, 50, 60], 255)); + let bytes = apng(&[(a.clone(), (1, 10)), (a.clone(), (1, 10)), (a, (1, 10)), (b.clone(), (1, 10))]); + let s = open("hold.png", &bytes).unwrap().unwrap(); + assert_eq!(s.frame_count(), 2); + assert_eq!(s.frame_start(1), Some(ms(300))); + assert_eq!(s.info().duration, ms(400)); + assert!(!s.info().video.as_ref().unwrap().has_alpha); + assert_eq!(s.info().frame_rate(), FrameRate::new(10, 1), "the most common frame length is 100 ms"); + assert_eq!(pixels(&s, ms(350)), b); +} + +#[test] +fn animated_gif_plays_every_frame_with_transparency() { + let (red, green) = (picture([255, 0, 0], 0), picture([0, 255, 0], 255)); + let bytes = gif(&[(red, 40), (green, 0)]); + let s = open_bytes("wave.gif", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie); + assert_eq!(info.video.as_ref().unwrap().codec, "GIF"); + assert_eq!(info.duration, ms(140), "40 ms, then a zero delay played as 100 ms as browsers do"); + assert!(info.video.as_ref().unwrap().has_alpha); + let first = pixels(&*s, Tick::ZERO); + assert_eq!(first[3], 0, "the GIF's transparent pixels stay transparent"); + assert_eq!(&first[12..16], &[255, 0, 0, 255]); + assert_eq!(&pixels(&*s, ms(40))[0..4], &[0, 255, 0, 255]); +} + +#[test] +fn single_frame_files_stay_stills() { + let one_frame_apng = apng(&[(picture([1, 2, 3], 255), (1, 10))]); + let one_frame_gif = gif(&[(picture([1, 2, 3], 255), 100)]); + let mut plain = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_raw(W, H, picture([1, 2, 3], 0)).unwrap().write_to(&mut plain, image::ImageFormat::Png).unwrap(); + for (name, bytes) in [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("plain.png", plain.into_inner())] { + assert!(open(name, &bytes).is_none(), "{name}"); + let s = open_bytes(name, bytes.into(), &[]).unwrap(); + assert_eq!(s.info().kind, MediaKind::Still, "{name}"); + } +} + +#[test] +fn damaged_files_never_panic() { + let frames: Vec<_> = (0..6u8).map(|i| (picture([i * 40, 0, 255 - i * 40], i * 40), (1, 10))).collect(); + let png_bytes = apng(&frames); + let gif_bytes = gif(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); + for whole in [png_bytes, gif_bytes] { + for cut in (0..whole.len()).step_by(7) { + // truncated: opens as fewer frames, a still or an error, never a panic + let _ = open_bytes("cut", whole[..cut].to_vec().into(), &[]); + } + let mut flipped = whole.clone(); + for i in (40..flipped.len()).step_by(11) { + if let Some(b) = flipped.get_mut(i) { + *b ^= 0x5A; + } + } + let _ = open_bytes("flipped", flipped.into(), &[]); + } +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let frames: Vec<_> = (0..4u8).map(|i| (picture([i * 60, 1, 2], 255), (1, 10))).collect(); + let whole = apng(&frames); + // cut inside the last frame's data: the first three frames still play + let cut = whole.len() - 20; + let s = open("cut.png", &whole[..cut]).expect("still animated").expect("opens"); + assert_eq!(s.frame_count(), 3); + assert_eq!(s.info().duration, ms(300)); + assert_eq!(pixels(&s, Tick::ZERO), frames[0].0); +} + +#[test] +fn frame_delays_have_browser_minimums_and_never_overflow() { + let d = |n: u32, den: u32| Delay::from_numer_denom_ms(n, den); + assert_eq!(Format::Gif.frame_ticks(d(0, 1)), ms(100)); + assert_eq!(Format::Gif.frame_ticks(d(10, 1)), ms(100)); + assert_eq!(Format::Gif.frame_ticks(d(20, 1)), ms(20)); + assert_eq!(Format::Apng.frame_ticks(d(0, 1)), ms(10)); + assert_eq!(Format::Apng.frame_ticks(d(40, 1)), ms(40)); + assert_eq!(Format::Apng.frame_ticks(d(1000, 3)), Tick(TICKS_PER_SECOND / 3)); + assert!(Format::Apng.frame_ticks(d(u32::MAX, 1)).0 > 0); +} + +#[test] +fn clip_rate_follows_the_common_frame_length() { + assert_eq!(rate_of(&[ms(40), ms(40), ms(100)]), FrameRate::new(25, 1)); + assert_eq!(rate_of(&[ms(5_000), ms(5_000)]), FrameRate::FPS_30, "0.2 fps is not a video rate"); + assert_eq!(rate_of(&[]), FrameRate::FPS_30); +} diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index bfb391dd2..5c2b5f28b 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,10 +96,64 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } +/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], +/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. +#[derive(Clone, Default)] +pub struct Context { + cancel: Option>, + catch_up: Option, + draft: bool, + background: bool, +} + +impl Context { + /// This thread's hints. + pub fn current() -> Self { + Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } + } + + /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must + /// not keep another job's hints after a panic). + pub fn run(self, f: impl FnOnce() -> R) -> R { + struct Restore(Context); + impl Drop for Restore { + fn drop(&mut self) { + let prev = std::mem::take(&mut self.0); + CURRENT.with(|c| *c.borrow_mut() = prev.cancel); + CATCH_UP.with(|c| c.set(prev.catch_up)); + DRAFT.with(|c| c.set(prev.draft)); + BACKGROUND.with(|c| c.set(prev.background)); + } + } + let _restore = Restore(Context::current()); + CURRENT.with(|c| *c.borrow_mut() = self.cancel); + CATCH_UP.with(|c| c.set(self.catch_up)); + DRAFT.with(|c| c.set(self.draft)); + BACKGROUND.with(|c| c.set(self.background)); + f() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn context_carries_the_hints_to_another_thread_and_restores_its_own() { + let flag = Arc::new(AtomicBool::new(true)); + let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); + std::thread::scope(|s| { + s.spawn(|| { + let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); + assert_eq!(seen, (true, true, true, Some(Tick(5)))); + // a panic inside leaves this thread's own hints in place + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); + assert!(r.is_err()); + assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); + }); + }); + } + #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs index 2d5832faf..474d1236e 100644 --- a/crates/media/src/dpx.rs +++ b/crates/media/src/dpx.rs @@ -1,16 +1,23 @@ //! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). //! -//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per -//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into -//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the -//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit -//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line -//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing -//! density) transfer is shown as stored, without a film-log conversion. +//! The first image element is decoded: luma, RGB, RGBA, ABGR, or Y'CbCr (CbYCrY 4:2:2, CbYACrYA +//! 4:2:2:4, CbYCr 4:4:4, CbYCrA 4:4:4:4), 8, 10, 12 or 16 bits per component, big-endian (`SDPX`) or +//! little-endian (`XPDS`) files. 10-bit components are filled into 32-bit words (packing 1, method +//! A: two padding bits at the bottom; packing 2, method B: at the top), 12-bit components fill +//! 16-bit words the same two ways; packed 10/12-bit components (packing 0) follow each other with no +//! padding, the first in the low bits of a 32-bit word, a component crossing into the next word +//! continuing in its low bits. Scan lines start on a 32-bit boundary and may carry end-of-line +//! padding. Orientations 0–3 (either scan direction, either line order) are honoured. RGB and luma +//! code values map linearly onto 8 bits (full range); a logarithmic (printing density) transfer is +//! shown as stored, without a film-log conversion. Y'CbCr is converted with the matrix the +//! colorimetric field names (BT.709, or BT.601 for the 601 and composite video codes; by picture +//! size when unspecified), in video range unless the reference low / high data codes span the +//! whole code range; 4:2:2 chroma is shared by the two pixels of a pair. //! -//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" -//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed -//! orientations. +//! Not decoded (an "unsupported" error): run-length encoded elements, 1-bit and floating-point +//! components, transposed orientations. + +use filmcraft_color::{Matrix, Range, normalize_c, normalize_y, ycbcr_to_rgb}; use crate::{MediaError, Result, still::Raster}; @@ -63,6 +70,8 @@ enum Packing { Words { shift: u32 }, /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. Ten { pad: u32 }, + /// `bits`-bit components back to back in 32-bit words, low bits first (packing 0). + Packed { bits: u32 }, } impl Packing { @@ -73,6 +82,7 @@ impl Packing { Packing::Bytes => comps, Packing::Words { .. } => comps.checked_mul(2)?, Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + Packing::Packed { bits } => comps.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?, }; raw.checked_next_multiple_of(4) } @@ -93,6 +103,69 @@ impl Packing { } } } + Packing::Packed { bits } => { + let mask = (1u64 << bits) - 1; + for i in 0..comps { + let bit = i * bits as usize; + let at = bit / 32 * 4; + let pair = u64::from(order.u32(line, at)) | (u64::from(order.u32(line, at + 4)) << 32); + out.push(((pair >> (bit % 32)) & mask) as u16); + } + } + } + } +} + +/// What the stored components of a pixel are. +#[derive(Clone, Copy)] +enum Components { + /// `n` components per pixel; the indices of R, G, B and A among them (alpha `None`: opaque). + Rgb { n: usize, map: [Option; 4] }, + /// `n` components per pixel: Cb (4:2:2: Cb on even pixels, Cr on odd ones), Y', then Cr (4:4:4 + /// only), then alpha when `alpha`. + YCbCr { n: usize, sub: bool, alpha: bool, matrix: Matrix, range: Range }, +} + +impl Components { + fn per_pixel(self) -> usize { + match self { + Components::Rgb { n, .. } | Components::YCbCr { n, .. } => n, + } + } + + fn has_alpha(self) -> bool { + match self { + Components::Rgb { map, .. } => map[3].is_some(), + Components::YCbCr { alpha, .. } => alpha, + } + } + + /// RGBA8 of pixel `x` from the unpacked component values of its scan line (`bits` per value). + fn pixel(self, line: &[u16], x: usize, bits: u32) -> [u8; 4] { + let max = (1u32 << bits) - 1; + let to8 = |v: u32| ((v.min(max) * 255 + max / 2) / max) as u8; + let get = |i: usize| line.get(i).map(|&v| u32::from(v)); + match self { + Components::Rgb { n, map } => map.map(|slot| match slot { + Some(i) => get(x * n + i).map_or(0, to8), + None => 255, + }), + Components::YCbCr { n, sub, alpha, matrix, range } => { + let base = x * n; + let (cb, cr) = if sub { + let pair = (x & !1) * n; + (get(pair), get(pair + n)) + } else { + (get(base), get(base + 2)) + }; + // a missing Cr (odd width, 4:2:2) is neutral + let mid = 1u32 << (bits - 1); + let y = normalize_y(get(base + 1).unwrap_or(0), bits, range); + let rgb = ycbcr_to_rgb(y, normalize_c(cb.unwrap_or(mid), bits, range), normalize_c(cr.unwrap_or(mid), bits, range), matrix); + let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = if alpha { get(base + n - 1).map_or(0, to8) } else { 255 }; + [c(rgb[0]), c(rgb[1]), c(rgb[2]), a] + } } } } @@ -110,7 +183,10 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { let orientation = order.u16(bytes, 768); let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); let el = ELEMENT; + let low_code = order.u32(bytes, el + 4); + let high_code = order.u32(bytes, el + 12); let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let colorimetric = bytes.get(el + 22).copied().unwrap_or(0); let bits = bytes.get(el + 23).copied().unwrap_or(0); let packing = order.u16(bytes, el + 24); let encoding = order.u16(bytes, el + 26); @@ -120,14 +196,6 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { return Err(err(name, "empty picture")); } let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; - // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components - let (n, map): (usize, [Option; 4]) = match descriptor { - 6 => (1, [Some(0), Some(0), Some(0), None]), - 50 => (3, [Some(0), Some(1), Some(2), None]), - 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), - 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), - d => return Err(unsupported(name, format!("descriptor {d}"))), - }; if encoding != 0 { return Err(unsupported(name, "run-length encoding".into())); } @@ -138,6 +206,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { (10, 2) => Packing::Ten { pad: 0 }, (12, 1) => Packing::Words { shift: 4 }, (12, 2) => Packing::Words { shift: 0 }, + (10 | 12, 0) => Packing::Packed { bits: u32::from(bits) }, (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), }; let (flip_x, flip_y) = match orientation { @@ -147,6 +216,27 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 3 => (true, true), o => return Err(unsupported(name, format!("orientation {o}"))), }; + let max = (1u32 << bits) - 1; + // video range unless the reference codes span every code value + let range = if low_code == 0 && high_code == max { Range::Full } else { Range::Limited }; + let matrix = match colorimetric { + 5 | 6 => Matrix::Bt709, + 7..=10 => Matrix::Bt601, + _ => filmcraft_frame::default_matrix(w as u32, h as u32), + }; + let ycbcr = |n, sub, alpha| Components::YCbCr { n, sub, alpha, matrix, range }; + let components = match descriptor { + 6 => Components::Rgb { n: 1, map: [Some(0), Some(0), Some(0), None] }, + 50 => Components::Rgb { n: 3, map: [Some(0), Some(1), Some(2), None] }, + 51 => Components::Rgb { n: 4, map: [Some(0), Some(1), Some(2), Some(3)] }, + 52 => Components::Rgb { n: 4, map: [Some(3), Some(2), Some(1), Some(0)] }, + 100 => ycbcr(2, true, false), + 101 => ycbcr(3, true, true), + 102 => ycbcr(3, false, false), + 103 => ycbcr(4, false, true), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + let n = components.per_pixel(); let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; @@ -157,8 +247,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { // the last line needs no end-of-line padding let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; - let max = (1u32 << bits) - 1; - let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let bits = u32::from(bits); let mut rgba = vec![0u8; pixels * 4]; let mut line = Vec::with_capacity(comps); for y in 0..h { @@ -168,19 +257,12 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { for x in 0..w { let dx = if flip_x { w - 1 - x } else { x }; let o = (dy * w + dx) * 4; - let Some(px) = rgba.get_mut(o..o + 4) else { continue }; - for (c, slot) in map.iter().enumerate() { - let v = match slot { - Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), - None => 255, - }; - if let Some(d) = px.get_mut(c) { - *d = v; - } + if let Some(px) = rgba.get_mut(o..o + 4) { + px.copy_from_slice(&components.pixel(&line, x, bits)); } } } - let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + let has_alpha = components.has_alpha() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) } @@ -250,14 +332,84 @@ mod tests { assert_eq!(r.rgba, [255, 0, 0, 128]); } + /// Packing 0: components back to back, low bits first, each line on a 32-bit boundary. + fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec { + let mut out = Vec::new(); + for line in lines { + let (mut acc, mut n) = (0u64, 0u32); + for &v in *line { + acc |= u64::from(v) << n; + n += bits; + while n >= 32 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + } + } + out + } + + #[test] + fn packed_ten_and_twelve_bit_components_cross_words() { + // 2 × 2 RGB 10-bit: 6 components (60 bits) per line, the fourth straddles two words + let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]]; + for big in [true, false] { + let r = decode("a.dpx", &file(big, 2, 2, 50, 10, 0, 0, &packed(&lines, 10, big))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 255, 255, 0, 255, 0, 255, 255, 255, 255, 255]); + } + // 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words + let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]]; + let r = decode("a.dpx", &file(true, 3, 1, 50, 12, 0, 0, &packed(&line, 12, true))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn ycbcr_descriptors_convert_to_rgb() { + // 8-bit CbYCrY, video range, BT.709 (colorimetric 6): black, white, then a red pixel pair + // (the 709 code triple of red is rounded, hence green 1) + let mut f = file(true, 4, 1, 100, 8, 0, 0, &[128, 16, 128, 235, 102, 63, 240, 63]); + f[ELEMENT + 22] = 6; + let r = decode("a.dpx", &f).unwrap(); + assert_eq!(r.rgba, [0, 0, 0, 255, 255, 255, 255, 255, 255, 1, 0, 255, 255, 1, 0, 255]); + assert!(!r.has_alpha); + // red in BT.601 (colorimetric 7): the 601 code triple, rounded + let mut f = file(true, 2, 1, 100, 8, 0, 0, &[90, 81, 240, 81]); + f[ELEMENT + 22] = 7; + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [254, 0, 0, 255, 254, 0, 0, 255]); + // 10-bit CbYCr filled (method A), full range from the reference codes: mid grey + let word = |a: u32, b: u32, c: u32| ((a << 22) | (b << 12) | (c << 2)).to_be_bytes(); + let mut f = file(true, 1, 1, 102, 10, 1, 0, &word(512, 512, 512)); + f[ELEMENT + 4..ELEMENT + 8].copy_from_slice(&0u32.to_be_bytes()); + f[ELEMENT + 12..ELEMENT + 16].copy_from_slice(&1023u32.to_be_bytes()); + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [128, 128, 128, 255]); + // 16-bit CbYCrA: white at half opacity + let v = |x: u16| x.to_be_bytes(); + let data: Vec = [v(32768), v(60160), v(32768), v(32768)].concat(); + let r = decode("a.dpx", &file(true, 1, 1, 103, 16, 0, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 128]); + assert!(r.has_alpha); + // 8-bit CbYACrYA, 3 pixels: the last pixel has no Cr and is neutral + let r = decode("a.dpx", &file(true, 3, 1, 101, 8, 0, 0, &[128, 235, 255, 128, 16, 0, 128, 235, 255, 0, 0, 0])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255]); + assert!(r.has_alpha); + } + #[test] fn hostile_headers_fail_cleanly() { // huge dimensions with no data, truncated data, unsupported layouts assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 150, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 3, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 16, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 3, 3, 100, 12, 0, 0, &[0; 20])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index a73451e7d..9be4dca4a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -9,6 +9,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +pub mod animated; pub mod cache; pub mod cancel; pub mod digits; @@ -171,7 +172,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// File extensions we recognise at import (the media browser filters on these). -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "dng"]; pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"]; pub const VIDEO_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv", @@ -185,7 +186,8 @@ pub fn is_importable(path: &Path) -> bool { /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; -/// Open media from bytes, trying `extra` openers first, then the built-in ones (stills, WAV). +/// Open media from bytes, trying `extra` openers first, then the built-in ones (WAV, animated +/// images, stills). pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result { for o in extra { if let Some(r) = o(name, bytes.clone()) { @@ -195,6 +197,10 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result crate::still::dng::sequence_timing(&info, rate), + None => (rate, None), + }; let ext = first.rsplit('.').next().unwrap_or("image").to_ascii_uppercase(); let n = frames.len() as i64; let info = MediaInfo { @@ -148,19 +154,19 @@ impl ImageSequenceSource { frame_rate: rate, par: (1, 1), codec: format!("{ext} sequence"), - pixel_format: if has_alpha { "RGBA 8-bit".into() } else { "RGB 8-bit".into() }, - color: filmcraft_color::ColorInfo::SRGB_FULL, + pixel_format, + color: frame.color, has_alpha, bitrate: None, hdr: None, }), audio_streams: Vec::new(), container: "Image Sequence".into(), - start_timecode: None, + start_timecode, file_size: None, }; let cache = FrameCache::new(256 << 20); - cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.rgba))); + cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(frame)); Ok(Self { info, frames, loader, cache }) } @@ -184,8 +190,7 @@ impl ImageSequenceSource { } let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?; let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?; - let img = crate::still::decode_raster(path, &bytes)?; - let f = Arc::new(VideoFrame::rgba8(img.width, img.height, img.rgba)); + let f = Arc::new(decode_frame(path, &bytes)?.0); self.cache.insert(i, f.clone()); Ok(f) } @@ -216,6 +221,19 @@ impl MediaSource for ImageSequenceSource { } } +/// Decode one frame file: DNG camera raw as linear floats (with its metadata, for the sequence's +/// timing), every other format through the still decoders as RGBA8. Returns the frame, whether it +/// has alpha, and its pixel format for the media info. +fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, Option)> { + if crate::still::dng::sniff(bytes) { + let d = crate::still::dng::decode(name, bytes)?; + return Ok((d.frame, false, d.pixel_format, Some(d.info))); + } + let img = crate::still::decode_raster(name, bytes)?; + let pixel_format = if img.has_alpha { "RGBA 8-bit" } else { "RGB 8-bit" }.to_string(); + Ok((VideoFrame::rgba8(img.width, img.height, img.rgba), img.has_alpha, pixel_format, None)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index 67e1cc37e..b50832ec1 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -1,4 +1,6 @@ -//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX) as media sources. +//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, DNG camera raw) as media sources. +//! Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would show +//! their first frame. use std::sync::Arc; @@ -7,6 +9,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; +pub mod dng; + /// Default still duration (Premiere's preference default is 5 seconds). pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND); @@ -55,6 +59,10 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { + // a DNG is a TIFF too: develop its raw image instead of reading its preview + if dng::sniff(bytes) { + return dng::still(name, bytes); + } let r = decode_raster(name, bytes)?; Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } diff --git a/crates/media/src/still/dng.rs b/crates/media/src/still/dng.rs new file mode 100644 index 000000000..d4d142732 --- /dev/null +++ b/crates/media/src/still/dng.rs @@ -0,0 +1,210 @@ +//! DNG camera raw stills and CinemaDNG frames (decoded by `filmcraft-dng`). +//! +//! The raw image is developed the way the DNG specification describes (linearisation, black and +//! white levels, the camera's as-shot white balance, demosaic, default crop, colour matrices and +//! baseline exposure) into scene-linear Rec. 709, kept as 32-bit floats: the compositor's working +//! format, so highlights above 1.0 survive into effects and colour work, exactly as with OpenEXR. +//! A white the sensor clipped is 1.0 × 2^BaselineExposure. +//! +//! A numbered folder of `.dng` frames imports as an image sequence (CinemaDNG); the first frame's +//! CinemaDNG FrameRate and TimeCodes tags give the sequence its frame rate and start time code. + +use std::sync::Arc; + +use filmcraft_color::{ColorInfo, Transfer}; +use filmcraft_frame::VideoFrame; +use filmcraft_time::FrameRate; + +use super::{DEFAULT_STILL_DURATION, StillSource}; +use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo}; + +/// Whether `b` is a DNG file. +pub fn sniff(b: &[u8]) -> bool { + filmcraft_dng::sniff(b) +} + +/// The colour of developed frames: scene-linear light, Rec. 709 primaries. +const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL }; + +/// A developed DNG frame. +pub struct Decoded { + /// Linear Rec. 709 RGBA floats (opaque), default-crop sized. + pub frame: VideoFrame, + /// For the media info, e.g. "Bayer 12-bit raw (lossless JPEG)". + pub pixel_format: String, + pub info: filmcraft_dng::Info, +} + +fn error(name: &str, e: filmcraft_dng::Error) -> MediaError { + match e { + filmcraft_dng::Error::Unsupported(m) => MediaError::Unsupported(format!("{name}: {m}")), + e => MediaError::Decode(format!("{name}: {e}")), + } +} + +/// Develop a DNG file. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let img = filmcraft_dng::decode(bytes).map_err(|e| error(name, e))?; + let (w, h) = (u32::try_from(img.width), u32::try_from(img.height)); + let (Ok(w), Ok(h)) = (w, h) else { return Err(MediaError::Decode(format!("{name}: picture too large"))) }; + let i = &img.info; + let kind = if i.cfa { "Bayer" } else { "linear" }; + let pixel_format = format!("{kind} {}-bit raw ({})", i.bits, i.compression); + let mut frame = VideoFrame::rgba_f32(w, h, img.rgba); + frame.color = LINEAR_709; + Ok(Decoded { frame, pixel_format, info: img.info }) +} + +/// Open a DNG file as a still. +pub fn still(name: &str, bytes: &[u8]) -> Result { + let d = decode(name, bytes)?; + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::Still, + duration: DEFAULT_STILL_DURATION, + video: Some(VideoStreamInfo { + width: d.frame.width, + height: d.frame.height, + frame_rate: FrameRate::FPS_29_97, + par: (1, 1), + codec: "DNG".into(), + pixel_format: d.pixel_format, + color: LINEAR_709, + has_alpha: false, + bitrate: None, + hdr: None, + }), + audio_streams: Vec::new(), + container: "Image".into(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + Ok(StillSource { info, frame: Arc::new(d.frame) }) +} + +/// The frame rate and start time code (in frames at that rate) of a CinemaDNG sequence whose first +/// frame is `info`: the FrameRate tag when present (else `default`), the TimeCodes tag when present. +pub fn sequence_timing(info: &filmcraft_dng::Info, default: FrameRate) -> (FrameRate, Option) { + let rate = info.frame_rate.map(|(n, d)| FrameRate::new(n, d).sane()).unwrap_or(default); + let tc = info.timecode.map(|t| { + let drop = t.drop_frame && rate.supports_drop_frame(); + filmcraft_time::fields_to_frames(i64::from(t.hours), i64::from(t.minutes), i64::from(t.seconds), i64::from(t.frames), rate, drop) + }); + (rate, tc) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use filmcraft_dng::tiff::tags; + use filmcraft_dng::writer::{Compression, Options, Value, write}; + use filmcraft_frame::PixelData; + + use super::*; + use crate::sequence::{FrameLoader, ImageSequenceSource}; + use crate::{FrameRequest, MediaSource}; + + /// A 16 × 12 RGGB DNG of a flat grey at `level` (0–1 of the 12-bit range above black 0), white + /// balanced by neutral (0.5, 1, 0.5); no colour matrix, so the camera values are taken as Rec. 709. + fn grey_dng(level: f64, extra: Vec<(u16, Value)>) -> Vec { + let (w, h) = (16, 12); + let neutral = [0.5, 1.0, 0.5]; + let pat = [0usize, 1, 1, 2]; + let px: Vec = (0..w * h).map(|i| (level * neutral[pat[(i / w % 2) * 2 + i % w % 2]] * 4095.0).round() as u16).collect(); + let mut main = vec![(tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral))]; + main.extend(extra); + let o = Options { + width: w, + height: h, + bits: 12, + compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 }, + raw_tags: vec![(tags::WHITE_LEVEL, Value::Short(vec![4095]))], + main_tags: main, + preview: true, + ..Default::default() + }; + write(&px, &o) + } + + fn red(f: &VideoFrame) -> f32 { + match &f.data { + PixelData::RgbaF32(p) => p[(5 * 16 + 5) * 4], + _ => panic!("float frames"), + } + } + + #[test] + fn dng_still_opens_as_linear_float() { + let f = grey_dng(0.5, vec![]); + // a DNG is a TIFF, but the TIFF decoder must not get it + let src = crate::open_bytes("A001_C001_0001.dng", f.clone().into(), &[]).unwrap(); + let info = src.info(); + assert_eq!(info.kind, MediaKind::Still); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str(), v.has_alpha), (16, 12, "DNG", false)); + assert_eq!(v.pixel_format, "Bayer 12-bit raw (lossless JPEG)"); + assert_eq!(v.color.transfer, Transfer::Linear); + let frame = src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap(); + assert_eq!(frame.color.transfer, Transfer::Linear); + assert!((red(&frame) - 0.5).abs() < 1e-3, "{}", red(&frame)); + assert!(crate::STILL_EXTENSIONS.contains(&"dng")); + assert!(crate::is_importable(std::path::Path::new("/cards/A001/frame_000001.DNG"))); + } + + #[test] + fn broken_dngs_are_errors() { + let f = grey_dng(0.5, vec![]); + for n in [8, 64, f.len() / 2, f.len() - 3] { + assert!(crate::open_bytes("x.dng", f[..n].to_vec().into(), &[]).is_err(), "{n}"); + } + // lossy DNG is named as unsupported + let mut lossy = grey_dng(0.5, vec![]); + let at = lossy.windows(4).position(|w| w == [0x03, 0x01, 0x03, 0x00]).unwrap(); // tag 259 SHORT in the raw IFD + lossy[at + 8..at + 10].copy_from_slice(&34892u16.to_le_bytes()); + let e = crate::open_bytes("x.dng", lossy.into(), &[]).err().unwrap(); + assert!(matches!(e, MediaError::Unsupported(ref m) if m.contains("lossy JPEG")), "{e}"); + } + + #[test] + fn cinemadng_sequence_takes_rate_and_time_code_from_the_frames() { + let tags = || { + vec![ + (tags::FRAME_RATE, Value::SRational(vec![(24, 1)])), + // 10:00:00:12 + (tags::TIME_CODES, Value::Byte(vec![0x12, 0x00, 0x00, 0x10, 0, 0, 0, 0])), + ] + }; + let files: HashMap> = (0..3).map(|i| (format!("/c/A_{i:04}.dng"), grey_dng(0.2 + 0.2 * f64::from(i), tags()))).collect(); + let loader: FrameLoader = + Arc::new(move |p: &str| files.get(p).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, p.to_string()))); + let frames = (0..3).map(|i| Some(format!("/c/A_{i:04}.dng"))).collect(); + // the caller's (Indeterminate Media Timebase) rate is 25; the frames say 24 + let s = ImageSequenceSource::new("A_0000.dng", frames, FrameRate::FPS_25, loader).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::ImageSequence); + assert_eq!(info.frame_rate(), FrameRate::FPS_24); + assert_eq!(info.duration, FrameRate::FPS_24.tick_of(3)); + assert_eq!(info.start_timecode, Some(10 * 3600 * 24 + 12)); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.codec.as_str(), v.color.transfer), ("DNG sequence", Transfer::Linear)); + for i in 0..3 { + let f = s.video_frame(FrameRequest::full(FrameRate::FPS_24.tick_of(i))).unwrap(); + let want = 0.2 + 0.2 * i as f32; + assert!((red(&f) - want).abs() < 1e-3, "frame {i}: {}", red(&f)); + } + } + + #[test] + fn timing_without_cinemadng_tags_is_the_callers() { + let info = filmcraft_dng::probe(&grey_dng(0.5, vec![])).unwrap(); + assert_eq!(sequence_timing(&info, FrameRate::FPS_25), (FrameRate::FPS_25, None)); + // drop frame only where the rate has it + let mut i = info.clone(); + i.frame_rate = Some((30_000, 1001)); + i.timecode = Some(filmcraft_dng::Timecode { hours: 0, minutes: 1, seconds: 0, frames: 2, drop_frame: true }); + assert_eq!(sequence_timing(&i, FrameRate::FPS_25), (FrameRate::FPS_29_97, Some(1800))); + i.frame_rate = Some((25, 1)); + assert_eq!(sequence_timing(&i, FrameRate::FPS_24), (FrameRate::FPS_25, Some(1502))); + } +} diff --git a/crates/media/src/wav.rs b/crates/media/src/wav.rs index 4a0df3d2b..a15f0d6b2 100644 --- a/crates/media/src/wav.rs +++ b/crates/media/src/wav.rs @@ -62,6 +62,7 @@ impl WavSource { let mut data = None; let mut bext_tr = None; let mut ixml_tr = None; + let mut ds64_data = None; for _ in 0..MAX_CHUNKS { let Some(body) = pos.checked_add(8).filter(|&b| b <= len) else { break }; let mut hdr = [0u8; 8]; @@ -69,8 +70,12 @@ impl WavSource { let id = &hdr[0..4]; let mut clen = u64::from(le32(&hdr, 4)); let rest = len - body; - if id == b"data" && (clen == 0xFFFF_FFFF || clen > rest) { - clen = rest; + if id == b"data" { + // RF64 sentinel: the real size is ds64.dataSize (EBU 3306 §3.4), else the rest of the file + if clen == 0xFFFF_FFFF { + clen = ds64_data.unwrap_or(rest); + } + clen = clen.min(rest); } if clen > rest { break; @@ -87,6 +92,11 @@ impl WavSource { } fmt = Some((tag, ch, rate, bits)); } + // ds64: riffSize u64, dataSize u64, sampleCount u64, ... + b"ds64" if clen >= 16 => { + let b = read_range(&*reader, body + 8, 8).map_err(io)?; + ds64_data = Some(u64::from(le32(&b, 0)) | u64::from(le32(&b, 4)) << 32); + } b"data" => data = Some((body, clen)), // bext: Description 256, Originator 32, OriginatorReference 32, OriginationDate 10, // OriginationTime 8, then TimeReference (low u32, high u32) diff --git a/crates/mpa/Cargo.toml b/crates/mpa/Cargo.toml new file mode 100644 index 000000000..8abf08c22 --- /dev/null +++ b/crates/mpa/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-mpa" +description = "Clean-room MPEG-1/2 Audio Layer I and Layer II (MP1 / MP2) decoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/mpa/README.md b/crates/mpa/README.md new file mode 100644 index 000000000..caa1afde6 --- /dev/null +++ b/crates/mpa/README.md @@ -0,0 +1,76 @@ +# filmcraft-mpa + +Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2) in pure Rust. Layer L0: +depends only on `filmcraft-bitstream` and `thiserror`. Builds for `wasm32-unknown-unknown`; no +`unsafe`. No GPL/LGPL code (FFmpeg, libmad, mpg123, twolame) was consulted; FFmpeg (and twolame, +when installed) are used only as external fixture generators and test oracles. + +## Why + +symphonia (the bootstrap decoder for MP3 and others) rejects every Layer II frame whose bit rate / +channel mode combination ISO/IEC 11172-3 does not allow: mono above 192 kbit/s and stereo below +64 kbit/s. ffmpeg writes such streams by default (mono MP2 is 384 kbit/s unless asked otherwise), +so those tracks played as silence in every container (#809). + +## Specification + +ISO/IEC 11172-3:1993: §2.4.1 bit stream syntax (header, Layer I and Layer II audio data), +§2.4.2 semantics, §2.4.3.1 frame length, §2.4.3.2 synthesis subband filter (Figure A.2), +§2.4.3.3 Layer I and §2.4.3.4 Layer II decoding; Annex B: Table 3-B.1 scalefactors (computed as +2^(1 - i/3)), Table 3-B.2a-d Layer II allocation tables, Table 3-B.3 synthesis window (the 257 +coefficients `D[0..=256]`, exact multiples of 2⁻¹⁶, the rest from the prototype's symmetry; +a unit test checks the typed table is the smooth prototype), Table 3-B.4 quantization classes. +ISO/IEC 13818-3: the lower sampling frequencies (16 / 22.05 / 24 kHz), their bit rate tables and +the Layer II allocation table B.1 (its rows confirmed against FFmpeg's decode). + +## API + +```rust +let mut dec = filmcraft_mpa::Decoder::new(); +let h = filmcraft_mpa::parse_header(frame)?; // layer, rate, mode, frame size +let out = dec.decode(frame)?; // h.samples() per channel (384 or 1152) +dec.reset(); // before decoding from another position +``` + +## Decoder + +- Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo with every bound; + MPEG-1, MPEG-2 lower sampling frequencies and the unofficial MPEG-2.5; with or without CRC. +- Layer II table selection by sampling frequency and bit rate per channel, with the ranges of + Table 3-B.2 taken as open-ended so the disallowed combinations decode the way encoders write + them (mono 384 kbit/s uses table a at 48 kHz, b at 44.1 / 32 kHz; stereo 32 / 48 kbit/s uses + c, or d at 32 kHz). +- Requantization `(2v + 1 - steps) / steps` (the standard's `C · (s''' + D)` for every class), + grouped 3-, 5- and 9-step codewords, scalefactor selection information. +- Output: planar f32 at full scale ±1. +- Errors (forbidden allocation, reserved header fields, data running past the frame) return + `Err`; garbage never panics (unit fuzz test and damaged-frame oracle test). + +## Accuracy (vs FFmpeg as an external oracle) + +`tests/oracle.rs`, worst channel SNR against FFmpeg's float decoder. FFmpeg's own fixed-point +decoder agrees with its float decoder to ~77 dB (the error sits at the 16-bit LSB); the test +requires 70 dB, and a wrong table entry or misread field costs tens of dB. + +| Stream | SNR | +|---|---| +| Layer II mono 48 kHz, FFmpeg's default 384 kbit/s (not allowed by 11172-3) | 77.1 dB | +| Layer II mono 44.1 kHz 320 kbit/s pink noise (not allowed) | 72.9 dB | +| Layer II stereo 48 kHz 32 kbit/s (table c; not allowed) | 83.2 dB | +| Layer II stereo 48 kHz 192 kbit/s, 44.1 kHz 256 / 96 kbit/s, 32 kHz 128 kbit/s | 83.3-83.6 dB | +| Layer II mono 32 kHz 48 kbit/s (table d) | 76.9 dB | +| Layer II MPEG-2: stereo 24 kHz 160 kbit/s, mono 22.05 kHz 64 kbit/s, stereo 16 kHz 32 kbit/s | 76.9-83.5 dB | +| Layer II joint stereo and dual channel from twolame (when installed) | 83.4-83.6 dB | +| Synthetic valid random frames: Layer I mono / stereo / dual / joint stereo at every bound, MPEG-1 and MPEG-2; Layer II joint stereo at every bound and dual channel with every allocation table | 74.1-80.0 dB | + +No installed encoder writes Layer I, so Layer I is checked on synthetic frames (random +allocations, scalefactors and samples) that FFmpeg decodes as it would any stream. Through +`filmcraft-codecs` (`tests/mpeg_oracle.rs`), mono 384 kbit/s MP2 in MPEG-TS, Matroska and a raw +`.mp2` file is within 3.1·10⁻⁵ of FFmpeg's decode. + +## Gaps + +- Layer III is not decoded here (symphonia does it); `parse_header` reads its headers. +- The free format (bit rate index 0) is refused: its frame length is only known from the next + sync word. +- The CRC is read, not checked; ancillary data is skipped. diff --git a/crates/mpa/src/lib.rs b/crates/mpa/src/lib.rs new file mode 100644 index 000000000..9834e5292 --- /dev/null +++ b/crates/mpa/src/lib.rs @@ -0,0 +1,411 @@ +//! Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2), written from +//! ISO/IEC 11172-3 (§2.4 bit stream syntax and decoding, Annex B tables) and ISO/IEC 13818-3 +//! (the lower sampling frequencies 16 / 22.05 / 24 kHz). +//! +//! - Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo; every bit rate +//! and sampling frequency of MPEG-1, MPEG-2 and the unofficial MPEG-2.5 (8 / 11.025 / 12 kHz). +//! - Lenient where encoders are: Layer II bit rate / mode combinations that ISO/IEC 11172-3 does not +//! allow (mono above 192 kbit/s, which is ffmpeg's default for mono, and stereo below 64 kbit/s) +//! decode with the allocation table their bit rate per channel selects (see [`Header`]). +//! - Output: planar f32 at full scale ±1, 384 (Layer I) or 1152 (Layer II) samples per channel. +//! +//! Not decoded: Layer III (use an MP3 decoder; [`parse_header`] still reads its headers), the free +//! format (bit rate index 0, whose frame length is only known from the next sync word) and the +//! CRC (read, not checked, like most decoders). + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod tables; + +use filmcraft_bitstream::BitReader; +use tables::AllocTable; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("not an MPEG audio frame")] + NoSync, + #[error("unsupported MPEG audio: {0}")] + Unsupported(&'static str), + #[error("invalid MPEG audio frame: {0}")] + Invalid(&'static str), +} + +pub type Result = std::result::Result; + +/// MPEG audio version (`ID` bit, and the MPEG-2.5 extension of the sync word). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Version { + /// ISO/IEC 11172-3: 32 / 44.1 / 48 kHz. + Mpeg1, + /// ISO/IEC 13818-3 lower sampling frequencies: 16 / 22.05 / 24 kHz. + Mpeg2, + /// The unofficial extension to 8 / 11.025 / 12 kHz. + Mpeg25, +} + +/// Channel mode (`mode`, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Stereo, + /// Intensity stereo above the bound given by `mode_extension` (Layers I and II). + JointStereo, + /// Two independent mono channels. + DualChannel, + Mono, +} + +/// The 32-bit frame header (§2.4.1.3, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub version: Version, + /// 1, 2 or 3. + pub layer: u8, + /// A 16-bit CRC follows the header. + pub crc: bool, + /// 0 for the free format. + pub bitrate_kbps: u32, + pub sample_rate: u32, + pub padding: bool, + pub mode: Mode, + pub mode_extension: u8, + /// Bytes in the frame, header included (0 for the free format). + pub frame_bytes: usize, +} + +impl Header { + /// Output channels: 1 for mono, else 2. + pub fn channels(&self) -> usize { + if self.mode == Mode::Mono { 1 } else { 2 } + } + /// Samples per channel the frame decodes to. + pub fn samples(&self) -> usize { + match (self.layer, self.version) { + (1, _) => 384, + (3, Version::Mpeg2 | Version::Mpeg25) => 576, + _ => 1152, + } + } + /// The first subband coded as intensity stereo (`bound`, §2.4.2.3): `mode_extension` 0-3 gives + /// 4, 8, 12 or 16 in joint stereo; otherwise every subband is coded per channel. + fn bound(&self) -> usize { + if self.mode == Mode::JointStereo { 4 * (self.mode_extension as usize + 1) } else { 32 } + } +} + +/// Parse the frame header at the start of `b`. +pub fn parse_header(b: &[u8]) -> Result
{ + let [0xFF, b1, b2, b3, ..] = *b else { + return Err(if b.len() < 4 && b.first().is_none_or(|&x| x == 0xFF) { Error::Invalid("truncated header") } else { Error::NoSync }); + }; + if b1 & 0xE0 != 0xE0 { + return Err(Error::NoSync); + } + let version = match (b1 >> 3) & 3 { + 3 => Version::Mpeg1, + 2 => Version::Mpeg2, + 0 => Version::Mpeg25, + _ => return Err(Error::Invalid("reserved version")), + }; + let layer = match (b1 >> 1) & 3 { + 3 => 1, + 2 => 2, + 1 => 3, + _ => return Err(Error::Invalid("reserved layer")), + }; + let crc = b1 & 1 == 0; + let bitrate_index = (b2 >> 4) as usize; + let rate_index = ((b2 >> 2) & 3) as usize; + let padding = (b2 >> 1) & 1 == 1; + let mode = match b3 >> 6 { + 0 => Mode::Stereo, + 1 => Mode::JointStereo, + 2 => Mode::DualChannel, + _ => Mode::Mono, + }; + let mode_extension = (b3 >> 4) & 3; + let base = *tables::SAMPLE_RATES.get(rate_index).ok_or(Error::Invalid("reserved sampling frequency"))?; + let sample_rate = match version { + Version::Mpeg1 => base, + Version::Mpeg2 => base / 2, + Version::Mpeg25 => base / 4, + }; + let rates = match (version, layer) { + (Version::Mpeg1, 1) => &tables::BITRATES_MPEG1_L1, + (Version::Mpeg1, 2) => &tables::BITRATES_MPEG1_L2, + (Version::Mpeg1, _) => &tables::BITRATES_MPEG1_L3, + (_, 1) => &tables::BITRATES_LSF_L1, + _ => &tables::BITRATES_LSF_L23, + }; + let bitrate_kbps = *rates.get(bitrate_index).ok_or(Error::Invalid("forbidden bit rate index"))?; + // §2.4.3.1: Layer I slots are 4 bytes, 12 slots per 384-sample frame per bit/s over the + // sampling frequency; Layers II / III have 1-byte slots, 144 per 1152 samples (72 for the + // 576-sample Layer III frames of MPEG-2). + let bits_per_second = u64::from(bitrate_kbps) * 1000; + let rate = u64::from(sample_rate); + let pad = u64::from(padding); + let frame_bytes = if bitrate_kbps == 0 { + 0 + } else if layer == 1 { + (12 * bits_per_second / rate + pad) * 4 + } else { + let slots = if layer == 3 && version != Version::Mpeg1 { 72 } else { 144 }; + slots * bits_per_second / rate + pad + }; + Ok(Header { version, layer, crc, bitrate_kbps, sample_rate, padding, mode, mode_extension, frame_bytes: frame_bytes as usize }) +} + +/// One decoded frame. +#[derive(Clone, Debug)] +pub struct Frame { + pub header: Header, + /// Planar samples, [`Header::channels`] × [`Header::samples`]. + pub channels: Vec>, +} + +/// Subband samples of one frame: `[channel][time slot][subband]`, up to 36 time slots. +type Subbands = [[[f32; tables::SUBBANDS]; 36]; 2]; + +/// A Layer I / Layer II decoder. It keeps the synthesis filter state between frames, so frames +/// must be fed in order; call [`Decoder::reset`] before decoding from another position. +pub struct Decoder { + /// The synthesis filter's `V` vector per channel (§2.4.3.2, Figure A.2). + v: [[f32; 1024]; 2], + sb: Box, +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +impl Decoder { + pub fn new() -> Self { + Self { v: [[0.0; 1024]; 2], sb: Box::new([[[0.0; 32]; 36]; 2]) } + } + + /// Forget the filter history. + pub fn reset(&mut self) { + self.v = [[0.0; 1024]; 2]; + } + + /// Decode the frame at the start of `frame` (which may run on into the next frames; only + /// [`Header::frame_bytes`] are read). + pub fn decode(&mut self, frame: &[u8]) -> Result { + let header = parse_header(frame)?; + if header.layer == 3 { + return Err(Error::Unsupported("Layer III")); + } + if header.frame_bytes == 0 { + return Err(Error::Unsupported("free-format bit rate")); + } + let data = frame.get(..header.frame_bytes).ok_or(Error::Invalid("truncated frame"))?; + let mut r = BitReader::new(data); + r.skip(32 + if header.crc { 16 } else { 0 }).map_err(|_| Error::Invalid("truncated frame"))?; + let nch = header.channels(); + for ch in self.sb.iter_mut() { + for slot in ch.iter_mut() { + *slot = [0.0; 32]; + } + } + let slots = if header.layer == 1 { + layer1(&mut r, &header, &mut self.sb)?; + 12 + } else { + layer2(&mut r, &header, &mut self.sb)?; + 36 + }; + let window = tables::window(); + let matrix = tables::matrix(); + let mut channels = vec![vec![0f32; slots * 32]; nch]; + for (ch, out) in channels.iter_mut().enumerate() { + let (Some(v), Some(sb)) = (self.v.get_mut(ch), self.sb.get(ch)) else { continue }; + for (slot, o) in sb.iter().take(slots).zip(out.as_chunks_mut::<32>().0) { + synthesize(v, matrix, window, slot, o); + } + } + Ok(Frame { header, channels }) + } +} + +fn read(r: &mut BitReader, n: u32) -> Result { + r.read_bits(n).map_err(|_| Error::Invalid("frame data runs past the frame")) +} + +/// Requantize an `steps`-level code `v` (§2.4.3.3.4 / §2.4.3.4.4): the fractional value with +/// the MSB inverted, `C · (s''' + D)`, which for every class is `(2v + 1 - steps) / steps`. +fn requantize(v: u32, steps: u32) -> f32 { + (2.0 * v as f32 + 1.0 - steps as f32) / steps as f32 +} + +/// Layer I audio data (§2.4.1.5): allocation, scalefactors and 12 samples per subband. +fn layer1(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let bound = if nch == 1 { 32 } else { h.bound() }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + alloc[ch][sb] = read(r, 4)?; + } + } else { + let a = read(r, 4)?; + alloc[0][sb] = a; + alloc[1][sb] = a; + } + } + if alloc.iter().flatten().any(|&a| a == 15) { + return Err(Error::Invalid("forbidden Layer I allocation")); + } + let mut scf = [[0f32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scf[ch][sb] = tables::scalefactor(read(r, 6)?); + } + } + } + for s in 0..12 { + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + let a = alloc[ch][sb]; + if a != 0 { + let nb = a + 1; + out[ch][s][sb] = requantize(read(r, nb)?, (1 << nb) - 1) * scf[ch][sb]; + } + } + } else if alloc[0][sb] != 0 { + let nb = alloc[0][sb] + 1; + let q = requantize(read(r, nb)?, (1 << nb) - 1); + for ch in 0..nch { + out[ch][s][sb] = q * scf[ch][sb]; + } + } + } + } + Ok(()) +} + +/// Read the three samples of one granule in a subband with `steps` quantization steps. +fn granule(r: &mut BitReader, steps: u16) -> Result<[f32; 3]> { + let steps = u32::from(steps); + let mut q = [0u32; 3]; + if let Some(bits) = tables::grouping(steps as u16) { + // three samples in one codeword: c = s0 + steps·s1 + steps²·s2 (§2.4.3.3.4); what is left + // of an invalid codeword beyond steps³ - 1 is ignored + let mut c = read(r, bits)?; + for s in q.iter_mut() { + *s = c % steps; + c /= steps; + } + } else { + let nb = (steps + 1).trailing_zeros(); + for s in q.iter_mut() { + *s = read(r, nb)?.min(steps - 1); + } + } + Ok(q.map(|v| requantize(v, steps))) +} + +/// Layer II audio data (§2.4.1.6): allocation, scalefactor selection, scalefactors and 12 +/// granules of three samples per subband. +fn layer2(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let per_channel = h.bitrate_kbps / nch as u32; + let table = AllocTable::select(h.version != Version::Mpeg1, h.sample_rate, per_channel); + // at most 32 for every table (a compile-time check in `tables`); the clamp keeps every + // `[sb]` below in bounds even so + let sblimit = table.sblimit().min(tables::SUBBANDS); + let bound = if nch == 1 { sblimit } else { h.bound().min(sblimit) }; + let mut steps = [[0u16; tables::SUBBANDS]; 2]; + for sb in 0..sblimit { + let row = table.row(sb); + let nbal = row.len().trailing_zeros(); + let pick = |r: &mut BitReader| -> Result { Ok(row.get(read(r, nbal)? as usize).copied().unwrap_or(0)) }; + if sb < bound { + for ch in 0..nch { + steps[ch][sb] = pick(r)?; + } + } else { + let s = pick(r)?; + steps[0][sb] = s; + steps[1][sb] = s; + } + } + let mut scfsi = [[0u32; tables::SUBBANDS]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] != 0 { + scfsi[ch][sb] = read(r, 2)?; + } + } + } + // the scalefactor for each third of the frame (4 granules each) + let mut scf = [[[0f32; 3]; tables::SUBBANDS]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] == 0 { + continue; + } + let mut s = || -> Result { Ok(tables::scalefactor(read(r, 6)?)) }; + scf[ch][sb] = match scfsi[ch][sb] { + 0 => [s()?, s()?, s()?], + 1 => { + let (a, b) = (s()?, s()?); + [a, a, b] + } + 2 => { + let a = s()?; + [a, a, a] + } + _ => { + let (a, b) = (s()?, s()?); + [a, b, b] + } + }; + } + } + for gr in 0..12 { + let part = gr / 4; + for sb in 0..sblimit { + if sb < bound { + for ch in 0..nch { + if steps[ch][sb] != 0 { + let q = granule(r, steps[ch][sb])?; + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } else if steps[0][sb] != 0 { + let q = granule(r, steps[0][sb])?; + for ch in 0..nch { + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } + } + Ok(()) +} + +/// The synthesis subband filter (§2.4.3.2, Figure A.2) for one time slot: 32 subband samples in, +/// 32 PCM samples out. +fn synthesize(v: &mut [f32; 1024], n: &[[f32; 32]; 64], d: &[f32; 512], s: &[f32; 32], out: &mut [f32; 32]) { + v.copy_within(0..960, 64); + for (vi, row) in v.iter_mut().zip(n.iter()) { + *vi = row.iter().zip(s.iter()).map(|(a, b)| a * b).sum(); + } + for (j, o) in out.iter_mut().enumerate() { + let mut acc = 0f32; + for i in 0..8 { + acc += v[i * 128 + j] * d[i * 64 + j] + v[i * 128 + 96 + j] * d[i * 64 + 32 + j]; + } + *o = acc; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mpa/src/tables.rs b/crates/mpa/src/tables.rs new file mode 100644 index 000000000..11d56c5aa --- /dev/null +++ b/crates/mpa/src/tables.rs @@ -0,0 +1,203 @@ +//! Tables from ISO/IEC 11172-3 Annex B and ISO/IEC 13818-3 Annex B. + +use std::sync::OnceLock; + +/// Bit rates in kbit/s by `bitrate_index` (ISO/IEC 11172-3 §2.4.2.3; ISO/IEC 13818-3 §2.4.2.3 for +/// the lower sampling frequencies). Index 0 is the free format, index 15 is forbidden. +pub(crate) const BITRATES_MPEG1_L1: [u32; 15] = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448]; +pub(crate) const BITRATES_MPEG1_L2: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]; +pub(crate) const BITRATES_MPEG1_L3: [u32; 15] = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; +pub(crate) const BITRATES_LSF_L1: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256]; +pub(crate) const BITRATES_LSF_L23: [u32; 15] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]; + +/// Sampling frequencies of MPEG-1 by `sampling_frequency` (3 is reserved); MPEG-2 halves them, +/// the unofficial MPEG-2.5 quarters them. +pub(crate) const SAMPLE_RATES: [u32; 3] = [44_100, 48_000, 32_000]; + +/// Coefficients `D[0..=256]` of the synthesis window (Table 3-B.3), in units of 2⁻¹⁶: the table's +/// nine-decimal values are exact multiples of 1/65536. The rest of the window follows from the +/// symmetry of the prototype filter, see [`window`]. +const WINDOW_HALF: [i32; 257] = [ + 0, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -3, -3, -4, -4, -5, // + -5, -6, -7, -7, -8, -9, -10, -11, -13, -14, -16, -17, -19, -21, -24, -26, // + -29, -31, -35, -38, -41, -45, -49, -53, -58, -63, -68, -73, -79, -85, -91, -97, // + -104, -111, -117, -125, -132, -139, -147, -154, -161, -169, -176, -183, -190, -196, -202, -208, // + 213, 218, 222, 225, 227, 228, 228, 227, 224, 221, 215, 208, 200, 189, 177, 163, // + 146, 127, 106, 83, 57, 29, -2, -36, -72, -111, -153, -197, -244, -294, -347, -401, // + -459, -519, -581, -645, -711, -779, -848, -919, -991, -1064, -1137, -1210, -1283, -1356, -1428, -1498, // + -1567, -1634, -1698, -1759, -1817, -1870, -1919, -1962, -2001, -2032, -2057, -2075, -2085, -2087, -2080, -2063, // + 2037, 2000, 1952, 1893, 1822, 1739, 1644, 1535, 1414, 1280, 1131, 970, 794, 605, 402, 185, // + -45, -288, -545, -814, -1095, -1388, -1692, -2006, -2330, -2663, -3004, -3351, -3705, -4063, -4425, -4788, // + -5153, -5517, -5879, -6237, -6589, -6935, -7271, -7597, -7910, -8209, -8491, -8755, -8998, -9219, -9416, -9585, // + -9727, -9838, -9916, -9959, -9966, -9935, -9863, -9750, -9592, -9389, -9139, -8840, -8492, -8092, -7640, -7134, // + 6574, 5959, 5288, 4561, 3776, 2935, 2037, 1082, 70, -998, -2122, -3300, -4533, -5818, -7154, -8540, // + -9975, -11455, -12980, -14548, -16155, -17799, -19478, -21189, -22929, -24694, -26482, -28289, -30112, -31947, -33791, -35640, // + -37489, -39336, -41176, -43006, -44821, -46617, -48390, -50137, -51853, -53534, -55178, -56778, -58333, -59838, -61289, -62684, // + -64019, -65290, -66494, -67629, -68692, -69679, -70590, -71420, -72169, -72835, -73415, -73908, -74313, -74630, -74856, -74992, // + 75038, +]; + +/// `D[i]` for `i` in `0..=256`, in units of 2⁻¹⁶. +pub(crate) fn window_half(i: usize) -> Option { + WINDOW_HALF.get(i).copied() +} + +/// The 512 synthesis window coefficients `D[i]` (Table 3-B.3). The table stores the symmetric +/// prototype `h[i] = h[512 - i]` with every other block of 64 negated, so for `i > 256` +/// `D[i] = -D[512 - i]`, except at multiples of 64 where both halves share the block parity. +pub(crate) fn window() -> &'static [f32; 512] { + static W: OnceLock<[f32; 512]> = OnceLock::new(); + W.get_or_init(|| { + let mut w = [0f32; 512]; + for (i, d) in w.iter_mut().enumerate() { + let (j, sign) = if i <= 256 { (i, 1.0) } else { (512 - i, if i % 64 == 0 { 1.0 } else { -1.0 }) }; + *d = sign * window_half(j).unwrap_or(0) as f32 / 65_536.0; + } + w + }) +} + +/// The matrixing coefficients `N[i][k] = cos((16 + i)(2k + 1)π / 64)` of the synthesis subband +/// filter (§2.4.3.2, Figure A.2), `i` in `0..64`, `k` in `0..32`. +pub(crate) fn matrix() -> &'static [[f32; 32]; 64] { + static N: OnceLock<[[f32; 32]; 64]> = OnceLock::new(); + N.get_or_init(|| { + let mut n = [[0f32; 32]; 64]; + for (i, row) in n.iter_mut().enumerate() { + for (k, c) in row.iter_mut().enumerate() { + *c = (((16 + i) * (2 * k + 1)) as f64 * std::f64::consts::PI / 64.0).cos() as f32; + } + } + n + }) +} + +/// Scalefactors (Table 3-B.1): `2^(1 - index / 3)`. Index 63 is not used by encoders; it continues +/// the progression rather than failing the frame. +pub(crate) fn scalefactor(index: u32) -> f32 { + static S: OnceLock<[f32; 64]> = OnceLock::new(); + let s = S.get_or_init(|| { + let mut s = [0f32; 64]; + for (i, v) in s.iter_mut().enumerate() { + *v = 2f64.powf(1.0 - i as f64 / 3.0) as f32; + } + s + }); + s.get(index as usize).copied().unwrap_or(0.0) +} + +// Layer II possible quantizations per subband (Tables 3-B.2a-d; ISO/IEC 13818-3 Table B.1): the +// number of quantization steps for each value of the subband's allocation field, 0 meaning +// nothing is transmitted. A row has 2^nbal entries. + +/// 3-B.2a/b subbands 0-2. +const A_LOW: [u16; 16] = [0, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767, 65535]; +/// 3-B.2a/b subbands 3-10. +const A_MID: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 65535]; +/// 3-B.2a/b subbands 11-22. +const A_HIGH: [u16; 8] = [0, 3, 5, 7, 9, 15, 31, 65535]; +/// 3-B.2a/b subbands 23-29. +const A_TOP: [u16; 4] = [0, 3, 5, 65535]; +/// 3-B.2c/d subbands 0-1. +const C_LOW: [u16; 16] = [0, 3, 5, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767]; +/// 3-B.2c/d subbands 2-11; 13818-3 B.1 subbands 4-10. +const C_HIGH: [u16; 8] = [0, 3, 5, 9, 15, 31, 63, 127]; +/// 13818-3 B.1 subbands 0-3. +const LSF_LOW: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383]; +/// 13818-3 B.1 subbands 11-29. +const LSF_TOP: [u16; 4] = [0, 3, 5, 9]; + +/// Subbands of the polyphase filter bank: the size of the decoder's per-subband arrays. +pub(crate) const SUBBANDS: usize = 32; + +// Every table's `sblimit` fits the per-subband arrays, so `sb < sblimit` indexes them in bounds. +const _: () = { + let tables = [AllocTable::A, AllocTable::B, AllocTable::C, AllocTable::D, AllocTable::Lsf]; + let mut i = 0; + while i < tables.len() { + assert!(tables[i].sblimit() <= SUBBANDS); + i += 1; + } +}; + +/// A Layer II bit allocation table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AllocTable { + /// 3-B.2a: 27 subbands. + A, + /// 3-B.2b: 30 subbands. + B, + /// 3-B.2c: 8 subbands. + C, + /// 3-B.2d: 12 subbands. + D, + /// ISO/IEC 13818-3 Table B.1 (lower sampling frequencies): 30 subbands. + Lsf, +} + +impl AllocTable { + /// The table for a Layer II stream (§2.4.3.3.1 and Table 3-B.2): by sampling frequency and bit + /// rate per channel. ISO/IEC 11172-3 lists only the allowed bit rate / mode combinations; + /// encoders also write the others (ffmpeg's default for mono is 384 kbit/s), so the bit rate + /// ranges are taken as open-ended: at least 56 kbit/s per channel at 48 kHz, or 56-80 kbit/s, + /// is table a; 96 kbit/s and up at 44.1 / 32 kHz is table b; 48 kbit/s and below is table c + /// (d at 32 kHz). The free format (`kbps_per_channel` 0) uses a or b like the high rates. + pub(crate) fn select(lsf: bool, sample_rate: u32, kbps_per_channel: u32) -> Self { + if lsf { + return Self::Lsf; + } + let free = kbps_per_channel == 0; + if (sample_rate == 48_000 && (free || kbps_per_channel >= 56)) || (56..=80).contains(&kbps_per_channel) { + Self::A + } else if free || kbps_per_channel >= 96 { + Self::B + } else if sample_rate == 32_000 { + Self::D + } else { + Self::C + } + } + + /// Subbands with an allocation (`sblimit`): at most [`SUBBANDS`] (checked at compile time). + pub(crate) const fn sblimit(self) -> usize { + match self { + Self::A => 27, + Self::B | Self::Lsf => 30, + Self::C => 8, + Self::D => 12, + } + } + + /// The quantization steps per allocation value for subband `sb`; its length is 2^nbal. A match, + /// not an index: any `sb` gets a row (subbands past `sblimit` get the table's last one). + pub(crate) fn row(self, sb: usize) -> &'static [u16] { + match self { + Self::A | Self::B => match sb { + 0..=2 => &A_LOW, + 3..=10 => &A_MID, + 11..=22 => &A_HIGH, + _ => &A_TOP, + }, + Self::C | Self::D => match sb { + 0..=1 => &C_LOW, + _ => &C_HIGH, + }, + Self::Lsf => match sb { + 0..=3 => &LSF_LOW, + 4..=10 => &C_HIGH, + _ => &LSF_TOP, + }, + } + } +} + +/// How the samples of a quantization class are coded (Table 3-B.4): `Some(bits)` for the grouped +/// classes (three samples in one codeword), `None` for one `log2(steps + 1)`-bit code per sample. +pub(crate) fn grouping(steps: u16) -> Option { + match steps { + 3 => Some(5), + 5 => Some(7), + 9 => Some(10), + _ => None, + } +} diff --git a/crates/mpa/src/tests.rs b/crates/mpa/src/tests.rs new file mode 100644 index 000000000..1f2e20a66 --- /dev/null +++ b/crates/mpa/src/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use tables::window_half; + +/// A frame header: MPEG-1 unless `lsf`, `layer` 1/2, no CRC, `bitrate_index`, `rate_index` +/// (0: 44.1 kHz, 1: 48 kHz, 2: 32 kHz), `mode` 0-3 and `mode_extension`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A frame of `header` with all-zero audio data: no subband has an allocation. +fn silent(h: [u8; 4]) -> Vec { + let mut f = h.to_vec(); + f.resize(parse_header(&h).unwrap().frame_bytes, 0); + f +} + +#[test] +fn headers() { + // Layer II 48 kHz stereo 192 kbit/s: 144 * 192000 / 48000 = 576 bytes + let h = parse_header(&header(false, 2, 10, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.layer, h.bitrate_kbps, h.sample_rate, h.frame_bytes, h.channels(), h.samples()), (Version::Mpeg1, 2, 192, 48_000, 576, 2, 1152)); + // ffmpeg's default for mono: Layer II 384 kbit/s, which ISO/IEC 11172-3 does not allow + let h = parse_header(&header(false, 2, 14, 1, 3, 0)).unwrap(); + assert_eq!((h.bitrate_kbps, h.frame_bytes, h.mode, h.channels()), (384, 1152, Mode::Mono, 1)); + // Layer I 44.1 kHz 384 kbit/s with padding: (12 * 384000 / 44100 + 1) * 4 = 420 + let mut b = header(false, 1, 12, 0, 1, 2); + b[2] |= 2; + let h = parse_header(&b).unwrap(); + assert_eq!((h.layer, h.frame_bytes, h.samples(), h.mode, h.mode_extension, h.bound()), (1, 420, 384, Mode::JointStereo, 2, 12)); + // MPEG-2 Layer II 24 kHz 160 kbit/s: 144 * 160000 / 24000 = 960; Layer I 22.05 kHz 256 kbit/s + let h = parse_header(&header(true, 2, 14, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.sample_rate, h.frame_bytes), (Version::Mpeg2, 24_000, 960)); + let h = parse_header(&header(true, 1, 14, 0, 0, 0)).unwrap(); + assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes), (22_050, 256, 4 * (12 * 256_000 / 22_050))); + // MPEG-2.5 and Layer III headers parse too + assert_eq!(parse_header(&[0xFF, 0xE5, 0x10, 0xC0]).unwrap().sample_rate, 11_025); + let h = parse_header(&[0xFF, 0xFB, 0x90, 0x44]).unwrap(); + assert_eq!((h.layer, h.frame_bytes), (3, 417)); +} + +#[test] +fn bad_headers_are_errors() { + assert_eq!(parse_header(&[]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0xFF, 0xFD]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0x00, 0xFD, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0x1D, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0xED, 0x90, 0x00]), Err(Error::Invalid("reserved version"))); + assert_eq!(parse_header(&[0xFF, 0xF9, 0x90, 0x00]), Err(Error::Invalid("reserved layer"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0xF0, 0x00]), Err(Error::Invalid("forbidden bit rate index"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0x9C, 0x00]), Err(Error::Invalid("reserved sampling frequency"))); + let mut d = Decoder::new(); + assert_eq!(d.decode(&[0xFF, 0xFB, 0x90, 0x44]).unwrap_err(), Error::Unsupported("Layer III")); + assert_eq!(d.decode(&[0xFF, 0xFD, 0x04, 0x00, 0, 0]).unwrap_err(), Error::Unsupported("free-format bit rate")); + assert_eq!(d.decode(&silent(header(false, 2, 10, 1, 0, 0))[..100]).unwrap_err(), Error::Invalid("truncated frame")); +} + +#[test] +fn silent_frames_decode_to_silence() { + let mut d = Decoder::new(); + for (h, ch, n) in [ + (header(false, 2, 14, 1, 3, 0), 1, 1152), + (header(false, 2, 1, 2, 0, 0), 2, 1152), + (header(true, 2, 8, 0, 1, 3), 2, 1152), + (header(false, 1, 6, 0, 2, 0), 2, 384), + (header(false, 1, 4, 1, 3, 0), 1, 384), + ] { + let f = d.decode(&silent(h)).unwrap(); + assert_eq!(f.channels.len(), ch, "{h:?}"); + assert!(f.channels.iter().all(|c| c.len() == n && c.iter().all(|&s| s == 0.0)), "{h:?}"); + } +} + +/// Table 3-B.3 as typed in must be the smooth prototype filter: with every other block of 64 +/// negated back, its third differences stay within the rounding of the 2⁻¹⁶ units (a mistyped +/// coefficient shows up as a spike of hundreds). +#[test] +fn synthesis_window_is_the_smooth_prototype() { + let h: Vec = (0..=256).map(|i| window_half(i).unwrap() as f64 * if (i / 64) % 2 == 1 { -1.0 } else { 1.0 }).collect(); + let d1: Vec = h.windows(2).map(|w| w[1] - w[0]).collect(); + let d2: Vec = d1.windows(2).map(|w| w[1] - w[0]).collect(); + let worst = d2.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f64::max); + assert!(worst <= 8.0, "{worst}"); + assert_eq!(window_half(257), None); + let w = tables::window(); + // symmetry of the full window and the table's endpoints + assert_eq!((w[0], w[256], w[511], w[448]), (0.0, 75_038.0 / 65_536.0, 1.0 / 65_536.0, w[64])); + assert_eq!(w[300], -w[212]); +} + +#[test] +fn layer2_allocation_tables() { + use tables::AllocTable as T; + for t in [T::A, T::B, T::C, T::D, T::Lsf] { + for sb in 0..t.sblimit() { + let row = t.row(sb); + assert!(row.len().is_power_of_two() && row[0] == 0, "{t:?} {sb}"); + } + } + // the allowed combinations (Table 3-B.2) and the ones encoders write anyway + assert_eq!(T::select(false, 48_000, 192), T::A); + assert_eq!(T::select(false, 44_100, 64), T::A); + assert_eq!(T::select(false, 44_100, 96), T::B); + assert_eq!(T::select(false, 32_000, 192), T::B); + assert_eq!(T::select(false, 48_000, 32), T::C); + assert_eq!(T::select(false, 44_100, 48), T::C); + assert_eq!(T::select(false, 32_000, 48), T::D); + assert_eq!(T::select(false, 48_000, 384), T::A); + assert_eq!(T::select(false, 44_100, 384), T::B); + assert_eq!(T::select(false, 32_000, 16), T::D); + assert_eq!(T::select(true, 24_000, 160), T::Lsf); +} + +#[test] +fn requantization_is_symmetric() { + assert_eq!([0, 1, 2].map(|v| requantize(v, 3)), [-2.0 / 3.0, 0.0, 2.0 / 3.0]); + assert_eq!(requantize(4, 9), 0.0); + assert_eq!(requantize(0, 65_535), -65_534.0 / 65_535.0); +} + +/// Random audio data behind valid headers, and damaged headers: errors or noise, never a panic. +#[test] +fn random_frames_never_panic() { + let mut x = 0x1234_5678u32; + let mut rnd = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + let mut d = Decoder::new(); + for k in 0..4000 { + let mut h = header(k % 3 == 0, 1 + (k % 2) as u8, 1 + (rnd() % 14) as u8, (rnd() % 3) as u8, (rnd() % 4) as u8, (rnd() % 4) as u8); + if k % 5 == 0 { + h[1] &= 0xFE; // CRC present + } + if k % 7 == 0 { + h[2] ^= rnd() as u8; + h[3] ^= rnd() as u8; + } + let len = parse_header(&h).map(|p| p.frame_bytes).unwrap_or(64); + let mut f = h.to_vec(); + f.extend((0..len.saturating_sub(4) + (rnd() % 8) as usize).map(|_| rnd() as u8)); + if k % 11 == 0 { + f.truncate(rnd() as usize % f.len()); + } + if let Ok(fr) = d.decode(&f) { + assert!(fr.channels.iter().flatten().all(|s| s.is_finite())); + } + } +} diff --git a/crates/mpa/tests/oracle.rs b/crates/mpa/tests/oracle.rs new file mode 100644 index 000000000..57ad6544f --- /dev/null +++ b/crates/mpa/tests/oracle.rs @@ -0,0 +1,572 @@ +//! Layer I and Layer II against ffmpeg's decoder as the reference. +//! +//! - Encoded streams: ffmpeg's MP2 encoder (mono, stereo, MPEG-1 and MPEG-2 sampling frequencies, +//! every Layer II allocation table, and bit rate / mode combinations ISO/IEC 11172-3 does not +//! allow but ffmpeg writes: its default for mono is 384 kbit/s); twolame, when installed, for +//! joint stereo and dual channel. +//! - Synthetic streams: valid random frames for what no installed encoder writes: Layer I (mono, +//! stereo, dual channel, joint stereo at every bound) and Layer II joint stereo / dual channel +//! with every allocation table. ffmpeg decodes them as it would any stream. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use filmcraft_bitstream::BitWriter; +use filmcraft_mpa::{Decoder, Mode, parse_header}; + +/// A stream from an encoder. +struct Encoded { + name: &'static str, + input: &'static str, + /// Encoder arguments after the input (ffmpeg) or flags (twolame). + enc: &'static [&'static str], + rate: u32, + channels: usize, + /// The bit rate the frames must carry (proves which combination is covered). + kbps: u32, + mode: Mode, + twolame: bool, +} + +const TONES: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t)"; + +const ENCODED: &[Encoded] = &[ + // ffmpeg's default for mono: 384 kbit/s, which ISO/IEC 11172-3 does not allow (table a) + Encoded { + name: "mono_48k_default", + input: "sine=frequency=1000:sample_rate=48000", + enc: &[], + rate: 48_000, + channels: 1, + kbps: 384, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "stereo_48k_192k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "192k"], + rate: 48_000, + channels: 2, + kbps: 192, + mode: Mode::Stereo, + twolame: false, + }, + // table b + Encoded { + name: "stereo_44k_256k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "256k"], + rate: 44_100, + channels: 2, + kbps: 256, + mode: Mode::Stereo, + twolame: false, + }, + // table b, mono above 192 kbit/s (not allowed) + Encoded { + name: "mono_44k_320k", + input: "anoisesrc=color=pink:sample_rate=44100:amplitude=0.3:seed=5", + enc: &["-b:a", "320k"], + rate: 44_100, + channels: 1, + kbps: 320, + mode: Mode::Mono, + twolame: false, + }, + // table c, and stereo below 64 kbit/s (not allowed) + Encoded { + name: "stereo_48k_32k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "32k"], + rate: 48_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "stereo_44k_96k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "96k"], + rate: 44_100, + channels: 2, + kbps: 96, + mode: Mode::Stereo, + twolame: false, + }, + // table d + Encoded { + name: "mono_32k_48k", + input: "sine=frequency=440:sample_rate=32000", + enc: &["-b:a", "48k"], + rate: 32_000, + channels: 1, + kbps: 48, + mode: Mode::Mono, + twolame: false, + }, + // table a at 32 kHz + Encoded { + name: "stereo_32k_128k", + input: TONES, + enc: &["-ar", "32000", "-b:a", "128k"], + rate: 32_000, + channels: 2, + kbps: 128, + mode: Mode::Stereo, + twolame: false, + }, + // MPEG-2 lower sampling frequencies (ISO/IEC 13818-3 table) + Encoded { + name: "lsf_stereo_24k_160k", + input: TONES, + enc: &["-ar", "24000", "-b:a", "160k"], + rate: 24_000, + channels: 2, + kbps: 160, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "lsf_mono_22k_64k", + input: "sine=frequency=700:sample_rate=22050", + enc: &["-b:a", "64k"], + rate: 22_050, + channels: 1, + kbps: 64, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "lsf_stereo_16k_32k", + input: TONES, + enc: &["-ar", "16000", "-b:a", "32k"], + rate: 16_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + // twolame: joint stereo (intensity coding above the bound) and dual channel + Encoded { + name: "twolame_joint_48k_128k", + input: TONES, + enc: &["-b", "128", "-m", "j"], + rate: 48_000, + channels: 2, + kbps: 128, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_joint_44k_64k", + input: TONES, + enc: &["-b", "64", "-m", "j"], + rate: 44_100, + channels: 2, + kbps: 64, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_dual_44k_192k", + input: TONES, + enc: &["-b", "192", "-m", "d"], + rate: 44_100, + channels: 2, + kbps: 192, + mode: Mode::DualChannel, + twolame: true, + }, +]; + +fn tool(name: &str) -> Option { + filmcraft_testkit::oracle::find_tool(name) +} + +fn make_encoded(ff: &Path, s: &Encoded) -> Option { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + if s.twolame { + let twolame = tool("twolame")?; + return filmcraft_testkit::fixtures::generate(&out, |tmp| { + let wav = tmp.with_extension("wav"); + let made = Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", &format!("{}:s={}", s.input, s.rate), "-t", "1", "-c:a", "pcm_s16le"]) + .arg(&wav) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + && Command::new(twolame).arg("--quiet").args(s.enc).arg(&wav).arg(tmp).stdin(Stdio::null()).status().is_ok_and(|x| x.success()); + let _ = std::fs::remove_file(&wav); + made + }); + } + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1", "-c:a", "mp2"]) + .args(s.enc) + .args(["-f", "mp2"]) + .arg(tmp) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + }) +} + +/// The frames of a raw stream (an ID3 tag or garbage before the first frame is not expected). +fn frames(data: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut p = 0; + while let Some(h) = data.get(p..).and_then(|b| parse_header(b).ok()) { + let Some(f) = data.get(p..p + h.frame_bytes).filter(|_| h.frame_bytes > 0) else { break }; + out.push(f); + p += h.frame_bytes; + } + out +} + +/// ffmpeg's decode of `file`, interleaved f32. +fn ffmpeg_decode(ff: &Path, file: &Path) -> Vec { + let o = Command::new(ff).args(["-v", "error", "-f", "mp3", "-i"]).arg(file).args(["-f", "f32le", "-"]).output().unwrap(); + assert!(o.status.success(), "{}: {}", file.display(), String::from_utf8_lossy(&o.stderr)); + o.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Decode `file` with our decoder and return the worst channel's SNR against ffmpeg (dB). +fn snr_vs_ffmpeg(ff: &Path, file: &Path, rate: u32, channels: usize) -> f64 { + let data = std::fs::read(file).unwrap(); + let fr = frames(&data); + assert!(fr.len() > 10, "{}: {} frames", file.display(), fr.len()); + let want = ffmpeg_decode(ff, file); + let n = want.len() / channels; + // ffmpeg drops the first frame of a stream whose second frame changes the channel mode + // (twolame starts in joint stereo and switches to stereo): start there too + let first = if first_frame_mode_changes(&data) { 1 } else { 0 }; + let mut dec = Decoder::new(); + let mut ours: Vec> = vec![Vec::new(); channels]; + for f in &fr[first..] { + let d = dec.decode(f).unwrap_or_else(|e| panic!("{}: {e}", file.display())); + assert_eq!((d.header.sample_rate, d.channels.len()), (rate, channels), "{}", file.display()); + for (o, c) in ours.iter_mut().zip(d.channels) { + o.extend(c); + } + } + assert_eq!(n, ours[0].len(), "{}: sample count", file.display()); + let mut worst = f64::INFINITY; + for (c, ours) in ours.iter().enumerate() { + let (mut sig, mut err) = (0f64, 0f64); + for (i, &a) in ours.iter().enumerate() { + let b = want[i * channels + c] as f64; + sig += b * b; + err += (a as f64 - b) * (a as f64 - b); + } + worst = worst.min(10.0 * (sig / err.max(1e-30)).log10()); + } + worst +} + +fn first_frame_mode_changes(data: &[u8]) -> bool { + let f = frames(data); + f.len() > 1 && parse_header(f[0]).unwrap().mode != parse_header(f[1]).unwrap().mode +} + +/// The minimum SNR against ffmpeg's float decoder. ffmpeg's own fixed-point decoder agrees with +/// it to ~77 dB (the error is at the 16-bit LSB, ~-96 dBFS); we measure 73-84 dB. A wrong table +/// entry or a misread field costs tens of dB. +const MIN_SNR: f64 = 70.0; + +#[test] +fn encoded_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for s in ENCODED { + let Some(f) = make_encoded(&ff, s) else { + assert!(s.twolame, "could not generate {}", s.name); + eprintln!("{}: twolame not installed, skipped", s.name); + continue; + }; + let data = std::fs::read(&f).unwrap(); + let h = parse_header(&data).unwrap(); + assert_eq!((h.layer, h.bitrate_kbps, h.mode), (2, s.kbps, s.mode), "{}", s.name); + let snr = snr_vs_ffmpeg(&ff, &f, s.rate, s.channels); + println!("{}: {} Hz, {} kbit/s, {:?}: SNR vs ffmpeg {snr:.1} dB", s.name, s.rate, s.kbps, s.mode); + assert!(snr >= MIN_SNR, "{}: SNR {snr:.1} dB < {MIN_SNR}", s.name); + } +} + +struct Rng(u32); +impl Rng { + fn next(&mut self) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 + } + fn below(&mut self, n: u32) -> u32 { + self.next() % n + } +} + +/// Header bytes (no CRC): `lsf` MPEG-2, `layer` 1/2, the indices, `mode` 0-3, `ext`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A valid random Layer I frame: random allocations (as many as the frame holds), scalefactors +/// and samples. +fn random_layer1(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let bound = if hd.mode == Mode::JointStereo { 4 * (hd.mode_extension as usize + 1) } else { 32 }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if (ch == 0 || sb < bound) && rng.below(3) != 0 { + alloc[ch][sb] = 1 + rng.below(14); + } + } + if sb >= bound { + alloc[1][sb] = alloc[0][sb]; + } + } + let bits = |alloc: &[[u32; 32]; 2]| { + let mut n = 32 + 4 * (32 + bound.min(32) * (nch - 1)); + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + n += 6; + if ch == 0 || sb < bound { + n += 12 * (alloc[ch][sb] as usize + 1); + } + } + } + } + n + }; + // drop allocations from the top until the frame holds them + let mut sb = 31; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = 0; + alloc[1][sb] = 0; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb], 4); + } + } + let mut scf = || 10 + rng.below(40); + let mut scfs = vec![]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scfs.push(scf()); + } + } + } + for s in scfs { + w.write_bits(s, 6); + } + for _ in 0..12 { + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + let nb = alloc[ch][sb] + 1; + if alloc[ch][sb] != 0 { + // any code but the forbidden all-ones + w.write_bits(rng.below((1 << nb) - 1), nb); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// Allocation field widths (nbal) per subband of the Layer II table the test frames use, by the +/// same selection as the decoder; every table's allocation value 1 is the 3-step class. +fn nbal(lsf: bool, rate: u32, kbps_per_channel: u32) -> Vec { + let rep = |v: &[(u32, usize)]| v.iter().flat_map(|&(n, k)| std::iter::repeat_n(n, k)).collect::>(); + if lsf { + rep(&[(4, 4), (3, 7), (2, 19)]) + } else if (rate == 48_000 && kbps_per_channel >= 56) || (56..=80).contains(&kbps_per_channel) { + rep(&[(4, 11), (3, 12), (2, 4)]) + } else if kbps_per_channel >= 96 { + rep(&[(4, 11), (3, 12), (2, 7)]) + } else if rate == 32_000 { + rep(&[(4, 2), (3, 10)]) + } else { + rep(&[(4, 2), (3, 6)]) + } +} + +/// A valid random Layer II frame using only the 3-step class (allocation value 1, grouped). +fn random_layer2(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let nbal = nbal(h[1] & 0x08 == 0, hd.sample_rate, hd.bitrate_kbps / nch as u32); + let sblimit = nbal.len(); + let bound = if hd.mode == Mode::JointStereo { (4 * (hd.mode_extension as usize + 1)).min(sblimit) } else { sblimit }; + let mut alloc = [[false; 32]; 2]; + let mut scfsi = [[0u32; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + alloc[ch][sb] = if ch == 1 && sb >= bound { alloc[0][sb] } else { rng.below(3) != 0 }; + scfsi[ch][sb] = rng.below(4); + } + } + let scf_bits = |s: u32| { + if s == 0 { + 18 + } else if s == 2 { + 6 + } else { + 12 + } + }; + let bits = |alloc: &[[bool; 32]; 2]| { + let mut n = 32; + for sb in 0..sblimit { + n += nbal[sb] as usize * if sb < bound { nch } else { 1 }; + for ch in 0..nch { + if alloc[ch][sb] { + n += 2 + scf_bits(scfsi[ch][sb]); + if ch == 0 || sb < bound { + n += 12 * 5; + } + } + } + } + n + }; + let mut sb = sblimit - 1; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = false; + alloc[1][sb] = false; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb] as u32, nbal[sb]); + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + w.write_bits(scfsi[ch][sb], 2); + } + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + for _ in 0..scf_bits(scfsi[ch][sb]) / 6 { + w.write_bits(10 + rng.below(40), 6); + } + } + } + } + for _ in 0..12 { + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + if alloc[ch][sb] { + w.write_bits(rng.below(27), 5); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// (name, layer, MPEG-2, bitrate index, sampling frequency index, mode, mode extension) +const SYNTHETIC: &[(&str, u8, bool, u8, u8, u8, u8)] = &[ + ("l1_mono_44k_192k", 1, false, 6, 0, 3, 0), + ("l1_stereo_48k_384k", 1, false, 12, 1, 0, 0), + ("l1_dual_32k_256k", 1, false, 8, 2, 2, 0), + ("l1_joint4_48k_192k", 1, false, 6, 1, 1, 0), + ("l1_joint8_44k_320k", 1, false, 10, 0, 1, 1), + ("l1_joint12_32k_128k", 1, false, 4, 2, 1, 2), + ("l1_joint16_48k_448k", 1, false, 14, 1, 1, 3), + ("l1_lsf_stereo_24k_128k", 1, true, 8, 1, 0, 0), + ("l1_lsf_joint_16k_64k", 1, true, 4, 2, 1, 1), + ("l2_joint4_48k_192k", 2, false, 10, 1, 1, 0), + ("l2_joint8_44k_256k", 2, false, 12, 0, 1, 1), + ("l2_joint12_48k_64k", 2, false, 4, 1, 1, 2), + ("l2_joint16_32k_96k", 2, false, 6, 2, 1, 3), + ("l2_dual_44k_160k", 2, false, 9, 0, 2, 0), + ("l2_dual_32k_384k", 2, false, 14, 2, 2, 0), + ("l2_lsf_joint_22k_96k", 2, true, 10, 0, 1, 1), + ("l2_lsf_mono_24k_160k", 2, true, 14, 1, 3, 0), +]; + +#[test] +fn synthetic_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (k, &(name, layer, lsf, br, sr, mode, ext)) in SYNTHETIC.iter().enumerate() { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{name}.mp{layer}")); + let f = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let mut rng = Rng(0x9E37_79B9 ^ k as u32); + let h = header(lsf, layer, br, sr, mode, ext); + let data: Vec = (0..40).flat_map(|_| if layer == 1 { random_layer1(&mut rng, h) } else { random_layer2(&mut rng, h) }).collect(); + std::fs::write(tmp, data).is_ok() + }) + .unwrap(); + let h = parse_header(&std::fs::read(&f).unwrap()).unwrap(); + let snr = snr_vs_ffmpeg(&ff, &f, h.sample_rate, h.channels()); + println!("{name}: SNR vs ffmpeg {snr:.1} dB"); + assert!(snr >= MIN_SNR, "{name}: SNR {snr:.1} dB < {MIN_SNR}"); + } +} + +/// Damaged frames (bit flips, truncation, scrambled headers) give errors or noise, never a panic. +#[test] +fn damaged_frames_never_panic() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut rng = Rng(0x00C0_FFEE); + let mut streams: Vec> = ENCODED.iter().filter(|s| !s.twolame).take(5).map(|s| std::fs::read(make_encoded(&ff, s).unwrap()).unwrap()).collect(); + let mut synth = Rng(7); + streams.push((0..8).flat_map(|_| random_layer1(&mut synth, header(false, 1, 12, 1, 1, 1))).collect()); + for data in &streams { + let mut dec = Decoder::new(); + for fr in frames(data).into_iter().take(8) { + for k in 0..300 { + let mut m = fr.to_vec(); + match k % 3 { + 0 => { + for _ in 0..1 + rng.below(8) { + let bit = rng.below(m.len() as u32 * 8) as usize; + m[bit / 8] ^= 0x80 >> (bit % 8); + } + } + 1 => m.truncate(rng.below(m.len() as u32) as usize), + _ => { + // keep the sync word; scramble version, layer, rates and mode + m[1] ^= rng.next() as u8 & 0x1F; + m[2] ^= rng.next() as u8; + m[3] ^= rng.next() as u8; + } + } + if let Ok(d) = dec.decode(&m) { + assert!(d.channels.iter().flatten().all(|s| s.is_finite())); + } + } + } + } +} + +#[test] +#[ignore] +fn generate_fixtures() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpa fixtures") else { return }; + for s in ENCODED { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + filmcraft_testkit::fixtures::generate_and_report(&format!("mpa/{}", s.name), &[out], || make_encoded(&ff, s)); + } +} diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index eba1ffa04..88f037965 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -175,6 +175,20 @@ pub struct FrameInfo { const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350]; /// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3). +/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or +/// `None` without a valid header there. For containers that store frames in chunks that need not +/// follow them (AVI): the frames are found again with it. +pub fn frame_bytes(codec: &Codec, b: &[u8]) -> Option { + let sync = match codec { + Codec::MpegAudio => AudioSync::Mpa, + Codec::AacAdts => AudioSync::Adts, + Codec::AacLatm => AudioSync::Loas, + Codec::Ac3 | Codec::Eac3 => AudioSync::Ac3, + _ => return None, + }; + frame_len(sync, b) +} + pub fn frame_info(codec: &Codec, b: &[u8]) -> Option { match codec { Codec::MpegAudio => { diff --git a/crates/mpegts/src/lib.rs b/crates/mpegts/src/lib.rs index 074d39512..4330ebcb5 100644 --- a/crates/mpegts/src/lib.rs +++ b/crates/mpegts/src/lib.rs @@ -21,7 +21,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] mod es; -pub use es::{FrameInfo, frame_info}; +pub use es::{FrameInfo, frame_bytes, frame_info}; mod pes; mod ps; mod ts; diff --git a/crates/platform/src/hardware_encode.rs b/crates/platform/src/hardware_encode.rs index c715e927f..eaf7e0dc6 100644 --- a/crates/platform/src/hardware_encode.rs +++ b/crates/platform/src/hardware_encode.rs @@ -133,13 +133,18 @@ pub fn videotoolbox_encoder_factory( Ok(c) => c, Err(why) => { log::info!("hardware {name} encoding declined: {why}"); + filmcraft_export::note_hw_encode_declined(); return None; } }; match VtEncoder::new(config) { - Ok(vt) => Some(Ok(Box::new(HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 }))), + Ok(vt) => { + filmcraft_export::note_hw_encode_session(); + Some(Ok(Box::new(HardwareEncoder { vt, y: Vec::new(), u: Vec::new(), v: Vec::new(), decoded: 0 }))) + } Err(why) => { log::info!("hardware {name} encoding declined: {why}"); + filmcraft_export::note_hw_encode_declined(); None } } @@ -222,6 +227,7 @@ impl VideoEncoder for HardwareEncoder { } filmcraft_export::timed(filmcraft_export::Stage::Convert, || filmcraft_export::rgba_to_yuv420_8(f.rgba, w, h, &mut self.y, &mut self.u, &mut self.v)); let packets = self.vt.encode(f.index, &self.y, &self.u, &self.v).map_err(ExportError::Encode)?; + filmcraft_export::note_hw_encode_frame(); // the muxer needs the parameter sets after the first group of pictures if f.index == 0 { let params = self.vt.parameter_sets().ok_or_else(|| ExportError::Encode("VideoToolbox returned no parameter sets for the first frame".into()))?; diff --git a/crates/platform/src/videotoolbox_encode.rs b/crates/platform/src/videotoolbox_encode.rs index 5edf47673..844860540 100644 --- a/crates/platform/src/videotoolbox_encode.rs +++ b/crates/platform/src/videotoolbox_encode.rs @@ -556,7 +556,7 @@ impl VtEncoder { VtRate::Cbr { kbps } => { // true constant bitrate where the OS offers it (macOS 13+); otherwise an average // with a one-second ceiling at the same rate - if !cbr_key.is_some_and(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) == 0) { + if cbr_key.is_none_or(|key| set_value(session, key, CFNumber::new_i32(bps(kbps)).as_ref()) != 0) { require(set_value(session, avg_key, CFNumber::new_i32(bps(kbps)).as_ref()), "the bitrate")?; let limits = CFArray::::from_objects(&[CFNumber::new_i32(bps(kbps) / 8).as_ref(), CFNumber::new_f64(1.0).as_ref()]); let _ = set_value(session, limits_key, limits.as_ref()); diff --git a/crates/platform/tests/hardware_encode.rs b/crates/platform/tests/hardware_encode.rs index 94b1b928a..e4959415d 100644 --- a/crates/platform/tests/hardware_encode.rs +++ b/crates/platform/tests/hardware_encode.rs @@ -9,7 +9,8 @@ use std::panic::AssertUnwindSafe; use std::sync::Arc; use filmcraft_export::{ - BitrateMode, EncodedPacket, EncoderFrame, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, Progress, VideoEncoder, export, rgba_to_yuv420_8, + BitrateMode, EncodedPacket, EncoderFrame, ExportSettings, Format, H264Pass, H264Profile, HardwareEncoding, Progress, VideoEncoder, export, hw_encode_stats, + rgba_to_yuv420_8, }; use filmcraft_frame::PixelData; use filmcraft_isobmff::{CodecConfig, SampleEntry}; @@ -133,6 +134,22 @@ fn factory_only_acts_when_asked() { assert!(videotoolbox_encoder_factory(Format::H264, W, H, FPS, &ExportSettings { hardware_encoding: HardwareEncoding::Off, ..s }).is_none()); } +/// `perf.stats` reads these counters: a declined request, a session and every encoded picture must show. +/// The counters are process-wide and other tests add to them, so only increases are checked. +#[test] +fn hardware_encoder_counters_follow_the_export() { + let s = hardware_settings(); + let before = hw_encode_stats(); + // an odd picture size is declined before any session is created + assert!(videotoolbox_encoder_factory(Format::H264, W + 1, H, FPS, &s).is_none()); + assert!(hw_encode_stats().declined > before.declined, "a declined request is counted"); + let Some(mut enc) = hardware(&s, W, H) else { return }; + let created = hw_encode_stats(); + assert!(created.sessions > before.sessions, "a created session is counted"); + encode_all(&mut enc, 3); + assert!(hw_encode_stats().frames >= created.frames + 3, "every encoded picture is counted"); +} + #[test] fn round_trip_through_our_software_decoder() { let s = hardware_settings(); diff --git a/crates/project/examples/effect_catalog.rs b/crates/project/examples/effect_catalog.rs new file mode 100644 index 000000000..12f17fe78 --- /dev/null +++ b/crates/project/examples/effect_catalog.rs @@ -0,0 +1,34 @@ +//! Print FilmCraft's effect and transition catalog as the Effects panel lists it, as JSON: one +//! object per item with its `id`, display `name`, `kind` (`Video`, `Audio`, `VideoTransition`, +//! `AudioTransition`) and Effects-panel folder `path` (e.g. `["Video Effects", "Blur & Sharpen"]`). +//! Items with no Effects-panel folder (intrinsic Motion / Opacity / Volume…, graphic layers) and +//! the hidden obsolete transitions are left out, as in the panel (the same top-level folder filter +//! as `panels/effects.rs`). `cargo xtask parity --area effects|transitions` reads this to measure +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-project --example effect_catalog +//! ``` + +use std::process::ExitCode; + +use filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = filmcraft_project::effect_defs() + .iter() + .filter(|d| d.category.first().is_some_and(|top| EFFECT_TOP_FOLDERS.contains(top))) + .map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "path": d.category})) + .collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("effect_catalog: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 1291ca053..fb55786db 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1595,9 +1595,10 @@ impl Project { // a multi-camera source sequence edits in as a multi-camera clip showing its first angle let multicam = it.as_sequence().and_then(|q| q.multicam.as_ref()).map(|m| MulticamSel { enabled: true, angle: m.first_video_angle().unwrap_or(0) as u32 }); - let source_channels = match (kind, &it.kind) { - (TrackKind::Audio, ItemKind::Media(m)) => m.interpret.audio_channels.as_ref().and_then(|a| a.clips.first().cloned()).unwrap_or_default(), - _ => Vec::new(), + let source_channels = if kind == TrackKind::Audio { + self.resolve_media(item).and_then(|(_, m, _)| m.interpret.audio_channels.as_ref()).and_then(|a| a.clips.first().cloned()).unwrap_or_default() + } else { + Vec::new() }; let id = ClipId(self.alloc_id()); let dur = seq_rate.snap_nearest(source.duration).max(seq_rate.frame_duration()); @@ -1671,6 +1672,16 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: } impl Project { + /// Available picture and sound, resolving a subclip to its parent media. Missing parents, + /// cycles and overly deep subclip chains carry neither stream. + pub fn source_streams(&self, item: ItemId) -> (bool, bool) { + let Some(pi) = self.item(item) else { return (false, false) }; + match &pi.kind { + ItemKind::Subclip { .. } => self.resolve_media(item).map_or((false, false), |(_, m, _)| (m.info.has_video(), m.info.has_audio())), + _ => (pi.has_video(), pi.has_audio()), + } + } + /// Resolve media through at most sixteen items, preserving the outermost subclip range. /// Missing parents, cycles and longer chains are unavailable media. pub fn resolve_media(&self, item: ItemId) -> Option<(ItemId, &MediaClip, Option)> { @@ -1895,6 +1906,7 @@ mod tests { fn media_resolution_bounds_depth_and_preserves_the_outer_range() { let (mut p, media, _) = demo_project(); assert_eq!(p.resolve_media(media).map(|(id, _, range)| (id, range)), Some((media, None))); + let streams = p.source_streams(media); let mut parent = media; let mut last_range = TimeRange::default(); for n in 0..15 { @@ -1902,9 +1914,14 @@ mod tests { parent = p.add_item("Sub", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None); } assert_eq!(p.resolve_media(parent).map(|(id, _, range)| (id, range)), Some((media, Some(last_range)))); + assert_eq!(p.source_streams(parent), streams); let too_deep = p.add_item("Too deep", Label::Iris, ItemKind::Subclip { parent, range: last_range, restrict_trims: false }, None); assert!(p.resolve_media(too_deep).is_none()); assert!(p.resolve_media(ItemId(u64::MAX)).is_none()); + assert_eq!(p.source_streams(too_deep), (false, false)); + assert_eq!(p.source_streams(ItemId(u64::MAX)), (false, false)); + p.item_mut(parent).unwrap().kind = ItemKind::Subclip { parent, range: last_range, restrict_trims: false }; + assert_eq!(p.source_streams(parent), (false, false)); } #[test] diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 404ee794f..fd211c243 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -351,7 +351,8 @@ pub fn clip_is_mono(item: &TrackItem, sources: &dyn SourceProvider) -> Option Vec> { let mut out = vec![vec![0.0f32; len]; w]; let item_s0 = item.start.to_units_floor(sr as i64); @@ -394,6 +395,17 @@ fn raw_channels(item: &TrackItem, src: SourceAudio, x0: i64, len: usize, sr: u32 return out; } if w == 2 { + // a 5.1 source (or a six-channel pick) folds down with ITU-R BS.775, as the Mix and the + // Source Monitor do: picking its first two channels would drop C (dialogue) and surrounds + if let Some(six) = source_51_fold(item, buf.channel_count()) { + let picked: Vec<&[f32]> = six.iter().filter_map(|&c| buf.channels.get(c).map(Vec::as_slice)).collect(); + let conv = chans::convert(&picked, Layout::Surround51, Layout::Stereo, Mixdown::FrontRear); + for (dst, ch) in out.iter_mut().zip(&conv) { + let m = n.min(ch.len()); + dst[off..off + m].copy_from_slice(&ch[..m]); + } + return out; + } let (left, right) = source_pair(item, buf.channel_count()); for (c, dst) in out.iter_mut().enumerate() { let ch = &buf.channels[if c == 0 { left } else { right }]; @@ -431,6 +443,17 @@ pub fn source_51(item: &TrackItem, available: usize) -> Vec { } } +/// The six source channels (L, R, C, LFE, Ls, Rs) a clip on a stereo track folds down: a six-channel +/// pick (Modify ▸ Audio Channels), else all of a six-channel source. `None` plays [`source_pair`] +/// (sources with another channel count have no known 5.1 layout). +fn source_51_fold(item: &TrackItem, available: usize) -> Option> { + match item.source_channels.len() { + 0 if available == 6 => Some((0..6).collect()), + 6.. => Some(source_51(item, available)), + _ => None, + } +} + /// The source channels (left, right) an audio clip plays: its `source_channels` (one = mono on /// both sides; Modify ▸ Audio Channels, Breakout to Mono), else the first two (mono sources on /// both sides). Channels the source doesn't have fall back to the first. diff --git a/crates/render/src/colorman.rs b/crates/render/src/colorman.rs index 06a7afea1..e645b9c3a 100644 --- a/crates/render/src/colorman.rs +++ b/crates/render/src/colorman.rs @@ -3,7 +3,10 @@ //! transform. The maths lives in `filmcraft_color::transform`. //! //! A sequence whose pipeline is "plain" (Rec. 709 working space without wide gamut) and media -//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged. +//! whose metadata says Rec. 709 / sRGB / linear take the original fast path unchanged. On such a +//! sequence, display-referred HDR and wide-gamut Y'CbCr (PQ, HLG, BT.2020, P3) stays on the GPU +//! path as well, with its input transform run in the compositor's shader ([`gpu_input`]); log +//! media and the other pipelines are converted here, on the CPU. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -51,6 +54,18 @@ pub fn input_transform(cs: ColorSpace, range: Range, pipe: &ColorPipeline, peak_ t } +/// The input transform of a frame the GPU compositor can colour manage itself (#965): a Y'CbCr +/// frame of display-referred HDR or wide-gamut media (PQ, HLG, BT.2020, P3) on a plain pipeline, +/// whose working → monitor transform is the identity. None for log media (camera-gamut +/// compression), RGB frames and other pipelines, which [`decode`] converts on the CPU. +pub fn gpu_input(project: &Project, item: ItemId, pipe: &ColorPipeline, cs: ColorSpace, frame: &VideoFrame) -> Option> { + if !pipe.is_plain() || !matches!(frame.data, filmcraft_frame::PixelData::Yuv8 { .. } | filmcraft_frame::PixelData::Yuv16 { .. }) { + return None; + } + let t = input_transform(cs, frame.color.range, pipe, source_peak_nits(project, item)); + t.gpu_params().map(|_| t) +} + /// Decode a frame of `item` (box-decimated by `n`) into the working space of `pipe`. pub fn decode(project: &Project, item: ItemId, frame: &VideoFrame, n: usize, pipe: &ColorPipeline) -> Image { let cs = source_space(project, item, frame); diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index f4eeb3ba0..b8dfce28b 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -77,6 +77,13 @@ pub(crate) fn dec(c: [f32; 3]) -> [f32; 3] { [srgb_to_linear(c[0].clamp(0.0, 1.0)), srgb_to_linear(c[1].clamp(0.0, 1.0)), srgb_to_linear(c[2].clamp(0.0, 1.0))] } +/// Stable seed for the *exact* frame timestamp. A fixed 30 Hz quantizer +/// would reuse the same noise field at 60/120 fps and can repeat at 30 fps +/// through floating-point rounding of seconds. +pub(crate) fn noise_frame_seed(cx: &FxCtx) -> u64 { + cx.t.0 as u64 +} + pub(crate) fn hash3(x: usize, y: usize, z: u64) -> f32 { let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ z.wrapping_mul(0x1656_67B1_9E37_79F9); h ^= h >> 31; @@ -110,7 +117,7 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let amt = f(e, "amount", cx) / 100.0; let colored = b(e, "color"); let clip = b(e, "clip"); - let frame = (cx.seconds * 30.0) as u64; + let frame = noise_frame_seed(cx); let w = img.w; img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { for x in 0..w { diff --git a/crates/render/src/gpufx.rs b/crates/render/src/gpufx.rs index 572d9627d..6876a6d9e 100644 --- a/crates/render/src/gpufx.rs +++ b/crates/render/src/gpufx.rs @@ -320,12 +320,17 @@ impl FxOp { g: 100.0 / f(e, "gamma", cx).max(1.0), } } - "extract" => FxOp::Extract { - lo: f(e, "black", cx) / 255.0, - hi: f(e, "white", cx) / 255.0, - soft: f(e, "softness", cx) / 100.0 * 0.2 + 1e-4, - invert: b(e, "invert"), - }, + "extract" => { + let softness = f(e, "softness", cx); + FxOp::Extract { + lo: f(e, "black", cx) / 255.0, + hi: f(e, "white", cx) / 255.0, + // Zero softness is a hard cutoff. A tiny substitute fade amplifies + // CPU/GPU transfer-function rounding and introduces unwanted grey edges. + soft: if softness <= 0.0 { 0.0 } else { softness / 100.0 * 0.2 + 1e-4 }, + invert: b(e, "invert"), + } + } "invert" => FxOp::Invert { channel: choice(e, "channel"), blend: f(e, "blend", cx) / 100.0 }, "posterize" => FxOp::Posterize { n: f(e, "levels", cx).max(2.0) - 1.0 }, "gaussian_blur" | "gaussian_blur_legacy" => { @@ -730,7 +735,12 @@ impl FxOp { let (lo, hi, soft, inv) = (*lo, *hi, *soft, *invert); img.map_rgb(|c, _, _| { let l = linear_to_srgb(luma709(c[0], c[1], c[2]).max(0.0)); - let inside = ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0)); + let inside = if soft <= 0.0 { + // Match the feathered function's limiting value at the cutoffs. + if l > lo && l < hi { 1.0 } else { 0.0 } + } else { + ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0)) + }; let v = if inv { 1.0 - inside } else { inside }; [v, v, v] }); @@ -1086,3 +1096,55 @@ fn resample_cpu(src: &Image, inv: &Affine, rect: [u32; 4]) -> Image { }); out } + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::{ParamValue, find_effect}; + use filmcraft_time::Tick; + + #[test] + fn extract_zero_softness_has_no_fractional_edge() { + let cx = FxCtx { + t: Tick::ZERO, + px_scale: 1.0, + seconds: 0.0, + timecode: "", + clip_name: "", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + }; + // Both sides of each cutoff, including samples inside the former artificial fade. + let lo = 90.0 / 255.0; + let hi = 160.0 / 255.0; + let samples = [lo - 0.00005, lo + 0.00005, hi - 0.00005, hi + 0.00005]; + for softness in [0.0, -1.0] { + for invert in [false, true] { + for alpha in [1.0, 0.5] { + let mut e = find_effect("extract").unwrap().instance(); + for (key, value) in [("black", 90.0), ("white", 160.0), ("softness", softness)] { + e.params.get_mut(key).unwrap().value = ParamValue::Float(value); + } + e.params.get_mut("invert").unwrap().value = ParamValue::Bool(invert); + let mut img = Image { + w: 4, + h: 1, + px: samples + .iter() + .flat_map(|v| { + let c = filmcraft_color::srgb_to_linear(*v) * alpha; + [c, c, c, alpha] + }) + .collect(), + }; + FxOp::eval(&e, &cx, 4, 1).unwrap().apply(&mut img); + for (pixel, inside) in img.px.as_chunks::<4>().0.iter().zip([false, true, true, false]) { + let value = if inside != invert { alpha } else { 0.0 }; + assert_eq!(*pixel, [value, value, value, alpha], "softness={softness}, invert={invert}"); + } + } + } + } + } +} diff --git a/crates/render/src/mixer_tests.rs b/crates/render/src/mixer_tests.rs index b9705e1e3..c5312fb00 100644 --- a/crates/render/src/mixer_tests.rs +++ b/crates/render/src/mixer_tests.rs @@ -638,6 +638,25 @@ fn a_51_track_passes_through_a_51_mix_and_folds_into_stereo() { assert!(close(meters[&crate::mixer::MASTER][5], 0.6, 1e-6)); } +#[test] +fn a_51_source_on_a_stereo_track_folds_down_instead_of_dropping_centre_and_surrounds() { + // the default after New Sequence From Clip: a 5.1 file on a stereo track in a stereo Mix + let mut r = Rig::with_channels(&[chan_dc], 6); + let k = std::f32::consts::FRAC_1_SQRT_2; + let (lo, ro) = (0.1 + k * 0.3 + k * 0.5, 0.2 + k * 0.3 + k * 0.6); + let st = mix_all(&r, 0, 256); + assert_eq!(st.len(), 2); + assert!(close(st[0][9], lo, 1e-6) && close(st[1][9], ro, 1e-6), "BS.775 fold, got {} / {}", st[0][9], st[1][9]); + // a six-channel pick folds the picked channels (here the source reversed: Rs, Ls, LFE, C, R, L) + r.track(0).items[0].source_channels = vec![5, 4, 3, 2, 1, 0]; + let st = mix_all(&r, 0, 256); + assert!(close(st[0][9], 0.6 + k * 0.4 + k * 0.2, 1e-6) && close(st[1][9], 0.5 + k * 0.4 + k * 0.1, 1e-6)); + // a stereo pick still plays exactly those two channels + r.track(0).items[0].source_channels = vec![2, 4]; + let st = mix_all(&r, 0, 256); + assert!(close(st[0][9], 0.3, 1e-6) && close(st[1][9], 0.5, 1e-6)); +} + #[test] fn stereo_track_through_a_51_submix_into_a_stereo_mix() { let mut r = Rig::new(&[chan_dc]); diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 4dd0d4d0e..166b615bc 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -39,6 +39,10 @@ pub struct PlanLayer { pub blend: Blend, /// Standard effects to run on the frame before it is placed (None: draw the frame directly). pub fx: Option>, + /// Colour management of a Y'CbCr frame that needs more than the default decode (HDR, BT.2020, + /// P3 media on a Rec. 709 sequence; [`crate::colorman::gpu_input`]): run on the GPU as on the + /// CPU. None: the default decode. + pub input: Option>, } /// The GPU effect stage of a layer: the frame is decoded into a working image of `size` (the @@ -54,7 +58,7 @@ pub struct LayerFx { impl PlanLayer { /// A layer drawing `frame` directly. pub fn new(frame: Arc, matrix: Affine, opacity: f32, blend: Blend) -> Self { - Self { frame, matrix, opacity, blend, fx: None } + Self { frame, matrix, opacity, blend, fx: None, input: None } } /// Size of the picture the matrix places: the working image with effects, else the frame. @@ -408,10 +412,13 @@ fn push_item( let time = crate::video_source_time(item, t, src.info().frame_rate()); let Ok(frame) = src.video_frame(FrameRequest { time, scale: want }) else { return }; let cs = crate::colorman::source_space(project, item.item, &frame); - // log / HDR / wide-gamut media is converted on the CPU (below), and so are blended + // HDR and wide-gamut media on a Rec. 709 sequence is converted on the GPU too (#965); log + // media and other pipelines are converted on the CPU (below), and so are blended // in-between frames (Frame Blending / Optical Flow on speed-changed clips) + let pipe = &seq.settings.color; + let input = if crate::colorman::needs_management(pipe, cs, &frame) { crate::colorman::gpu_input(project, item.item, pipe, cs, &frame) } else { None }; let plain = - !crate::colorman::needs_management(&seq.settings.color, cs, &frame) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none(); + (input.is_some() || !crate::colorman::needs_management(pipe, cs, &frame)) && crate::interpolation_blend(item, t, src.info().frame_rate()).is_none(); if plain && !chain.is_empty() { // GPU effect stage: the working image the CPU would decode (`base_layer`), the // effects evaluated for it, placed as `item_layer` places it @@ -435,7 +442,7 @@ fn push_item( .then_apply(&motion) .then_apply(&Affine::scale(1.0 / px_scale as f64, 1.0 / px_scale as f64)); let fx = LayerFx { size: (lw as u32, lh as u32), decimation: n as u32, ops }; - out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)) }); + out.push(PlanLayer { frame, matrix: m, opacity: op * extra_opacity, blend: bl, fx: Some(Arc::new(fx)), input }); return; } } else if plain { @@ -450,7 +457,7 @@ fn push_item( }; let px_scale = frame.width as f64 / size.0.max(1) as f64; let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion).then_apply(&Affine::scale(1.0 / px_scale, 1.0 / px_scale)); - out.push(PlanLayer::new(frame, m, op * extra_opacity, bl)); + out.push(PlanLayer { input, ..PlanLayer::new(frame, m, op * extra_opacity, bl) }); return; } } @@ -468,8 +475,12 @@ fn near_identity(m: &Affine) -> bool { /// A layer's working image with its effects applied, on the CPU (the reference for the GPU /// effect stage). pub fn effect_image(frame: &VideoFrame, fx: &LayerFx) -> crate::Image { - let (w, h, px) = frame.to_linear_f32_decimated(fx.decimation.max(1) as usize); - let mut img = crate::Image { w, h, px }; + effect_image_with(frame, fx, None) +} + +/// [`effect_image`] of a colour-managed layer ([`PlanLayer::input`]). +pub fn effect_image_with(frame: &VideoFrame, fx: &LayerFx, input: Option<&filmcraft_color::InputTransform>) -> crate::Image { + let mut img = linear_image(frame, fx.decimation.max(1) as usize, input); for op in &fx.ops { op.apply(&mut img); } @@ -506,12 +517,23 @@ pub fn execute_cpu(plan: &FramePlan) -> crate::Image { } } +/// A layer's frame (box-decimated by `n`) as a linear working image: the default decode, or +/// `input`'s table and pixel stage as `colorman::decode` runs them. +fn linear_image(frame: &VideoFrame, n: usize, input: Option<&filmcraft_color::InputTransform>) -> crate::Image { + let (w, h, px) = frame.to_linear_f32_decimated_with(n, input.map(|t| &t.table)); + let mut img = crate::Image { w, h, px }; + if let Some(t) = input.filter(|t| t.has_pixel_stage()) { + img.map_rgb(|c, _, _| t.apply(c)); + } + img +} + fn execute_layers(canvas: &mut crate::Image, layers: &[PlanLayer]) { let (width, height) = (canvas.w, canvas.h); for l in layers { let src = match &l.fx { - Some(fx) => effect_image(&l.frame, fx), - None => crate::Image { w: l.frame.width as usize, h: l.frame.height as usize, px: l.frame.to_linear_f32() }, + Some(fx) => effect_image_with(&l.frame, fx, l.input.as_deref()), + None => linear_image(&l.frame, 1, l.input.as_deref()), }; let placed = if l.matrix == Affine::scale(width as f64, height as f64) && src.w == 1 && src.h == 1 { crate::Image::filled(width, height, src.get(0, 0)) diff --git a/crates/render/src/region_tests.rs b/crates/render/src/region_tests.rs index 822fa5518..fd0e7f9bf 100644 --- a/crates/render/src/region_tests.rs +++ b/crates/render/src/region_tests.rs @@ -299,3 +299,28 @@ fn a_region_layer_expands_to_the_whole_layer() { assert_eq!(full.px.iter().filter(|v| **v == 1.0).count(), 2 * 2 * 4); assert!(full.px[..(2 * 6 + 4) * 4].iter().all(|v| *v == 0.0)); } + +/// HDR and wide-gamut Y'CbCr on a Rec. 709 sequence is planned as a GPU layer carrying its input +/// transform, not converted to a float image on the CPU (#965: 4K HLG / PQ playback fell to a +/// third of real time), and the plan still renders what the reference renderer does. +#[test] +fn hdr_media_is_planned_for_the_gpu_with_its_colour_management() { + use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; + let hdr = |transfer| ColorInfo { matrix: Matrix::Bt2020Ncl, transfer, primaries: Primaries::Bt2020, range: Range::Limited }; + for (name, color) in + [("HLG", hdr(Transfer::Hlg)), ("PQ", hdr(Transfer::Pq)), ("BT.2020 SDR", ColorInfo { primaries: Primaries::Bt2020, ..ColorInfo::REC709 })] + { + let f = Arc::new(VideoFrame { color, ..(*camera(W, H)).clone() }); + let (p, seq, map) = scene(&[Spec::new(f)]); + let opts = RenderOptions::default(); + let plan = plan::plan_frame(&p, seq, Tick::ZERO, opts, &map); + let plan::FramePlan::Layers { layers, .. } = &plan else { panic!("{name}: not a layer plan") }; + assert_eq!(layers.len(), 1, "{name}"); + assert!(matches!(layers[0].frame.data, PixelData::Yuv8 { .. }), "{name}: converted on the CPU"); + assert!(layers[0].input.is_some(), "{name}: no colour management for the GPU"); + let reference = render_sequence(&p, seq, Tick::ZERO, opts, &map); + let got = plan::execute_cpu(&plan); + let max = got.px.iter().zip(&reference.px).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(max < 1e-4, "{name}: plan differs from the reference by {max}"); + } +} diff --git a/crates/render/src/vfx/color.rs b/crates/render/src/vfx/color.rs index 309e6d729..5bff8ff82 100644 --- a/crates/render/src/vfx/color.rs +++ b/crates/render/src/vfx/color.rs @@ -337,7 +337,7 @@ pub fn noise(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { let colored = bv(e, "color"); let clip = bv(e, "clip"); let grain = (fv(e, "grain_size", cx).max(1.0) * cx.px_scale).max(1.0); - let frame = (cx.seconds * 30.0) as u64; + let frame = crate::effects::noise_frame_seed(cx); let w = img.w; img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| { for x in 0..w { diff --git a/crates/render/tests/noise_frame_cadence.rs b/crates/render/tests/noise_frame_cadence.rs new file mode 100644 index 000000000..b36a6f399 --- /dev/null +++ b/crates/render/tests/noise_frame_cadence.rs @@ -0,0 +1,42 @@ +use filmcraft_project::{ParamValue, find_effect}; +use filmcraft_render::{ + Image, + effects::{FxCtx, apply}, +}; +use filmcraft_time::FrameRate; + +fn render_noise(effect_id: &str, rate: FrameRate, frame: i64, amount: f64) -> Vec { + let mut image = Image::filled(16, 16, [0.5, 0.5, 0.5, 1.0]); + let mut effect = find_effect(effect_id).unwrap().instance(); + effect.param_mut("amount").unwrap().value = ParamValue::Float(amount); + let tick = rate.tick_of(frame); + let context = FxCtx { + t: tick, + px_scale: 1.0, + seconds: tick.seconds(), + timecode: "00:00:00:00", + clip_name: "generated matte", + project: None, + env: None, + working: filmcraft_color::WorkingSpace::Rec709, + }; + apply(&mut image, &effect, &context); + image.px +} + +#[test] +fn both_noise_variants_change_on_every_sequence_frame() { + for effect_id in ["noise", "noise_legacy"] { + for rate in [FrameRate::FPS_24, FrameRate::FPS_30, FrameRate::FPS_60, FrameRate::FPS_120] { + for frame in 0..5 { + let this = render_noise(effect_id, rate, frame, 100.0); + let next = render_noise(effect_id, rate, frame + 1, 100.0); + assert_ne!(this, next, "{effect_id} {rate:?} repeated frames {frame} and {}", frame + 1); + assert_eq!(this, render_noise(effect_id, rate, frame, 100.0), "same timestamp must be deterministic"); + } + } + // Floating-point second-to-30Hz conversion also repeated frames 122/123 at 30 fps. + assert_ne!(render_noise(effect_id, FrameRate::FPS_30, 122, 100.0), render_noise(effect_id, FrameRate::FPS_30, 123, 100.0)); + assert_eq!(render_noise(effect_id, FrameRate::FPS_60, 0, 0.0), [0.5, 0.5, 0.5, 1.0].repeat(16 * 16)); + } +} diff --git a/crates/render/tests/underline_direction.rs b/crates/render/tests/underline_direction.rs new file mode 100644 index 000000000..550222d71 --- /dev/null +++ b/crates/render/tests/underline_direction.rs @@ -0,0 +1,59 @@ +//! Horizontal vs vertical graphic text with underline (letter A, 60 px). +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::graphic::{new_text_layer, new_vertical_text_layer}; +use filmcraft_project::{EffectInstance, ParamValue}; +use filmcraft_render::graphic_clip::render_graphic_layers; +use filmcraft_render::image::Image; +use filmcraft_time::Tick; + +fn set(e: &mut EffectInstance, k: &str, v: ParamValue) { + e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v; +} + +fn draw(layer: EffectInstance) -> Image { + let mut img = Image::new(320, 200); + render_graphic_layers(&[layer], Tick::ZERO, (320, 200), &Affine::IDENTITY, &mut img); + img +} + +fn ink_box(img: &Image) -> [usize; 4] { + let mut b: Option<[usize; 4]> = None; + for y in 0..img.h { + for x in 0..img.w { + if img.get(x, y)[3] > 0.4 { + b = Some(match b { + Some(a) => [a[0].min(x), a[1].min(y), a[2].max(x), a[3].max(y)], + None => [x, y, x, y], + }); + } + } + } + b.expect("ink") +} + +fn count(img: &Image) -> usize { + (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.4).count() +} + +#[test] +fn underline_horizontal_vs_vertical_letter_a() { + let pos = Vec2::new(80.0, 100.0); + let mut h = new_text_layer("A", pos, 60.0); + let h_plain = draw(h.clone()); + set(&mut h, "underline", ParamValue::Bool(true)); + let h_ul = draw(h); + let mut v = new_vertical_text_layer("A", Vec2::new(200.0, 100.0), 60.0); + let v_plain = draw(v.clone()); + set(&mut v, "underline", ParamValue::Bool(true)); + let v_ul = draw(v); + + assert!(count(&h_ul) > count(&h_plain) + 20, "horizontal underline adds ink"); + assert!(count(&v_ul) > count(&v_plain) + 20, "vertical underline adds ink"); + + let hb = ink_box(&h_ul); + let hp = ink_box(&h_plain); + let vb = ink_box(&v_ul); + let vp = ink_box(&v_plain); + assert!(hb[3] > hp[3] + 1, "horizontal underline sits below the letter: plain {hp:?} ul {hb:?}"); + assert!(vb[2] > vp[2] + 1, "vertical underline sits to the right of the letter: plain {vp:?} ul {vb:?}"); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c28b8479e..50fbed58e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -682,7 +682,8 @@ pub fn layout_rich_uncached(text: &str, style: &TextStyle, runs: &[StyleRun], pa lay.underline_runs.push(*si); } } else if style.underline && pl.content > 0.0 { - lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]); + let y = baseline - style.baseline_shift + pm.underline_pos; + lay.underlines.push([x, y, x + pl.content, y + pm.underline_thickness]); lay.underline_runs.push(0); } lay.lines.push(Line { @@ -724,6 +725,7 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0; let flat = ParagraphStyle { vertical: true, ..Default::default() }; lay.vertical = true; + let rich = sty.rich(); let mut base = 0usize; let mut ncols = 0usize; for (c, p) in text.split('\n').enumerate() { @@ -761,6 +763,21 @@ fn layout_vertical(text: &str, sty: &Styles, para: &ParagraphStyle, col_w: f32, } g })); + // Underline runs down the column, to the right of the cell (the "below" side of vertical-rl). + let y_top = baseline - pl.ascent; + if rich { + for (_x0, x1, si) in &pl.underlines { + let st = sty.list[*si as usize]; + let um = fonts::face(sty.prim[*si as usize].face).metrics(st.size); + let x = *x1 + shift - st.baseline_shift + um.underline_pos; + lay.underlines.push([x, y_top, x + um.underline_thickness, y_top + row]); + lay.underline_runs.push(*si); + } + } else if style.underline && pl.content > 0.0 { + let x = shift + pl.left_trim + pl.content + pm.underline_pos; + lay.underlines.push([x, y_top, x + pm.underline_thickness, y_top + row]); + lay.underline_runs.push(0); + } lay.lines.push(Line { range: pl.range, baseline, @@ -998,6 +1015,23 @@ mod tests { assert_eq!(h.lines.len(), 2); } + #[test] + fn vertical_underline_runs_down_the_column() { + let s = TextStyle { underline: true, ..st(60.0) }; + let h = layout("A", &s, &ParagraphStyle::default()); + let v = layout("A", &s, &ParagraphStyle { vertical: true, ..Default::default() }); + assert_eq!(h.underlines.len(), 1); + assert_eq!(v.underlines.len(), 1, "vertical cells get an underline"); + let [hx0, hy0, hx1, hy1] = h.underlines[0]; + let [vx0, vy0, vx1, vy1] = v.underlines[0]; + assert!(hx1 - hx0 > hy1 - hy0, "horizontal underline is a wide strip"); + assert!(vy1 - vy0 > vx1 - vx0, "vertical underline is a tall strip"); + let runs = [StyleRun { range: 0..1, style: s.clone() }]; + let rich = layout_rich("A", &st(60.0), &runs, &ParagraphStyle { vertical: true, ..Default::default() }); + assert_eq!(rich.underlines.len(), 1); + assert_eq!(rich.underline_runs, vec![1]); + } + #[test] fn vertical_cells_keep_combining_marks_with_their_base() { let style = st(32.0); diff --git a/crates/text/tests/underline_shift.rs b/crates/text/tests/underline_shift.rs new file mode 100644 index 000000000..8cb5e4e33 --- /dev/null +++ b/crates/text/tests/underline_shift.rs @@ -0,0 +1,25 @@ +//! Whole-style underline must follow `baseline_shift` the same way character-style runs do. +use filmcraft_text::{ParagraphStyle, StyleRun, TextStyle, layout, layout_rich}; + +fn style(shift: f32) -> TextStyle { + TextStyle { size: 100.0, underline: true, baseline_shift: shift, ..Default::default() } +} + +fn underline_y(shift: f32) -> (f32, f32) { + let para = ParagraphStyle::default(); + let s = style(shift); + let plain = layout("Hi", &s, ¶); + let rich = layout_rich("Hi", &s, &[StyleRun { range: 0..2, style: s.clone() }], ¶); + (plain.underlines[0][1], rich.underlines[0][1]) +} + +#[test] +fn whole_style_underline_follows_baseline_shift() { + let (p0, r0) = underline_y(0.0); + for shift in [40.0, -40.0] { + let (plain, rich) = underline_y(shift); + assert!((plain - rich).abs() < 1e-3, "plain {plain} vs rich {rich} at shift {shift}"); + assert!((plain - (p0 - shift)).abs() < 1e-3, "plain underline y {plain} should move with shift {shift}"); + assert!((rich - (r0 - shift)).abs() < 1e-3, "rich underline y {rich} should move with shift {shift}"); + } +} diff --git a/crates/tts-text/src/normalize.rs b/crates/tts-text/src/normalize.rs index 235d8423a..b22ad3297 100644 --- a/crates/tts-text/src/normalize.rs +++ b/crates/tts-text/src/normalize.rs @@ -523,11 +523,11 @@ fn english_month(month: u64) -> Option<&'static str> { /// `1/2` → "one half"; other small denominators read "N ordinal(s)", anything /// bigger reads "N over M". fn parse_fraction(rest: &str) -> Option<(Vec, usize)> { - let (n, n_end) = read_u64(rest, 0, 2)?; + let (n, n_end) = read_u64(rest, 0, MAX_CARDINAL_DIGITS)?; if peek(rest, n_end) != Some('/') || n == 0 { return None; } - let (d, d_end) = read_u64(rest, n_end + 1, 2)?; + let (d, d_end) = read_u64(rest, n_end + 1, MAX_CARDINAL_DIGITS)?; if d == 0 || peek(rest, d_end).is_some_and(|c| c.is_ascii_alphanumeric() || c == '/') { return None; } @@ -587,7 +587,7 @@ fn parse_number_core(rest: &str, lang: Lang) -> Option<(Vec, usize)> { return Some((words, used)); } let value: u64 = digits.parse().ok()?; - if let Some((tokens, u)) = parse_continuation(rest, used, value) { + if let Some((tokens, u)) = parse_continuation(rest, used, value, &digits) { return Some((tokens, u)); } if has_leading_zero_str(&digits) { @@ -613,10 +613,16 @@ fn percent_suffix_len(rest: &str) -> Option { suffix.strip_prefix('%').map(|_| rest.len() - suffix.len() + 1) } -fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec, usize)> { +/// Retain digit-by-digit reading of zero-prefixed integer portions, even when +/// followed by a percent sign or decimal fraction. +fn spoken_integer(value: u64, digits: &str) -> Vec { + if has_leading_zero_str(digits) { digits.chars().map(|c| english_digit_word(c).into()).collect() } else { english_cardinal(value) } +} + +fn parse_continuation(rest: &str, used: usize, value: u64, digits: &str) -> Option<(Vec, usize)> { let after = rest.get(used..)?; if let Some(suffix_len) = percent_suffix_len(after) { - let mut words = english_cardinal(value); + let mut words = spoken_integer(value, digits); words.push("percent".into()); return Some((words.into_iter().map(Token::Word).collect(), used + suffix_len)); } @@ -629,7 +635,7 @@ fn parse_continuation(rest: &str, used: usize, value: u64) -> Option<(Vec None } Some('.') if peek(after, 1).is_some_and(|c| c.is_ascii_digit()) => { - let mut words = english_cardinal(value); + let mut words = spoken_integer(value, digits); words.push("point".into()); let (frac, frac_used) = digit_run_as_words(after.get(1..)?); if frac.is_empty() { @@ -674,7 +680,7 @@ fn parse_digit_groups(rest: &str, used: usize) -> Option<(Vec, usize)> { } if groups.len() == 1 { let group = groups.first()?; - let leading_zero = group.starts_with('0'); + let leading_zero = has_leading_zero_str(rest.get(..used)?) || group.starts_with('0'); // Total digits across the whole cluster: the integer before the dash plus this group. let total = rest.get(..used).map_or(0, |head| head.chars().filter(char::is_ascii_digit).count()) + group.len(); if !leading_zero && total < 7 { @@ -701,7 +707,7 @@ fn parse_digit_groups(rest: &str, used: usize) -> Option<(Vec, usize)> { return Some((words, end)); } let total: usize = groups.iter().map(String::len).sum::() + rest.get(..used).map_or(0, |head| head.chars().filter(char::is_ascii_digit).count()); - let leading_zero = groups.iter().any(|g| g.starts_with('0')); + let leading_zero = has_leading_zero_str(rest.get(..used)?) || groups.iter().any(|g| g.starts_with('0')); if total >= 7 || leading_zero { let mut words: Vec = Vec::new(); if let Some(head) = rest.get(..used) { @@ -875,8 +881,8 @@ fn english_ordinal(value: u64) -> String { "nine" => "ninth", "twelve" => "twelfth", t if t.ends_with('y') => { - let stem = t.get(..t.len() - 1).unwrap_or(t); - return format!("{prefix}{stem}ieth"); + let root = t.get(..t.len() - 1).unwrap_or(t); + return attach(stem, &format!("{prefix}{root}ieth")); } t => return attach(stem, &format!("{prefix}{t}th")), }; @@ -1182,6 +1188,28 @@ mod tests { assert_eq!(words("101st", Lang::EnUs), "one hundred first"); } + #[test] + fn magnitude_is_kept_before_exact_tens_ordinals() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("20th", "twentieth"), + ("100th", "one hundredth"), + ("101st", "one hundred first"), + ("119th", "one hundred nineteenth"), + ("120th", "one hundred twentieth"), + ("130th", "one hundred thirtieth"), + ("150th", "one hundred fiftieth"), + ("190th", "one hundred ninetieth"), + ("1,020th", "one thousand twentieth"), + ("1120th", "one thousand one hundred twentieth"), + ("121st", "one hundred twenty-first"), + ("200th", "two hundredth"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + } + #[test] fn money() { assert_eq!(words("$3.50", Lang::EnUs), "three dollars and fifty cents"); @@ -1204,6 +1232,29 @@ mod tests { assert_eq!(words("$0.50", Lang::EnUs), "zero dollars and fifty cents"); } + #[test] + fn first_digit_group_keeps_leading_zeros() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("01-2", "zero one two"), + ("01-2-3", "zero one two three"), + ("0800-55", "zero eight zero zero five five"), + ("0800-1-2", "zero eight zero zero one two"), + ("0-2", "zero to two"), + ("0-2-3", "zero to two to three"), + ("2-3", "two to three"), + ("5-01", "five zero one"), + ("555-0134", "five five five zero one three four"), + ("0800-100", "zero eight zero zero one zero zero"), + ("5-10-15", "five to ten to fifteen"), + ("zero one two", "zero one two"), + ("zero eight zero zero five five", "zero eight zero zero five five"), + ] { + assert_eq!(words(input, lang), expected, "{lang:?}: {input}"); + } + } + } + #[test] fn short_hyphenated_groups_keep_every_number() { for lang in [Lang::EnUs, Lang::EnGb] { @@ -1338,6 +1389,47 @@ mod tests { assert_eq!(words("7/32", Lang::EnUs), "seven over thirty-two"); } + #[test] + fn fraction_operands_may_have_three_digits() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("7/100", "seven over one hundred"), + ("100/7", "one hundred sevenths"), + ("7/99", "seven over ninety-nine"), + ("99/7", "ninety-nine sevenths"), + ("7/0", "seven slash zero"), + ("1/2", "one half"), + ("3/4", "three quarters"), + ("22/7", "twenty-two sevenths"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + assert_eq!(words("10/7/2026", Lang::EnUs), "October seventh twenty twenty-six"); + assert_eq!(words("7/10/2026", Lang::EnGb), "the seventh of October twenty twenty-six"); + } + + #[test] + fn percent_and_decimal_suffixes_keep_leading_zeroes() { + for lang in [Lang::EnUs, Lang::EnGb] { + for (input, expected) in [ + ("07", "zero seven"), + ("007", "zero zero seven"), + ("07%", "zero seven percent"), + ("007%", "zero zero seven percent"), + ("007 %", "zero zero seven percent"), + ("07.5", "zero seven point five"), + ("007.5", "zero zero seven point five"), + ("007.5%", "zero zero seven point five percent"), + ("20%", "twenty percent"), + ("0.5", "zero point five"), + ("0.5%", "zero point five percent"), + ] { + assert_eq!(words(input, lang), expected, "{input:?} {lang:?}"); + } + } + } + #[test] fn pause_markers() { let tokens = normalize("[pause 1s]", Lang::EnUs).unwrap(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4a99b17b2..b63880d4e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-ui = { workspace = true } filmcraft-time = { workspace = true } filmcraft-geom = { workspace = true } filmcraft-color = { workspace = true } @@ -32,6 +33,8 @@ serde_json = { workspace = true } log = { workspace = true } rayon = { workspace = true } web-time = "1" +# Unicode Bidirectional Algorithm for Arabic and Hebrew captions in reading order (MIT OR Apache-2.0). +unicode-bidi = "0.3.18" # ArtCraft wordmark (PNG) for the About dialog and Home screen. image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 69ccdc1e3..cb6181612 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho ## Localisation Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified -Chinese, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference, +Chinese, German, Russian, French and Indonesian (which covers the whole interface). The language is stored in the engine's `general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. Its default, System Language (`system`), follows the operating system: the first of the user's preferred languages that the interface has (the host supplies them through @@ -32,11 +32,11 @@ 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, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they -fall back to English elsewhere. +English. Ukrainian and Russian currently cover the core menus, German every menu; they fall 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 @@ -59,3 +59,28 @@ range validation and undo; the UI owns the drag gesture. See `source_drag_ui` an Source range handles use transient gesture previews and the engine's integer-frame `source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing keeps Source navigation, markers and marked-range playback separate from Program. + +## Pane layout controls + +Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and +keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved +dock tree and panel identities to the shared model. Panel tabs or their header grip can move +between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating +panels move by their empty header and resize from the bottom-right corner. Timeline sequence +tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip +moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout +JSON, and workspaces without floating data still load. Automation IDs and workspace commands +remain the integration boundary. + +Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support +Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused +widgets in a background window. The optional `--control-input-only` native launch mode isolates +automation from native widget/menu input; `ui.inspect` reports native and effective focus +separately. Focus and input-ownership regressions cover document/playhead preservation. + +Compact monitor layouts separate the current and duration timecodes from zoom, resolution and +settings. Source video/audio drag choices retain their existing placement behavior. Transport +buttons use `craft-ui::layout::fit_toolbar`; commands beyond the available width remain available +through More, including their selected state and Source/Program routing. A scrolling fallback +retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts, +control bounds, overflow actions and Source drag payloads across monitor widths and themes. diff --git a/crates/ui-egui/examples/bench/playback.rs b/crates/ui-egui/examples/bench/playback.rs index dee93ea05..94f56d103 100644 --- a/crates/ui-egui/examples/bench/playback.rs +++ b/crates/ui-egui/examples/bench/playback.rs @@ -347,11 +347,16 @@ impl Bench { } } for r in &recs { - rep.latency.push(ms(r.finished - r.queued)); - rep.service.push(ms(r.finished - r.started)); - rep.job_cpu.push(ms(r.cpu)); - rep.src_wall.push(ms(r.source_wall)); - rep.src_cpu.push(ms(r.source_cpu)); + // Preroll jobs run before the play clock; counting them as on-clock latency made + // the LongGOP seek look like a mid-play stall (`lat.max` ≈ `preroll_ms`). + let on_clock = r.queued >= anchor && due_at.contains_key(&r.key.frame) && r.key.target == target; + if on_clock { + rep.latency.push(ms(r.finished - r.queued)); + rep.service.push(ms(r.finished - r.started)); + rep.job_cpu.push(ms(r.cpu)); + rep.src_wall.push(ms(r.source_wall)); + rep.src_cpu.push(ms(r.source_cpu)); + } if !due_at.contains_key(&r.key.frame) || r.key.target != target { rep.wasted_jobs += 1; } diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 9b9ae3bb9..850fcb8dd 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -10,6 +10,15 @@ pub struct Element { pub id: String, pub label: String, pub rect: [f32; 4], + /// Present only when this entry represents a real egui widget, rather than a click alias. + #[serde(rename = "widgetId", skip_serializing_if = "Option::is_none")] + pub widget_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub focusable: Option, + #[serde(skip)] + pub(crate) response_id: Option, } #[derive(Default)] @@ -21,7 +30,25 @@ pub struct Registry { impl Registry { pub fn add(&mut self, id: &str, rect: Rect, label: &str) { - self.elements.push(Element { id: id.to_string(), label: label.to_string(), rect: [rect.min.x, rect.min.y, rect.width(), rect.height()] }); + self.elements.push(Element { + id: id.to_string(), + label: label.to_string(), + rect: [rect.min.x, rect.min.y, rect.width(), rect.height()], + widget_id: None, + enabled: None, + focusable: None, + response_id: None, + }); + } + /// Observe the widget without changing its accessibility role or input behavior. + pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + self.add(id, response.rect, label); + if let Some(element) = self.elements.last_mut() { + element.widget_id = Some(format!("{:016x}", response.id.value())); + element.enabled = Some(response.enabled()); + element.focusable = Some(response.sense.is_focusable()); + element.response_id = Some(response.id); + } } pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); diff --git a/crates/ui-egui/src/calc.rs b/crates/ui-egui/src/calc.rs new file mode 100644 index 000000000..df4db9112 --- /dev/null +++ b/crates/ui-egui/src/calc.rs @@ -0,0 +1,184 @@ +//! Calculations typed into number fields (#817): `100*2`, `1920/3`, `(50+10)*1.5`, and, applied to +//! the field's current value, `*2` or `/3`. Reads `+ - * /` (also `×` and `÷`), brackets, decimals +//! and signs; a unit (letters, `%`, `°`) and spaces are ignored. Anything else gives `None`, never a +//! panic: the text is capped, nesting is bounded, and only finite results come back. + +/// Longest text read (characters). +const MAX_LEN: usize = 256; +/// Deepest nesting of brackets and signs. +const MAX_DEPTH: usize = 64; + +/// The value of `text` typed into a field that holds `current`. A text starting with `*` or `/` +/// applies to `current` (`*2` doubles it); `+` and `-` at the start are signs. +pub fn eval(text: &str, current: f64) -> Option { + let raw: Vec = text.chars().collect(); + if raw.len() > MAX_LEN { + return None; + } + // letters between two digits (`1e3`, `10x20`) are not a unit: refuse rather than guess + let mut i = 0; + while let Some(c) = raw.get(i) { + if c.is_alphabetic() { + let end = raw.iter().skip(i).position(|c| !c.is_alphabetic()).map_or(raw.len(), |n| i + n); + let before = i.checked_sub(1).and_then(|b| raw.get(b)).is_some_and(|c| c.is_ascii_digit()); + let after = raw.get(end).is_some_and(|c| c.is_ascii_digit() || *c == '.'); + if before && after { + return None; + } + i = end; + } else { + i += 1; + } + } + let chars: Vec = raw + .iter() + .copied() + .filter(|c| !(c.is_alphabetic() || c.is_whitespace() || matches!(c, '%' | '°'))) + .map(|c| match c { + '×' => '*', + '÷' => '/', + c => c, + }) + .collect(); + let mut p = Parser { chars: &chars, pos: 0 }; + let v = if matches!(chars.first(), Some('*' | '/')) { + let t = p.term_from(current.is_finite().then_some(current)?, 0)?; + p.expr_from(t, 0)? + } else { + p.expr(0)? + }; + (p.pos == chars.len() && v.is_finite()).then_some(v) +} + +struct Parser<'a> { + chars: &'a [char], + pos: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.chars.get(self.pos).copied() + } + + /// expr := term (('+' | '-') term)* + fn expr(&mut self, depth: usize) -> Option { + let t = self.term(depth)?; + self.expr_from(t, depth) + } + + fn expr_from(&mut self, mut v: f64, depth: usize) -> Option { + while let Some(op @ ('+' | '-')) = self.peek() { + self.pos += 1; + let r = self.term(depth)?; + v = finite(if op == '+' { v + r } else { v - r })?; + } + Some(v) + } + + /// term := factor (('*' | '/') factor)* + fn term(&mut self, depth: usize) -> Option { + let f = self.factor(depth)?; + self.term_from(f, depth) + } + + fn term_from(&mut self, mut v: f64, depth: usize) -> Option { + while let Some(op @ ('*' | '/')) = self.peek() { + self.pos += 1; + let r = self.factor(depth)?; + v = finite(if op == '*' { v * r } else { v / r })?; + } + Some(v) + } + + /// factor := ('+' | '-') factor | number | '(' expr ')' + fn factor(&mut self, depth: usize) -> Option { + if depth >= MAX_DEPTH { + return None; + } + match self.peek()? { + '+' => { + self.pos += 1; + self.factor(depth + 1) + } + '-' => { + self.pos += 1; + self.factor(depth + 1).map(|v| -v) + } + '(' => { + self.pos += 1; + let v = self.expr(depth + 1)?; + if self.peek()? != ')' { + return None; + } + self.pos += 1; + Some(v) + } + _ => self.number(), + } + } + + fn number(&mut self) -> Option { + let start = self.pos; + while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '.') { + self.pos += 1; + } + let s: String = self.chars.get(start..self.pos)?.iter().collect(); + s.parse::().ok().and_then(finite) + } +} + +fn finite(v: f64) -> Option { + v.is_finite().then_some(v) +} + +#[cfg(test)] +mod tests { + use super::eval; + + #[test] + fn calculations() { + assert_eq!(eval("100*2", 0.0), Some(200.0)); + assert_eq!(eval("1920/3", 0.0), Some(640.0)); + assert_eq!(eval("(50+10)*1.5", 0.0), Some(90.0)); + assert_eq!(eval("2+3*4", 0.0), Some(14.0), "* before +"); + assert_eq!(eval("10-2-3", 0.0), Some(5.0), "left to right"); + assert_eq!(eval("-(4+1)", 0.0), Some(-5.0)); + assert_eq!(eval(" .5 + 1. ", 0.0), Some(1.5)); + assert_eq!(eval("6×7", 0.0), Some(42.0)); + assert_eq!(eval("9÷3", 0.0), Some(3.0)); + assert_eq!(eval("42", 0.0), Some(42.0), "a plain number"); + } + + #[test] + fn units_are_ignored() { + assert_eq!(eval("50%*2", 0.0), Some(100.0)); + assert_eq!(eval("90°/2", 0.0), Some(45.0)); + assert_eq!(eval("1920 px / 2", 0.0), Some(960.0)); + assert_eq!(eval("-6 dB", 0.0), Some(-6.0)); + } + + #[test] + fn a_leading_times_or_divide_applies_to_the_current_value() { + assert_eq!(eval("*2", 150.0), Some(300.0)); + assert_eq!(eval("/3", 300.0), Some(100.0)); + assert_eq!(eval("/2+1", 10.0), Some(6.0)); + assert_eq!(eval("-10", 50.0), Some(-10.0), "a leading minus is a sign"); + assert_eq!(eval("+10", 50.0), Some(10.0), "a leading plus is a sign"); + // the field's text with an operation added after it + assert_eq!(eval("200/3", 200.0), Some(200.0 / 3.0)); + } + + #[test] + fn bad_input_gives_nothing() { + for bad in ["", " ", "abc", "2*", "*", "(1+2", "1+2)", "1..2", "1/0", "0/0", "2**3", "1,5", "()"] { + assert_eq!(eval(bad, 7.0), None, "{bad:?}"); + } + assert_eq!(eval("*2", f64::NAN), None); + assert_eq!(eval(&"9".repeat(400), 0.0), None, "too long"); + assert_eq!(eval(&format!("{}1{}", "(".repeat(100), ")".repeat(100)), 0.0), None, "too deep"); + assert_eq!(eval(&format!("{}1", "-".repeat(100)), 0.0), None, "too many signs"); + // letters between digits are refused, not dropped (`1e3` is not 13) + assert_eq!(eval("1e3", 0.0), None); + assert_eq!(eval("10x20", 0.0), None); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cd4084815..3b5391125 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,7 +308,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if req.method == "ui.panel.show" { app.show_panel(panel); } else { - app.ui.dock.close(panel); + if let Err(error) = app.apply_dock(craft_ui::docking::Action::Close { panel }) { + return err(&error); + } } ok(Value::Null) } @@ -319,6 +321,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), }; let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -332,6 +335,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -343,6 +347,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -350,6 +355,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -359,6 +365,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers(p))); app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, @@ -372,18 +379,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) pressed: false, modifiers: Default::default(), }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; let m = as_pressed(m | modifiers(p)); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.type" => { let Some(text) = s("text") else { return err("missing `text`") }; + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); app.synthetic.push(egui::Event::Text(text.to_string())); app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); Outcome::AfterInput @@ -424,9 +435,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "ui.focus" => { - // Explicit request: activate the app and take keyboard focus. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - ok(Value::Null) + if p.is_null() || p.as_object().is_some_and(|object| object.is_empty()) { + // Existing explicit request: activate the app and take OS keyboard focus. + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + return ok(Value::Null); + } + let Some(object) = p.as_object().filter(|object| object.len() == 1 && object.contains_key("id")) else { + return err("ui.focus expects {} or {id: string}"); + }; + let Some(id) = object.get("id").and_then(Value::as_str) else { return err("ui.focus needs a string `id`") }; + let Some(element) = app.auto.find(id) else { return err(format!("no element `{id}` (see ui.elements)")) }; + let Some(widget_id) = element.response_id else { return err(format!("element `{id}` has no focusable widget")) }; + let Some(response) = ctx.read_response(widget_id) else { return err(format!("widget `{id}` is not present")) }; + if !response.enabled() + || !response.sense.is_focusable() + || !response.interact_rect.is_finite() + || !response.interact_rect.is_positive() + || !response.interact_rect.intersects(ctx.content_rect()) + { + return err(format!("widget `{id}` is not enabled, visible and focusable")); + } + // A widget target never activates the native window. The next ui.inspect reports + // actual focus ownership separately from this request acknowledgement. + response.request_focus(); + ok(json!({"focusRequested": id, "widgetId": format!("{:016x}", widget_id.value())})) } "app.quit" => { // scripted quits don't wait on the save prompt; unsaved changes stay in the recovery journal @@ -457,10 +489,22 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); + let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { + let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + json!({ + "widgetId": format!("{:016x}", id.value()), + "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), + "label": elements.first().map(|element| element.label.as_str()), + "active": ctx.read_response(id).is_some_and(|response| response.has_focus()), + }) + }); json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, + "keyboardFocus": keyboard_focus, + "input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" }, + "nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)}, "ui": app.ui, "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0}, "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()}, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 70beb2a28..48d29b552 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -5,6 +5,8 @@ use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::{Deserialize, Serialize}; +use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; + use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -140,6 +142,16 @@ pub enum SplitSize { FixedB(f32), } +impl SplitSize { + fn shared(self) -> SharedSplitSize { + match self { + Self::Ratio(value) => SharedSplitSize::Ratio(value), + Self::FixedA(value) => SharedSplitSize::FixedFirst(value), + Self::FixedB(value) => SharedSplitSize::FixedSecond(value), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -186,11 +198,24 @@ pub struct WorkspacePrefs { pub struct SavedWorkspace { pub name: String, pub layout: serde_json::Value, + /// Kept raw for the same per-workspace failure isolation as the dock tree. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub floating: serde_json::Value, } impl WorkspacePrefs { pub fn load(path: &std::path::Path) -> Self { - std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + use std::io::Read as _; + // Preferences are untrusted; bound reading before JSON can allocate from it. + const MAX_BYTES: u64 = 4 * 1024 * 1024; + let Some(bytes) = std::fs::File::open(path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= MAX_BYTES).then_some(bytes) + }) else { + return Self::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() } pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> { if let Some(d) = path.parent() { @@ -231,7 +256,55 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { /// The saved layout of a workspace: the user's saved version, else the built-in default. pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode { - prefs.saved.iter().find(|s| s.name == name).and_then(|s| serde_json::from_value(s.layout.clone()).ok()).unwrap_or_else(|| workspace(name)) + saved_docking(prefs, name).root +} + +pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { + let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { + let mut layout = Layout { + root: serde_json::from_value(saved.layout.clone()).ok()?, + floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? }, + }; + if layout.floating.is_empty() { + migrate_legacy_effects(&mut layout.root); + } + layout.validate().ok().map(|()| layout) + }); + saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] }) +} + +/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside +/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known +/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation. +fn migrate_legacy_effects(root: &mut DockNode) { + let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return }; + if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 { + return; + } + fn selected_source(node: &DockNode) -> bool { + match node { + DockNode::Tabs { panels, active } => { + panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls) + } + DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b), + } + } + fn remove_inactive(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { + panels.remove(0); + *active = 0; + } + DockNode::Split { a, b, .. } => { + remove_inactive(a); + remove_inactive(b); + } + DockNode::Tabs { .. } => {} + } + } + if selected_source(root) { + remove_inactive(root); + } } /// The default layout of a named workspace. @@ -265,7 +338,7 @@ pub fn workspace(name: &str) -> DockNode { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), ), - vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EffectControls, EssentialGraphics], 1)), + vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), "Audio" => hsplit( FixedB(320.0), @@ -330,17 +403,7 @@ impl DockNode { } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { - match self { - DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), - DockNode::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|x| *x == p) { - *active = i; - true - } else { - false - } - } - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) } pub fn is_visible(&self, p: PanelKind) -> bool { match self { @@ -350,19 +413,7 @@ impl DockNode { } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { - if let DockNode::Split { a, b, .. } = self { - a.close(p); - b.close(p); - let empty = |n: &DockNode| matches!(n, DockNode::Tabs { panels, .. } if panels.is_empty()); - if empty(a) { - *self = (**b).clone(); - } else if empty(b) { - *self = (**a).clone(); - } - } else if let DockNode::Tabs { panels, active } = self { - panels.retain(|x| *x != p); - *active = (*active).min(panels.len().saturating_sub(1)); - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p }); } /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after /// it was closed, so opening a sequence always has somewhere to show it. @@ -386,38 +437,44 @@ impl DockNode { } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if self.contains(p) { - self.activate(p); + if crate::dock_shared::encode(self, &[]).is_err() { return; } - fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { - match n { - DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), - DockNode::Tabs { panels, active } => { - if panels.contains(&near) { - panels.push(p); - *active = panels.len() - 1; - true - } else { - false - } - } - } - } - /// The first group of the tree, even an empty one (closing the last panel leaves the - /// root as an empty group: a panel opened then must still have somewhere to go). - fn add_first(n: &mut DockNode, p: PanelKind) { - match n { - DockNode::Split { a, .. } => add_first(a, p), - DockNode::Tabs { panels, active } => { - panels.push(p); - *active = panels.len() - 1; - } - } - } - if !add(self, p, near) && !add(self, p, PanelKind::Project) && !add(self, p, PanelKind::Program) { - add_first(self, p); - } + let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)).or_else(|| { + let mut panels = Vec::new(); + self.panels(&mut panels); + panels.first().copied() + }); + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor }); + } +} + +/// A floating group retains the same panel identities as docked tabs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Floating { + pub panels: Vec, + pub active: usize, + /// [x, y, width, height] in logical points. + pub rect: [f32; 4], +} + +/// App persistence shape; all mutations pass through the shared docking model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layout { + pub root: DockNode, + #[serde(default)] + pub floating: Vec, +} + +impl Layout { + pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { + crate::dock_shared::apply(self, action) + } + pub fn validate(&self) -> Result<(), String> { + crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string()) + } + pub fn contains(&self, panel: PanelKind) -> bool { + self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel)) } } @@ -434,6 +491,7 @@ pub struct Group { #[derive(Debug, Clone, PartialEq)] pub enum DockAction { Activate(PanelKind), + BeginDrag(PanelKind), Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), @@ -444,22 +502,40 @@ pub enum DockAction { MoveSequence(u64, usize), } -/// Size of a split's first child in `avail` points: the requested size, keeping both children at -/// least 20 points when there is room (and never more than `avail`). Must not panic for any size: -/// a tiny window (or browser canvas) gives splits less than 40 points. -fn split_first(size: SplitSize, avail: f32) -> f32 { - let want = match size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px, - SplitSize::FixedB(px) => avail - px, - }; - let lo = 20.0_f32.min(avail * 0.5); - let hi = (avail - 20.0).max(lo); - want.clamp(lo, hi) -} - /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { + let mut actions = Vec::new(); + layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); + for action in actions { + crate::dock_shared::apply_tree(node, action); + } +} + +/// Lay out chrome while returning mutations for the application's UI command route. +pub fn layout_with_actions( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { + layout_inner(ui, node, rect, t, path, &[], out, reg, actions); +} + +fn layout_inner( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + tree_path: &[bool], + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { match node { DockNode::Tabs { panels, active } => { let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; @@ -467,45 +543,24 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active }); } DockNode::Split { vertical, size, a, b } => { - let g = t.gap; - let total = if *vertical { rect.height() } else { rect.width() }; - let avail = (total - g).max(0.0); - let first = split_first(*size, avail); - let (ra, gutter, rb) = if *vertical { - ( - Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first + g), rect.max), - ) - } else { - ( - Rect::from_min_max(rect.min, pos2(rect.min.x + first, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first, rect.min.y), pos2(rect.min.x + first + g, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max), - ) - }; - // gutter drag (a slightly larger hit area than the visible gap) - let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) }); + let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; let id = egui::Id::new(("dock-gutter", path.to_string())); - let resp = ui.interact(hit, id, Sense::drag()); - reg.add(&format!("dock.gutter.{path}"), hit, "gutter"); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(if *vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; + let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; + let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); + if response.delta != 0.0 + && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) + { + actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); } - if resp.dragged() { - let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x }; - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); - *size = match *size { - SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), - SplitSize::FixedA(_) => SplitSize::FixedA(nf), - SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf), - }; - } - if resp.dragged() || resp.hovered() { - ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 })); - } - layout(ui, a, ra, t, &format!("{path}a"), out, reg); - layout(ui, b, rb, t, &format!("{path}b"), out, reg); + let mut first_path = tree_path.to_vec(); + first_path.push(false); + let mut second_path = tree_path.to_vec(); + second_path.push(true); + layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); + layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); } } } @@ -526,11 +581,34 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, ) -> Vec { + let chrome = draw_group_chrome_details(ui, g, seqs, t, reg); + paint_group_focus(ui, g, focused, t); + chrome.actions +} + +#[derive(Default)] +pub struct Chrome { + pub actions: Vec, + pub tabs: Vec<(PanelKind, Rect)>, +} + +pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t: &Tokens, reg: &mut crate::automation::Registry) -> Chrome { let mut actions = Vec::new(); + let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); let compact = g.panels.len() == 1 && g.panels[0].compact(); + // A narrow header grip moves the entire panel, including Timeline panels whose tabs + // separately reorder open sequences. + if let Some(panel) = active_panel { + let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); + let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); + if response.drag_started() { + actions.push(DockAction::BeginDrag(panel)); + } + } if compact { // grip dots let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); @@ -592,16 +670,34 @@ pub fn draw_group_chrome( if let Some(r) = more { let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - reg.add(&name, r, "more tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); + paint_widget_focus(&painter, &resp, t); if resp.clicked() { let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); list_opened = !open; } } + // The wheel over the strip steps through the tabs: down = next, up = previous, one tab + // per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends. + let wheel_id = egui::Id::new(("tab-wheel", g.path.clone())); + if ui.rect_contains_pointer(strip) && !tabs.is_empty() { + const NOTCH: f32 = 40.0; + let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; + let steps = (total / NOTCH).trunc(); + ui.ctx().data_mut(|d| d.insert_temp(wheel_id, total - steps * NOTCH)); + let to = (active_at as i64).saturating_sub(steps as i64).clamp(0, tabs.len() as i64 - 1) as usize; + if let Some(tab) = tabs.get(to).filter(|_| to != active_at) { + activate(tab, &mut actions); + actions.push(DockAction::Focus(tab.panel)); + } + } else { + ui.ctx().data_mut(|d| d.remove::(wheel_id)); + } let mut x = strip.min.x + 12.0; // sequence tabs as drawn (for dragging one past its neighbours), and the one being dragged let mut seq_rects: Vec<(u64, Rect)> = Vec::new(); @@ -616,19 +712,46 @@ pub fn draw_group_chrome( // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()), + None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; + if tab.seq.is_none() && resp.drag_started() { + actions.push(DockAction::BeginDrag(*p)); + } + if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) { + *rect = rect.union(tab_rect); + } else { + tab_rects.push((*p, tab_rect)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.set_selected(is_active); + }); + paint_widget_focus(&painter, &resp, t); if let Some(id) = tab.seq { seq_rects.push((id, tab_rect)); if resp.dragged() { dragged = Some(id); } + // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) + let home = egui::Id::new(("tab-seq-home", id)); + if resp.drag_started() + && let Some(at) = seqs.open.iter().position(|(s, _)| *s == id) + { + ui.data_mut(|d| d.insert_temp(home, at)); + } + if resp.drag_stopped() + && let Some(at) = ui.data_mut(|d| d.remove_temp::(home)) + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + { + actions.push(DockAction::MoveSequence(id, at)); + } } if let Some(id) = tab.seq { - reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title); + reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); } if tab.seq.is_none() || is_active { - reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title()); + reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title()); } let label_x = tab_rect.min.x + 8.0 + close_w; let label_w = galley.size().x; @@ -639,10 +762,12 @@ pub fn draw_group_chrome( // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - reg.add(&format!("timeline.tab.{id}.close"), cr.expand(4.0), "close sequence"); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); + paint_widget_focus(&painter, &cresp, t); if cresp.clicked() { actions.push(DockAction::CloseSequence(id)); closed = true; @@ -651,12 +776,14 @@ pub fn draw_group_chrome( if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu"); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); // 1 pt underline spanning label + ≡, 23 pt below the frame top let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); + paint_widget_focus(&painter, &mresp, t); if mresp.clicked() { actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } @@ -711,8 +838,8 @@ pub fn draw_group_chrome( let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() }; let resp = ui.button(label); match tab.seq { - Some(id) => reg.add(&format!("timeline.tabs.list.{id}"), resp.rect, &tab.title), - None => reg.add(&format!("panel.tabs.list.{}", tab.panel.id()), resp.rect, &tab.title), + Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title), + None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title), } if resp.clicked() { picked = Some(tab); @@ -733,10 +860,6 @@ pub fn draw_group_chrome( ui.ctx().data_mut(|d| d.remove::(list_id)); } } - // focus outline - if active_panel == Some(focused) { - painter.rect_stroke(g.rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); - } // clicking anywhere in the panel focuses it if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) @@ -744,7 +867,20 @@ pub fn draw_group_chrome( { actions.push(DockAction::Focus(p)); } - actions + Chrome { actions, tabs: tab_rects } +} + +/// Paint once after the group's contents so child backgrounds cannot hide its focus (#1075). +pub(crate) fn paint_group_focus(ui: &egui::Ui, group: &Group, focused: PanelKind, tokens: &Tokens) { + if group.panels.get(group.active).copied() == Some(focused) { + ui.painter().rect_stroke(group.rect, 0.0, Stroke::new(1.0, tokens.focus), StrokeKind::Inside); + } +} + +fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { + if response.has_focus() { + painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); + } } /// Placeholder body for panels that are not implemented yet. @@ -758,6 +894,9 @@ mod tests { #[test] fn split_first_never_panics_on_tiny_areas() { + let split_first = |size: SplitSize, avail: f32| { + split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size() + }; for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] { for size in [ SplitSize::Ratio(0.0), diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs new file mode 100644 index 000000000..d5bb28c20 --- /dev/null +++ b/crates/ui-egui/src/dock_shared.rs @@ -0,0 +1,113 @@ +//! Convert the persisted workspace schema to shared docking transactions. + +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; + +use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; + +const MAX_DEPTH: usize = 32; +const MAX_NODES: usize = 4096; +const MAX_PANELS: usize = 128; + +fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result, String> { + *count = count.saturating_add(1); + if depth > MAX_DEPTH || *count > MAX_NODES { + return Err("workspace dock tree exceeds supported limits".into()); + } + Ok(match node { + DockNode::Tabs { panels, active } => { + if panels.len() > MAX_PANELS { + return Err("too many workspace tabs".into()); + } + Node::Tabs { panels: panels.clone(), active: *active } + } + DockNode::Split { vertical, size, a, b } => Node::Split { + axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size: match *size { + SplitSize::Ratio(value) => SharedSplitSize::Ratio(value), + SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value), + SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value), + }, + first: Box::new(encode_node(a, depth.saturating_add(1), count)?), + second: Box::new(encode_node(b, depth.saturating_add(1), count)?), + }, + }) +} + +fn decode_node(node: Node) -> Result { + Ok(match node { + Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, + Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()), + Node::Split { axis, size, first, second } => DockNode::Split { + vertical: axis == SplitAxis::Vertical, + size: match size { + SharedSplitSize::Ratio(value) => SplitSize::Ratio(value), + SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value), + SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value), + }, + a: Box::new(decode_node(*first)?), + b: Box::new(decode_node(*second)?), + }, + }) +} + +pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result, String> { + if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) { + return Err("too many floating workspace panels".into()); + } + let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) }; + Ok(SharedLayout { + root, + floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn decode(layout: SharedLayout) -> Result { + Ok(Layout { + root: match layout.root { + Some(root) => decode_node(root)?, + None => DockNode::Tabs { panels: vec![], active: 0 }, + }, + floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + let mut shared = encode(&layout.root, &layout.floating)?; + shared.validate().map_err(|error| error.to_string())?; + check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let proposed = decode(shared)?; + encode(&proposed.root, &proposed.floating)?; + *layout = proposed; + Ok(()) +} + +pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { + let Ok(mut shared) = encode(root, &[]) else { return false }; + if shared.apply(action).is_err() { + return false; + } + let Ok(layout) = decode(shared) else { return false }; + if encode(&layout.root, &[]).is_err() { + return false; + } + *root = layout.root; + true +} + +/// Keep one docked destination available for dragging floating panels back into the workspace. +pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { + let panel = match action { + Action::Float { panel, .. } => Some(panel), + Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel), + _ => None, + }; + if let Some(panel) = panel { + let docked = SharedLayout { root: layout.root.clone(), floating: vec![] }; + if docked.contains(panel) && docked.panels().len() <= 1 { + return Err("the last docked panel must remain in the workspace".into()); + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 000000000..8d206d711 --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -0,0 +1,399 @@ +//! FilmCraft's panel chrome and document tabs over the shared docking transactions. + +use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::dock::{self, DockAction, DockNode, Group, PanelKind}; +use crate::{FilmcraftApp, panels}; + +/// Single source for rendering and hit testing: saved offscreen positions remain +/// reachable, and every panel has enough room for its chrome. +fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { + let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0))); + rect.translate(vec2( + (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), + (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), + )) +} + +fn drag_id() -> egui::Id { + egui::Id::new("dock-panel-drag") +} + +pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { + if id == "window.dockAction" { + return Some((|| { + let value = params.get("action").cloned().ok_or("missing docking action")?; + let action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; + app.apply_dock(action)?; + Ok(Value::Null) + })()); + } + if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { + return None; + } + Some((|| { + let panel = match params.get("panel") { + None => app.ui.focused, + Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, + }; + let action = match id { + "window.closePanel" => Action::Close { panel }, + "window.floatPanel" => { + let rect = match params.get("rect") { + None => default_float_rect(ctx.content_rect()), + Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?, + }; + Action::Float { panel, rect } + } + _ => { + let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; + let zone_name = match params.get("zone") { + None => "center", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + let zone = match zone_name.to_ascii_lowercase().as_str() { + "left" => Zone::Left, + "right" => Zone::Right, + "top" => Zone::Top, + "bottom" => Zone::Bottom, + "center" | "tab" => Zone::Center, + _ => return Err("unknown docking zone".into()), + }; + let placement = match params.get("before") { + Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, + None => Placement::Split(zone), + }; + Action::Move { panel, anchor, placement } + } + }; + app.apply_dock(action)?; + Ok(json!({"panel": panel.id()})) + })()) +} + +pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] { + let center = screen.center(); + [center.x - 210.0, center.y - 160.0, 420.0, 320.0] +} + +impl FilmcraftApp { + fn dock_command(&mut self, ctx: &egui::Context, action: Action) -> Result<(), String> { + let value = serde_json::to_value(action).map_err(|error| error.to_string())?; + crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ()) + } + + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { + let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. }); + let reveal = match &action { + Action::Activate { panel } => Some(*panel), + _ => None, + }; + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + crate::dock_shared::check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let layout = crate::dock_shared::decode(shared)?; + crate::dock_shared::encode(&layout.root, &layout.floating)?; + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + if let Some(panel) = reveal { + self.pending_panel_raise = Some(panel); + } + if relocating + || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) + { + self.ui.keys.maximized = None; + } + Ok(()) + } + + fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { + self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); + child.set_clip_rect(content.intersect(ui.clip_rect())); + panels::show(self, &mut child, panel, content); + } + + /// OS drag positions come from the host cursor hook, so egui pointer capture alone + /// cannot decide whether a Timeline is covered. Use the same geometry and layer + /// ordering as our renderer before any panel can dispatch an edit. + pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool { + if !ctx.content_rect().contains(pos) { + return false; + } + if let Some(panel) = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))) + { + return panel == PanelKind::Timeline; + } + for index in self.floating_order(ctx).into_iter().rev() { + if let Some(group) = self.ui.floating.get(index) + && floating_rect(group, ctx.content_rect()).contains(pos) + { + return group.panels.get(group.active) == Some(&PanelKind::Timeline); + } + } + true + } + + fn floating_order(&self, ctx: &egui::Context) -> Vec { + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut order: Vec<_> = (0..self.ui.floating.len()).collect(); + order.sort_by_key(|index| { + self.ui + .floating + .get(*index) + .and_then(|group| group.panels.first()) + .and_then(|panel| { + let id = egui::Id::new(("floating-panel", panel.id())); + layers.iter().position(|layer| layer.id == id) + }) + .unwrap_or(usize::MAX) + }); + order + } + + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { + let t = self.tokens; + let ctx = ui.ctx().clone(); + if let Some(panel) = self.pending_panel_raise.take() + && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) + { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + } + let maximized = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); + let mut dock = match maximized { + Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 }, + None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), + }; + let mut groups = vec![]; + let mut layout_actions = Vec::new(); + dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions); + let seqs = dock::SeqTabs { + open: self + .session + .state + .open_sequences + .iter() + .filter_map(|id| { + self.session + .project + .item(*id) + .filter(|item| matches!(item.kind, filmcraft_project::ItemKind::Sequence(_))) + .map(|item| (id.0, item.name.clone())) + }) + .collect(), + active: self.session.state.active_sequence.map(|id| id.0), + }; + let mut actions = vec![]; + let mut targets = vec![]; + for group in &groups { + let chrome = dock::draw_group_chrome_details(ui, group, &seqs, &t, &mut self.auto); + if let Some(target) = target(group, chrome.tabs, true) { + targets.push(target); + } + actions.extend(chrome.actions); + } + if maximized.is_none() { + self.ui.dock = dock; + } + for group in &groups { + if let Some(panel) = group.panels.get(group.active).copied() { + self.panel_body(ui, panel, group.content); + } + } + for group in &groups { + dock::paint_group_focus(ui, group, self.ui.focused, &t); + } + let floating_order = self.floating_order(&ctx); + for index in floating_order.into_iter().filter(|_| maximized.is_none()) { + if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { + targets.push(target); + } + } + for action in layout_actions { + let _ = self.dock_command(&ctx, action); + } + for action in actions { + match action { + DockAction::Activate(panel) => { + let _ = self.dock_command(&ctx, Action::Activate { panel }); + } + DockAction::Close(panel) => { + let _ = self.dock_command(&ctx, Action::Close { panel }); + } + DockAction::Focus(panel) => self.ui.focused = panel, + DockAction::BeginDrag(panel) => { + ctx.data_mut(|data| data.insert_temp(drag_id(), panel)); + } + DockAction::OpenSequence(id) => { + let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline }); + if self.session.state.active_sequence.map(|item| item.0) != Some(id) + && let Err(error) = self.session.execute("sequence.open", json!({"item": id})) + { + self.ui.status = error.to_string(); + } + } + DockAction::CloseSequence(id) => { + if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) { + self.ui.status = error.to_string(); + } + } + DockAction::MoveSequence(id, index) => { + if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { + self.ui.status = error.to_string(); + } + } + DockAction::PanelMenu(panel, pos) => { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + }); + } + } + } + self.panel_drag(&ctx, &targets); + panels::panel_menu_popup(self, ui); + self.cancel_value_drag(ui.ctx()); + } + + fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { + let floating = self.ui.floating.get(index)?.clone(); + let panel = *floating.panels.first()?; + let rect = floating_rect(&floating, ctx.content_rect()); + let t = self.tokens; + let id = egui::Id::new(("floating-panel", panel.id())); + let mut output = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); + let compact = floating.panels.len() == 1 && panel.compact(); + let header = if compact { 8.0 } else { t.tab_h }; + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header)); + let response = ui.interact(strip, id.with("move"), Sense::drag()); + self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel"); + let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect }; + let group = Group { + path: format!("f{index}"), + rect, + content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max), + panels: floating.panels.clone(), + active: floating.active, + }; + let chrome = dock::draw_group_chrome_details(ui, &group, seqs, &t, &mut self.auto); + actions.extend(chrome.actions); + if let Some(active) = group.panels.get(group.active).copied() { + self.panel_body(ui, active, group.content); + } + ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); + let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); + let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); + for step in 0..3 { + let offset = 4.0 + step as f32 * 4.0; + ui.painter() + .line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint)); + } + if response.hovered() || response.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + let delta = floating_resize_delta(ui, &response); + if response.has_focus() { + ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside); + } + if delta != egui::Vec2::ZERO { + next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0))); + } + if next != rect { + let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); + } + dock::paint_group_focus(ui, &group, self.ui.focused, &t); + output = target(&group, chrome.tabs, false); + }); + output + } + + fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; + let canceled = + ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); + if canceled || (!self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) { + ctx.data_mut(|data| data.remove::(drag_id())); + return; + } + let Some(pos) = ctx.pointer_latest_pos() else { return }; + let force_float = ctx.input(|input| input.modifiers.command); + let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let t = self.tokens; + if let Some(proposal) = &proposal { + painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + if let Some(mark) = proposal.insertion { + painter.rect_filled(mark, 0.0, t.focus); + } + } + let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0)); + painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85)); + painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + if ctx.input(|input| input.pointer.primary_released()) { + ctx.data_mut(|data| data.remove::(drag_id())); + let action = match proposal { + Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), + None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { + Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] }) + } + None => None, + }; + if let Some(action) = action + && self.dock_command(ctx, action).is_ok() + { + self.ui.focused = panel; + } + } + } +} + +fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { + let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); + // A compact grip still identifies the source group to the shared resolver. + for panel in &group.panels { + if !tabs.iter().any(|(candidate, _)| candidate == panel) { + tabs.push((*panel, Rect::NOTHING)); + } + } + Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true }) +} + +/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on. +fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 { + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, amount) in [ + (egui::Key::ArrowLeft, vec2(-8.0, 0.0)), + (egui::Key::ArrowRight, vec2(8.0, 0.0)), + (egui::Key::ArrowUp, vec2(0.0, -8.0)), + (egui::Key::ArrowDown, vec2(0.0, 8.0)), + ] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += amount; + } + } + }); + } + if response.enabled() { delta } else { egui::Vec2::ZERO } +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index c8db19cdd..496372e05 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -263,7 +263,7 @@ struct JobProvider { impl filmcraft_render::SourceProvider for JobProvider { fn source(&self, item: ItemId) -> Option { - let s = self.inner.source(item)?; + let s = self.inner.pool.monitor_source(item, self.inner.source(item)?); Some(Arc::new(TimedSource(s))) } } @@ -585,7 +585,9 @@ impl FrameServer { // the frame due now; once the clock runs, the frames before it are late self.request_job(key, rate.tick_of(key.frame), scale, project, 0, false, (!preroll && dir > 0).then_some(Tick::ZERO)); } - let ahead = prefetch_depth(speed); + // During preroll the clock has not started: only the frames that gate start, not a + // prefetch run that walks the rest of a LongGOP and evicts the playhead pictures. + let ahead = if preroll { (PREROLL_FRAMES - 1).max(0) } else { prefetch_depth(speed) }; for i in 0..ahead { let f = key.frame + (lead + i * stride) * dir * step; if f >= 0 { @@ -914,7 +916,7 @@ fn plan_job(job: &Job, seq: ItemId, pool: &Arc, services: &Arc, services: &Arc Result<(), String>>; + +/// A fallback graphics mode, as the host reports it. +pub struct GraphicsNotice { + /// The mode's English name ("DirectX 12 only", "software rendering"), translated when shown. + pub mode: String, + reset: ResetFn, + /// The default mode is saved for the next start. + pub reset_done: bool, + /// Closed for this session. + pub closed: bool, +} + +impl GraphicsNotice { + pub fn new(mode: &str, reset: ResetFn) -> Self { + Self { mode: mode.to_string(), reset, reset_done: false, closed: false } + } + + /// Use Default Graphics: save the default mode for the next start. + pub fn reset(&mut self) -> Result<(), String> { + (self.reset)()?; + self.reset_done = true; + Ok(()) + } +} + +/// Draw the notice (bottom right, above the status bar) until it is closed. +pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(mut notice) = app.graphics_notice.take() else { return }; + if notice.closed { + app.graphics_notice = Some(notice); + return; + } + let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode)); + let (mut reset, mut close) = (false, false); + let shown = egui::Window::new("graphics-notice") + .id(egui::Id::new("graphics-notice")) + .title_bar(false) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -32.0]) + .show(ctx, |ui| { + ui.set_max_width(360.0); + ui.label(egui::RichText::new(&title).strong()); + ui.add_space(4.0); + if notice.reset_done { + ui.label(tl!("The default graphics are used from the next start.")); + } else { + ui.label(tl!("An earlier start failed with the default graphics. Updating the graphics driver usually fixes this.")); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if !notice.reset_done { + let label = tl!("Use Default Graphics"); + let r = ui.button(label); + app.auto.add_response("graphics.notice.reset", &r, label); + reset = r.clicked(); + } + let label = tl!("Close"); + let c = ui.button(label); + app.auto.add_response("graphics.notice.close", &c, label); + close = c.clicked(); + }); + }); + if let Some(shown) = shown { + app.auto.add("graphics.notice", shown.response.rect, &title); + } + if reset && let Err(e) = notice.reset() { + app.ui.status = e; + } + notice.closed |= close; + app.graphics_notice = Some(notice); +} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 51a33dd5d..11c51af7c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6,7 +6,7 @@ # apps, text addresses the user informally ("du") and commands use the infinitive ("Projekt # öffnen…"). Product, format and feature names (FilmCraft, ArtCraft, Lumetri, Essential Graphics, # EDL, AAF…) keep their spelling. This catalog covers every menu, including the native macOS -# Settings menu; anything else is shown in English. +# app menu; anything else is shown in English. # # Terms: track = Spur, bin = Ablage, marker = Marker, playhead = Abspielposition, In/Out = In-/Out- # Punkt, transition = Übergang, frame hold = Standbild, panel = Bedienfeld, workspace = @@ -180,6 +180,8 @@ Guide Templates Hilfslinienvorlagen Help Hilfe Hide All Caption Tracks Alle Untertitelspuren ausblenden + Hide FilmCraft FilmCraft ausblenden + Hide Others Andere ausblenden High Quality Playback Wiedergabe in hoher Qualität History Verlauf Import From Importieren aus @@ -269,6 +271,7 @@ Properties Eigenschaften Proxy Proxy Purple Lila + Quit FilmCraft FilmCraft beenden Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen… Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen… Rectangle Rechteck diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 55d1e5b76..6119ccc99 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -677,6 +677,7 @@ Extract (') Extraer (') Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) Button Editor Editor de botones + More transport controls Más controles de reproducción Multi-Camera Record on Grabación multicámara activada Multi-Camera Record off Grabación multicámara desactivada {first} - Merged {first} - Combinado @@ -808,6 +809,7 @@ Relink others automatically Volver a vincular los demás automáticamente Search in: Buscar en: Browse… Examinar… + A file dialog is already open Ya hay un cuadro de diálogo de archivos abierto Exact name matches only Solo coincidencias exactas de nombre No candidates yet — Search a folder or Locate the file. Aún no hay candidatos: busca en una carpeta o localiza el archivo. ✔ same file ✔ mismo archivo @@ -2803,8 +2805,8 @@ Ignore Audio Ignorar audio Ignore Video Ignorar video Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado - Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado - Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado + Create a bin of subclips from each detected cut point Crear una bandeja de subclips a partir de cada punto de corte detectado + Create a clip marker at each detected cut point Crear un marcador de clip en cada punto de corte detectado Sensitivity: Sensibilidad: Normalize Mix Track to: Normalizar pista de mezcla a: Disabled clips Clips desactivados @@ -2980,6 +2982,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24 bits + FLAC, stereo 48 kHz 24-bit, lossless FLAC, estéreo, 48 kHz y 24 bits, sin pérdida + FLAC (lossless) FLAC (sin pérdida) + Compression Level Nivel de compresión + 0 fastest, 8 smallest 0 más rápido, 8 más pequeño APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 @@ -3488,6 +3496,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones. Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas Include Alpha Channel Incluir canal alfa Sequence Settings Ajustes de secuencia Color Management Gestión del color @@ -3551,6 +3560,11 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + + Dock Panel Acoplar panel + Undock Panel Desacoplar panel + Docking Action Acción de acoplamiento + Resize floating panel Cambiar tamaño del panel flotante Project Notes Notas del proyecto Get Project Notes Obtener notas del proyecto Edit Project Notes Editar notas del proyecto @@ -3559,3 +3573,16 @@ Compression Compresión ZIP (1 scan line) ZIP (1 línea de exploración) ZIP (16 scan lines) ZIP (16 líneas de exploración) + Safe graphics mode: {mode} Modo gráfico seguro: {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo. + Use Default Graphics Usar gráficos predeterminados + The default graphics are used from the next start. Los gráficos predeterminados se usarán a partir del próximo inicio. + DirectX 12 only Solo DirectX 12 + software rendering renderizado por software + Label Color Presets Ajustes preestablecidos de color de etiqueta + {name} (Modified) {name} (modificado) + Revert to the preset's colors and names Volver a los colores y nombres del ajuste preestablecido + Import a preset file… Importar un archivo de ajuste preestablecido… + Export the labels to a preset file… Exportar las etiquetas a un archivo de ajuste preestablecido… + Show the presets folder Mostrar la carpeta de ajustes preestablecidos + Delete this preset Eliminar este ajuste preestablecido diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0db318758..edcba88a6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2805,8 +2805,8 @@ Ignore Audio Ignorer l'audio Ignore Video Ignorer la vidéo Apply a cut at each detected cut point Appliquer une coupe à chaque point de coupe détecté - Create a subclip for each detected cut point Créer un sous-clip pour chaque point de coupe détecté - Generate clip markers at each detected cut point Générer des marqueurs de clip à chaque point de coupe détecté + Create a bin of subclips from each detected cut point Créer un chutier de sous-clips à partir de chaque point de coupe détecté + Create a clip marker at each detected cut point Créer un marqueur de clip à chaque point de coupe détecté Sensitivity: Sensibilité : Normalize Mix Track to: Normaliser la piste de mixage à : Disabled clips Clips désactivés @@ -3478,6 +3478,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options. Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer + Drag inward to round the corners Glisser vers l'intérieur pour arrondir les angles Include Alpha Channel Inclure la couche alpha Sequence Settings Paramètres de séquence Color Management Gestion des couleurs @@ -3548,3 +3549,16 @@ Compression Compression ZIP (1 scan line) ZIP (1 ligne de balayage) ZIP (16 scan lines) ZIP (16 lignes de balayage) + Safe graphics mode: {mode} Mode graphique sans échec : {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un démarrage précédent a échoué avec les graphismes par défaut. Mettre à jour le pilote graphique résout généralement le problème. + Use Default Graphics Utiliser les graphismes par défaut + The default graphics are used from the next start. Les graphismes par défaut seront utilisés au prochain démarrage. + DirectX 12 only DirectX 12 uniquement + software rendering rendu logiciel + Label Color Presets Paramètres prédéfinis de couleur d'étiquette + {name} (Modified) {name} (modifié) + Revert to the preset's colors and names Revenir aux couleurs et aux noms du paramètre prédéfini + Import a preset file… Importer un fichier de paramètre prédéfini… + Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini… + Show the presets folder Afficher le dossier des paramètres prédéfinis + Delete this preset Supprimer ce paramètre prédéfini diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv new file mode 100644 index 000000000..c70b1ba56 --- /dev/null +++ b/crates/ui-egui/src/i18n/id.tsv @@ -0,0 +1,3564 @@ +# Indonesian (id) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Indonesian (Bahasa Indonesia) video-editing vocabulary. No proprietary localisation resource, +# string table or product UI was consulted or copied; add entries the same way. Commands use the +# base verb ("Simpan Sebagai…", "Tambah Penanda"), messages address the user as "Anda", and menu +# labels keep the title case of the English source. Product, format and technology names +# (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, EDL, AAF…) keep their spelling, and common loanwords +# of the trade (render, preset, mixer, gain, ripple, roll, slip, slide, snap, scrub, offline) stay +# as they are. Decimal fractions in descriptions use a comma (29,97 fps). This catalog has the same +# entries as `es.tsv` plus the native macOS Settings menu; strings added later show in English until +# they are translated. A native-speaker review is welcome. +# +# Terms: clip = klip, track = trek, sequence = sekuens, timeline = linimasa, bin = bin, playhead = +# kepala putar, In/Out = Masuk / Keluar (titik masuk / titik keluar), marker = penanda, keyframe = +# bingkai kunci, frame = bingkai, frame hold = tahan bingkai, edit point = titik edit, trim = +# pangkas, panel = panel, workspace = ruang kerja, captions = takarir, subtitle = subtitel, proxy = +# proksi, insert = sisipkan, overwrite = timpa, lift = angkat, extract = ekstrak, nest = sarangkan, +# footage = rekaman, waveform = bentuk gelombang, scopes = skop, blur = buram, dissolve = larut, +# wipe = sapuan, glow = pendar, noise = derau, error = galat, file = berkas, guide = garis bantu, +# thumbnail = gambar mini, voice-over = sulih suara, default = bawaan, preferences (also the native +# macOS Settings menu) = Preferensi, settings = pengaturan. + + About FilmCraft Tentang FilmCraft + Add Audio Submix Track Tambah Trek Submix Audio + Add Caption at Playhead Tambah Takarir di Kepala Putar + Add Chapter Marker… Tambah Penanda Bab… + Add Edit Tambah Titik Edit + Add Edit to All Tracks Tambah Titik Edit ke Semua Trek + Add Flash Cue Marker… Tambah Penanda Isyarat Flash… + Add Frame Hold Tambah Tahan Bingkai + Add Guide… Tambah Garis Bantu… + Add Marker Tambah Penanda + Add New Caption Track… Tambah Trek Takarir Baru… + Add Range Marker Tambah Penanda Rentang + Add Range Marker to In and Out Tambah Penanda Rentang dari Masuk ke Keluar + Add Tracks… Tambah Trek… + Adjustment Layer… Lapisan Penyesuaian… + Align to Selection Ratakan ke Pilihan + Align to Video Frame Ratakan ke Bingkai Video + Align to Video Frame as Group Ratakan ke Bingkai Video sebagai Grup + All Panels Semua Panel + Alpha Alfa + Appearance Penampilan + Appearance… Penampilan… + Apply Audio Transition Terapkan Transisi Audio + Apply Default Transitions to Selection Terapkan Transisi Bawaan ke Pilihan + Apply Video Transition Terapkan Transisi Video + Arrange Susun + ArtCraft Website Situs Web ArtCraft + Assembly Perakitan + Attach Proxies… Lampirkan Proksi… + Audio Channels… Kanal Audio… + Audio Clip Mixer Mixer Klip Audio + Audio Gain… Gain Audio… + Audio Hardware… Perangkat Keras Audio… + Audio In Audio Masuk + Audio Meters Meter Audio + Audio Options Opsi Audio + Audio Out Audio Keluar + Audio Track Mixer Mixer Trek Audio + Audio Waveform Bentuk Gelombang Audio + Auto Save… Simpan Otomatis… + Automate to Sequence… Otomatiskan ke Sekuens… + Bars and Tone… Bar dan Nada… + Bin Bin + Bin From Selection Bin dari Pilihan + Black Video… Video Hitam… + Blue Biru + Bottom Bawah + Breakout to Mono Pecah ke Mono + Bring Forward Bawa Maju + Bring to Front Bawa ke Depan + Brown Cokelat + Captions Takarir + Captions and Graphics Takarir dan Grafis + Captions… Takarir… + Caribbean Karibia + Center Horizontally Tengahkan Secara Horizontal + Center Vertically Tengahkan Secara Vertikal + Cerulean Biru Langit + Clear Bersihkan + Clear Guides Hapus Garis Bantu + Clear In Hapus Masuk + Clear In and Out Hapus Masuk dan Keluar + Clear Markers Hapus Penanda + Clear Out Hapus Keluar + Clear Selected Marker Hapus Penanda Terpilih + Close Tutup + Close All Other Projects Tutup Semua Proyek Lain + Close All Projects Tutup Semua Proyek + Close Gap Tutup Celah + Close Project Tutup Proyek + Color Management… Manajemen Warna… + Color Matte… Matte Warna… + Comparison View Tampilan Perbandingan + Composite Video Video Komposit + Consolidate Duplicates Gabungkan Duplikat + Copy Salin + Copy Paste Includes Sequence Markers Salin Tempel Menyertakan Penanda Sekuens + Create Captions from Transcript… Buat Takarir dari Transkrip… + Create Multi-Camera Source Sequence… Buat Sekuens Sumber Multi-Kamera… + Create Proxies… Buat Proksi… + Cut Potong + Darkest Tergelap + Delete Render Files Hapus Berkas Render + Delete Render Files In to Out Hapus Berkas Render dari Masuk ke Keluar + Delete Tracks… Hapus Trek… + Delete Transcript Hapus Transkrip + Demo Footage Rekaman Demo + Demo Project Proyek Demo + Deselect All Batalkan Semua Pilihan + Detach Proxies Lepaskan Proksi + Display Mode Mode Tampilan + Distribute Distribusikan + Distribute Horizontally Distribusikan Secara Horizontal + Distribute Space Horizontally Distribusikan Jarak Secara Horizontal + Distribute Space Vertically Distribusikan Jarak Secara Vertikal + Distribute Vertically Distribusikan Secara Vertikal + Duplicate Duplikat + Dynamic Audio Waveforms Bentuk Gelombang Audio Dinamis + Edit Edit + Edit Offline… Edit Offline… + Edit Original Edit Asli + Edit Subclip… Edit Subklip… + Editing Penyuntingan + Effect Controls Kontrol Efek + Effects Efek + Ellipse Elips + Enable Aktifkan + Enable Remix Aktifkan Remix + Essential Graphics Grafis Esensial + Essential Sound Suara Esensial + Events Peristiwa + Export Ekspor + Export As Motion Graphics Template… Ekspor sebagai Templat Grafis Gerak… + Extract Ekstrak + Extract Audio Ekstrak Audio + Field Options… Opsi Field… + File Berkas + File… Berkas… + Fill frame Isi bingkai + FilmCraft Help… Bantuan FilmCraft… + FilmCraft on GitHub FilmCraft di GitHub + FilmCraft on getartcraft.com FilmCraft di getartcraft.com + Find Next Cari Berikutnya + Find… Cari… + Fit Pas + Fit to frame Paskan ke bingkai + Flatten Ratakan + Forest Hijau Hutan + Frame Blending Pencampuran Bingkai + Frame Hold Options… Opsi Tahan Bingkai… + Frame Sampling Pengambilan Sampel Bingkai + From Bin Dari Bin + From Source Monitor Dari Monitor Sumber + From Source Monitor, Match Frame Dari Monitor Sumber, Cocokkan Bingkai + From file… Dari berkas… + Full Penuh + Generate Audio Waveform Buat Bentuk Gelombang Audio + Get Media File Properties for Dapatkan Properti Berkas Media untuk + Go to Gap Ke Celah + Go to In Ke Masuk + Go to Next Caption Segment Ke Segmen Takarir Berikutnya + Go to Next Marker Ke Penanda Berikutnya + Go to Out Ke Keluar + Go to Previous Caption Segment Ke Segmen Takarir Sebelumnya + Go to Previous Marker Ke Penanda Sebelumnya + Go to Split Ke Split + Graphics and Titles Grafis dan Judul + Graphics… Grafis… + Green Hijau + Group Grupkan + Guide Templates Templat Garis Bantu + Help Bantuan + Hide All Caption Tracks Sembunyikan Semua Trek Takarir + High Quality Playback Pemutaran Kualitas Tinggi + History Riwayat + Import From Impor Dari + Import Image Sequence… Impor Urutan Gambar… + Import from Media Browser Impor dari Peramban Media + Import… Impor… + Info Info + Ingest Settings… Pengaturan Ingest… + Insert Sisipkan + Insert Frame Hold Segment Sisipkan Segmen Tahan Bingkai + Install Motion Graphics Template… Pasang Templat Grafis Gerak… + Interpret Footage… Interpretasikan Rekaman… + Join the ArtCraft Discord… Gabung ke Discord ArtCraft… + Keyboard Shortcuts… Pintasan Keyboard… + Label Label + Labels… Label… + Language Bahasa + Lavender Lavender + Learning Belajar + Left Kiri + Libraries Pustaka + Lift Angkat + Light Terang + Link Tautkan + Link Media… Tautkan Media… + Refresh Changed Media Segarkan Media yang Berubah + Linked Selection Pilihan Tertaut + Lock Guides Kunci Garis Bantu + Lumetri Color Lumetri Color + Lumetri Scopes Lumetri Scopes + Magnification Pembesaran + Make Offline… Jadikan Offline… + Make Subclip… Buat Subklip… + Make Subsequence Buat Subsekuens + Manage Guides… Kelola Garis Bantu… + Mark Clip Tandai Klip + Mark In Tandai Masuk + Mark Out Tandai Keluar + Mark Selection Tandai Pilihan + Mark Split Tandai Split + Markers Penanda + Match Frame Cocokkan Bingkai + Media Analysis & Transcription… Analisis Media & Transkripsi… + Media Browser Peramban Media + Media Cache… Cache Media… + Media… Media… + Medium Sedang + Memory… Memori… + Merge Clips… Gabungkan Klip… + Metadata Metadata + Modify Ubah + Motion Graphics Template… Templat Grafis Gerak… + Multi-Camera Multi-Kamera + Multi-Camera View Tampilan Multi-Kamera + Nest… Sarangkan… + New Baru + New Layer Lapisan Baru + New Project Panel Panel Proyek Baru + Next in Sequence Berikutnya di Sekuens + Next in Track Berikutnya di Trek + Normalize Mix Track… Normalisasi Trek Mix… + Offline File… Berkas Offline… + Open Project… Buka Proyek… + Optical Flow Aliran Optik + Overwrite Timpa + Paste Tempel + Paste Attributes… Tempel Atribut… + Paste Insert Tempel Sisip + Paused Resolution Resolusi Saat Jeda + Playback Resolution Resolusi Pemutaran + Playback… Pemutaran… + Plugins… Plugin… + Polygon Poligon + Preferences Preferensi + Previous in Sequence Sebelumnya di Sekuens + Previous in Track Sebelumnya di Trek + Program Program + Progress Kemajuan + Project Proyek + Project Manager… Manajer Proyek… + Project Settings Pengaturan Proyek + Project… Proyek… + Properties Properti + Purple Ungu + Reconnect Full Resolution Media… Sambungkan Ulang Media Resolusi Penuh… + Recover Unsaved Changes… Pulihkan Perubahan yang Belum Disimpan… + Rectangle Persegi Panjang + Red Merah + Redo Ulangi + Reference Monitor Monitor Referensi + Remix Remix + Remix Properties… Properti Remix… + Remove Attributes… Hapus Atribut… + Remove Filler Words Hapus Kata Pengisi + Remove Pauses Hapus Jeda + Remove Unused Hapus yang Tidak Dipakai + Rename… Ganti Nama… + Render Audio Render Audio + Render Effects In to Out Render Efek dari Masuk ke Keluar + Render In to Out Render dari Masuk ke Keluar + Render Selection Render Pilihan + Replace Fonts in Projects… Ganti Font di Proyek… + Replace With Clip Ganti dengan Klip + Report an Issue… Laporkan Masalah… + Reset All Parameters Atur Ulang Semua Parameter + Reset Duration Atur Ulang Durasi + Reset to Saved Layout Kembalikan ke Tata Letak Tersimpan + Restore Captions from Source Clip Pulihkan Takarir dari Klip Sumber + Reveal Log Files… Tampilkan Berkas Log… + Reverse Match Frame Cocokkan Bingkai Terbalik + Revert Kembalikan + Revert Remix Kembalikan Remix + Review Tinjauan + Right Kanan + Ripple Delete Hapus Ripple + Ripple Sequence Markers Ripple Penanda Sekuens + Rose Merah Mawar + Safe Margins Margin Aman + Save Simpan + Save All Simpan Semua + Save As… Simpan Sebagai… + Save Guides as Template… Simpan Garis Bantu sebagai Templat… + Save a Copy… Simpan Salinan… + Save as Template… Simpan sebagai Templat… + Scale to Frame Size Skalakan ke Ukuran Bingkai + Scene Edit Detection… Deteksi Pergantian Adegan… + Scratch Disks… Disk Kerja… + Search Bin Bin Pencarian + Select Pilih + Select All Pilih Semua + Select All Matching Pilih Semua yang Cocok + Select Label Group Pilih Grup Label + Select Next Graphic Pilih Grafis Berikutnya + Select Next Layer Pilih Lapisan Berikutnya + Select Previous Graphic Pilih Grafis Sebelumnya + Select Previous Layer Pilih Lapisan Sebelumnya + Selection Follows Playhead Pilihan Mengikuti Kepala Putar + Selection as FilmCraft Project… Pilihan sebagai Proyek FilmCraft… + Selection… Pilihan… + Send Backward Kirim Mundur + Send to Back Kirim ke Belakang + Sequence Sekuens + Sequence From Clip Sekuens dari Klip + Sequence Settings… Pengaturan Sekuens… + Sequence… Sekuens… + Show Active Caption Tracks Only Tampilkan Hanya Trek Takarir Aktif + Show All Caption Tracks Tampilkan Semua Trek Takarir + Show All Marker Colors Tampilkan Semua Warna Penanda + Show Guides Tampilkan Garis Bantu + Show Rulers Tampilkan Penggaris + Show Through Edits Tampilkan Edit Tembus + Simplify Sequence… Sederhanakan Sekuens… + Snap in Program Monitor Snap di Monitor Program + Snap in Timeline Snap di Linimasa + Source Sumber + Source Settings… Pengaturan Sumber… + Speed/Duration… Kecepatan/Durasi… + Synchronize… Sinkronkan… + System Compatibility Report… Laporan Kompatibilitas Sistem… + Tan Cokelat Muda + Teal Hijau Toska + Text Teks + Time Interpolation Interpolasi Waktu + Timecode Kode Waktu + Timecode… Kode Waktu… + Timeline Linimasa + Timeline… Linimasa… + Toggle Proxies Alihkan Proksi + Tools Alat + Top Atas + Transcribe Sequence… Transkripsikan Sekuens… + Transcribe… Transkripsikan… + Transcript Transkrip + Transparent Video… Video Transparan… + Trim Edit Edit Pangkas + Trim… Pangkas… + Undo Urungkan + Ungroup Pisahkan Grup + Universal Counting Leader… Leader Hitung Mundur Universal… + Update Metadata… Perbarui Metadata… + Upgrade Caption to Graphic Tingkatkan Takarir menjadi Grafis + Upgrade to Source Graphic Tingkatkan menjadi Grafis Sumber + Vertical Text Teks Vertikal + Video In Video Masuk + Video Options Opsi Video + Video Out Video Keluar + Video and Audio Waveform Split Video dan Bentuk Gelombang Audio Terpisah + View Tampilan + Violet Violet + Window Jendela + Workspaces Ruang Kerja + Yellow Kuning + Zoom In Perbesar + Zoom Out Perkecil + Zoom to Sequence Zoom ke Sekuens + Home Beranda + Import Impor + {name} - Edited {name} - Diedit + Full screen Layar penuh + Volume Volume + Master volume: use the Audio Track Mixer Volume master: gunakan Mixer Trek Audio + Search Cari + Quick Export Ekspor Cepat + Join the ArtCraft Discord (discord.gg/artcraft) Gabung ke Discord ArtCraft (discord.gg/artcraft) + Cancel Batal + OK OK + Interpret Footage Interpretasikan Rekaman + Color Management · {n} item Manajemen Warna · {n} item + Color Management · {n} items Manajemen Warna · {n} item + Media colour space (from file metadata): {detected} Ruang warna media (dari metadata berkas): {detected} + Override media colour space: Timpa ruang warna media: + Use file metadata Gunakan metadata berkas + Sequence Color Management Manajemen Warna Sekuens + Working Color Space Ruang Warna Kerja + Wide gamut color (composite in BT.2020) Warna gamut lebar (komposit dalam BT.2020) + Auto tone map media (HDR and log into SDR) Pemetaan nada otomatis media (HDR dan log ke SDR) + Audio Gain Gain Audio + Set Gain to: Atur Gain ke: + Adjust Gain by: Sesuaikan Gain sebesar: + Normalize Max Peak to: Normalisasi Puncak Maks ke: + Normalize All Peaks to: Normalisasi Semua Puncak ke: + Peak Amplitude: {peak} Amplitudo Puncak: {peak} + Standard Standar + Adaptive Adaptif + Mono Mono + Stereo Stereo + Add Tracks Tambah Trek + Amount Jumlah + Placement Penempatan + Track type Jenis trek + Add video tracks Tambah trek video + Add audio tracks Tambah trek audio + Add audio submix tracks Tambah trek submix audio + After {name} Setelah {name} + Before First Track Sebelum Trek Pertama + Delete Tracks Hapus Trek + Video Tracks Trek Video + Audio Tracks Trek Audio + Delete Video Tracks Hapus Trek Video + Delete Audio Tracks Hapus Trek Audio + All Empty Tracks Semua Trek Kosong + Caption Tracks Trek Takarir + Delete Caption Tracks Hapus Trek Takarir + Delete Caption Track Hapus Trek Takarir + Delete Empty Caption Tracks Hapus Trek Takarir Kosong + Version {version} Versi {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Editor video non-linear clean-room berbahasa Rust murni. Bagian dari keluarga ArtCraft. + Join the ArtCraft Discord Gabung ke Discord ArtCraft + Source code on GitHub Kode sumber di GitHub + Report an issue Laporkan masalah + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Font: Inter, Noto Serif dan JetBrains Mono (SIL OFL 1.1). + just now baru saja + {n} s ago {n} dtk lalu + {n} min ago {n} mnt lalu + {n} h ago {n} jam lalu + All Events Semua Peristiwa + Warnings and Errors Peringatan dan Galat + Errors Only Hanya Galat + Clear All Hapus Semua + No events. Tidak ada peristiwa. + Show finished jobs Tampilkan tugas yang selesai + Cancelled Dibatalkan + Failed: {e} Gagal: {e} + Done Selesai + No background jobs. Tidak ada tugas latar belakang. + Recover Unsaved Changes Pulihkan Perubahan yang Belum Disimpan + Untitled Tanpa Judul + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft ditutup saat “{name}” masih memiliki perubahan yang belum disimpan. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft keluar secara tak terduga saat “{name}” masih memiliki perubahan yang belum disimpan. + Recover unsaved changes from {when}? Pulihkan perubahan yang belum disimpan dari {when}? + The project had not been saved yet. Proyek ini belum pernah disimpan. + {n} sessions have unsaved changes: {n} sesi memiliki perubahan yang belum disimpan: + Discard Buang + Recover Pulihkan + Not Now Jangan Sekarang + Are you sure you want to discard your changes to '{file}'? Yakin ingin membuang perubahan Anda pada '{file}'? + Yes Ya + No Tidak + Add media to your project. Drop files anywhere in the window, or choose a source below. Tambahkan media ke proyek Anda. Letakkan berkas di mana saja di jendela, atau pilih sumber di bawah. + Browse files… Telusuri berkas… + Movies, audio and stills from disk Film, audio, dan gambar diam dari disk + Demo footage Rekaman demo + Six procedural 1080p clips with sound Enam klip prosedural 1080p dengan suara + Demo project Proyek demo + A cut sequence with transitions, effects and music Sekuens yang sudah dipotong dengan transisi, efek, dan musik + Bars and Tone Bar dan Nada + SMPTE HD bars with 1 kHz reference tone Bar SMPTE HD dengan nada referensi 1 kHz + Community Komunitas + Join us on Discord Bergabunglah dengan kami di Discord + OMF Export Settings Pengaturan Ekspor OMF + AAF Export Settings Pengaturan Ekspor AAF + OMF Title: Judul OMF: + Mixdown video Mixdown video + Audio: Audio: + Encapsulate Enkapsulasi + Separate Audio Audio Terpisah + Embed Sematkan + Separate Files Berkas Terpisah + Link to Media Tautkan ke Media + Audio File Format: Format Berkas Audio: + Sample Rate: Laju Sampel: + Bits per Sample: Bit per Sampel: + Rendering: Rendering: + Trim audio files Pangkas berkas audio + Handle Frames: Bingkai Handle: + Render audio clip effects Render efek klip audio + Breakout to mono Pecah ke mono + Exported {path} Diekspor: {path} + Select a clip to view its metadata. Pilih klip untuk melihat metadatanya. + Clip: {name} Klip: {name} + Open Buka + (no sequence) (tanpa sekuens) + Show or hide {color} markers Tampilkan atau sembunyikan penanda {color} + Marker Penanda + {tc} Chapter {tc} Bab + No markers. Press M to add one. Tidak ada penanda. Tekan M untuk menambahkan. + All markers are hidden by the colour filter. Semua penanda disembunyikan oleh filter warna. + Type: Jenis: + Video: Video: + Codec: Codec: + Duration: Durasi: + Settings: Pengaturan: + Playhead: Kepala putar: + Video {n}: Video {n}: + Audio {n}: Audio {n}: + UI: UI: + {fps} fps · {n} frames queued {fps} fps · {n} bingkai dalam antrean + {panel} — coming in a later milestone {panel} — hadir di milestone berikutnya + Close Panel Tutup Panel + Close Other Timeline Panels Tutup Panel Linimasa Lain + Close All Tutup Semua + Maximize Frame Maksimalkan Bingkai + Restore Frame Pulihkan Bingkai + Restore Workspace Pulihkan Ruang Kerja + Reveal Sequence in Project Tampilkan Sekuens di Proyek + Video Thumbnails Gambar Mini Video + Audio Waveforms Bentuk Gelombang Audio + Delete Preset Hapus Preset + Export Preset… Ekspor Preset… + Import Presets… Impor Preset… + Effect presets Preset efek + Save Preset Simpan Preset + Name: Nama: + Type Jenis + Scale Skala + Anchor to In Point Jangkarkan ke Titik Masuk + Anchor to Out Point Jangkarkan ke Titik Keluar + Without keyframes Tanpa bingkai kunci + Description: Deskripsi: + Saved preset “{name}” Preset “{name}” disimpan + Current duration {seconds} s Durasi saat ini {seconds} dtk + Remix Properties Properti Remix + Target Duration: Durasi Target: + Segments: Segmen: + Fewer · More Lebih Sedikit · Lebih Banyak + Variations: Variasi: + Remixed to {seconds} s Di-remix menjadi {seconds} dtk + {mode} · no clip {mode} · tanpa klip + {mode} · no sequence {mode} · tanpa sekuens + Remove Row Hapus Baris + Add a timecode row Tambah baris kode waktu + Panner Panner + Mute Bisukan + Send {n} Level Level Kirim {n} + Show Keyframes Tampilkan Bingkai Kunci + Clip Keyframes Bingkai Kunci Klip + Track Keyframes Bingkai Kunci Trek + Delete Hapus + Untitled Workspace Ruang Kerja Tanpa Judul + New Workspace Ruang Kerja Baru + Edit Workspaces Edit Ruang Kerja + Rename Ganti Nama + Restore Original Pulihkan Asli + Search effects Cari efek + Apply to Selected Clips Terapkan ke Klip Terpilih + Metadata Display Tampilan Metadata + Save As New View Preset Simpan sebagai Preset Tampilan Baru + Manage Saved View Presets Kelola Preset Tampilan Tersimpan + Freeform View Options Opsi Tampilan Bebas + Save Arrangement Simpan Susunan + Saves the view, columns, sort, thumbnail and font size. Menyimpan tampilan, kolom, urutan, gambar mini, dan ukuran font. + Grid spacing: Jarak kisi: + Default clip size: Ukuran klip bawaan: + Snap to grid Snap ke kisi + Show clip names Tampilkan nama klip + Show durations Tampilkan durasi + Show: Tampilkan: + Search fields Bidang pencarian + Project Metadata Metadata Proyek + Add Property… Tambah Properti… + Column order Urutan kolom + {n}. (empty) {n}. (kosong) + Restore Pulihkan + Overwrite with Current View Timpa dengan Tampilan Saat Ini + Graphics text search arrives with M10.1–M10.2 Pencarian teks grafis hadir pada M10.1–M10.2 + Open a sequence to work with captions Buka sekuens untuk bekerja dengan takarir + Add captions Tambah takarir + Create a caption track or import a caption file. Buat trek takarir atau impor berkas takarir. + Create new caption track Buat trek takarir baru + Import captions file… Impor berkas takarir… + New {format} track Trek {format} baru + Add caption at playhead Tambah takarir di kepala putar + Split caption at playhead Pisahkan takarir di kepala putar + Merge selected captions Gabungkan takarir terpilih + Delete selected captions Hapus takarir terpilih + No captions on this track. Press + to add one at the playhead. Tidak ada takarir di trek ini. Tekan + untuk menambahkan di kepala putar. + No matching captions. Tidak ada takarir yang cocok. + Open a sequence to see its transcript Buka sekuens untuk melihat transkripnya + Transcribe sequence Transkripsikan sekuens + Speech-to-text turns the dialogue into editable text. Ucapan-ke-teks mengubah dialog menjadi teks yang dapat diedit. + This build has no speech-to-text; import a transcript with transcript.set. Build ini tidak memiliki ucapan-ke-teks; impor transkrip dengan transcript.set. + Transcribe Transkripsikan + Extract selected text Ekstrak teks terpilih + Lift selected text Angkat teks terpilih + Remove filler words Hapus kata pengisi + Create captions Buat takarir + Speaker Pembicara + Box Kotak + Align Ratakan + Align left Rata kiri + Align center Rata tengah + Align right Rata kanan + Position Posisi + Position: top Posisi: atas + Position: middle Posisi: tengah + Position: bottom Posisi: bawah + (gap) (celah) + Out: {name} Keluar: {name} + In: {name} Masuk: {name} + Play Around Edit (Space loops, Shift+K once) Putar di Sekitar Edit (Space berulang, Shift+K sekali) + Trim Backward Many Pangkas Mundur Banyak + Trim Backward Pangkas Mundur + Apply Default Transitions Terapkan Transisi Bawaan + Apply Default Transitions to Selection (Shift+D) Terapkan Transisi Bawaan ke Pilihan (Shift+D) + Trim Forward Pangkas Maju + Trim Forward Many Pangkas Maju Banyak + Exit Trim Mode Keluar dari Mode Pangkas + Voice-over record (right-click: Voice-Over Record Settings…) Rekam sulih suara (klik kanan: Pengaturan Rekam Sulih Suara…) + Voice-Over Record Settings… Pengaturan Rekam Sulih Suara… + Recording voice-over… Merekam sulih suara… + Voice-over recorded: {p} Sulih suara direkam: {p} + Voice-over recording stopped (nothing recorded) Perekaman sulih suara dihentikan (tidak ada yang direkam) + Post-roll Post-roll + ● Recording ● Merekam + Voice-Over Record Settings Pengaturan Rekam Sulih Suara + Source: Sumber: + Default Input Input Bawaan + Input: Input: + Channel {n} Kanal {n} + Countdown Sound Cues Isyarat Suara Hitung Mundur + Pre-roll: Pre-roll: + seconds detik + Post-roll: Post-roll: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. Pemutaran memulai pre-roll sebelum kepala putar (atau titik Masuk); jika Masuk/Keluar ditandai, perekaman dimulai dan berhenti di sana. + Effect Efek + Clip Fx Editor Editor Fx Klip + Track Fx Editor Editor Fx Trek + Master Gain Gain Master + Reset Atur Ulang + Band {n} Pita {n} + Solo Solo + Bypass Lewati + Thr Amb + Ratio Rasio + Att Atk + Rel Lps + Gain Gain + Output Gain Gain Output + Limiter Limiter + Link Channels Tautkan Kanal + Limiter Threshold Ambang Limiter + Limiter Release Pelepasan Limiter + Auto Gate Gate Otomatis + Threshold Ambang + Attack Serangan + Release Pelepasan + Hold Tahan + Compressor Kompresor + Auto Makeup Makeup Otomatis + Makeup Makeup + Expander Ekspander + Soft Clip Soft Clip + (no sequences) (tidak ada sekuens) + (no clips) (tidak ada klip) + Show Transport Controls Tampilkan Kontrol Transport + Loop Putar Berulang + Multi-Camera Audio Follows Video Audio Multi-Kamera Mengikuti Video + Multi-Camera Record Rekam Multi-Kamera + Multi-Camera Selection Top Down Pilihan Multi-Kamera dari Atas ke Bawah + Show Multi-Camera Preview Monitor Tampilkan Monitor Pratinjau Multi-Kamera + Auto-Adjust Multi-Camera Playback Quality Sesuaikan Otomatis Kualitas Pemutaran Multi-Kamera + Transmit Multi-Camera View Kirim Tampilan Multi-Kamera + Multi-Camera Layout Tata Letak Multi-Kamera + Automatic Otomatis + Edit Cameras… Edit Kamera… + Add Marker (M) Tambah Penanda (M) + Mark In (I) Tandai Masuk (I) + Mark Out (O) Tandai Keluar (O) + Go to In (Shift+I) Ke Masuk (Shift+I) + Step Back 1 Frame (Left) Mundur 1 Bingkai (Kiri) + Play-Stop Toggle (Space) Putar/Berhenti (Space) + Step Forward 1 Frame (Right) Maju 1 Bingkai (Kanan) + Go to Out (Shift+O) Ke Keluar (Shift+O) + Insert (,) Sisipkan (,) + Overwrite (.) Timpa (.) + Export Frame (Shift+E) Ekspor Bingkai (Shift+E) + Lift (;) Angkat (;) + Extract (') Ekstrak (') + Export Frame: use Export mode (M6) Ekspor Bingkai: gunakan mode Ekspor (M6) + Button Editor Editor Tombol + Multi-Camera Record on Rekam Multi-Kamera aktif + Multi-Camera Record off Rekam Multi-Kamera nonaktif + {first} - Merged {first} - Digabung + {first} Multicam {first} Multikamera + In Points Titik Masuk + Out Points Titik Keluar + Clip Marker Penanda Klip + Audio Audio + Merge Clips Gabungkan Klip + Create Multi-Camera Source Sequence Buat Sekuens Sumber Multi-Kamera + Synchronize Clips Sinkronkan Klip + Clip Name: Nama Klip: + Sequence Name: Nama Sekuens: + {n} clip(s): {names} {n} klip: {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} klip dipilih; klip di trek referensi tetap di tempatnya. + Synchronize Point Titik Sinkronisasi + Ignore Hours Abaikan Jam + first marker penanda pertama + Offset Audio by: Geser Audio sebesar: + Offset: Offset: + frames bingkai + Reference track: Trek referensi: + lowest (V1…) terendah (V1…) + Remove Audio from AV Clip Hapus Audio dari Klip AV + Camera 1 Kamera 1 + All Cameras Semua Kamera + Switch Audio Ganti Audio + Camera Names Nama Kamera + Clip Names Nama Klip + Enumerate Cameras Nomori Kamera + Camera Angle Metadata Metadata Sudut Kamera + Move source clips to Processed Clips bin Pindahkan klip sumber ke bin Klip Terproses + Synchronized {n} clip group(s) {n} grup klip disinkronkan + Merged clip created Klip gabungan dibuat + Multi-camera source sequence with {n} camera(s) Sekuens sumber multi-kamera dengan {n} kamera + No multi-camera clip at the playhead Tidak ada klip multi-kamera di kepala putar + Camera {n} — {name} Kamera {n} — {name} + Page {page} of {pages} Halaman {page} dari {pages} + Previous page Halaman sebelumnya + Next page Halaman berikutnya + Multi-Camera Record On/Off (0) Rekam Multi-Kamera Aktif/Nonaktif (0) + Record Rekam + Edit Cameras Edit Kamera + Cameras shown in the Multi-Camera view (uncheck to hide). Kamera yang ditampilkan di tampilan Multi-Kamera (hapus centang untuk menyembunyikan). + Recorded {n} multi-camera cut(s) {n} potongan multi-kamera direkam + Clamp Signal Batasi Sinyal + Presets Preset + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Bentuk Gelombang ({kind}) + Parade Type Jenis Parade + Waveform Type Jenis Bentuk Gelombang + Vectorscope Targets Target Vektorskop + Colour Space Ruang Warna + Brightness Kecerahan + Signal Scale Skala Sinyal + Histogram Histogram + Paste Attributes Tempel Atribut + Remove Attributes Hapus Atribut + New Offline File Berkas Offline Baru + Nested Sequence Name Nama Sekuens Bersarang + Make Subclip Buat Subklip + Edit Subclip Edit Subklip + Modify Clip: Audio Channels Ubah Klip: Kanal Audio + Modify Clip: Timecode Ubah Klip: Kode Waktu + Frame Hold Options Opsi Tahan Bingkai + Field Options Opsi Field + Save Project Simpan Proyek + Motion Gerak + Opacity Opasitas + Time Remapping Pemetaan Ulang Waktu + Channel Volume Volume Kanal + In Point Titik Masuk + Out Point Titik Keluar + Playhead Kepala Putar + Source Timecode Kode Waktu Sumber + Sequence Time Waktu Sekuens + Video Attributes Atribut Video + Audio Attributes Atribut Audio + No effects Tidak ada efek + Scale Attribute Times Skalakan Waktu Atribut + File Name: Nama Berkas: + Tape Name: Nama Pita: + Contains: Berisi: + Video Video + Width: Lebar: + Height: Tinggi: + Frame Rate: Laju Bingkai: + Media Start: Awal Media: + Subclip: {name} Subklip: {name} + Start: Mulai: + End: Akhir: + Restrict Trims To Subclip Boundaries Batasi Pemangkasan ke Batas Subklip + Convert to Master Clip Konversi ke Klip Master + Clip Channel Format: Format Kanal Klip: + Number of Audio Clips: Jumlah Klip Audio: + Media Source Channels Kanal Sumber Media + Ch. {n} Kan. {n} + Clip {n} Klip {n} + Source channels the selected audio clips play: Kanal sumber yang diputar klip audio terpilih: + Set Timecode: Atur Kode Waktu: + Use the file's timecode Gunakan kode waktu berkas + Hold On Tahan Pada + Timecode: Kode Waktu: + Hold Filters Tahan Filter + Reverse Field Dominance Balik Dominasi Field + Processing Options Opsi Pemrosesan + None Tidak Ada + Always Deinterlace Selalu Deinterlace + Flicker Removal Penghilangan Kedip + Save changes to “{project_name}” before closing? Simpan perubahan pada “{project_name}” sebelum menutup? + Don't Save Jangan Simpan + All media is online. Semua media online. + {name} (copy) {name} (salinan) + Offline Offline + Missing Hilang + Link Media Tautkan Media + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} klip tidak dapat menemukan medianya. Temukan, cari di folder, atau biarkan offline. + Clip Name Nama Klip + File Name Nama Berkas + File Path Jalur Berkas + Status Status + Match file properties: Cocokkan properti berkas: + File Extension Ekstensi Berkas + Clip ID (fingerprint) ID Klip (sidik jari) + Duration Durasi + Media Start Awal Media + Frame Size / Rate Ukuran / Laju Bingkai + Align Timecode Selaraskan Kode Waktu + Relink others automatically Tautkan ulang yang lain secara otomatis + Search in: Cari di: + Browse… Telusuri… + Exact name matches only Hanya nama yang sama persis + No candidates yet — Search a folder or Locate the file. Belum ada kandidat — Cari di folder atau Temukan berkasnya. + ✔ same file ✔ berkas sama + ✔ matches ✔ cocok + ✖ different file ✖ berkas berbeda + Preview Pratinjau + Offline All Jadikan Semua Offline + Locate… Temukan… + Linked {n} clip(s) {n} klip ditautkan + Nothing found with that name. Tidak ditemukan apa pun dengan nama itu. + Make Offline Jadikan Offline + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Jadikan {n} klip terpilih offline. Klip tersebut menampilkan slate offline sampai Anda menautkannya lagi. + Media files remain on disk Berkas media tetap ada di disk + Media files are deleted Berkas media dihapus + Create Proxies Buat Proksi + {n} clip(s) selected. {n} klip dipilih. + Format and size Format dan ukuran + Destination Tujuan + Next to the original media, in a Proxies folder Di samping media asli, dalam folder Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Proksi dibuat di latar belakang dan dilampirkan setelah selesai. Ekspor selalu menggunakan media resolusi penuh. + Creating {n} proxy file(s)… Membuat {n} berkas proksi… + Project Manager Manajer Proyek + Sequences Sekuens + Resulting Project Proyek Hasil + Collect Files and Copy to New Location Kumpulkan Berkas dan Salin ke Lokasi Baru + Consolidate and Transcode Konsolidasi dan Transkode + Options Opsi + Exclude Unused Clips Kecualikan Klip yang Tidak Dipakai + Include Handles: Sertakan Handle: + Include Proxies Sertakan Proksi + Include Preview Files Sertakan Berkas Pratinjau + Destination Path Jalur Tujuan + Disk space: original {original} · resulting {result} ({n} files) Ruang disk: asli {original} · hasil {result} ({n} berkas) + Disk space: — Ruang disk: — + Calculate Hitung + Project Manager: writing {path} Manajer Proyek: menulis {path} + Timeline: (no sequences) Linimasa: (tidak ada sekuens) + Browse Telusuri + Select audio clips, then click a preset to apply it. Pilih klip audio, lalu klik preset untuk menerapkannya. + {name} (user) {name} (pengguna) + Select audio clips to edit them here Pilih klip audio untuk mengeditnya di sini + Multiple Clips Selected Beberapa Klip Dipilih + The selected clips have different audio types. Assign one type to edit them together. Klip terpilih memiliki jenis audio yang berbeda. Tetapkan satu jenis untuk mengeditnya bersama. + Assign an audio type to the selection: Tetapkan jenis audio untuk pilihan: + Clear Audio Type Hapus Jenis Audio + Preset: Preset: + (Custom) (Kustom) + Preset name Nama preset + Reduce Noise Kurangi Derau + Reduce Rumble Kurangi Gemuruh + DeHum DeHum + DeEss DeEss + Reduce Reverb Kurangi Gema + Dynamics Dinamika + EQ EQ + Enhance Speech Tingkatkan Ucapan + Low Tone Nada Rendah + High Tone Nada Tinggi + Mix Campuran + Reverb Reverb + Stereo Width Lebar Stereo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Remix musik ke durasi target belum tersedia. Sebagai gantinya, pangkas klip dan tambahkan crossfade. + Pan Pan + Auto-Match Cocokkan Otomatis + Not matched yet Belum dicocokkan + Matches each of the {n} clips on its own. Mencocokkan masing-masing dari {n} klip secara terpisah. + Duck against: Duck terhadap: + Untagged Tanpa tag + Sensitivity Sensitivitas + Reduce By Kurangi Sebesar + Fades Fade + Generate Keyframes Buat Bingkai Kunci + Clip Volume Volume Klip + (no clip selected) (tidak ada klip yang dipilih) + Master · {name} Master · {name} + Add Lumetri Color to clip Tambahkan Lumetri Color ke klip + Input LUT LUT Input + Color Warna + Temperature Suhu + Tint Rona + Saturation Saturasi + Exposure Eksposur + Contrast Kontras + Highlights Sorotan + Shadows Bayangan + Whites Putih + Blacks Hitam + HDR HDR + HDR White Putih HDR + HDR Specular Spekular HDR + Look Look + Look LUT LUT Look + Intensity Intensitas + Adjustments Penyesuaian + Faded Film Film Pudar + Sharpen Pertajam + Vibrance Vibrance + RGB Curves Kurva RGB + HDR Range Rentang HDR + Hue Saturation Curves Kurva Hue Saturasi + Hue vs Sat Hue vs Sat + Hue vs Hue Hue vs Hue + Hue vs Luma Hue vs Luma + Luma vs Sat Luma vs Sat + Sat vs Sat Sat vs Sat + Midtones Nada Tengah + Key Key + Hue Hue + Hue Range Rentang Hue + Saturation Min Saturasi Min + Luma Min Luma Min + Luma Max Luma Maks + Soften Lembutkan + Show Mask Tampilkan Masker + Off Mati + Color/Gray Warna/Abu-abu + Color/Black Warna/Hitam + White/Black Putih/Hitam + Refine Perhalus + Denoise Hilangkan Derau + Blur Buram + Correction Koreksi + Hue Shift Geser Hue + Midpoint Titik Tengah + Roundness Kebulatan + Feather Feather + LUT LUT + Color Match Pencocokan Warna + Reference Referensi + Face Detection (protect skin tones) Deteksi Wajah (lindungi warna kulit) + Apply Match Terapkan Pencocokan + Create ellipse mask Buat masker elips + Create 4-point polygon mask Buat masker poligon 4 titik + Free draw bezier Gambar bezier bebas + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Klik di monitor Program untuk menempatkan titik masker (seret untuk kurva); klik titik pertama atau tekan Enter untuk menutup + Delete Mask Hapus Masker + Inverted Terbalik + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Seret: pindahkan · Alt-klik: halus/sudut · Cmd-klik: hapus + Track selected mask backward Lacak masker terpilih mundur + Track selected mask backward 1 frame Lacak masker terpilih mundur 1 bingkai + Track selected mask forward 1 frame Lacak masker terpilih maju 1 bingkai + Track selected mask forward Lacak masker terpilih maju + Stop tracking Hentikan pelacakan + Tracking method: {method} Metode pelacakan: {method} + Composite Komposit + Comparison Perbandingan + Video and Waveform Video dan Bentuk Gelombang + System Default Bawaan Sistem + Default location Lokasi bawaan + Font family Keluarga font + Label Colors Warna Label + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Bingkai terdekode yang disimpan untuk monitor dan gambar mini: {used} dari {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft membiarkan sisa RAM bebas untuk aplikasi lain. + not available in this session tidak tersedia di sesi ini + Currently: {loc} — {files} file(s), {size} Saat ini: {loc} — {files} berkas, {size} + ({ch} output channels) ({ch} kanal output) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Program kiri → output perangkat {l}, kanan → output perangkat {r}{dev}. Output di luar kanal perangkat kembali ke 1 dan 2. + Auto-saves go to: {dir} Simpanan otomatis disimpan ke: {dir} + Recovery copy: {dir} Salinan pemulihan: {dir} + Last auto-save {a} · last recovery copy {j} Simpan otomatis terakhir {a} · salinan pemulihan terakhir {j} + Last auto-save {a} Simpan otomatis terakhir {a} + Last recovery copy {j} Salinan pemulihan terakhir {j} + Remove Media Cache Files Hapus Berkas Cache Media + Deleted {n} file(s), {size}. {n} berkas dihapus, {size}. + Reset… Atur Ulang… + Source · {name} Sumber · {name} + Reset Effect Atur Ulang Efek + Reset Parameter Atur Ulang Parameter + Save Preset… Simpan Preset… + Custom Setup Penyiapan Kustom + Edit… Edit… + Show graphs Tampilkan grafik + Toggle animation Alihkan animasi + Eyedropper (pick a color from the Program monitor) Pipet (ambil warna dari monitor Program) + Eyedropper: no picture to sample Pipet: tidak ada gambar untuk diambil sampelnya + Add/Remove Keyframe Tambah/Hapus Bingkai Kunci + Temporal Interpolation Interpolasi Temporal + Linear Linier + Bezier Bezier + Auto Bezier Bezier Otomatis + Continuous Bezier Bezier Kontinu + Ease In Ease In + Ease Out Ease Out + Select a clip to grade Pilih klip untuk di-grading + Select a clip to see its properties Pilih klip untuk melihat propertinya + Anchor point Titik jangkar + Rotation Rotasi + Level Level + Speed {n}% Kecepatan {n}% + Value Nilai + Velocity Kecepatan + Default Bawaan + Custom ({n} points) Kustom ({n} titik) + Select a graphic clip, or use the Type tool (T) on the Program monitor Pilih klip grafis, atau gunakan alat Teks (T) di monitor Program + Create new graphic Buat grafis baru + New Text Layer Lapisan Teks Baru + New Text Teks Baru + New Rectangle Persegi Panjang Baru + New Ellipse Elips Baru + Delete Layer Hapus Lapisan + Select a layer to edit its properties. Pilih lapisan untuk mengedit propertinya. + Align Left Ratakan Kiri + Align Center Horizontally Ratakan Tengah Secara Horizontal + Align Right Ratakan Kanan + Align Top Ratakan Atas + Align Center Vertically Ratakan Tengah Secara Vertikal + Align Bottom Ratakan Bawah + Font Font + Font Style Gaya Font + Font Size Ukuran Font + Alignment Perataan + Left Align Text Rata Kiri Teks + Center Align Text Rata Tengah Teks + Right Align Text Rata Kanan Teks + Justify Rata Kiri-Kanan + Tracking Tracking + Leading Spasi Baris + Baseline Shift Geser Garis Dasar + Text Box Width Lebar Kotak Teks + Style Gaya + Faux Bold Tebal Semu + Faux Italic Miring Semu + All Caps Semua Kapital + Small Caps Kapital Kecil + Underline Garis Bawah + OpenType OpenType + Kerning Kerning + Ligatures Ligatur + Shape Bentuk + Size Ukuran + Corner Radius Radius Sudut + Polygon Sides Sisi Poligon + Fill Isian + Stroke Garis Tepi + Stroke 2 Garis Tepi 2 + Background Latar Belakang + Opacity Opasitas + Size Ukuran + Corner Radius Radius Sudut + Shadow Bayangan + Angle Sudut + Distance Jarak + Blur Buram + Search templates Cari templat + All Categories Semua Kategori + Install… Pasang… + Install Motion Graphics Template (.fcgt) Pasang Templat Grafis Gerak (.fcgt) + No templates match. Tidak ada templat yang cocok. + {category} · My Template {category} · Templat Saya + Apply Terapkan + Remove Hapus + {n} editable properties {n} properti yang dapat diedit + Template: {name} Templat: {name} + Intro Duration Durasi Intro + Outro Duration Durasi Outro + Roll Gulung + Start Off Screen Mulai di Luar Layar + End Off Screen Berakhir di Luar Layar + Preroll Preroll + Postroll Postroll + Video Frame Bingkai Video + Pin To Sematkan ke + Pinned Edges Tepi Tersemat + FilmCraft Graphics Template Templat Grafis FilmCraft + {n} Font Font {n} + {n} Size Ukuran {n} + {n} Color Warna {n} + {n} Position Posisi {n} + Show {n} Tampilkan {n} + My Templates Templat Saya + Export As Motion Graphics Template Ekspor sebagai Templat Grafis Gerak + Category: Kategori: + Editable properties: Properti yang dapat diedit: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Disimpan sebagai templat grafis FilmCraft (.fcgt) di folder templat Anda. + Exported graphics template to {path} Templat grafis diekspor ke {path} + Replace Fonts in Projects Ganti Font di Proyek + Fonts used in the project: Font yang dipakai di proyek: + {f} (missing) {f} (hilang) + Replace: Ganti: + Replaced {n} font uses {n} penggunaan font diganti + Show/Hide Effects and Sends Tampilkan/Sembunyikan Efek dan Send + Audio Track Mixer menu Menu Mixer Trek Audio + Show/Hide Tracks… Tampilkan/Sembunyikan Trek… + Meter Input(s) Only Hanya Meter Input + Show/Hide Tracks Tampilkan/Sembunyikan Trek + Show All Tampilkan Semua + Post-Fader Pasca-Fader + Remove Effect Hapus Efek + Fill the slots above first Isi slot di atas terlebih dahulu + pre pra + Pre-Fader Pra-Fader + Mute Send Bisukan Send + Remove Send Hapus Send + New Submix Submix Baru + Pan / balance (drag; double-click: centre) Pan / keseimbangan (seret; klik ganda: tengah) + Solo safe Aman dari solo + Enable track for recording Aktifkan trek untuk perekaman + Volume (drag; double-click: 0 dB) Volume (seret; klik ganda: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panner 5.1 (seret penanda; klik ganda: tengah depan) + Center Tengah + Go to In Point Ke Titik Masuk + Go to Out Point Ke Titik Keluar + Play In to Out Putar dari Masuk ke Keluar + Record (voice-over on the record-enabled track) Rekam (sulih suara di trek yang diaktifkan untuk perekaman) + Write keyframes Tulis bingkai kunci + Something went wrong while drawing the window. Your project is still open; save it to be safe. Terjadi kesalahan saat menggambar jendela. Proyek Anda masih terbuka; simpan agar aman. + Continue Lanjutkan + Go Back Kembali + Go Forward Maju + Up One Level Naik Satu Tingkat + File Types Displayed Jenis Berkas yang Ditampilkan + List View Tampilan Daftar + Thumbnail View Tampilan Gambar Mini + Ingest Ingest + Open Ingest Settings Buka Pengaturan Ingest + Import as Image Sequence Impor sebagai Urutan Gambar + {sel} of {total} items selected {sel} dari {total} item dipilih + {total} items {total} item + Cannot open {dir}: {e} Tidak dapat membuka {dir}: {e} + Favorites Favorit + Local Drives Drive Lokal + Network Jaringan + Recent Directories Direktori Terbaru + (none) (tidak ada) + Remove from Favorites Hapus dari Favorit + Add to Favorites Tambah ke Favorit + Clear Recent Directories Hapus Direktori Terbaru + Open In Source Monitor Buka di Monitor Sumber + Reveal in Finder Tampilkan di Finder + Edit Columns… Edit Kolom… + Hover Scrub Scrub saat Melayang + New Media Browser Panel Panel Peramban Media Baru + Create Folder For Imported Projects Buat Folder untuk Proyek yang Diimpor + Allow Duplicate Media During Project Import Izinkan Media Duplikat Saat Impor Proyek + Refresh Segarkan + Edit Columns Edit Kolom + Guides Garis Bantu + (no audio) (tanpa audio) + Generating waveform… Membuat bentuk gelombang… + Reference {tc} Referensi {tc} + Set Atur + Set the reference to the playhead Atur referensi ke kepala putar + Reference: next frame Referensi: bingkai berikutnya + Reference: previous frame Referensi: bingkai sebelumnya + RGBA Channels Kanal RGBA + Add Guide Tambah Garis Bantu + Save Guides as Template Simpan Garis Bantu sebagai Templat + Manage Guides Kelola Garis Bantu + Orientation: Orientasi: + Vertical Vertikal + Horizontal Horizontal + Position: Posisi: + No saved guide templates. Tidak ada templat garis bantu tersimpan. + Project: {name} Proyek: {name} + Bin: {name} Bin: {name} + Up one level Naik satu tingkat + Video Usage: {vu} Audio Usage: {au} Penggunaan Video: {vu} Penggunaan Audio: {au} + Icon View Tampilan Ikon + Freeform View Tampilan Bebas + Sort Icons Urutkan Ikon + Clear (Delete) Hapus (Delete) + New Item Item Baru + New Bin Bin Baru + Find Cari + Automate to Sequence Otomatiskan ke Sekuens + Select items to clear Pilih item yang akan dihapus + User Order Urutan Pengguna + Descending Menurun + Refresh Project Segarkan Proyek + Reveal Project in Finder… Tampilkan Proyek di Finder… + New Bin From Selection Bin Baru dari Pilihan + New Search Bin Bin Pencarian Baru + New Project Shortcut Pintasan Proyek Baru + List Daftar + Icon Ikon + Freeform Bebas + Preview Area Area Pratinjau + Thumbnails Gambar Mini + Thumbnails show effects applied Gambar mini menampilkan efek yang diterapkan + Thumbnail controls for all pointing devices Kontrol gambar mini untuk semua perangkat penunjuk + Refresh Sort Order Segarkan Urutan + Metadata Display… Tampilan Metadata… + Save Current View Preset Simpan Preset Tampilan Saat Ini + Restore View Preset Pulihkan Preset Tampilan + Project View Preset {n} Preset Tampilan Proyek {n} + Freeform View Options… Opsi Tampilan Bebas… + Media missing Media hilang + Unreadable Tidak terbaca + Open in Source Monitor Buka di Monitor Sumber + Open in Timeline Buka di Linimasa + New Sequence From Clip Sekuens Baru dari Klip + Set Poster Frame Atur Bingkai Poster + Proxy Proksi + Open in Place Buka di Tempat + Open in New Tab Buka di Tab Baru + Open in New Window Buka di Jendela Baru + {n} items {n} item + Stack Tumpuk + Unstack Lepas Tumpukan + Clip Size Ukuran Klip + Larger Lebih Besar + Smaller Lebih Kecil + Align to Grid Ratakan ke Kisi + Reset to Grid Atur Ulang ke Kisi + Save Arrangement… Simpan Susunan… + Restore Arrangement Pulihkan Susunan + Delete Arrangement Hapus Susunan + Drop media here to create sequence. Letakkan media di sini untuk membuat sekuens. + Double-click a sequence in the Project panel to open it here. Klik ganda sekuens di panel Proyek untuk membukanya di sini. + Open Demo Project Buka Proyek Demo + Insert and overwrite sequences as nests or individual clips Sisipkan dan timpa sekuens sebagai sarang atau klip individual + Snap in Timeline (S) Snap di Linimasa (S) + Caption track options Opsi trek takarir + Timeline Display Settings Pengaturan Tampilan Linimasa + Show Video Thumbnails Tampilkan Gambar Mini Video + Head Awal + Head and Tail Awal dan Akhir + Continuous Kontinu + Show Audio Waveform Tampilkan Bentuk Gelombang Audio + Expand All Tracks Bentangkan Semua Trek + Minimize All Tracks Minimalkan Semua Trek + Unrendered: should play back in real time Belum dirender: seharusnya dapat diputar secara real time + Unrendered: render to play back in real time Belum dirender: render agar dapat diputar secara real time + Rendered preview Pratinjau yang dirender + ✓ Enable ✓ Aktifkan + Unlink Lepas Tautan + (Default) (Bawaan) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Level Hitam Input + (RGB) Input White Level (RGB) Level Putih Input + (RGB) Output Black Level (RGB) Level Hitam Output + (RGB) Output White Level (RGB) Level Putih Output + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Titik Hitam 10 Bit + 10 Bit White Point Titik Putih 10 Bit + 105mm Prime Lensa Prime 105mm + 29.97 fps Drop-Frame 29,97 fps Drop-Frame + 29.97 fps Non Drop-Frame 29,97 fps Non Drop-Frame + 35mm Prime Lensa Prime 35mm + 3D Roll Gulung 3D + 3D Rotate Rotasi 3D + 3D Spin Putaran 3D + 3D Spinback Putaran Balik 3D + 4-Color Gradient Gradien 4 Warna + 5.1 Media Media 5.1 + 5.1 Mixdown Type Jenis Mixdown 5.1 + 50-300mm Zoom Lensa Zoom 50-300mm + 59.94 fps Drop-Frame 59,94 fps Drop-Frame + 59.94 fps Non Drop-Frame 59,94 fps Non Drop-Frame + ASC CDL ASC CDL + Accessible color contrast Kontras warna yang aksesibel + Action-Safe Margin Margin Aman Aksi + Active Monitor Monitor Aktif + Add Tambah + Add tracks automatically when editing source clips onto the Timeline Tambah trek secara otomatis saat mengedit klip sumber ke Linimasa + Place files dropped onto the Timeline directly on the Timeline Tempatkan berkas yang diletakkan ke Linimasa langsung di Linimasa + 1 file 1 berkas + {n} files {n} berkas + Additional Scale Skala Tambahan + Additive Dissolve Larut Aditif + Additive Dissolve (Legacy) Larut Aditif (Lama) + Adjust Sesuaikan + Advanced Lanjutan + Aggressive Agresif + Align and Transform Ratakan dan Transformasi + All lines Semua baris + Allow Selection tool to choose Roll and Ripple trims without modifier key Izinkan alat Seleksi memilih pemangkasan Roll dan Ripple tanpa tombol pengubah + Allow current tool to change trim type of previously selected edit point Izinkan alat saat ini mengubah jenis pemangkasan titik edit yang dipilih sebelumnya + Allow duplicate media during project import Izinkan media duplikat saat impor proyek + Alpha Adjust Penyesuaian Alfa + Alpha Blurriness Keburaman Alfa + Alpha Channel Kanal Alfa + Alpha Glow Pendar Alfa + Always import embedded CEA-708 closed captions with wide aspect. Selalu impor takarir tertutup CEA-708 tersemat dengan aspek lebar. + Always override audio tags Selalu timpa tag audio + Ambience Ambiens + Ambience Intensity Intensitas Ambiens + Ambience Light Color Warna Cahaya Ambiens + Amount of Noise Jumlah Derau + Amount to Decolor Jumlah Penghilangan Warna + Amount to Tint Jumlah Pewarnaan + Amplifier Penguat + Amplify Perkuat + Amplitude Amplitudo + Amplitude and Compression Amplitudo dan Kompresi + Analog Analog + Analog Delay Delay Analog + Analyze all imported media to search for visuals or audio Analisis semua media yang diimpor untuk pencarian visual atau audio + Anamorphic Anamorfik + Anchor Jangkar + Anchor Point Titik Jangkar + Angle Sudut + Animation Animasi + Anti-aliasing Quality Kualitas Anti-aliasing + Anti-flicker Filter Filter Anti-kedip + Antialiasing for Best Quality Antialiasing untuk Kualitas Terbaik + Asymmetry Asimetri + At Startup Saat Memulai + At playback end, return to beginning when restarting playback Di akhir pemutaran, kembali ke awal saat memulai ulang pemutaran + Attempt to force hardware to document sample rate Coba paksa perangkat keras ke laju sampel dokumen + Audio Codec Codec Audio + Audio Duration Durasi Audio + Audio Effects Efek Audio + Audio Hardware Perangkat Keras Audio + Audio In Point Titik Masuk Audio + Audio Info Info Audio + Audio Out Point Titik Keluar Audio + Audio Samples Sampel Audio + Audio Time Units Satuan Waktu Audio + Audio Transition Default Duration Durasi Bawaan Transisi Audio + Audio Transitions Transisi Audio + Audio Usage Penggunaan Audio + Auto Otomatis + Auto Align Perataan Otomatis + Auto Reframe Bingkai Ulang Otomatis + Auto Save Simpan Otomatis + Auto Save also saves the current project(s) Simpan Otomatis juga menyimpan proyek saat ini + Auto-tag audio types in the timeline Tandai otomatis jenis audio di linimasa + Auto-transcribe all imported clips Transkripsikan otomatis semua klip yang diimpor + Auto-transcribe only clips in sequences Transkripsikan otomatis hanya klip di sekuens + Automatch Time Waktu Pencocokan Otomatis + Automatic Click Remover Penghapus Klik Otomatis + Automatically Hide Dependent Clips Sembunyikan Otomatis Klip Dependen + Automatically Save Every Simpan Otomatis Setiap + Automatically delete cache files older than Hapus otomatis berkas cache yang lebih lama dari + Automatically delete oldest cache files when cache exceeds Hapus otomatis berkas cache terlama saat cache melebihi + Automatically refresh growing files Segarkan otomatis berkas yang sedang bertambah + Automatically resume playback for growing files in Source Monitor Lanjutkan otomatis pemutaran berkas yang sedang bertambah di Monitor Sumber + Automatically save projects Simpan proyek secara otomatis + Automatically transcribe clips Transkripsikan klip secara otomatis + Automation Keyframe Optimization Optimasi Bingkai Kunci Otomasi + Background Color Warna Latar Belakang + Background Corner Radius Radius Sudut Latar Belakang + Background Opacity Opasitas Latar Belakang + Background Size Ukuran Latar Belakang + Background fill Isian latar belakang + Balance Keseimbangan + Balanced Voice Suara Seimbang + Band 1 Pita 1 + Band 2 Pita 2 + Band 3 Pita 3 + Band 4 Pita 4 + Band 5 Pita 5 + Band Pass Band Pass + Band Slide Geser Pita + Band Stop Band Stop + Bandpass Bandpass + Barn Doors Pintu Gudang + Basic Dasar + Basic 3D 3D Dasar + Basic Correction Koreksi Dasar + Bass Bass + Behind Di Belakang + Bessel Bessel + Bevel Alpha Bevel Alfa + Bevel join Sambungan bevel + Binauralizer - Ambisonics Binauralizer - Ambisonik + Bins Bin + Black Hitam + Black & White Hitam & Putih + Black Input Level Level Input Hitam + Black and white Hitam putih + Black and white with crushed blacks and strong contrast Hitam putih dengan hitam pekat dan kontras kuat + Bleach Bypass Bleach Bypass + Bleach-bypass style: desaturated with hard contrast Gaya bleach-bypass: saturasi rendah dengan kontras keras + Blend Campur + Blend Mode Mode Campur + Blend With Original Campur dengan Asli + Blending Mode Mode Pencampuran + Block Dissolve Larut Blok + Block Height Tinggi Blok + Block Motion Gerak Blok + Block Size Ukuran Blok + Block Width Lebar Blok + Blocks Blok + Blue Blurriness Keburaman Biru + Blue Curve Kurva Biru + Blue Offset Offset Biru + Blue Power Pangkat Biru + Blue Slope Kemiringan Biru + Blue-Blue Biru-Biru + Blue-Const Biru-Konst + Blue-Green Biru-Hijau + Blue-Red Biru-Merah + Blur & Sharpen Buram & Pertajam + Blur Amount Jumlah Buram + Blur Angle Sudut Buram + Blur Dimensions Dimensi Buram + Blur Dissolve Larut Buram + Blur Layer Lapisan Buram + Blur Length Panjang Buram + Blurriness Keburaman + Bokeh Blur Buram Bokeh + Boost Penguatan + Border Batas + Border Color Warna Batas + Border Width Lebar Batas + Borders Batas + Bottom Center Tengah Bawah + Bottom Left Kiri Bawah + Bottom Right Kanan Bawah + Bounce Pantul + Branching Percabangan + Bright Cerah + Bright Air Udara Cerah + Bright Studio Studio Cerah + Brightness & Contrast Kecerahan & Kontras + British Stack Tumpukan Inggris + Brush Hardness Kekerasan Kuas + Brush Opacity Opasitas Kuas + Brush Position Posisi Kuas + Brush Size Ukuran Kuas + Brush Spacing (secs) Jarak Kuas (dtk) + Brush Strokes Sapuan Kuas + Bubbles Gelembung + Bulge Gembung + Bulge Smoother Penghalus Gembung + Bump Height Tinggi Tonjolan + Burn Alpha Bakar Alfa + Burn Chroma Bakar Kroma + Burn Color Warna Bakar + Butt cap Ujung rata + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Simpan hasil analisis di cache untuk dipakai ulang + Camera Angle Sudut Kamera + Camera Blur Buram Kamera + Camera Shake Guncangan Kamera + Capitalisation Kapitalisasi + Cathedral Katedral + Cell Pattern Pola Sel + Center Channel Pan Pan Kanal Tengah + Center Gradient Gradien Tengah + Center Input Input Tengah + Center Out Tengah ke Luar + Center Point (Latitude) Titik Tengah (Lintang) + Center Point (Longitude) Titik Tengah (Bujur) + Center Split Belah Tengah + Center of Sphere Pusat Bola + Cents Sen + Change to Color Ubah ke Warna + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Perubahan disalin di latar belakang sesaat setelah setiap edit. Jika FilmCraft keluar secara tak terduga, perubahan itu ditawarkan saat FilmCraft dimulai berikutnya. + Channel Kanal + Channel Blur Buram Kanal + Channel Mix Campuran Kanal + Channel Mixer Mixer Kanal + Chaos Kekacauan + Chebyshev Chebyshev + Checker Wipe Sapuan Kotak-kotak + Checkerboard Papan Catur + Chinese Tionghoa + Choke Choke + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Kroma + Chroma Leak Kebocoran Kroma + Chromatic Aberration (Blue) Aberasi Kromatik (Biru) + Chromatic Aberration (Green) Aberasi Kromatik (Hijau) + Chromatic Aberration (Red) Aberasi Kromatik (Merah) + Cineon Converter Konverter Cineon + Circle Lingkaran + Clarity Kejernihan + Clean Combo Kombo Bersih + Clean Up Noisy Dialogue Bersihkan Dialog Berderau + Clear Muddiness Hilangkan Kekeruhan + Client Klien + Clip Klip + Clip Level Level Klip + Clipping Clipping + Clock Wipe Sapuan Jam + Clock Wipe (Legacy) Sapuan Jam (Lama) + Clockwise Searah Jarum Jam + Clone Klon + Closed Captions Takarir Tertutup + Color & Alpha Warna & Alfa + Color 1 Warna 1 + Color 2 Warna 2 + Color 3 Warna 3 + Color 4 Warna 4 + Color Balance Keseimbangan Warna + Color Burn Bakar Warna + Color Channel Kanal Warna + Color Correction Koreksi Warna + Color Distortion Distorsi Warna + Color Dodge Dodge Warna + Color Emboss Emboss Warna + Color Key Key Warna + Color Pass Lolos Warna + Color Replace Ganti Warna + Color Space Ruang Warna + Color Theme Tema Warna + Color To Leave Warna yang Dipertahankan + Color Tolerance Toleransi Warna + Color Wheels & Match Roda Warna & Pencocokan + Colorize Warnai + Columns Kolom + Comment Komentar + Complexity Kompleksitas + Composite In Back Komposit di Belakang + Composite In Front Komposit di Depan + Composite Using Komposit Menggunakan + Composite on Original Komposit pada Asli + Compound Blur Buram Majemuk + Compression Before Clipping Kompresi Sebelum Clipping + Constant Gain Gain Konstan + Constant Power Daya Konstan + Conversion Type Jenis Konversi + Convolution Reverb Reverb Konvolusi + Cool Blue Biru Sejuk + Cool, desaturated and punchy Sejuk, saturasi rendah, dan tegas + Core Width Lebar Inti + Corner Sudut + Corner Pin Pin Sudut + Count Jumlah + Counter-clockwise Berlawanan Arah Jarum Jam + Crash Recovery Pemulihan Crash + Crawl Left Merayap ke Kiri + Crawl Right Merayap ke Kanan + Create folder for imported projects Buat folder untuk proyek yang diimpor + Creative Kreatif + Crop Potong Tepi + Crop Less <-> Smooth More Potong Lebih Sedikit <-> Lebih Halus + Cross Displacement Perpindahan Silang + Cross Dissolve Larut Silang + Cross Dissolve (Legacy) Larut Silang (Lama) + Cross Zoom Zoom Silang + Cross Zoom (Legacy) Zoom Silang (Lama) + Crossfade Crossfade + Crossovers Crossover + Crystallize Kristalisasi + Crystals Kristal + Cube Spin Putaran Kubus + Curl Radius Radius Gulungan + Current Time Waktu Saat Ini + Cursor Kursor + Curvature Kelengkungan + Curve Kurva + Curves Kurva + Custom Kustom + Custom Color Warna Kustom + Cutoff Batas Potong + Cyan-teal shadows and warm highlights with a little more contrast Bayangan sian-toska dan sorotan hangat dengan sedikit lebih banyak kontras + Cycle (in Revolutions) Siklus (dalam Putaran) + Cycle Evolution Evolusi Siklus + Damping Peredaman + Damping HF Peredaman HF + Damping LF Peredaman LF + Dark Gelap + Darken Gelapkan + Darker Color Warna Lebih Gelap + Date Created Tanggal Dibuat + Date Modified Tanggal Diubah + Day for night: darker, cooler and less saturated Siang jadi malam: lebih gelap, lebih sejuk, dan saturasi lebih rendah + DeEsser DeEsser + DeHummer DeHummer + DeNoise DeNoise + DeReverb DeReverb + Decay Peluruhan + Decay Time (RT60) Waktu Peluruhan (RT60) + Default Audio Tracks Trek Audio Bawaan + Default Emoji font Font Emoji bawaan + Default Media Scaling Penskalaan Media Bawaan + Default Output Output Bawaan + Default closed caption font Font takarir tertutup bawaan + Default language Bahasa bawaan + Default paragraph direction Arah paragraf bawaan + Default subtitle and text font Font subtitel dan teks bawaan + Delay Delay + Delay 1 Delay 1 + Delay 2 Delay 2 + Delay 3 Delay 3 + Delay 4 Delay 4 + Delay and Echo Delay dan Gema + Delete files older than Hapus berkas yang lebih lama dari + Delete oldest files when the cache exceeds Hapus berkas terlama saat cache melebihi + Delete… Hapus… + Depth Kedalaman + Desaturate Hilangkan Saturasi + Description Deskripsi + Detail Amplitude Amplitudo Detail + Detail Level Level Detail + Detailed Analysis Analisis Terperinci + Device Class Kelas Perangkat + Dialogue Dialog + Diamond Berlian + Difference Selisih + Dimensions Dimensi + Dimmed Diredupkan + Dip to Black Celup ke Hitam + Dip to Black (Legacy) Celup ke Hitam (Lama) + Dip to Color Celup ke Warna + Dip to White Celup ke Putih + Dip to White (Legacy) Celup ke Putih (Lama) + Direction Arah + Directional Terarah + Directional Blur Buram Terarah + Directional Blur (Legacy) Buram Terarah (Lama) + Disable video output when in the background Nonaktifkan output video saat di latar belakang + Disperse Sebarkan + Displacement Perpindahan + Display Color Management Manajemen Warna Layar + Display Color Settings Pengaturan Warna Layar + Display out of sync indicators for unlinked clips Tampilkan indikator tidak sinkron untuk klip yang tidak tertaut + Dissolve Larut + Dissolve Blocks Blok Larut + Distance Jarak + Distance to Image Jarak ke Gambar + Distort Distorsi + Distortion Distorsi + Distortion Complexity Kompleksitas Distorsi + Distortion Rate Laju Distorsi + Distortion Type Jenis Distorsi + Divide Bagi + Do not delete cache files automatically Jangan hapus berkas cache secara otomatis + Don't label speakers Jangan beri label pembicara + Double-click Klik ganda + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Dekode draf pada resolusi pemutaran rendah (H.264: lebih cepat, beberapa bingkai kurang tersaring) + Drive Drive + Drop Shadow Bayangan Jatuh + Dry Kering + Dry Out Output Kering + Dry Up a Roomy Voice Keringkan Suara yang Bergema + Duck Deep Under Dialogue Duck Dalam di Bawah Dialog + Duck Under Dialogue Duck di Bawah Dialog + Duck Under Voice and Effects Duck di Bawah Suara dan Efek + Ducking Ducking + Dutch Belanda + Dynamic Link Dynamic Link + Dynamics Processing Pemrosesan Dinamika + Early Reflections Pantulan Awal + Earthquake Gempa Bumi + Ease In and Out Ease In dan Out + Easing Easing + Echo Gema + Echo Glow Pendar Gema + Echo Operator Operator Gema + Echo Time (seconds) Waktu Gema (detik) + Echoes Gema + Edge Color Warna Tepi + Edge Feather Feather Tepi + Edge Glow Pendar Tepi + Edge Sharpness Ketajaman Tepi + Edge Softness Kelembutan Tepi + Edge Thickness Ketebalan Tepi + Edge Thin Penipisan Tepi + Edge Threshold Ambang Tepi + Edge Type Jenis Tepi + Edge Width Lebar Tepi + Elliptical Eliptis + Emboss Emboss + Enable HSL Secondary Aktifkan HSL Sekunder + Enable Mercury Transmit Aktifkan Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Aktifkan enkode ProRes dengan akselerasi perangkat keras, jika tersedia + Enable clip and XMP metadata linking Aktifkan penautan metadata klip dan XMP + Enable developer mode Aktifkan mode pengembang + Enable hardware accelerated decoding (requires restart) Aktifkan dekode dengan akselerasi perangkat keras (perlu mulai ulang) + Enable language auto-detection Aktifkan deteksi bahasa otomatis + Enable proxies Aktifkan proksi + End Color Warna Akhir + End Point Titik Akhir + End of Ramp Akhir Gradasi + Error Galat + Evolution Evolusi + Evolution Options Opsi Evolusi + Exciter Amount Jumlah Exciter + Exciter Mode Mode Exciter + Exclusion Eksklusi + Exponential Fade Fade Eksponensial + Extended Dynamic Range Monitoring Pemantauan Rentang Dinamis Diperluas + FFT Filter Filter FFT + Fade Fade + Fade Out Fade Out + Faded Matte Matte Pudar + Falloff Penurunan + Far Away Jauh + Fast Cepat + Faster Motion Gerak Lebih Cepat + Feedback Umpan Balik + Feedback 1 Umpan Balik 1 + Feedback 2 Umpan Balik 2 + Feedback 3 Umpan Balik 3 + Feedback 4 Umpan Balik 4 + Feet + Frames 16mm Kaki + Bingkai 16mm + Feet + Frames 35mm Kaki + Bingkai 35mm + Female Perempuan + File Size Ukuran Berkas + Fill Background Isi Latar Belakang + Fill Color Warna Isian + Fill Left with Right Isi Kiri dengan Kanan + Fill Point Titik Isian + Fill Right with Left Isi Kanan dengan Kiri + Fill Selector Pemilih Isian + Film Dissolve Larut Film + Film Dissolve (Legacy) Larut Film (Lama) + Film Roll Gulungan Film + Filter Filter + Filter Frequency Frekuensi Filter + Filter Resonance Resonansi Filter + Filter and EQ Filter dan EQ + Final Delay Delay Akhir + Find Edges Temukan Tepi + Flanger Flanger + Flare Suar + Flare Brightness Kecerahan Suar + Flare Center Pusat Suar + Flare Color Warna Suar + Flash Kilat + Flicker Kedip + Flickers Kedipan + Flip Motion Gerak Balik + Flip Over Balik + Float Melayang + Focus Fokus + Focus Blur Buram Fokus + Focus Point Titik Fokus + Focus Shape Bentuk Fokus + Focus Size Ukuran Fokus + Fold Motion Gerak Lipat + Foldback Foldback + For color management parameters, go to the Settings tab in the Lumetri Color panel. Untuk parameter manajemen warna, buka tab Pengaturan di panel Lumetri Color. + Format Format + Four Scopes (RGB, 8 bit) Empat Skop (RGB, 8 bit) + Four Scopes (YUV, float, no clamp) Empat Skop (YUV, float, tanpa pembatasan) + Fractal Influence Pengaruh Fraktal + Fractal Type Jenis Fraktal + Frame Bingkai + Frame Color Warna Bingkai + Frame Count Jumlah Bingkai + Frame Layout Tata Letak Bingkai + Frame Rate Laju Bingkai + Frame Width Lebar Bingkai + Frame cache for monitors and thumbnails Cache bingkai untuk monitor dan gambar mini + Frames Bingkai + Framing Pembingkaian + French Prancis + Frequency Frekuensi + From Dari + From East Dari Timur + From North Dari Utara + From South Dari Selatan + From West Dari Barat + From the Left Dari Kiri + From the Right Dari Kanan + Front + LFE Depan + LFE + Front + Rear Depan + Belakang + Front + Rear + LFE Depan + Belakang + LFE + Front Only Hanya Depan + Fuzz Fuzz + GB GB + Gamma Gamma + Gamma Correction Koreksi Gamma + Gamut Warning Peringatan Gamut + Gamut Warning Color Warna Peringatan Gamut + Gap Celah + Gap Color Warna Celah + Gaussian Blur Buram Gaussian + Gaussian Blur (Legacy) Buram Gaussian (Lama) + General Umum + Generate Buat + Generate Timecode Buat Kode Waktu + Generate waveforms and conform audio using multithreading Buat bentuk gelombang dan sesuaikan audio menggunakan multithreading + Generate waveforms automatically during import Buat bentuk gelombang secara otomatis saat impor + German Jerman + Ghosts Bayangan Hantu + Glass Kaca + Glint Kilau + Glitch Glitch + Glow Pendar + Glow Brightness Kecerahan Pendar + Glow Color Warna Pendar + Glow Only Hanya Pendar + Glow Radius Radius Pendar + Glow Saturation Saturasi Pendar + Glow Width Lebar Pendar + Golden Hour Golden Hour + Good Baik + Gradient Gradien + Gradient End Akhir Gradien + Gradient Layer Lapisan Gradien + Gradient Placement Penempatan Gradien + Gradient Scatter Sebaran Gradien + Gradient Shape Bentuk Gradien + Gradient Start Awal Gradien + Gradient Wipe Sapuan Gradien + Gradient Wipe (Legacy) Sapuan Gradien (Lama) + Grain Size Ukuran Butiran + Graphic Equalizer (10 Bands) Equalizer Grafis (10 Pita) + Graphic Equalizer (20 Bands) Equalizer Grafis (20 Pita) + Graphic Equalizer (30 Bands) Equalizer Grafis (30 Pita) + Graphics Grafis + Green Blurriness Keburaman Hijau + Green Curve Kurva Hijau + Green Offset Offset Hijau + Green Power Pangkat Hijau + Green Slope Kemiringan Hijau + Green-Blue Hijau-Biru + Green-Const Hijau-Konst + Green-Green Hijau-Hijau + Green-Red Hijau-Merah + Grid Kisi + Grow Tumbuh + Growing Files Berkas yang Bertambah + Grunge Grunge + Grunge & Distort Grunge & Distorsi + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Putih Grafis HDR (Nit) + HLS Vectorscope + Histogram Vektorskop HLS + Histogram + HSL Secondary HSL Sekunder + Hard Keras + Hard Clip Clip Keras + Hard Light Cahaya Keras + Hard Limiter Limiter Keras + Hard Mix Campuran Keras + Hardware decoding Dekode perangkat keras + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Dekode perangkat keras: Otomatis menggunakan dekoder video sistem (VideoToolbox di macOS, Media Foundation di Windows, VA-API atau NVDEC untuk H.264 dan HEVC di Linux) untuk stream yang didukungnya, dan dekoder FilmCraft sendiri untuk yang lainnya atau jika perangkat keras gagal. Media yang sudah terbuka tetap memakai dekodernya sampai dibuka ulang. + Head Size Ukuran Kepala + Heart Hati + Height Tinggi + Hexagon Segi Enam + Hide Back Face Sembunyikan Sisi Belakang + High Tinggi + High Cutoff Batas Atas + High Frequency Cut Potong Frekuensi Tinggi + High Frequency Damping Peredaman Frekuensi Tinggi + High Pass High Pass + High Shelf High Shelf + High Shelf Gain Gain High Shelf + High contrast with held highlights and deep shadows Kontras tinggi dengan sorotan terjaga dan bayangan pekat + Highlight Sorotan + Highlight Blue Balance Keseimbangan Biru Sorotan + Highlight Brightness Kecerahan Sorotan + Highlight Color Warna Sorotan + Highlight Green Balance Keseimbangan Hijau Sorotan + Highlight Red Balance Keseimbangan Merah Sorotan + Highlight Rolloff Rolloff Sorotan + Highlight Threshold Ambang Sorotan + Highlight Tint Rona Sorotan + Highlights Lightness Kecerahan Sorotan + Highpass Highpass + Hindi Hindi + Hindi digits Angka Hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Perataan Horizontal + Horizontal Blocks Blok Horizontal + Horizontal Decentering Pergeseran Pusat Horizontal + Horizontal Displacement Perpindahan Horizontal + Horizontal Flip Balik Horizontal + Horizontal Margin Margin Horizontal + Horizontal and Vertical Horizontal dan Vertikal + Hue Center Pusat Hue + Hue Tolerance Toleransi Hue + I/O Buffer Size Ukuran Buffer I/O + Ignore Alpha Abaikan Alfa + Image Control Kontrol Gambar + Immersive Video Video Imersif + Import numbered stills as image sequences Impor gambar diam bernomor sebagai urutan gambar + Impulse Impuls + In a Room Di Dalam Ruangan + In the Media Cache Di Cache Media + In the project Di proyek + In/Out Duration Durasi Masuk/Keluar + Include captions on import Sertakan takarir saat impor + Incoming Masuk + Indeterminate Media Timebase Basis Waktu Media Tak Tentu + Initial Delay Delay Awal + Inner Dalam + Inner Radius Radius Dalam + Input Boost Penguatan Input + Inset Sisipan + Inside Di Dalam + Inside Color Warna Dalam + Inter Inter + Interior Room Tone Nada Ruang Interior + Internal Black Point Titik Hitam Internal + Internal White Point Titik Putih Internal + Interpolation Interpolasi + Intersect Perpotongan + Inverse Falloff Penurunan Terbalik + Invert Balikkan + Invert Alpha Balikkan Alfa + Invert Blur Balikkan Buram + Invert Fill Balikkan Isian + Invert Gradient Balikkan Gradien + Invert Left Balikkan Kiri + Invert Right Balikkan Kanan + Iris Iris + Iris Box Iris Kotak + Iris Cross Iris Silang + Iris Diamond Iris Berlian + Iris Radius Radius Iris + Iris Rotation Rotasi Iris + Iris Round Iris Bulat + Iris Shape Bentuk Iris + Italian Italia + Japanese Jepang + JetBrains Mono JetBrains Mono + Join Through Edits Gabungkan Edit Tembus + Ripple Trim In Pangkas Ripple Masuk + Ripple Trim Out Pangkas Ripple Keluar + Roll Edit Edit Roll + Trim In Pangkas Masuk + Trim Out Pangkas Keluar + Kaleidoscope Kaleidoskop + Keep a recovery copy of unsaved changes Simpan salinan pemulihan untuk perubahan yang belum disimpan + Key Color Warna Key + Keying Keying + Korean Korea + Label Defaults Label Bawaan + Label speakers Beri label pembicara + Labels Label + Large Hall Aula Besar + Large Trim Offset Offset Pangkas Besar + Large Volume Adjustment Penyesuaian Volume Besar + Latch Latch + Layer Name Nama Lapisan + Layers Lapisan + Leave Color Pertahankan Warna + Left - Left Kiri - Kiri + Left - Right Kiri - Kanan + Left channel to device output Kanal kiri ke output perangkat + Left to Right Kiri ke Kanan + Left to right Kiri ke kanan + Legacy Lama + Length Panjang + Lens Blur Buram Lensa + Lens Distortion Distorsi Lensa + Lens Flare Suar Lensa + Lens Type Jenis Lensa + Level 1 Level 1 + Level 2 Level 2 + Level 3 Level 3 + Level 4 Level 4 + Levels Level + Lifted blacks, softer contrast, muted colour Hitam terangkat, kontras lebih lembut, warna redup + Light 1 Cahaya 1 + Light 2 Cahaya 2 + Light 3 Cahaya 3 + Light 4 Cahaya 4 + Light 5 Cahaya 5 + Light Angle Sudut Cahaya + Light Color Warna Cahaya + Light Intensity Intensitas Cahaya + Light Leak Kebocoran Cahaya + Light Leaks Kebocoran Cahaya + Light Sweep Sapuan Cahaya + Light Type Jenis Cahaya + Lighten Terangkan + Lighter Color Warna Lebih Terang + Lighting Effects Efek Pencahayaan + Lightning Petir + Lights & Blurs Cahaya & Buram + Lights & Glows Cahaya & Pendar + Line Thickness Ketebalan Garis + Linear Burn Bakar Linier + Linear Dodge (Add) Dodge Linier (Tambah) + Linear Light Cahaya Linier + Linear Ramp Gradasi Linier + Linear Wipe Sapuan Linier + Linear Wipe (Legacy) Sapuan Linier (Lama) + Linear keyframe thinning Penipisan bingkai kunci linier + Linear to Log Linier ke Log + Liquid Distortion Distorsi Cair + Local Projects Proyek Lokal + Location Lokasi + Log Note Catatan Log + Log to Linear Log ke Linier + Log to Log Log ke Log + Logo Cutout Potongan Logo + Long Shadow Bayangan Panjang + Look-Ahead Time Waktu Lihat ke Depan + Loudness Kenyaringan + Loudness Maximizer Pemaksimal Kenyaringan + Loudness Meter Meter Kenyaringan + Louver Kisi-kisi + Low Rendah + Low Frequency Cut Potong Frekuensi Rendah + Low Pass Low Pass + Low Shelf Low Shelf + Low Shelf Gain Gain Low Shelf + Lower Left Kiri Bawah + Lower Right Kanan Bawah + Lowpass Lowpass + Luma Luma + Luma Curve Kurva Luma + Luma Fade Fade Luma + Luma Key Key Luma + Luma Source Sumber Luma + Luma and Chroma Luma dan Kroma + Lumetri Presets Preset Lumetri + Luminance Luminans + Luminance Threshold Ambang Luminans + Luminosity Luminositas + MB MB + Magenta Magenta + Magnify Pembesar + Magnify (Legacy) Pembesar (Lama) + Maintain pitch while shuttling Pertahankan nada saat shuttle + Major Radius Radius Mayor + Male Laki-laki + Mango Mangga + Map Black To Petakan Hitam ke + Map White To Petakan Putih ke + Maps full-range levels into 16–235 (video range) with a luma curve Memetakan level rentang penuh ke 16–235 (rentang video) dengan kurva luma + Mask Only Hanya Masker + Master Amplitude Amplitudo Master + Mastering Mastering + Match frame sets in point Cocokkan bingkai menetapkan titik masuk + Matte Matte + Matte Alpha Alfa Matte + Matte Cleanup Pembersihan Matte + Matte Generation Pembuatan Matte + Matte Luma Luma Matte + Maximum Maksimum + Maximum Amplitude Amplitudo Maksimum + Maximum Block Size Ukuran Blok Maksimum + Maximum Blur Buram Maksimum + Maximum Project Versions Versi Proyek Maksimum + Maximum Reduction Pengurangan Maksimum + Maximum Scale Skala Maksimum + Media Media + Media Analysis Analisis Media + Media Analysis & Transcription Analisis Media & Transkripsi + Media Cache Cache Media + Media Cache Database Basis Data Cache Media + Media Cache Files Berkas Cache Media + Media Cache Management Manajemen Cache Media + Media Duration Durasi Media + Media End Akhir Media + Media Time Waktu Media + Media Timecode Kode Waktu Media + Media Type Jenis Media + 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. Analisis media dan transkripsi akan disimpan di folder Cache Media. Analisis ulang tidak diperlukan jika media diimpor ke proyek lain di komputer ini. + Median Median + Medium Room Ruangan Sedang + Memory Memori + Metadata & Timecode Burn-in Burn-in Metadata & Kode Waktu + Method Metode + Metrics Kerning Kerning Metrik + Mid Tengah + Mid Point Titik Tengah + Midtone Blue Balance Keseimbangan Biru Nada Tengah + Midtone Green Balance Keseimbangan Hijau Nada Tengah + Midtone Red Balance Keseimbangan Merah Nada Tengah + Midtones Lightness Kecerahan Nada Tengah + Minimum Minimum + Minimum time Waktu minimum + Minimum time interval thinning Penipisan interval waktu minimum + Minor Radius Radius Minor + Mirror Cermin + Mirror Alternate Cermin Bergantian + Missing font replacement Pengganti font yang hilang + Miter join Sambungan miter + Miter limit Batas miter + Mixed Crystals Kristal Campuran + Mode Mode + Modern High Gain Gain Tinggi Modern + Modulation Modulasi + Modulation Rate Laju Modulasi + Mono Media Media Mono + Monochrome Monokrom + Monochrome toned cyan-blue Monokrom bernuansa biru sian + Monochrome toned warm brown Monokrom bernuansa cokelat hangat + Monoscopic Monoskopik + Monospace only Hanya monospace + More colour in the less saturated areas Lebih banyak warna di area yang kurang jenuh + Morph Cut Morph Cut + Mosaic Mozaik + Mosaic (Legacy) Mozaik (Lama) + Motion Blur Buram Gerak + Motion Camera Kamera Gerak + Motion Preset Preset Gerak + Motion Tween Tween Gerak + Move Pindah + Movie Film + Movie (audio and video) Film (audio dan video) + Multiband Compressor Kompresor Multiband + Multichannel Mono Media Media Mono Multikanal + Multiply Kalikan + Multitap Delay Delay Multitap + Music Musik + Mute input during timeline recording Bisukan input saat perekaman di linimasa + Name Nama + Narrow Sempit + Neon Color Warna Neon + Neon Wipe Sapuan Neon + Next to original media files Di samping berkas media asli + Night Malam + No Motion Tanpa Gerak + No Scroll Tanpa Gulir + No plugins are installed. Changes will take effect the next time you start FilmCraft. Tidak ada plugin yang terpasang. Perubahan akan berlaku saat Anda memulai FilmCraft berikutnya. + Noise Derau + Noise & Grain Derau & Butiran + Noise (Legacy) Derau (Lama) + Noise Level Level Derau + Noise Reduction/Restoration Pengurangan Derau/Restorasi + Noise Type Jenis Derau + Non-Additive Dissolve Larut Non-Aditif + Normal Normal + Notch 1 Notch 1 + Notch 2 Notch 2 + Notch 3 Notch 3 + Notch 4 Notch 4 + Notch 5 Notch 5 + Notch 6 Notch 6 + Notch Filter Filter Notch + Notch Width Lebar Notch + Number Of Echoes Jumlah Gema + Number of Harmonics Jumlah Harmonik + Obsolete Usang + Octagon Segi Delapan + Offset Offset + Offset (Turbulence) Offset (Turbulensi) + Offset From Offset Dari + Offset To Offset Ke + Omni Omni + On Nyala + On Original Image Pada Gambar Asli + On Transparent Pada Transparan + Open Air Udara Terbuka + Open Most Recent Buka yang Terbaru + Open in new window Buka di jendela baru + Open in place Buka di tempat + Open new tab Buka tab baru + Opens up the shadows Membuka bayangan + Order Urutan + Orientation Orientasi + Outer Luar + Outgoing Keluar + Output Output + Output Mapping Pemetaan Output + Outside Di Luar + Outside Color Warna Luar + Overflow Luapan + Overlay Overlay + Overshoot Overshoot + Page Peel Kelupas Halaman + Page Scroll Gulir Halaman + Page Turn Balik Halaman + Paint Bucket Ember Cat + Paint On Black Lukis di Atas Hitam + Paint On Original Image Lukis di Atas Gambar Asli + Paint On Transparent Lukis di Atas Transparan + Paint On White Lukis di Atas Putih + Paint Style Gaya Lukis + Paint Surface Permukaan Lukis + Pan (Y Axis) Pan (Sumbu Y) + Panel Wipe Sapuan Panel + Panels Panel + Panner - Ambisonics Panner - Ambisonik + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Equalizer Parametrik + Passband Ripple Riak Passband + Path Jalur + Path Points Titik Jalur + Pause Media Encoder queue during playback Jeda antrean Media Encoder selama pemutaran + Peak Frequency Frekuensi Puncak + Peak Gain Gain Puncak + Pedestal Pedestal + Pentagon Segi Lima + Per line Per baris + Percent Blur Persen Buram + Perspective Perspektif + Phase Difference Selisih Fase + Phaser Phaser + Phone Call Panggilan Telepon + Phosphor Fosfor + Phosphor Color Warna Fosfor + Photocopy Fotokopi + Photocopy Color Warna Fotokopi + Pillow Bantal + Pin All Sematkan Semua + Pin Horizontal Sematkan Horizontal + Pin Light Pin Light + Pin Vertical Sematkan Vertikal + Pinning Penyematan + Pitch Shifter Pengubah Nada + Plate Pelat + Plateau Wipe Sapuan Dataran + Plateaus Dataran + Plates Pelat + Play after rendering previews Putar setelah merender pratinjau + Play audio while scrubbing in Source and Program Monitors Putar audio saat scrub di Monitor Sumber dan Program + Playback Pemutaran + Playhead position determines trim monitor loop playback Posisi kepala putar menentukan pemutaran berulang monitor pangkas + Plugins Plugin + Podcast Voice Suara Podcast + Point 1 Titik 1 + Point 1 Color Warna Titik 1 + Point 1 Latitude Lintang Titik 1 + Point 1 Longitude Bujur Titik 1 + Point 2 Titik 2 + Point 2 Color Warna Titik 2 + Point 2 Latitude Lintang Titik 2 + Point 2 Longitude Bujur Titik 2 + Point 3 Titik 3 + Point 3 Color Warna Titik 3 + Point 3 Latitude Lintang Titik 3 + Point 3 Longitude Bujur Titik 3 + Point 4 Titik 4 + Point 5 Titik 5 + Point 6 Titik 6 + Point 7 Titik 7 + Point 8 Titik 8 + Points Titik + Policy Kebijakan + Pop Motion Gerak Pop + Portuguese Portugis + Position & Rotation Posisi & Rotasi + Position Amount Jumlah Posisi + Position, Scale & Rotation Posisi, Skala & Rotasi + Position, Scale, Rotation Posisi, Skala, Rotasi + Posterize Posterisasi + Posterize Time Posterisasi Waktu + Pre-Delay Pra-Delay + Prefix Awalan + Presence Lift Angkat Presence + Preserve Luminosity Pertahankan Luminositas + Preserve Scale Pertahankan Skala + ProcAmp ProcAmp + Projecting cap Ujung menonjol + Projects Proyek + Pull Motion Gerak Tarik + Pulls highlights and whites down Menurunkan sorotan dan putih + Push Dorong + Push (Legacy) Dorong (Lama) + Q Q + RAM reserved for other applications RAM yang dicadangkan untuk aplikasi lain + RGB RGB + RGB Split Pisah RGB + RGB-White RGB-Putih + Radial Radial + Radial Blur Buram Radial + Radial Ramp Gradasi Radial + Radial Wipe Sapuan Radial + Radial Wipe (Legacy) Sapuan Radial (Lama) + Radio Announcer Penyiar Radio + Radio Voice Suara Radio + Radius Radius + Ramp Gradasi + Ramp Shape Bentuk Gradasi + Random Acak + Random Seed Benih Acak + Range Rentang + Ray Sinar + Ray Color Warna Sinar + Ray Length Panjang Sinar + Rays Sinar + Rays Only Hanya Sinar + Read Baca + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Keburaman Merah + Red Curve Kurva Merah + Red Offset Offset Merah + Red Power Pangkat Merah + Red Slope Kemiringan Merah + Red-Blue Merah-Biru + Red-Const Merah-Konst + Red-Green Merah-Hijau + Red-Red Merah-Merah + Reduce Axis Kurangi Sumbu + Reduce Interlace Flicker Kurangi Kedip Interlace + Reflected Terpantul + Reflection Angle Sudut Pantulan + Reflection Center Pusat Pantulan + Refraction Refraksi + Reframe Offset Offset Bingkai Ulang + Reframe Zoom Zoom Bingkai Ulang + Refresh growing Files Every Segarkan Berkas yang Bertambah Setiap + Relaxed Santai + Release Time Waktu Pelepasan + Relief Relief + Remaining Tersisa + Remove Fringe Hapus Pinggiran + Render Edit in Audition files to: Render berkas Edit in Audition ke: + Render audio when rendering video Render audio saat merender video + Repair Perbaikan + Repeat Edge Pixels Ulangi Piksel Tepi + Replace Color Ganti Warna + Replace With Clip From Source Monitor Ganti dengan Klip dari Monitor Sumber + Replicate Replikasi + Responsive Design - Position Desain Responsif - Posisi + Responsive Design - Time Desain Responsif - Waktu + Restore open sequences when opening projects Pulihkan sekuens yang terbuka saat membuka proyek + Result Hasil + Retro Retro + Reveal Nested Sequence Tampilkan Sekuens Bersarang + Reveal Original Image Tampilkan Gambar Asli + Reveal in Project Tampilkan di Proyek + Reverb Amount Jumlah Reverb + Reverse Balik + Right - Left Kanan - Kiri + Right - Right Kanan - Kanan + Right channel to device output Kanal kanan ke output perangkat + Right to left Kanan ke kiri + Ripple trim adds edits to keep both sides of trim in sync Pangkas ripple menambahkan edit agar kedua sisi pemangkasan tetap sinkron + Roll (Z Axis) Gulung (Sumbu Z) + Room Size Ukuran Ruangan + Rotation Amount Jumlah Rotasi + Roughen Kasarkan + Roughen Color Warna Kasar + Roughen Edges Kasarkan Tepi + Round cap Ujung bulat + Round join Sambungan bulat + Rounded Crop Potong Tepi Membulat + Rows Baris + Russian Rusia + Rusty Berkarat + Rusty Color Warna Karat + SFX SFX + Sample Rate Laju Sampel + Saturation at 50 % Saturasi pada 50 % + Save .cfa and .pek media cache files next to original media files when possible Simpan berkas cache media .cfa dan .pek di samping berkas media asli bila memungkinkan + Scale (Degrees) Skala (Derajat) + Scale Amount Jumlah Skala + Scale From Skala Dari + Scale Height Skala Tinggi + Scale Match Pencocokan Skala + Scale To Skala Ke + Scale Width Skala Lebar + Scale to frame size Skalakan ke ukuran bingkai + Scan Lines Garis Pindai + Scene Adegan + Scientific Filter Filter Ilmiah + Scratch disk location for Captured Audio Lokasi disk kerja untuk Audio Rekaman + Screen Layar + Seconds Detik + Segments Segmen + Semi-tones Semiton + Sequence Name Nama Sekuens + Sequence Timecode Kode Waktu Sekuens + Set focus on the Timeline when performing Insert/Overwrite edits Fokuskan ke Linimasa saat melakukan edit Sisipkan/Timpa + Set to frame size Atur ke ukuran bingkai + Setting Pengaturan + Shadow Angle Sudut Bayangan + Shadow Blue Balance Keseimbangan Biru Bayangan + Shadow Blur Buram Bayangan + Shadow Color Warna Bayangan + Shadow Distance Jarak Bayangan + Shadow Green Balance Keseimbangan Hijau Bayangan + Shadow Only Hanya Bayangan + Shadow Opacity Opasitas Bayangan + Shadow Red Balance Keseimbangan Merah Bayangan + Shadow Size Ukuran Bayangan + Shadow Tint Rona Bayangan + Shadows Lightness Kecerahan Bayangan + Shake Guncang + Shape Dissolve Larut Bentuk + Shape Flow Aliran Bentuk + Shape Size Ukuran Bentuk + Shapes Bentuk + Shard Size Ukuran Serpihan + Sharp Colors Warna Tajam + Sharpen Amount Jumlah Penajaman + Shift Center To Geser Pusat ke + Shift clips that overlap trim point during ripple trimming Geser klip yang tumpang tindih dengan titik pangkas saat pemangkasan ripple + Shot Shot + Show "Project:" and "Bin:" in Project panel tab Tampilkan "Proyek:" dan "Bin:" di tab panel Proyek + Show Clip Mismatch Warning dialog Tampilkan dialog Peringatan Ketidakcocokan Klip + Show Event Indicator Tampilkan Indikator Peristiwa + Show Focus Area Tampilkan Area Fokus + Show Home Tampilkan Beranda + Show Mask Tracker Preview Tampilkan Pratinjau Pelacak Masker + Show Only Noise Tampilkan Hanya Derau + Show Open Dialog Tampilkan Dialog Buka + Show Project Load Error Dialog Tampilkan Dialog Galat Memuat Proyek + Show Rich Tool Tips Tampilkan Tooltip Lengkap + Show Tool Tips Tampilkan Tooltip + Show Workspace Reset Warning dialog on double click Tampilkan dialog Peringatan Atur Ulang Ruang Kerja saat klik ganda + Show system compatibility issues at startup Tampilkan masalah kompatibilitas sistem saat memulai + Shrink Susutkan + Shutter Angle Sudut Rana + Similarity Kemiripan + Simple Notch Filter Filter Notch Sederhana + Simple Parametric EQ EQ Parametrik Sederhana + Simple Text Teks Sederhana + Single-band Compressor Kompresor Pita Tunggal + Sinusoidal Sinusoidal + Skew Miring + Skew Axis Sumbu Kemiringan + Slats Bilah + Slice Irisan + Slices Irisan-irisan + Slide Geser + Slide (Legacy) Geser (Lama) + Slope Kemiringan + Slow Lambat + Slower Motion Gerak Lebih Lambat + Small Room Ruangan Kecil + Smart Limit Batas Cerdas + Smart Tools Alat Cerdas + Smart quotes Tanda kutip cerdas + Smooth Halus + Smooth Motion Gerak Halus + Smooth Scroll Gulir Halus + Smoothness Kehalusan + Snap playhead in Timeline when Snap is enabled Snap kepala putar di Linimasa saat Snap diaktifkan + Soft Lembut + Soft Clamp Pembatasan Lembut + Soft Edges (Best Quality) Tepi Lembut (Kualitas Terbaik) + Soft Light Cahaya Lembut + Soft Wipe Sapuan Lembut + Soften Harshness Lembutkan Kekasaran + Softness Kelembutan + Solarize Solarisasi + Solid Colors Warna Solid + Source Point Titik Sumber + Source Text Teks Sumber + Spacer Pengatur Jarak + Spanish Spanyol + Speaker Angle Sudut Speaker + Speaker Labeling Pelabelan Pembicara + Special Khusus + Special Effects Efek Khusus + Speech model Model ucapan + Speed Kecepatan + Spherize Bulatkan + Spiky Berduri + Spill Tumpahan + Spill Suppression Penekanan Tumpahan + Spin Putaran + Spin Direction Arah Putaran + Spin Motion Gerak Putar + Split Belah + Split (Legacy) Belah (Lama) + Spotlight Lampu Sorot + Spread Sebaran + Spring Motion Gerak Pegas + Square Persegi + Square (Width Only) Persegi (Hanya Lebar) + Square 1:1 Persegi 1:1 + Stabilization Stabilisasi + Stabilize Only Hanya Stabilkan + Stabilize, Crop Stabilkan, Potong Tepi + Stabilize, Crop, Auto-scale Stabilkan, Potong Tepi, Skala Otomatis + Stabilize, Synthesize Edges Stabilkan, Sintesis Tepi + Stages Tahap + Star Bintang + Star Wipe Sapuan Bintang + Start Angle Sudut Awal + Start Color Warna Awal + Start Point Titik Awal + Start at 0 Mulai dari 0 + Start at 1 Mulai dari 1 + Start of Ramp Awal Gradasi + Start with an Empty Project Mulai dengan Proyek Kosong + Starting Intensity Intensitas Awal + Static Plates Pelat Statis + Step forward/back many Maju/mundur banyak + Stereo Expand Perluas Stereo + Stereo Expander Pelebar Stereo + Stereo Imagery Citra Stereo + Stereo Media Media Stereo + Stereo Phasing Fase Stereo + Stereoscopic - Over/Under Stereoskopik - Atas/Bawah + Stereoscopic - Side by Side Stereoskopik - Berdampingan + Still Diam + Still Image Default Duration Durasi Bawaan Gambar Diam + Stone Church Gereja Batu + Stopband Attenuation Atenuasi Stopband + Straight Color Warna Lurus + Strength Kekuatan + Stretch Regangkan + Stretch Gradient to Fit Regangkan Gradien agar Pas + Stretch Map to Fit Regangkan Peta agar Pas + Stretch Width or Height Regangkan Lebar atau Tinggi + Stretch Wipe Sapuan Regang + Stretch to Fit Frame Regangkan agar Pas Bingkai + Stripe Garis + Stripes Garis-garis + Strobe Color Warna Strobo + Strobe Duration (secs) Durasi Strobo (dtk) + Strobe Light Lampu Strobo + Strobe Period (secs) Periode Strobo (dtk) + Stroke 2 Color Warna Garis Tepi 2 + Stroke 2 Type Jenis Garis Tepi 2 + Stroke 2 Width Lebar Garis Tepi 2 + Stroke Angle Sudut Sapuan + Stroke Color Warna Garis Tepi + Stroke Density Kerapatan Sapuan + Stroke Length Panjang Sapuan + Stroke Length (secs) Panjang Sapuan (dtk) + Stroke Only Hanya Garis Tepi + Stroke Randomness Keacakan Sapuan + Stroke Type Jenis Garis Tepi + Stroke Width Lebar Garis Tepi + Stroke line cap Ujung garis tepi + Stroke line join Sambungan garis tepi + Studio Reverb Reverb Studio + Stylize Stilisasi + Subclip End Akhir Subklip + Subclip Start Awal Subklip + Subspace Warp Warp Subruang + Subtitle Subtitel + Subtract Kurangi + Sum multichannel outputs to mono in Source Monitor Jumlahkan output multikanal ke mono di Monitor Sumber + Super Narrow Ultra Sempit + Surface Gloss Kilap Permukaan + Surface Material Bahan Permukaan + Surround Reverb Reverb Surround + Swap Tukar + Swap Channels Tukar Kanal + Swivel Ayun Poros + Synthetic Sintetis + TV Power Daya TV + Tape Pita + Tape Name Nama Pita + Tape/Tube Pita/Tabung + Target Aspect Aspek Target + Target Color Warna Target + Target Field of View Bidang Pandang Target + Target Loudness Kenyaringan Target + Teal & Orange Toska & Oranye + Telephone Telepon + Teletext Teleteks + Template Properties Properti Templat + Text Animator Animator Teks + Text Color Warna Teks + 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. Cache media menyimpan pratinjau render dari proyek yang belum disimpan dan berkas lain yang dapat dibuat ulang oleh FilmCraft. Menghapusnya selalu aman; FilmCraft membangunnya kembali bila diperlukan. + Thickness Ketebalan + Tile Gradient Gradien Ubin + Tilt Miringkan + Tilt (X Axis) Miringkan (Sumbu X) + Time Waktu + Time and Pitch Waktu dan Nada + Timecode Conversion Konversi Kode Waktu + Timeline Mouse Scrolling Gulir Mouse di Linimasa + Timeline Playback Auto-Scrolling Gulir Otomatis Pemutaran Linimasa + Tint Color Warna Rona + To Ke + Tolerance Toleransi + Tone Nada + Top Center Tengah Atas + Top Left Kiri Atas + Top Right Kanan Atas + Touch Touch + Track Matte Key Key Matte Trek + Transcription Transkripsi + Transcription preferences Preferensi transkripsi + Transform Transformasi + Transformers Trafo + Transience Transien + Transition Completion Penyelesaian Transisi + Transition Softness Kelembutan Transisi + Transparency Transparansi + Trash Sampah + Travel Motion Gerak Perjalanan + Treble Treble + Triangle Segitiga + Trim Pangkas + Tube Tabung + Tube-modeled Compressor Kompresor Model Tabung + Tubular Tubular + Turbulent Turbulen + Turbulent Displace Perpindahan Turbulen + Turbulent Sharp Turbulen Tajam + Turbulent Smooth Turbulen Halus + Turbulent Smoother Turbulen Lebih Halus + Tweed Tweed + Twirl Pusaran + Twirl (Legacy) Pusaran (Lama) + Twirl Center Pusat Pusaran + Twirl Radius Radius Pusaran + Twist Pilin + Twist Smoother Pilin Lebih Halus + Typewriter Mesin Tik + Ultra Key Ultra Key + Uniform Scale Skala Seragam + Uniform Spacing Jarak Seragam + Unsharp Mask Unsharp Mask + Update it at least every Perbarui setidaknya setiap + Update timeline dynamically during ripple edits. Perbarui linimasa secara dinamis selama edit ripple. + Upper Frequency Frekuensi Atas + Upper Left Kiri Atas + Upper Right Kanan Atas + Use Color Noise Gunakan Derau Warna + Use End Color Gunakan Warna Akhir + Use File Gunakan Berkas + Use Glow Color Gunakan Warna Pendar + Use Media Source Gunakan Sumber Media + Use Tint Color Gunakan Warna Rona + Utility Utilitas + VHS Damage Kerusakan VHS + VR Blur Buram VR + VR Chroma Leaks Kebocoran Kroma VR + VR Chromatic Aberrations Aberasi Kromatik VR + VR Color Gradients Gradien Warna VR + VR De-Noise Hilangkan Derau VR + VR Digital Glitch Glitch Digital VR + VR Fractal Noise Derau Fraktal VR + VR Glow Pendar VR + VR Gradient Wipe Sapuan Gradien VR + VR Iris Wipe Sapuan Iris VR + VR Light Leaks Kebocoran Cahaya VR + VR Light Rays Sinar Cahaya VR + VR Mobius Zoom Zoom Mobius VR + VR Plane to Sphere Bidang ke Bola VR + VR Projection Proyeksi VR + VR Random Blocks Blok Acak VR + VR Rotate Sphere Putar Bola VR + VR Sharpen Pertajam VR + VR Spherical Blur Buram Bola VR + Validate Content Credentials on import Validasi Kredensial Konten saat impor + Vectorscope (YUV) Vektorskop (YUV) + Vectorscope + Waveform YC Vektorskop + Bentuk Gelombang YC + Vectorscope HLS Vektorskop HLS + Vectorscope YUV Vektorskop YUV + Venetian Blinds Kerai Venesia + Vertical 4:5 Vertikal 4:5 + Vertical 9:16 Vertikal 9:16 + Vertical Alignment Perataan Vertikal + Vertical Blocks Blok Vertikal + Vertical Decentering Pergeseran Pusat Vertikal + Vertical Displacement Perpindahan Vertikal + Vertical Flip Balik Vertikal + Vertical Margin Margin Vertikal + Very Narrow Sangat Sempit + Video 1 Video 1 + Video 2 Video 2 + Video 3 Video 3 + Video 4 Video 4 + Video 5 Video 5 + Video 6 Video 6 + Video 7 Video 7 + Video 8 Video 8 + Video Codec Codec Video + Video Duration Durasi Video + Video Effects Efek Video + Video In Point Titik Masuk Video + Video Info Info Video + Video Limiter Limiter Video + Video Out Point Titik Keluar Video + Video Transition Default Duration Durasi Bawaan Transisi Video + Video Transitions Transisi Video + Video Usage Penggunaan Video + Vignette Vinyet + Vivid Light Cahaya Terang + Vocal Booth Bilik Vokal + Vocal Enhancer Penguat Vokal + Voice Suara + Voice in a Hall Suara di Aula + Volumetric Rays Sinar Volumetrik + Warm Film Film Hangat + Warm Room Ruangan Hangat + Warm Voice Suara Hangat + Warm print stock with gentle roll-off Stok cetak hangat dengan roll-off lembut + Warm, soft late-afternoon light Cahaya sore yang hangat dan lembut + Warning Peringatan + Warp Stabilizer Warp Stabilizer + Wave Gelombang + Wave Height Tinggi Gelombang + Wave Speed Kecepatan Gelombang + Wave Warp Lengkung Gelombang + Wave Width Lebar Gelombang + Waveform Bentuk Gelombang + Waveform (Luma) Bentuk Gelombang (Luma) + Waveform (RGB) Bentuk Gelombang (RGB) + Wavelength Panjang Gelombang + Wet Basah + Wet Out Output Basah + When Opening a Project Saat Membuka Proyek + Whip Cambuk + Whip (Legacy) Cambuk (Lama) + Whisper base (balanced) Whisper base (seimbang) + Whisper small (most accurate) Whisper small (paling akurat) + Whisper tiny (fastest) Whisper tiny (tercepat) + White Putih + White Input Level Level Input Putih + White Is High Putih Berarti Tinggi + Wide Outdoor Luar Ruangan Luas + Widener Pelebar + Width Lebar + Wiggle Goyang + Wipe Sapuan + Wipe Angle Sudut Sapuan + Wonder Glow Pendar Ajaib + Wrap Back Bungkus Balik + Write Tulis + Write XMP ID to files on import Tulis ID XMP ke berkas saat impor + Write clip markers to XMP Tulis penanda klip ke XMP + Write-on Tulis Muncul + X Rotation Rotasi X + Y Rotation Rotasi Y + YC YC + YC no Chroma YC tanpa Kroma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Sorotan kuning-hijau dan bayangan kebiruan dari kurva kanal bersilang + Z Position Posisi Z + Z Rotation Rotasi Z + Zoom Zoom + Zoom Amount Jumlah Zoom + Zoom Blur Buram Zoom + Zoom Strength Kekuatan Zoom + days hari + frames bingkai + milliseconds milidetik + minute(s) menit + second(s) detik + seconds detik + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Warna… + EDL… EDL… + Edit Workspaces… Edit Ruang Kerja… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Umum… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Simpan Perubahan pada Ruang Kerja Ini + Save as New Workspace… Simpan sebagai Ruang Kerja Baru… + Create Search Bin Buat Bin Pencarian + Edit Offline File Edit Berkas Offline + Source Settings Pengaturan Sumber + Update Metadata Perbarui Metadata + Automate To Sequence Otomatiskan ke Sekuens + Scene Edit Detection Deteksi Pergantian Adegan + Analyze Analisis + Normalize Mix Track Normalisasi Trek Mix + Simplify Sequence Sederhanakan Sekuens + Simplify Sederhanakan + Transcribe Sequence Transkripsikan Sekuens + Save as Template Simpan sebagai Templat + Flash Cue Marker Penanda Isyarat Flash + System Compatibility Report Laporan Kompatibilitas Sistem + no GPU adapter: the CPU compositor is used tidak ada adaptor GPU: kompositor CPU digunakan + Avid Log Exchange Avid Log Exchange + FilmCraft Project Proyek FilmCraft + {name} Selection.fcproj {name} Pilihan.fcproj + {name} (Simplified) {name} (Disederhanakan) + Find in: Cari di: + Find: Cari: + Search: Cari: + Clip Names and Markers Nama Klip dan Penanda + Match: Cocokkan: + All Semua + Any Salah Satu + Case Sensitive Peka Huruf Besar/Kecil + Scratch Disks Disk Kerja + Ingest Settings Pengaturan Ingest + Captured and Generated: Direkam dan Dibuat: + Video Previews: Pratinjau Video: + Audio Previews: Pratinjau Audio: + Project Auto Save: Simpan Otomatis Proyek: + Same as Project Sama dengan Proyek + Path: {path} Jalur: {path} + Transcode Transkode + Copy and Create Proxies Salin dan Buat Proksi + Action: Tindakan: + Destination: Tujuan: + Next to the media Di samping media + Video Rendering and Playback Rendering dan Pemutaran Video + Renderer: Perender: + Display Format: Format Tampilan: + Milliseconds Milidetik + Capture Rekam + Capture Format: Format Rekam: + Action and Title Safe Areas Area Aman Aksi dan Judul + Title Safe Area Area Aman Judul + Action Safe Area Area Aman Aksi + Total Duration Durasi Total + Average Data Rate Laju Data Rata-rata + Colour Warna + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} kanal + Media Name: Nama Media: + Scene: Adegan: + Shot/Take: Shot/Take: + Log Note: Catatan Log: + Codec: {codec} Codec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Tulis metadata klip terpilih ({n}) ke berkas XMP di samping medianya? + Existing XMP files written by other applications are left unchanged. Berkas XMP yang ditulis oleh aplikasi lain dibiarkan tidak berubah. + To {name} Ke {name} + Ordering: Pengurutan: + Sort Order Urutan Sortir + Selection Order Urutan Pilihan + Placement: Penempatan: + Sequentially Berurutan + At Unnumbered Markers Di Penanda Tanpa Nomor + Method: Metode: + Insert Edit Edit Sisip + Overwrite Edit Edit Timpa + Clip Overlap: Tumpang Tindih Klip: + Frames per Still: Bingkai per Gambar Diam: + Transitions Transisi + Apply Default Audio Transition Terapkan Transisi Audio Bawaan + Apply Default Video Transition Terapkan Transisi Video Bawaan + Ignore Options Opsi Abaikan + Ignore Audio Abaikan Audio + Ignore Video Abaikan Video + Apply a cut at each detected cut point Terapkan potongan di setiap titik potong yang terdeteksi + Create a bin of subclips from each detected cut point Buat bin subklip dari setiap titik potong yang terdeteksi + Create a clip marker at each detected cut point Buat penanda klip di setiap titik potong yang terdeteksi + Sensitivity: Sensitivitas: + Normalize Mix Track to: Normalisasi Trek Mix ke: + Disabled clips Klip nonaktif + Empty tracks Trek kosong + Gaps Celah + Video effects Efek video + Audio effects Efek audio + Text (graphics and captions) Teks (grafis dan takarir) + Clips Klip + Move clips to the lowest track possible Pindahkan klip ke trek serendah mungkin + Keep: Pertahankan: + Video and Audio Video dan Audio + Video Only Hanya Video + Audio Only Hanya Audio + Language: Bahasa: + Audio on track {t} Audio di trek {t} + Audio analysis: Analisis audio: + Recognize when different speakers are talking Kenali saat pembicara yang berbeda sedang berbicara + Template Name: Nama Templat: + Comments: Komentar: + (unavailable) (tidak tersedia) + Decoders Dekoder + Containers Kontainer + Export formats Format ekspor + Match {n} of {total} Kecocokan {n} dari {total} + Saved template {path} Templat disimpan: {path} + Scene Edit Detection: analysing in the background (see Progress) Deteksi Pergantian Adegan: menganalisis di latar belakang (lihat Kemajuan) + Delete Search Bin Hapus Bin Pencarian + (removed from {list}) (dihapus dari {list}) + {key} can't be used as a shortcut {key} tidak dapat digunakan sebagai pintasan + Keyboard Shortcuts Pintasan Keyboard + {active} (modified) {active} (diubah) + Loaded “{name}” “{name}” dimuat + My Shortcuts Pintasan Saya + Deleted preset “{active}” Preset “{active}” dihapus + Export… Ekspor… + Exported to {path} Diekspor ke {path} + Export: no file chosen (agents: shortcuts.export {path}) Ekspor: tidak ada berkas yang dipilih (agen: shortcuts.export {path}) + Imported “{name}” “{name}” diimpor + Import: no file chosen (agents: shortcuts.import {path}) Impor: tidak ada berkas yang dipilih (agen: shortcuts.import {path}) + Keyboard: US (macOS) Keyboard: US (macOS) + Keyboard: US Keyboard: US + Save preset as: Simpan preset sebagai: + Commands: Perintah: + {panel} Panel Panel {panel} + Application shortcuts work whatever panel has focus Pintasan aplikasi berfungsi di panel mana pun yang sedang fokus + Panel shortcuts override application shortcuts while that panel has focus Pintasan panel menggantikan pintasan aplikasi selama panel itu sedang fokus + Modifier held / selected Pengubah ditahan / dipilih + Search commands or keys Cari perintah atau tombol + Selected: {command} Dipilih: {command} + Command Perintah + Shortcut Pintasan + Type a shortcut… (Esc cancels) Ketik pintasan… (Esc membatalkan) + Add another shortcut Tambah pintasan lain + No matching commands Tidak ada perintah yang cocok + Key: Tombol: + Modifiers Pengubah + Nothing is assigned to this key Tidak ada yang ditetapkan ke tombol ini + Click a key on the keyboard to see its shortcuts Klik tombol pada keyboard untuk melihat pintasannya + Assign {key} to {command} Tetapkan {key} ke {command} + Conflict: {key} is assigned to {commands} ({ctx}) Konflik: {key} ditetapkan ke {commands} ({ctx}) + and dan + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Klik sel Pintasan suatu perintah lalu tekan tombol untuk menetapkannya; + menambahkan pintasan lain. + Cleared {n} shortcut(s) of {command} {n} pintasan dari {command} dihapus + No export preset named `{name}` Tidak ada preset ekspor bernama `{name}` + Open a sequence to export Buka sekuens untuk mengekspor + Destinations Tujuan + Media File Berkas Media + Queue ({n}) Antrean ({n}) + Send exports here with Send to Queue, then start the queue. Kirim ekspor ke sini dengan Kirim ke Antrean, lalu mulai antrean. + Ready Siap + Encoding {n}% Mengenkode {n}% + Start Queue Mulai Antrean + Stop Berhenti + Clear Finished Hapus yang Selesai + Not supported by FilmCraft's encoders yet Belum didukung oleh enkoder FilmCraft + Sequence name Nama sekuens + Folder Folder + Preset Preset + More presets… Preset lainnya… + Multiplexer Multiplexer + The sequence has no caption track. Sekuens tidak memiliki trek takarir. + Export Options Opsi Ekspor + Burn Captions Into Video Tanamkan Takarir ke Video + Create Sidecar File Buat Berkas Pendamping + File Format Format Berkas + SubRip Subtitle (.srt) Subtitel SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Judul + Creator Pembuat + Copyright Hak Cipta + Image Gambar + Frame Size Ukuran Bingkai + Match Source Cocokkan Sumber + Field Order Urutan Field + Aspect Aspek + Profile Profil + Bitrate Encoding Enkode Bitrate + Target Bitrate Bitrate Target + Adaptive ({bpp} bits per pixel) Adaptif ({bpp} bit per piksel) + Maximum Bitrate Bitrate Maksimum + Quality (CRF) Kualitas (CRF) + Key Frame Distance Jarak Bingkai Kunci + Quality Kualitas + Render at Maximum Depth Render pada Kedalaman Maksimum + Use Maximum Render Quality Gunakan Kualitas Render Maksimum + Export Audio Ekspor Audio + Audio Format Format Audio + Uncompressed (PCM) Tanpa Kompresi (PCM) + Match Source ({seq_sr} Hz) Cocokkan Sumber ({seq_sr} Hz) + Channels Kanal + Audio Bitrate Bitrate Audio + Sample Size Ukuran Sampel + 16 bit 16 bit + 24 bit 24 bit + Size / Opacity Ukuran / Opasitas + Image Overlay Overlay Gambar + PNG or JPEG file Berkas PNG atau JPEG + Name Overlay Overlay Nama + Timecode Overlay Overlay Kode Waktu + Luma Min / Max Luma Min / Maks + Loudness Normalization Normalisasi Kenyaringan + True Peak Limit Batas True Peak + Audio only Hanya audio + Scaling Penskalaan + Scale To Fit Skalakan agar Pas + Scale To Fill Skalakan untuk Mengisi + Stretch To Fill Regangkan untuk Mengisi + Summary Ringkasan + Sequence, {w}x{h}, {fps} fps, {rate} Hz Sekuens, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Perkiraan ukuran berkas: {size} + Send to Queue Kirim ke Antrean + Exporting {path} Mengekspor {path} + Added to the export queue Ditambahkan ke antrean ekspor + Preset Manager Manajer Preset + Search presets Cari preset + Favorites only Hanya favorit + Show in the Preset menu Tampilkan di menu Preset + User Pengguna + Save current settings as: Simpan pengaturan saat ini sebagai: + Export presets Ekspor preset + Import: no file chosen (agents: export.presets.import {path}) Impor: tidak ada berkas yang dipilih (agen: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Ekspor: tidak ada berkas yang dipilih (agen: export.presets.export {path, names}) + Quick Export: open a sequence first Ekspor Cepat: buka sekuens terlebih dahulu + File Name & Location Nama & Lokasi Berkas + Quick Export: {path} Ekspor Cepat: {path} + Graphics problem: switched to software compositing Masalah grafis: beralih ke pengomposisian perangkat lunak + GPU acceleration is unavailable; preview renders on the CPU Akselerasi GPU tidak tersedia; pratinjau dirender di CPU + Exporting Mengekspor + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Klik untuk memilih, atau klik di ruang kosong lalu seret untuk memilih dengan bingkai. Gunakan Shift, Opt, dan Cmd untuk opsi lain. + Click to select all clips to the right in all tracks. Shift-click for a single track. Klik untuk memilih semua klip di sebelah kanan di semua trek. Shift-klik untuk satu trek. + Click to select all clips to the left in all tracks. Shift-click for a single track. Klik untuk memilih semua klip di sebelah kiri di semua trek. Shift-klik untuk satu trek. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Seret titik edit untuk pangkas ripple; klip berikutnya bergeser agar celah tetap tertutup. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Seret titik edit untuk roll: titik keluar satu klip dan titik masuk klip berikutnya bergeser bersama. + Drag an edge to change the clip's speed so it fills the new duration. Seret tepi untuk mengubah kecepatan klip agar mengisi durasi baru. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Seret tepi klip musik untuk me-remix-nya ke durasi baru pada ketukan yang sesuai secara musikal. + Click to split a clip. Shift-click to split all tracks. Klik untuk membelah klip. Shift-klik untuk membelah semua trek. + Drag a clip to slip its source in/out without moving it. Seret klip untuk menggeser masuk/keluar sumbernya tanpa memindahkannya. + Drag a clip to slide it between its neighbours. Seret klip untuk menggesernya di antara klip tetangganya. + Drag to scroll the timeline. Seret untuk menggulir linimasa. + Click to zoom in; Opt-click to zoom out. Klik untuk memperbesar; Opt-klik untuk memperkecil. + FilmCraft hit an error FilmCraft mengalami galat + Details: {path} Detail: {path} + Opened {url} Membuka {url} + (empty) (kosong) + Step Back 1 Frame Mundur 1 Bingkai + Step Forward 1 Frame Maju 1 Bingkai + Match Playhead Cocokkan Kepala Putar + Gang to Program Gabungkan ke Program + AAC Audio Audio AAC + AAC Audio 48 kHz 320 kbps Audio AAC 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), stereo 48 kHz AAC-LC pada 320 kbps + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit + AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stereo 48 kHz 16-bit + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamorfik 2:1 (2.0) + Animated GIF GIF Animasi + Animated GIF 640×360 GIF Animasi 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 + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 dengan Alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Aplikasi + Apply Guide Template Terapkan Templat Garis Bantu + Auto Save Now Simpan Otomatis Sekarang + Auto-Match Loudness Cocokkan Kenyaringan Otomatis + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom gaya Avid: berkas gambar DNxHR HQ ditambah satu berkas PCM 24-bit 48 kHz per kanal (_A1, _A2…) + BMP BMP + BMP Sequence Urutan BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Mulai Mengedit Teks pada Lapisan Grafis + Begins With Diawali Dengan + Blur In Buram Masuk + Blur Out Buram Keluar + Brightens inside a feathered circular mask Mencerahkan bagian dalam masker lingkaran ber-feather + Broadcast Siaran + CBR CBR + Cancel Dynamic Trim Batalkan Pangkas Dinamis + Cancel Queued Export Batalkan Ekspor dalam Antrean + Center Left Kiri Tengah + Center Right Kanan Tengah + Center align text Rata tengah teks + Change Mask Ubah Masker + Clear All Events Hapus Semua Peristiwa + Clear Edit Point Selection Hapus Pilihan Titik Edit + Clear Finished Exports Hapus Ekspor yang Selesai + Clear Poster Frame Hapus Bingkai Poster + Close Bin Tutup Bin + Close Sequence Tutup Sekuens + Contains Berisi + Cut to Camera 1 Potong ke Kamera 1 + Cut to Camera 2 Potong ke Kamera 2 + Cut to Camera 3 Potong ke Kamera 3 + Cut to Camera 4 Potong ke Kamera 4 + Cut to Camera 5 Potong ke Kamera 5 + Cut to Camera 6 Potong ke Kamera 6 + Cut to Camera 7 Potong ke Kamera 7 + Cut to Camera 8 Potong ke Kamera 8 + Cut to Camera 9 Potong ke Kamera 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 Menggelapkan bagian luar masker oval ber-feather + Decrease Audio Tracks Height Kurangi Tinggi Trek Audio + Decrease Clip Volume Kurangi Volume Klip + Decrease Clip Volume Many Kurangi Volume Klip Banyak + Decrease Font Size by Five Units Kurangi Ukuran Font Lima Satuan + Decrease Font Size by One Unit Kurangi Ukuran Font Satu Satuan + Decrease Leading by Five Units Kurangi Spasi Baris Lima Satuan + Decrease Leading by One Unit Kurangi Spasi Baris Satu Satuan + Decrease Video Tracks Height Kurangi Tinggi Trek Video + Delete Captions Hapus Takarir + Delete Graphic Layer Hapus Lapisan Grafis + Delete Guide Template Hapus Templat Garis Bantu + Delete Media Cache Files Hapus Berkas Cache Media + Discard Unsaved Changes Buang Perubahan yang Belum Disimpan + Does Not Contain Tidak Berisi + Download Speech Model Unduh Model Ucapan + Dynamic Trim Stop Hentikan Pangkas Dinamis + Edit Caption Text Edit Teks Takarir + Ellipse Tool Alat Elips + Ends With Diakhiri Dengan + Entire Source Seluruh Sumber + EssentialSound EssentialSound + Export Frame Ekspor Bingkai + Extend Next Edit To Playhead Perpanjang Edit Berikutnya ke Kepala Putar + Extend Previous Edit To Playhead Perpanjang Edit Sebelumnya ke Kepala Putar + Extend Selected Edit to Playhead Perpanjang Edit Terpilih ke Kepala Putar + Extend Selection Down Perluas Pilihan ke Bawah + Extend Selection Left Perluas Pilihan ke Kiri + Extend Selection Right Perluas Pilihan ke Kanan + Extend Selection Up Perluas Pilihan ke Atas + Fade In Fade In + Gaussian Blur from 0 to 60 over the last second Buram Gaussian dari 0 ke 60 selama detik terakhir + Gaussian Blur from 60 to 0 over the first second Buram Gaussian dari 60 ke 0 selama detik pertama + Generate Ducking Keyframes Buat Bingkai Kunci Ducking + Go to Next Edit Point Ke Titik Edit Berikutnya + Go to Next Edit Point on Any Track Ke Titik Edit Berikutnya di Trek Mana Pun + Go to Previous Edit Point Ke Titik Edit Sebelumnya + Go to Previous Edit Point on Any Track Ke Titik Edit Sebelumnya di Trek Mana Pun + Go to Selected Clip End Ke Akhir Klip Terpilih + Go to Selected Clip Start Ke Awal Klip Terpilih + Go to Sequence End Ke Akhir Sekuens + Go to Sequence Start Ke Awal Sekuens + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 untuk situs berbagi video, VBR 1 pass, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pass, target 20 Mbps, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pass, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 untuk situs berbagi video, VBR 1 pass, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 pass, target 60 Mbps, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 pass, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 potret 9:16 untuk video sosial pendek, diskalakan untuk mengisi, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 pada ukuran dan laju bingkai sekuens; 0,05 bit per piksel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 pada ukuran dan laju bingkai sekuens; 0,1 bit per piksel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 pada ukuran dan laju bingkai sekuens; bitrate menyesuaikan ukuran bingkai (0,2 bit per piksel) + HD Anamorphic 1080 (1.333) HD Anamorfik 1080 (1.333) + Hand Tool Alat Tangan + High Quality 1080p HD Kualitas Tinggi 1080p HD + High Quality 2160p 4K Kualitas Tinggi 2160p 4K + Image Sequence Urutan Gambar + Increase Audio Tracks Height Tambah Tinggi Trek Audio + Increase Clip Volume Naikkan Volume Klip + Increase Clip Volume Many Naikkan Volume Klip Banyak + Increase Font Size by Five Units Perbesar Ukuran Font Lima Satuan + Increase Font Size by One Unit Perbesar Ukuran Font Satu Satuan + Increase Leading by Five Units Tambah Spasi Baris Lima Satuan + Increase Leading by One Unit Tambah Spasi Baris Satu Satuan + Increase Video Tracks Height Tambah Tinggi Trek Video + Large blocks resolving to the picture over the first second Blok besar yang berubah menjadi gambar selama detik pertama + Left align text Rata kiri teks + Looping GIF, 640×360 at 15 fps GIF berulang, 640×360 pada 15 fps + Lower First Bawah Dulu + 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), PCM 24-bit 48 kHz, kode waktu awal + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High long GOP (0,3 bit per piksel), PCM 24-bit 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM 24-bit 48 kHz, kode waktu awal + Main Main + Mark In (Hover Scrub) Tandai Masuk (Scrub saat Melayang) + Mark Out (Hover Scrub) Tandai Keluar (Scrub saat Melayang) + Masks Masker + Match Source – Adaptive High Bitrate Cocokkan Sumber – Bitrate Tinggi Adaptif + Match Source – Adaptive Low Bitrate Cocokkan Sumber – Bitrate Rendah Adaptif + Match Source – Adaptive Medium Bitrate Cocokkan Sumber – Bitrate Sedang Adaptif + Matches Cocok Dengan + Maximize or Restore Active Frame Maksimalkan atau Pulihkan Bingkai Aktif + Maximize or Restore Frame Under Cursor Maksimalkan atau Pulihkan Bingkai di Bawah Kursor + Media Browser Settings Pengaturan Peramban Media + MediaBrowser MediaBrowser + MediaCache MediaCache + Merge Captions Gabungkan Takarir + Mixer Mixer + Mosaic In Mozaik Masuk + Move All Audio Targets Down Pindahkan Semua Target Audio ke Bawah + Move All Audio Targets Up Pindahkan Semua Target Audio ke Atas + Move All Video Targets Down Pindahkan Semua Target Video ke Bawah + Move All Video Targets Up Pindahkan Semua Target Video ke Atas + Move Selection Down Pindahkan Pilihan ke Bawah + Move Selection End Pindahkan Pilihan ke Akhir + Move Selection Home Pindahkan Pilihan ke Awal + Move Selection Left Pindahkan Pilihan ke Kiri + Move Selection Page Down Pindahkan Pilihan Satu Halaman ke Bawah + Move Selection Page Up Pindahkan Pilihan Satu Halaman ke Atas + Move Selection Right Pindahkan Pilihan ke Kanan + Move Selection Up Pindahkan Pilihan ke Atas + Multi-Camera Record On/Off Toggle Alihkan Rekam Multi-Kamera Aktif/Nonaktif + Multicam Multikamera + Navigate to End of Segment Navigasi ke Akhir Segmen + Navigate to Next Line Navigasi ke Baris Berikutnya + Navigate to Next Word Navigasi ke Kata Berikutnya + Navigate to Previous Line Navigasi ke Baris Sebelumnya + Navigate to Previous Word Navigasi ke Kata Sebelumnya + Navigate to Start of Segment Navigasi ke Awal Segmen + Navigation Navigasi + Next Multi-Camera Page Halaman Multi-Kamera Berikutnya + Nudge Clip Selection Down Geser Sedikit Pilihan Klip ke Bawah + Nudge Clip Selection Left Five Frames Geser Sedikit Pilihan Klip ke Kiri Lima Bingkai + Nudge Clip Selection Left One Frame Geser Sedikit Pilihan Klip ke Kiri Satu Bingkai + Nudge Clip Selection Right Five Frames Geser Sedikit Pilihan Klip ke Kanan Lima Bingkai + Nudge Clip Selection Right One Frame Geser Sedikit Pilihan Klip ke Kanan Satu Bingkai + Nudge Clip Selection Up Geser Sedikit Pilihan Klip ke Atas + Nudge Selected Object down by five Geser Sedikit Objek Terpilih ke bawah sebanyak lima + Nudge Selected Object down by one Geser Sedikit Objek Terpilih ke bawah sebanyak satu + Nudge Selected Object to left by five Geser Sedikit Objek Terpilih ke kiri sebanyak lima + Nudge Selected Object to left by one Geser Sedikit Objek Terpilih ke kiri sebanyak satu + Nudge Selected Object to right by five Geser Sedikit Objek Terpilih ke kanan sebanyak lima + Nudge Selected Object to right by one Geser Sedikit Objek Terpilih ke kanan sebanyak satu + Nudge Selected Object up by five Geser Sedikit Objek Terpilih ke atas sebanyak lima + Nudge Selected Object up by one Geser Sedikit Objek Terpilih ke atas sebanyak satu + Nudge Volume +1dB Geser Volume +1dB + Nudge Volume +3dB Geser Volume +3dB + Nudge Volume -1dB Geser Volume -1dB + Nudge Volume -3dB Geser Volume -3dB + Numbered BMP stills (24-bit) Gambar diam BMP bernomor (24-bit) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Urutan JPEG + Targa Sequence Urutan Targa + DPX Sequence Urutan DPX + Numbered JPEG stills (quality 90) Gambar diam JPEG bernomor (kualitas 90) + Numbered Targa stills (24-bit, uncompressed) Gambar diam Targa bernomor (24-bit, tanpa kompresi) + Numbered DPX stills (10-bit RGB) Gambar diam DPX bernomor (RGB 10-bit) + OpenEXR OpenEXR + OpenEXR Sequence Urutan OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Gambar diam OpenEXR bernomor (half float, linier, tanpa kompresi) + Numbered PNG stills at the sequence's frame size Gambar diam PNG bernomor pada ukuran bingkai sekuens + Numbered TIFF stills (RGB, 8-bit) Gambar diam TIFF bernomor (RGB, 8-bit) + Opacity 0 → 100 % over the first second Opasitas 0 → 100 % selama detik pertama + Opacity 100 → 0 % over the last second Opasitas 100 → 0 % selama detik terakhir + Open Bin Buka Bin + Open Search Buka Pencarian + PNG PNG + PNG Sequence Urutan PNG + Panel Panel + Pen Tool Alat Pena + PiP 35% Upper Right PiP 35% Kanan Atas + Picture-in-picture: 35 % scale in the upper right Gambar-dalam-gambar: skala 35 % di kanan atas + Play Around Putar di Sekitar + Play In to Out with Preroll/Postroll Putar dari Masuk ke Keluar dengan Preroll/Postroll + Play from Playhead to Out Point Putar dari Kepala Putar ke Titik Keluar + Play/Stop Putar/Berhenti + Previous Multi-Camera Page Halaman Multi-Kamera Sebelumnya + Progressive Progresif + ProjectPanel 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), PCM 24-bit 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8-bit 4:2:2), PCM 24-bit 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM 24-bit 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM 24-bit 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM 24-bit 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM 24-bit 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) dengan kanal alfa, PCM 24-bit 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM 24-bit 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM 24-bit 48 kHz + Quit FilmCraft Keluar dari FilmCraft + Rate Stretch Tool Alat Regang Laju + Razor Tool Alat Silet + Rectangle Tool Alat Persegi Panjang + Remix Tool Alat Remix + Right align text Rata kanan teks + Ripple Edit Tool Alat Edit Ripple + Ripple Trim Next Edit to Playhead Pangkas Ripple Edit Berikutnya ke Kepala Putar + Ripple Trim Previous Edit to Playhead Pangkas Ripple Edit Sebelumnya ke Kepala Putar + Rolling Edit Tool Alat Edit Roll + Select Camera 1 Pilih Kamera 1 + Select Camera 2 Pilih Kamera 2 + Select Camera 3 Pilih Kamera 3 + Select Camera 4 Pilih Kamera 4 + Select Camera 5 Pilih Kamera 5 + Select Camera 6 Pilih Kamera 6 + Select Camera 7 Pilih Kamera 7 + Select Camera 8 Pilih Kamera 8 + Select Camera 9 Pilih Kamera 9 + Select Clip at Playhead Pilih Klip di Kepala Putar + Select Find Box Pilih Kotak Pencarian + Select Next Clip Pilih Klip Berikutnya + Select Next Panel Pilih Panel Berikutnya + Select Previous Clip Pilih Klip Sebelumnya + Select Previous Panel Pilih Panel Sebelumnya + Select to Next Line Pilih sampai Baris Berikutnya + Select to Next Word Pilih sampai Kata Berikutnya + Select to Previous Line Pilih sampai Baris Sebelumnya + Select to Previous Word Pilih sampai Kata Sebelumnya + Select to Segment End Pilih sampai Akhir Segmen + Select to Segment Start Pilih sampai Awal Segmen + Selection Tool Alat Seleksi + Set Comparison Reference Atur Referensi Perbandingan + Set Creative Look Atur Look Kreatif + Set Input LUT Atur LUT Input + Set Project Panel View Atur Tampilan Panel Proyek + Show Next Screen Tampilkan Layar Berikutnya + Show Previous Screen Tampilkan Layar Sebelumnya + Show Program Transcript Tampilkan Transkrip Program + Shuttle Left Shuttle ke Kiri + Shuttle Right Shuttle ke Kanan + Shuttle Slow Left Shuttle Lambat ke Kiri + Shuttle Slow Right Shuttle Lambat ke Kanan + Shuttle Stop Hentikan Shuttle + Slide Clip Selection Left Five Frames Slide Pilihan Klip ke Kiri Lima Bingkai + Slide Clip Selection Left One Frame Slide Pilihan Klip ke Kiri Satu Bingkai + Slide Clip Selection Right Five Frames Slide Pilihan Klip ke Kanan Lima Bingkai + Slide Clip Selection Right One Frame Slide Pilihan Klip ke Kanan Satu Bingkai + Slide Tool Alat Slide + Slip Clip Selection Left Five Frames Slip Pilihan Klip ke Kiri Lima Bingkai + Slip Clip Selection Left One Frame Slip Pilihan Klip ke Kiri Satu Bingkai + Slip Clip Selection Right Five Frames Slip Pilihan Klip ke Kanan Lima Bingkai + Slip Clip Selection Right One Frame Slip Pilihan Klip ke Kanan Satu Bingkai + Slip Tool Alat Slip + Social Vertical 1080×1920 Sosial Vertikal 1080×1920 + Soft Vignette Vinyet Lembut + Source In/Out Masuk/Keluar Sumber + Split Caption Pisahkan Takarir + Square Pixels (1.0) Piksel Persegi (1.0) + Stack Clips Tumpuk Klip + Start Export Queue Mulai Antrean Ekspor + Start Voice-over Recording Mulai Perekaman Sulih Suara + Step Back Five Frames Mundur Lima Bingkai + Step Back One Frame Mundur Satu Bingkai + Step Forward Five Frames Maju Lima Bingkai + Step Forward One Frame Maju Satu Bingkai + Stop Export Queue Hentikan Antrean Ekspor + Stop Multi-Camera Recording Hentikan Perekaman Multi-Kamera + Stop Voice-over Recording Hentikan Perekaman Sulih Suara + Sync Voice-over Capture Sinkronkan Perekaman Sulih Suara + TIFF TIFF + TIFF Sequence Urutan TIFF + TextPanel TextPanel + Thumbnail Size Next Ukuran Gambar Mini Berikutnya + Thumbnail Size Previous Ukuran Gambar Mini Sebelumnya + Toggle All Audio Targets Alihkan Semua Target Audio + Toggle All Source Audio Alihkan Semua Audio Sumber + Toggle All Source Video Alihkan Semua Video Sumber + Toggle All Video Targets Alihkan Semua Target Video + Toggle Audio During Scrubbing Alihkan Audio Saat Scrub + Toggle Full Screen Alihkan Layar Penuh + Toggle Mute for All Targeted Audio Tracks Alihkan Bisu untuk Semua Trek Audio Target + Toggle Solo for All Targeted Audio Tracks Alihkan Solo untuk Semua Trek Audio Target + Toggle Source/Program Monitor Focus Alihkan Fokus Monitor Sumber/Program + Toggle Target Audio 1 Alihkan Target Audio 1 + Toggle Target Audio 2 Alihkan Target Audio 2 + Toggle Target Audio 3 Alihkan Target Audio 3 + Toggle Target Audio 4 Alihkan Target Audio 4 + Toggle Target Audio 5 Alihkan Target Audio 5 + Toggle Target Audio 6 Alihkan Target Audio 6 + Toggle Target Audio 7 Alihkan Target Audio 7 + Toggle Target Audio 8 Alihkan Target Audio 8 + Toggle Target Video 1 Alihkan Target Video 1 + Toggle Target Video 2 Alihkan Target Video 2 + Toggle Target Video 3 Alihkan Target Video 3 + Toggle Target Video 4 Alihkan Target Video 4 + Toggle Target Video 5 Alihkan Target Video 5 + Toggle Target Video 6 Alihkan Target Video 6 + Toggle Target Video 7 Alihkan Target Video 7 + Toggle Target Video 8 Alihkan Target Video 8 + Toggle Track Output for All Targeted Video Tracks Alihkan Output Trek untuk Semua Trek Video Target + Toggle Trim Type Alihkan Jenis Pangkas + Toggle View Alihkan Tampilan + Track Select Backward Tool Alat Pilih Trek Mundur + Track Select Forward Tool Alat Pilih Trek Maju + Track Selected Mask Lacak Masker Terpilih + Trim Next Edit to Playhead Pangkas Edit Berikutnya ke Kepala Putar + Trim Previous Edit to Playhead Pangkas Edit Sebelumnya ke Kepala Putar + Trimming Pemangkasan + Type Tool Alat Teks + Unstack Clips Lepas Tumpukan Klip + Upper First Atas Dulu + VBR, 1 pass VBR, 1 pass + VBR, 2 pass VBR, 2 pass + CRF (constant quality) CRF (kualitas konstan) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Rekam Sulih Suara + Voiceover Sulih Suara + WAV, stereo 48 kHz 16-bit PCM WAV, PCM stereo 48 kHz 16-bit + WAV, stereo 48 kHz 24-bit PCM WAV, PCM stereo 48 kHz 24-bit + Warm Tint Rona Hangat + Warm highlights, brown shadows Sorotan hangat, bayangan cokelat + 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 & Sosial + Work Area Area Kerja + Write Automation Tulis Otomasi + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Zoom Monitor Program ke 100% + Zoom Program Monitor to Fit Zoom Monitor Program agar Pas + Zoom Source Monitor to 100% Zoom Monitor Sumber ke 100% + Zoom Source Monitor to Fit Zoom Monitor Sumber agar Pas + Zoom Tool Alat Zoom + with: dengan: + Interface Language Bahasa Antarmuka + System Language Bahasa Sistem + Cinematic Sinematik + Film Emulation Emulasi Film + Technical Teknis + Teal Shadows, Warm Skin Bayangan Toska, Kulit Hangat + Night Exterior Eksterior Malam + Golden Dusk Senja Keemasan + Steel Blue Biru Baja + Hard Sun Matahari Terik + Faded Print Cetakan Pudar + Silver Retention Retensi Perak + Cross Processed Proses Silang + Warm Negative Negatif Hangat + Neutral Mono Mono Netral + Hard Mono Mono Keras + Sepia Tone Nada Sepia + Cold Mono Mono Dingin + Lift Shadows Angkat Bayangan + Protect Highlights Lindungi Sorotan + Half Saturation Setengah Saturasi + Boost Vibrance Tingkatkan Vibrance + Legal Range Squeeze Pemampatan Rentang Legal + A boxed label with a round pointer; move the label and the pointer follows. Label berbingkai dengan penunjuk bulat; pindahkan label dan penunjuk akan mengikuti. + A rounded tag that fits its text, with a status dot. Tag membulat yang menyesuaikan teksnya, dengan titik status. + A title on a rounded card with a soft shadow. Judul pada kartu membulat dengan bayangan lembut. + Accent Color Warna Aksen + Bar Color Warna Bilah + Box Color Warna Kotak + Box Opacity Opasitas Kotak + Callout – Pointer Callout – Penunjuk + Callout – Tag Callout – Tag + Callouts Callout + Credits Kredit + Dot Color Warna Titik + Heading Judul Utama + Large centred title, accent rule and subtitle. Judul besar di tengah, garis aksen, dan subjudul. + Lower Third – Rule Lower Third – Garis + Lower Third – Slab Lower Third – Lempeng + Lower Thirds Lower Third + Name Color Warna Nama + Name Size Ukuran Nama + Name and role on a coloured slab that widens with the name. Nama dan peran pada lempeng berwarna yang melebar mengikuti nama. + Name over a thin rule that always matches the name's width. Nama di atas garis tipis yang selalu menyamai lebar nama. + Pointer Color Warna Penunjuk + Role Peran + Rule Color Warna Garis + Show Dot Tampilkan Titik + Show Role Tampilkan Peran + Slab Color Warna Lempeng + Tag Color Warna Tag + Title Color Warna Judul + Title Size Ukuran Judul + Title – Boxed Judul – Berbingkai + Title – Centered Judul – Di Tengah + Titles Judul + Select an item to preview it Pilih item untuk melihat pratinjaunya + End Credits Kredit Penutup + End Credits – Roll Kredit Penutup – Gulung + Ticker – Crawl Ticker – Merayap + Centred credits that roll up from below the frame and leave at the top. Kredit di tengah yang bergulung naik dari bawah bingkai dan keluar di atas. + A news-style line that crawls in from the right and leaves at the left. Baris bergaya berita yang merayap masuk dari kanan dan keluar di kiri. + out keluar + in masuk + The language changed but could not be saved: {e} Bahasa telah diubah tetapi tidak dapat disimpan: {e} + no Chinese font is installed on this system; the interface stays in English tidak ada font Tionghoa yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris + no Japanese font is installed on this system; the interface stays in English tidak ada font Jepang yang terpasang di sistem ini; antarmuka tetap dalam bahasa Inggris + Show Tampilkan + Sort Urutkan + Reverse the order Balik urutan + Grab bag Campur aduk + Table Tabel + No contributor data was built into this copy. Tidak ada data kontributor yang disertakan dalam salinan ini. + No model credits were built into this copy. Tidak ada kredit model yang disertakan dalam salinan ini. + Company Perusahaan + Model Model + Version Versi + Commits Commit + % of all commits % dari semua commit + Lines +/− Baris +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Clip Speed / Duration Kecepatan / Durasi Klip + Speed: Kecepatan: + Reverse Speed Balik Kecepatan + Ripple Edit, Shifting Trailing Clips Edit Ripple, Geser Klip Berikutnya + About Tentang + Contributors Kontributor + Models Model + Main (8-bit) Main (8-bit) + Main 10 (10-bit, HDR) Main 10 (10-bit, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8-bit; Main 10 untuk sekuens HDR) + Encoder Enkoder + Hardware (H.265 has no software encoder) Perangkat keras (H.265 tidak memiliki enkoder perangkat lunak) + Hardware (AV1 has no software encoder) Perangkat keras (AV1 tidak memiliki enkoder perangkat lunak) + Text Properties Properti Teks + Text Box Height Tinggi Kotak Teks + Paragraph Text Teks Paragraf + Point Text Teks Titik + Text Layer Type Jenis Lapisan Teks + Text Styling Gaya Teks + New Color Matte Matte Warna Baru + Color Matte Color Warna Matte Warna + Color: Warna: + · {time} left · sisa {time} + Discard take Buang take + Voice-over take discarded Take sulih suara dibuang + Binary assets added Aset biner ditambahkan + Binary assets removed Aset biner dihapus + Deselect All Project Items Batalkan Pilihan Semua Item Proyek + Display name Nama tampilan + First commit Commit pertama + H.265 (HEVC) H.265 (HEVC) + Last commit Commit terakhir + Line delta Delta baris + Lines added Baris ditambahkan + Lines deleted Baris dihapus + Merged PRs PR yang digabung + Name (A–Z) Nama (A–Z) + Real name Nama asli + Select All Project Items Pilih Semua Item Proyek + Username Nama pengguna + Vertical Type Tool Alat Teks Vertikal + Go to previous keyframe Ke bingkai kunci sebelumnya + Go to next keyframe Ke bingkai kunci berikutnya + Add keyframe Tambah bingkai kunci + Remove keyframe Hapus bingkai kunci + Turn on animation and add keyframe Aktifkan animasi dan tambah bingkai kunci + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Klik untuk memilih, atau klik di ruang kosong lalu seret untuk memilih dengan bingkai. Gunakan Shift, Alt, dan Ctrl untuk opsi lain. + Click to zoom in; Alt-click to zoom out. Klik untuk memperbesar; Alt-klik untuk memperkecil. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Seret: pindahkan · Alt-klik: halus/sudut · Ctrl-klik: hapus + Include Alpha Channel Sertakan Kanal Alfa + Sequence Settings Pengaturan Sekuens + Color Management Manajemen Warna + VR Properties Properti VR + Editing Mode: Mode Penyuntingan: + Timebase: Basis Waktu: + Frame Size: Ukuran Bingkai: + Pixel Aspect Ratio: Rasio Aspek Piksel: + Fields: Field: + Channel Format: Format Kanal: + Number of Channels: Jumlah Kanal: + Preview File Format: Format Berkas Pratinjau: + Working Color Space: Ruang Warna Kerja: + Projection: Proyeksi: + Layout: Tata Letak: + Captured View: Tampilan yang Direkam: + Video Previews Pratinjau Video + No Fields (Progressive Scan) Tanpa Field (Pindai Progresif) + Custom ({ratio}) Kustom ({ratio}) + {rate} frames/second {rate} bingkai/detik + {rate} fps Drop-Frame Timecode Kode Waktu Drop-Frame {rate} fps + {rate} fps Non Drop-Frame Timecode Kode Waktu Non Drop-Frame {rate} fps + {rate} fps Timecode Kode Waktu {rate} fps + horizontal horizontal + vertical vertikal + Scale motion effects proportionally when changing frame size Skalakan efek gerak secara proporsional saat mengubah ukuran bingkai + Maximum Bit Depth Kedalaman Bit Maksimum + Maximum Render Quality Kualitas Render Maksimum + Composite in Linear Color Komposit dalam Warna Linier + Auto Tone Map Media (HDR and log into SDR) Pemetaan Nada Otomatis Media (HDR dan log ke SDR) + Configure… Konfigurasi… + Not supported in FilmCraft yet Belum didukung di FilmCraft + FilmCraft always composites in linear light FilmCraft selalu mengomposit dalam cahaya linier + Loop marked range Ulangi rentang yang ditandai + Play Source Putar Sumber + Source Play/Stop Putar/Berhenti Sumber + Stop Source Hentikan Sumber + New Sequence Sekuens Baru + Tracks Trek + tracks trek + Clear Transition Hapus Transisi + Set Transition Duration Atur Durasi Transisi + Active Aktif + Appearance Mode Mode Penampilan + Dark Theme Tema Gelap + Light Theme Tema Terang + Next Appearance Mode Mode Penampilan Berikutnya + Sync with system Sinkronkan dengan sistem + UI Scale Skala UI + Auto (system) Otomatis (sistem) + In Masuk + Out Keluar + Export Markers as CSV… Ekspor Penanda sebagai CSV… + Fill Type Jenis Isian + Gradient Angle Sudut Gradien + Linear Gradient Gradien Linier + Solid Solid + GPU Rendering Rendering GPU + Composite on the GPU when available Komposit di GPU bila tersedia + Text to Speech Teks ke Ucapan + Edit Narration Edit Narasi + Project Notes Catatan Proyek + Get Project Notes Ambil Catatan Proyek + Edit Project Notes Edit Catatan Proyek + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Catatan untuk proyek ini: daftar tugas, instruksi, nomor bingkai… Disimpan bersama proyek. + Use Effect Shutter Angle Gunakan Sudut Rana Efek + Settings Preferensi diff --git a/crates/ui-egui/src/i18n/id.tsv.attribution b/crates/ui-egui/src/i18n/id.tsv.attribution new file mode 100644 index 000000000..ca37e39c2 --- /dev/null +++ b/crates/ui-egui/src/i18n/id.tsv.attribution @@ -0,0 +1,8 @@ +asset: id.tsv +title: Indonesian interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-11 by vangel76 +notes: Original English-to-Indonesian interface translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cc3879d26..d142b44b8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -805,6 +805,7 @@ Relink others automatically 他のクリップを自動的に再リンク Search in: 検索場所: Browse… 参照… + A file dialog is already open ファイルダイアログはすでに開いています Exact name matches only 名前の完全一致のみ No candidates yet — Search a folder or Locate the file. 候補はまだありません。フォルダーを検索するか、ファイルの場所を指定してください。 ✔ same file ✔ 同じファイル @@ -1819,7 +1820,7 @@ 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 独自のデコーダーを使用します。すでに開いているメディアは、開き直すまで現在のデコーダーを使い続けます。 + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. ハードウェアデコード: 「自動」では、対応しているストリームにはシステムのビデオデコーダー (macOS では VideoToolbox、Windows では Media Foundation、Linux では H.264・HEVC 用の VA-API または NVDEC) を使用し、それ以外のストリームや、ハードウェアで失敗した場合は FilmCraft 独自のデコーダーを使用します。すでに開いているメディアは、開き直すまで現在のデコーダーを使い続けます。 Head Size 頭部サイズ Heart ハート Height 高さ @@ -2282,7 +2283,7 @@ Repeat Edge Pixels エッジピクセルを繰り返す Replace Color 置換後の色 Replace With Clip From Source Monitor ソースモニターのクリップで置換 - Replicate 複製 + Replicate レプリケート Responsive Design - Position レスポンシブデザイン - 位置 Responsive Design - Time レスポンシブデザイン - 時間 Restore open sequences when opening projects プロジェクトを開くときに、開いていたシーケンスを復元する @@ -2793,8 +2794,8 @@ 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 検出したカット点ごとにクリップマーカーを生成 + Create a bin of subclips from each detected cut point 検出したカット点ごとにサブクリップのビンを作成 + Create a clip marker at each detected cut point 検出したカット点ごとにクリップマーカーを作成 Sensitivity: 感度: Normalize Mix Track to: ミックストラックのノーマライズ値: Disabled clips 無効なクリップ @@ -2839,7 +2840,7 @@ Keyboard: US キーボード: US 配列 Save preset as: 保存するプリセット名: Commands: コマンド: - {panel} Panel {panel}パネル + {panel} Panel {panel} パネル Application shortcuts work whatever panel has focus アプリケーションのショートカットは、どのパネルにフォーカスがあっても機能します Panel shortcuts override application shortcuts while that panel has focus パネルのショートカットは、そのパネルにフォーカスがある間、アプリケーションのショートカットより優先されます Modifier held / selected 押下中 / 選択中の修飾キー @@ -2969,6 +2970,12 @@ 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 + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24-bit + FLAC, stereo 48 kHz 24-bit, lossless FLAC、ステレオ 48 kHz 24-bit、ロスレス + FLAC (lossless) FLAC (ロスレス) + Compression Level 圧縮レベル + 0 fastest, 8 smallest 0 が最速、8 が最小 APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 @@ -2981,7 +2988,7 @@ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy Apple ProRes 4444 Apple ProRes 4444 - Apple ProRes 4444 with Alpha Apple ProRes 4444(アルファ付き) + Apple ProRes 4444 with Alpha Apple ProRes 4444 (アルファ付き) Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application アプリケーション Apply Guide Template ガイドテンプレートを適用 @@ -3487,6 +3494,7 @@ 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+クリック: 削除 + Drag inward to round the corners 内側にドラッグして角を丸める Include Alpha Channel アルファチャンネルを含める Sequence Settings シーケンス設定 Color Management カラーマネジメント @@ -3537,7 +3545,7 @@ Text to Speech テキスト読み上げ Edit Narration ナレーションを編集 Clear Transition トランジションを消去 - Set Transition Duration トランジションのデュレーションを設定 + Set Transition Duration トランジションの長さを設定 Refresh Changed Media 変更されたメディアを更新 Project Notes プロジェクトメモ Get Project Notes プロジェクトメモを取得 @@ -3547,3 +3555,36 @@ Compression 圧縮 ZIP (1 scan line) ZIP(1 スキャンライン) ZIP (16 scan lines) ZIP(16 スキャンライン) + + Caption Tracks キャプショントラック + Delete Caption Track キャプショントラックを削除 + Delete Caption Tracks キャプショントラックを削除 + Delete Empty Caption Tracks 空のキャプショントラックを削除 + Dock Panel パネルをドッキング + Undock Panel パネルのドッキングを解除 + Docking Action ドッキング操作 + Resize floating panel フローティングパネルのサイズを変更 + Eyedropper (pick a color from the Program monitor) スポイト (プログラムモニターから色を取得) + Eyedropper: no picture to sample スポイト: 色を取得できる画像がありません + In イン + Out アウト + More transport controls その他の再生コントロール + Ripple Trim In リップルトリム (イン) + Ripple Trim Out リップルトリム (アウト) + Roll Edit ロール編集 + Trim In トリム (イン) + Trim Out トリム (アウト) + Use Effect Shutter Angle エフェクトのシャッター角度を使用 + Safe graphics mode: {mode} セーフグラフィックモード: {mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 以前の起動がデフォルトのグラフィックで失敗しました。グラフィックドライバーを更新すると、通常は解決します。 + Use Default Graphics デフォルトのグラフィックを使用 + The default graphics are used from the next start. 次回の起動からデフォルトのグラフィックが使用されます。 + DirectX 12 only DirectX 12 のみ + software rendering ソフトウェアレンダリング + Label Color Presets ラベルカラープリセット + {name} (Modified) {name} (変更あり) + Revert to the preset's colors and names プリセットのカラーと名前に戻す + Import a preset file… プリセットファイルを読み込み… + Export the labels to a preset file… ラベルをプリセットファイルに書き出し… + Show the presets folder プリセットフォルダーを表示 + Delete this preset このプリセットを削除 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7225b5c98..9dd1ad9bc 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -45,6 +45,7 @@ pub enum Language { De, Ru, Fr, + Id, } static JAPANESE: OnceLock = OnceLock::new(); @@ -55,9 +56,10 @@ static CHINESE: OnceLock = OnceLock::new(); static GERMAN: OnceLock = OnceLock::new(); static RUSSIAN: OnceLock = OnceLock::new(); static FRENCH: OnceLock = OnceLock::new(); +static INDONESIAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr]; + pub const ALL: [Self; 10] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id]; pub fn name(self) -> &'static str { match self { @@ -70,6 +72,7 @@ impl Language { Self::De => "Deutsch", Self::Ru => "Русский", Self::Fr => "Français", + Self::Id => "Bahasa Indonesia", } } @@ -84,6 +87,7 @@ impl Language { "de" => Some(Self::De), "ru" => Some(Self::Ru), "fr" => Some(Self::Fr), + "id" => Some(Self::Id), _ => None, } } @@ -92,11 +96,12 @@ impl Language { /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is; /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one and any French (`fr-CA`, `fr_BE.UTF-8`…) - /// the French one. Chinese gets the Simplified catalog + /// the French one; any Indonesian (`id-ID`, `id_ID.UTF-8`, and the legacy `in-ID` that older Java + /// and Android runtimes report) the Indonesian one. Chinese gets the Simplified catalog /// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`, /// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 8] = [ + const PRIMARY: [(&str, Language); 10] = [ ("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), @@ -105,6 +110,8 @@ impl Language { ("de", Language::De), ("ru", Language::Ru), ("fr", Language::Fr), + ("id", Language::Id), + ("in", Language::Id), ]; tags.iter() .find_map(|tag| { @@ -134,6 +141,7 @@ impl Language { Self::De => "de", Self::Ru => "ru", Self::Fr => "fr", + Self::Id => "id", } } @@ -149,6 +157,7 @@ impl Language { Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))), Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))), Self::Fr => Some(FRENCH.get_or_init(|| Catalog::parse(include_str!("fr.tsv")))), + Self::Id => Some(INDONESIAN.get_or_init(|| Catalog::parse(include_str!("id.tsv")))), } } @@ -319,6 +328,7 @@ mod tests { ("de", include_str!("de.tsv")), ("ru", include_str!("ru.tsv")), ("fr", include_str!("fr.tsv")), + ("id", include_str!("id.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -366,6 +376,13 @@ mod tests { assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); assert_eq!(Language::parse("fr"), Some(Language::Fr)); assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); + assert_eq!(Language::Id.name(), "Bahasa Indonesia"); + assert_eq!(Language::Id.tr("File"), "Berkas"); + assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…"); + assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4"); + assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::parse("id"), Some(Language::Id)); + assert_eq!(Language::parse("id-ID"), None, "the preference stores the bare code"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -425,19 +442,62 @@ mod tests { assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); } - /// The Brazilian Portuguese catalog covers the menus; every entry must still be a menu label - /// (a renamed command would otherwise leave a dead entry and an untranslated menu item). + /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian + /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and + /// the native macOS Settings menu). Brazilian Portuguese played this role while it was + /// menus-only. + fn core_menu_labels() -> Vec { + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + entries.into_iter().map(|(_, en, _)| en).collect() + } + + /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native + /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so + /// strings added later show in English until they are translated. Every entry must still have a + /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. #[test] - fn portuguese_entries_are_menu_labels() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(!entries.is_empty()); - for (_, en, _) in entries { - assert!(known(&en), "not a menu label: {en}"); + fn portuguese_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); } - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + for en in core_menu_labels() { + need(&en); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, pt) in [ + ("File", "Arquivo"), + ("Settings", "Preferências"), + ("Gaussian Blur", "Desfoque gaussiano"), + ("Audio Track Mixer", "Misturador de faixas de áudio"), + ("Keyboard Shortcuts", "Atalhos de teclado"), + ("Playhead", "Cursor de reprodução"), + ] { + assert_eq!(Language::PtBr.tr(en), pt); + } + set_current(Language::PtBr); + assert!(matches_query("Gaussian Blur", "desfoque")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); } /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. @@ -484,6 +544,38 @@ mod tests { assert!(missing.is_empty(), "untranslated Chinese strings: {missing:#?}"); } + /// The Japanese catalog is complete: every menu label, every `tl!` literal and every name the + /// interface translates through `i18n::t`. + #[test] + fn japanese_translates_every_menu_label_tl_literal_and_name() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && !Language::Ja.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + for (_, text) in sources() { + for lit in tl_literals(&text) { + need(&lit); + } + } + for name in dynamic_names() { + need(&name); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated Japanese strings: {missing:#?}"); + } + /// Chinese switches and persists like the other languages when a Chinese face is available, /// and is refused (the interface stays as it was) without one. #[test] @@ -834,6 +926,24 @@ mod tests { let mut restarted = filmcraft_engine::Session::default(); restarted.prefs = serde_json::from_str(&prefs).unwrap(); assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Fr); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.indonesian", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("id")); + assert_eq!((app.ui.language, current()), (Language::Id, Language::Id)); + assert_eq!(app.session.prefs.general.interface_language, "id"); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(it.checked, Some(it.id == "app.language.indonesian"), "{}", it.id); + } + let items = crate::menus::menu_items(&app); + let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); + assert_eq!(label("file.saveAs"), Some("Simpan Sebagai…")); + assert_eq!(label("app.language.french"), Some("Français")); + assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"id\""), "{saved}"); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Id); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -865,6 +975,13 @@ mod tests { for tag in ["fr", "FR", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr_FR.UTF-8", "fr_BE.UTF-8@euro", "fr_CA"] { assert_eq!(l(&[tag]), Language::Fr, "{tag}"); } + for tag in ["id", "ID", "id-ID", "id_ID.UTF-8", "id_ID", "in", "in-ID", "in_ID.UTF-8"] { + assert_eq!(l(&[tag]), Language::Id, "{tag}"); + } + assert_eq!(l(&["jv-ID", "ms-MY", "id-ID", "en"]), Language::Id, "Javanese and Malay have no catalog"); + for none in [&["ind"][..], &["i"], &["-id"], &["ms"], &["ms-ID"], &["jv"]] { + assert_eq!(l(none), Language::En, "{none:?}"); + } assert_eq!(l(&["it-IT", "nl", "es-MX", "ja"]), Language::Es, "the first one the interface has"); assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::De); assert_eq!(l(&["it-CH", "fr-CH", "de-CH", "en"]), Language::Fr); @@ -921,17 +1038,13 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { - assert!(Language::Uk.has(&en), "missing Ukrainian menu label: {en}"); - } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); } } - /// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus - /// and the native macOS Settings menu); every entry must still be a menu label. + /// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native + /// macOS app menu); every entry must still be a menu label. #[test] fn german_entries_cover_the_original_menu_catalog() { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); @@ -939,17 +1052,17 @@ mod tests { let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); let (entries, _) = catalog::parse_entries(include_str!("de.tsv")); for (_, en, _) in entries { - assert!(known(&en) || en == "Settings", "not a menu label: {en}"); + assert!(known(&en) || crate::menus::NATIVE_APP_MENU.contains(&en.as_str()), "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::De.has(&en), "missing German menu label: {en}"); } // "Clip" and "Audio" are the same word in German, so check for an entry, not a change - for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + for en in crate::menus::MENUS.into_iter().chain(["Audio"]).chain(crate::menus::NATIVE_APP_MENU) { assert!(Language::De.has(en), "untranslated German menu: {en}"); } assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); + assert_eq!(Language::De.tr("Quit FilmCraft"), "FilmCraft beenden"); } /// French covers the whole interface (the Spanish catalog's entries and the native macOS @@ -963,8 +1076,7 @@ mod tests { for (ctx, en, _) in &entries { assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Fr.has(&en), "missing French menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -991,8 +1103,7 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -1000,6 +1111,41 @@ mod tests { } } + /// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS + /// Settings menu), but, like Japanese and French, the tests require only the core menus (the + /// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), so + /// strings added later show in English until they are translated. Every entry must still have a + /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. + #[test] + fn indonesian_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("id.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}"); + } + let (core, _) = catalog::parse_entries(include_str!("uk.tsv")); + for (_, en, _) in core { + assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}"); + } + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}"); + } + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, id) in [ + ("Settings", "Preferensi"), + ("Gaussian Blur", "Buram Gaussian"), + ("Audio Track Mixer", "Mixer Trek Audio"), + ("Sequence", "Sekuens"), + ("Playhead", "Kepala Putar"), + ] { + assert_eq!(Language::Id.tr(en), id); + } + set_current(Language::Id); + assert!(matches_query("Gaussian Blur", "buram")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); + } + /// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family. fn assert_bundled_fonts_cover(text: impl Iterator) { let ctx = egui::Context::default(); @@ -1033,6 +1179,15 @@ mod tests { assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))); } + #[test] + fn portuguese_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + // every Portuguese letter, even ones no entry uses yet, and every letter the entries use + let alphabet = "ÁÂÃÀÇÉÊÍÓÔÕÚÜáâãàçéêíóôõúü"; + let letters = entries.iter().flat_map(|(_, _, pt)| pt.chars()).filter(|ch| ch.is_alphabetic()); + assert_bundled_fonts_cover(alphabet.chars().chain(Language::PtBr.name().chars()).chain(letters)); + } + #[test] fn french_renders_with_bundled_fonts() { let (entries, _) = catalog::parse_entries(include_str!("fr.tsv")); @@ -1043,6 +1198,16 @@ mod tests { assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters)); } + #[test] + fn indonesian_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("id.tsv")); + // Indonesian is written in basic Latin letters; any other letter an entry uses, and its + // curly quotes, dashes and ellipses, must still render (symbols such as ✔ come from the + // English sources) + let letters = entries.iter().flat_map(|(_, _, id)| id.chars()).filter(|ch| ch.is_alphabetic() || "“”‘’–—…".contains(*ch)); + assert_bundled_fonts_cover(Language::Id.name().chars().chain(letters)); + } + #[test] fn japanese_needs_an_installed_font() { let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fc7b9fd50..b1229d3f5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1,9 +1,22 @@ # Brazilian Portuguese (pt-br) catalog. Same format as `es.tsv`: context source translation. # -# Clean-room: every entry was written from the plain meaning of the English label using ordinary -# Brazilian Portuguese computing vocabulary. No proprietary localisation resource, string table or -# product UI was consulted or copied. This catalog covers the menus; anything else is shown in -# English. +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Brazilian Portuguese video-editing vocabulary. No proprietary localisation resource, string table +# or product UI was consulted or copied; add entries the same way. Commands use the infinitive +# ("Salvar como…"), instructions use the polite imperative ("Selecione um clipe…"), and labels use +# sentence case. Product, format and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, +# EDL, AAF…) keep their spelling; decimals in prose use a comma ("29,97 fps"). This catalog has the +# same entries as `es.tsv` plus the native macOS Settings menu; strings added later show in English +# until they are translated. A native-speaker review is welcome. +# +# Terms: clip = clipe, track = faixa, sequence = sequência, timeline = linha do tempo, bin = pasta, +# playhead = cursor de reprodução, In/Out = entrada / saída, marker = marcador, keyframe = +# quadro-chave, frame = quadro, frame hold = quadro congelado, edit point = ponto de edição, panel = +# painel, workspace = área de trabalho, render = renderizar, preset = predefinição, captions = +# legendas, voice-over = narração, trim = aparar (aparagem), ripple = em cascata, roll = rolante, +# slip / slide = deslizar conteúdo / deslizar clipe, overwrite = sobrescrever, wipe = cortina, +# dissolve = dissolução, scopes = escopos, highlights = realces, offline = offline, legacy = legado, +# preferences (also the native macOS Settings menu) = Preferências, settings = configurações. About FilmCraft Sobre o FilmCraft Add Audio Submix Track Adicionar faixa de submixagem de áudio @@ -86,7 +99,6 @@ Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera… Create Proxies… Criar proxies… Cut Recortar - Dark Escuro Darkest Mais escuro Delete Render Files Excluir arquivos de renderização Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída @@ -184,6 +196,7 @@ Light Claro Link Vincular Link Media… Vincular mídia… + Refresh Changed Media Atualizar mídia alterada Linked Selection Seleção vinculada Lock Guides Bloquear guias Lumetri Color Cor Lumetri @@ -204,6 +217,7 @@ Media Browser Navegador de mídia Media Cache… Cache de mídia… Media… Mídia… + Medium Médio Memory… Memória… Merge Clips… Mesclar clipes… Metadata Metadados @@ -352,7 +366,3200 @@ Zoom In Ampliar Zoom Out Reduzir Zoom to Sequence Zoom para a sequência + Home Início + Import Importar + {name} - Edited {name} - Editado + Full screen Tela cheia + Volume Volume + Master volume: use the Audio Track Mixer Volume principal: use o misturador de faixas de áudio + Search Pesquisar + Quick Export Exportação rápida + Join the ArtCraft Discord (discord.gg/artcraft) Entre no Discord do ArtCraft (discord.gg/artcraft) + Cancel Cancelar + OK OK + Interpret Footage Interpretar clipes + Color Management · {n} item Gerenciamento de cores · {n} item + Color Management · {n} items Gerenciamento de cores · {n} itens + Media colour space (from file metadata): {detected} Espaço de cores da mídia (dos metadados do arquivo): {detected} + Override media colour space: Substituir o espaço de cores da mídia: + Use file metadata Usar os metadados do arquivo + Sequence Color Management Gerenciamento de cores da sequência + Working Color Space Espaço de cores de trabalho + Wide gamut color (composite in BT.2020) Cor de gama ampla (compor em BT.2020) + Auto tone map media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Audio Gain Ganho de áudio + Set Gain to: Definir ganho como: + Adjust Gain by: Ajustar ganho em: + Normalize Max Peak to: Normalizar pico máximo para: + Normalize All Peaks to: Normalizar todos os picos para: + Peak Amplitude: {peak} Amplitude de pico: {peak} + Standard Padrão + Adaptive Adaptável + Mono Mono + Stereo Estéreo + Add Tracks Adicionar faixas + Amount Quantidade + Placement Posicionamento + Track type Tipo de faixa + Add video tracks Adicionar faixas de vídeo + Add audio tracks Adicionar faixas de áudio + Add audio submix tracks Adicionar faixas de submixagem de áudio + After {name} Depois de {name} + Before First Track Antes da primeira faixa + Delete Tracks Excluir faixas + Video Tracks Faixas de vídeo + Audio Tracks Faixas de áudio + Delete Video Tracks Excluir faixas de vídeo + Delete Audio Tracks Excluir faixas de áudio + All Empty Tracks Todas as faixas vazias + Caption Tracks Faixas de legendas + Delete Caption Tracks Excluir faixas de legendas + Delete Caption Track Excluir faixa de legendas + Delete Empty Caption Tracks Excluir faixas de legendas vazias + Version {version} Versão {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Um editor de vídeo não linear em Rust puro, desenvolvido em sala limpa. Parte da família ArtCraft. + Join the ArtCraft Discord Entre no Discord do ArtCraft + Source code on GitHub Código-fonte no GitHub + Report an issue Relatar um problema + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Fontes: Inter, Noto Serif e JetBrains Mono (SIL OFL 1.1). + just now agora mesmo + {n} s ago há {n} s + {n} min ago há {n} min + {n} h ago há {n} h + All Events Todos os eventos + Warnings and Errors Avisos e erros + Errors Only Somente erros + Clear All Limpar tudo + No events. Nenhum evento. + Show finished jobs Mostrar tarefas concluídas + Cancelled Cancelada + Failed: {e} Falha: {e} + Done Concluída + No background jobs. Nenhuma tarefa em segundo plano. + Recover Unsaved Changes Recuperar alterações não salvas + Untitled Sem título + FilmCraft was closed while “{name}” had unsaved changes. O FilmCraft foi fechado enquanto “{name}” tinha alterações não salvas. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. O FilmCraft fechou inesperadamente enquanto “{name}” tinha alterações não salvas. + Recover unsaved changes from {when}? Recuperar as alterações não salvas de {when}? + The project had not been saved yet. O projeto ainda não tinha sido salvo. + {n} sessions have unsaved changes: {n} sessões têm alterações não salvas: + Discard Descartar + Recover Recuperar + Not Now Agora não + Are you sure you want to discard your changes to '{file}'? Tem certeza de que deseja descartar as alterações em '{file}'? + Yes Sim + No Não + Add media to your project. Drop files anywhere in the window, or choose a source below. Adicione mídia ao projeto. Solte arquivos em qualquer lugar da janela ou escolha uma origem abaixo. + Browse files… Procurar arquivos… + Movies, audio and stills from disk Filmes, áudio e imagens estáticas do disco + Demo footage Clipes de demonstração + Six procedural 1080p clips with sound Seis clipes procedurais 1080p com som + Demo project Projeto de demonstração + A cut sequence with transitions, effects and music Uma sequência editada com transições, efeitos e música + Bars and Tone Barras e tom + SMPTE HD bars with 1 kHz reference tone Barras SMPTE HD com tom de referência de 1 kHz + Community Comunidade + Join us on Discord Junte-se a nós no Discord + OMF Export Settings Configurações de exportação OMF + AAF Export Settings Configurações de exportação AAF + OMF Title: Título OMF: + Mixdown video Mixar vídeo + Audio: Áudio: + Encapsulate Encapsular + Separate Audio Áudio separado + Embed Incorporar + Separate Files Arquivos separados + Link to Media Vincular à mídia + Audio File Format: Formato de arquivo de áudio: + Sample Rate: Taxa de amostragem: + Bits per Sample: Bits por amostra: + Rendering: Renderização: + Trim audio files Aparar arquivos de áudio + Handle Frames: Quadros de margem: + Render audio clip effects Renderizar efeitos de clipes de áudio + Breakout to mono Separar em mono + Exported {path} Exportado: {path} + Select a clip to view its metadata. Selecione um clipe para ver seus metadados. + Clip: {name} Clipe: {name} + Open Abrir + (no sequence) (nenhuma sequência) + Show or hide {color} markers Mostrar ou ocultar os marcadores da cor {color} + Marker Marcador + {tc} Chapter {tc} Capítulo + No markers. Press M to add one. Nenhum marcador. Pressione M para adicionar um. + All markers are hidden by the colour filter. O filtro de cor oculta todos os marcadores. + Type: Tipo: + Video: Vídeo: + Codec: Codec: + Duration: Duração: + Settings: Configurações: + Playhead: Cursor de reprodução: + Video {n}: Vídeo {n}: + Audio {n}: Áudio {n}: + UI: Interface: + {fps} fps · {n} frames queued {fps} fps · {n} quadros na fila + {panel} — coming in a later milestone {panel}: disponível em uma versão futura + Close Panel Fechar painel + Close Other Timeline Panels Fechar os outros painéis de linha do tempo + Close All Fechar tudo + Maximize Frame Maximizar quadro do painel + Restore Frame Restaurar quadro do painel + Restore Workspace Restaurar área de trabalho + Reveal Sequence in Project Mostrar sequência no projeto + Video Thumbnails Miniaturas de vídeo + Audio Waveforms Formas de onda de áudio + Delete Preset Excluir predefinição + Export Preset… Exportar predefinição… + Import Presets… Importar predefinições… + Effect presets Predefinições de efeitos + Save Preset Salvar predefinição + Name: Nome: + Type Tipo + Scale Escala + Anchor to In Point Ancorar no ponto de entrada + Anchor to Out Point Ancorar no ponto de saída + Without keyframes Sem quadros-chave + Description: Descrição: + Saved preset “{name}” Predefinição “{name}” salva + Current duration {seconds} s Duração atual: {seconds} s + Remix Properties Propriedades da remixagem + Target Duration: Duração desejada: + Segments: Segmentos: + Fewer · More Menos · Mais + Variations: Variações: + Remixed to {seconds} s Remixado para {seconds} s + {mode} · no clip {mode} · nenhum clipe + {mode} · no sequence {mode} · nenhuma sequência + Remove Row Remover linha + Add a timecode row Adicionar uma linha de timecode + Panner Panorâmico + Mute Silenciar + Send {n} Level Nível do envio {n} + Show Keyframes Mostrar quadros-chave + Clip Keyframes Quadros-chave do clipe + Track Keyframes Quadros-chave da faixa + Delete Excluir + Untitled Workspace Área de trabalho sem título + New Workspace Nova área de trabalho + Edit Workspaces Editar áreas de trabalho + Rename Renomear + Restore Original Restaurar original + Search effects Pesquisar efeitos + Apply to Selected Clips Aplicar aos clipes selecionados + Metadata Display Exibição de metadados + Save As New View Preset Salvar como nova predefinição de visualização + Manage Saved View Presets Gerenciar predefinições de visualização salvas + Freeform View Options Opções da visualização de forma livre + Save Arrangement Salvar disposição + Saves the view, columns, sort, thumbnail and font size. Salva a visualização, as colunas, a ordenação, a miniatura e o tamanho da fonte. + Grid spacing: Espaçamento da grade: + Default clip size: Tamanho padrão do clipe: + Snap to grid Encaixar na grade + Show clip names Mostrar nomes dos clipes + Show durations Mostrar durações + Show: Mostrar: + Search fields Pesquisar campos + Project Metadata Metadados do projeto + Add Property… Adicionar propriedade… + Column order Ordem das colunas + {n}. (empty) {n}. (vazio) + Restore Restaurar + Overwrite with Current View Sobrescrever com a visualização atual + Graphics text search arrives with M10.1–M10.2 A pesquisa de texto em gráficos chega com M10.1–M10.2 + Open a sequence to work with captions Abra uma sequência para trabalhar com legendas + Add captions Adicionar legendas + Create a caption track or import a caption file. Crie uma faixa de legendas ou importe um arquivo de legendas. + Create new caption track Criar nova faixa de legendas + Import captions file… Importar arquivo de legendas… + New {format} track Nova faixa {format} + Add caption at playhead Adicionar legenda no cursor de reprodução + Split caption at playhead Dividir legenda no cursor de reprodução + Merge selected captions Mesclar legendas selecionadas + Delete selected captions Excluir legendas selecionadas + No captions on this track. Press + to add one at the playhead. Nenhuma legenda nesta faixa. Pressione + para adicionar uma no cursor de reprodução. + No matching captions. Nenhuma legenda correspondente. + Open a sequence to see its transcript Abra uma sequência para ver sua transcrição + Transcribe sequence Transcrever sequência + Speech-to-text turns the dialogue into editable text. A conversão de fala em texto transforma o diálogo em texto editável. + This build has no speech-to-text; import a transcript with transcript.set. Esta compilação não inclui conversão de fala em texto; importe uma transcrição com transcript.set. + Transcribe Transcrever + Extract selected text Extrair texto selecionado + Lift selected text Levantar texto selecionado + Remove filler words Remover muletas + Create captions Criar legendas + Speaker Locutor + Box Caixa + Align Alinhar + Align left Alinhar à esquerda + Align center Centralizar + Align right Alinhar à direita + Position Posição + Position: top Posição: superior + Position: middle Posição: meio + Position: bottom Posição: inferior + (gap) (lacuna) + Out: {name} Saída: {name} + In: {name} Entrada: {name} + Play Around Edit (Space loops, Shift+K once) Reproduzir ao redor da edição (Espaço em loop, Shift+K uma vez) + Trim Backward Many Aparar muitos para trás + Trim Backward Aparar para trás + Apply Default Transitions Aplicar transições padrão + Apply Default Transitions to Selection (Shift+D) Aplicar transições padrão à seleção (Shift+D) + Trim Forward Aparar para a frente + Trim Forward Many Aparar muitos para a frente + Exit Trim Mode Sair do modo de aparagem + Voice-over record (right-click: Voice-Over Record Settings…) Gravar narração (clique direito: Configurações de gravação de narração…) + Voice-Over Record Settings… Configurações de gravação de narração… + Recording voice-over… Gravando narração… + Voice-over recorded: {p} Narração gravada: {p} + Voice-over recording stopped (nothing recorded) Gravação de narração interrompida (nada foi gravado) + Post-roll Pós-roll + ● Recording ● Gravando + Voice-Over Record Settings Configurações de gravação de narração + Source: Origem: + Default Input Entrada padrão + Input: Entrada: + Channel {n} Canal {n} + Countdown Sound Cues Bipes de contagem regressiva + Pre-roll: Pré-roll: + seconds segundos + Post-roll: Pós-roll: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. A reprodução começa com o pré-roll antes do cursor de reprodução (ou do ponto de entrada); com entrada e saída marcadas, a gravação entra e sai nesses pontos. + Effect Efeito + Clip Fx Editor Editor de efeitos do clipe + Track Fx Editor Editor de efeitos da faixa + Master Gain Ganho principal + Reset Redefinir + Band {n} Banda {n} + Solo Solo + Bypass Ignorar + Thr Limiar + Ratio Razão + Att Ataque + Rel Liberação + Gain Ganho + Output Gain Ganho de saída + Limiter Limitador + Link Channels Vincular canais + Limiter Threshold Limiar do limitador + Limiter Release Liberação do limitador + Auto Gate Gate automático + Threshold Limiar + Attack Ataque + Release Liberação + Hold Retenção + Compressor Compressor + Auto Makeup Compensação automática + Makeup Compensação + Expander Expansor + Soft Clip Clipping suave + (no sequences) (nenhuma sequência) + (no clips) (nenhum clipe) + Show Transport Controls Mostrar controles de transporte + Loop Loop + Multi-Camera Audio Follows Video Áudio multicâmera segue o vídeo + Multi-Camera Record Gravação multicâmera + Multi-Camera Selection Top Down Seleção multicâmera de cima para baixo + Show Multi-Camera Preview Monitor Mostrar monitor de pré-visualização multicâmera + Auto-Adjust Multi-Camera Playback Quality Ajustar automaticamente a qualidade de reprodução multicâmera + Transmit Multi-Camera View Transmitir visualização multicâmera + Multi-Camera Layout Layout multicâmera + Automatic Automático + Edit Cameras… Editar câmeras… + Add Marker (M) Adicionar marcador (M) + Mark In (I) Marcar entrada (I) + Mark Out (O) Marcar saída (O) + Go to In (Shift+I) Ir para a entrada (Shift+I) + Step Back 1 Frame (Left) Voltar 1 quadro (Esquerda) + Play-Stop Toggle (Space) Reproduzir/parar (Espaço) + Step Forward 1 Frame (Right) Avançar 1 quadro (Direita) + Go to Out (Shift+O) Ir para a saída (Shift+O) + Insert (,) Inserir (,) + Overwrite (.) Sobrescrever (.) + Export Frame (Shift+E) Exportar quadro (Shift+E) + Lift (;) Levantar (;) + Extract (') Extrair (') + Export Frame: use Export mode (M6) Exportar quadro: use o modo Exportar (M6) + Button Editor Editor de botões + Multi-Camera Record on Gravação multicâmera ativada + Multi-Camera Record off Gravação multicâmera desativada + {first} - Merged {first} - Mesclado + {first} Multicam {first} Multicâmera + In Points Pontos de entrada + Out Points Pontos de saída + Clip Marker Marcador de clipe + Audio Áudio + Merge Clips Mesclar clipes + Create Multi-Camera Source Sequence Criar sequência de origem multicâmera + Synchronize Clips Sincronizar clipes + Clip Name: Nome do clipe: + Sequence Name: Nome da sequência: + {n} clip(s): {names} {n} clipe(s): {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clipe(s) selecionado(s); o clipe da faixa de referência não se move. + Synchronize Point Ponto de sincronização + Ignore Hours Ignorar horas + first marker primeiro marcador + Offset Audio by: Deslocar o áudio em: + Offset: Deslocamento: + frames quadros + Reference track: Faixa de referência: + lowest (V1…) a mais baixa (V1…) + Remove Audio from AV Clip Remover o áudio do clipe AV + Camera 1 Câmera 1 + All Cameras Todas as câmeras + Switch Audio Alternar áudio + Camera Names Nomes das câmeras + Clip Names Nomes dos clipes + Enumerate Cameras Numerar câmeras + Camera Angle Metadata Metadados de ângulo de câmera + Move source clips to Processed Clips bin Mover os clipes de origem para a pasta Clipes processados + Synchronized {n} clip group(s) {n} grupo(s) de clipes sincronizado(s) + Merged clip created Clipe mesclado criado + Multi-camera source sequence with {n} camera(s) Sequência de origem multicâmera com {n} câmera(s) + No multi-camera clip at the playhead Nenhum clipe multicâmera no cursor de reprodução + Camera {n} — {name} Câmera {n} — {name} + Page {page} of {pages} Página {page} de {pages} + Previous page Página anterior + Next page Próxima página + Multi-Camera Record On/Off (0) Gravação multicâmera ativada/desativada (0) + Record Gravar + Edit Cameras Editar câmeras + Cameras shown in the Multi-Camera view (uncheck to hide). Câmeras mostradas na visualização multicâmera (desmarque para ocultar). + Recorded {n} multi-camera cut(s) {n} corte(s) multicâmera gravado(s) + Clamp Signal Limitar sinal + Presets Predefinições + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Forma de onda ({kind}) + Parade Type Tipo de parade + Waveform Type Tipo de forma de onda + Vectorscope Targets Alvos do vetorscópio + Colour Space Espaço de cores + Brightness Brilho + Signal Scale Escala do sinal + Histogram Histograma + Paste Attributes Colar atributos + Remove Attributes Remover atributos + New Offline File Novo arquivo offline + Nested Sequence Name Nome da sequência aninhada + Make Subclip Criar subclipe + Edit Subclip Editar subclipe + Modify Clip: Audio Channels Modificar clipe: canais de áudio + Modify Clip: Timecode Modificar clipe: timecode + Frame Hold Options Opções de quadro congelado + Field Options Opções de campo + Save Project Salvar projeto + Motion Movimento + Opacity Opacidade + Time Remapping Remapeamento de tempo + Channel Volume Volume do canal + In Point Ponto de entrada + Out Point Ponto de saída + Playhead Cursor de reprodução + Source Timecode Timecode de origem + Sequence Time Tempo da sequência + Video Attributes Atributos de vídeo + Audio Attributes Atributos de áudio + No effects Nenhum efeito + Scale Attribute Times Escalar os tempos dos atributos + File Name: Nome do arquivo: + Tape Name: Nome da fita: + Contains: Contém: + Video Vídeo + Width: Largura: + Height: Altura: + Frame Rate: Taxa de quadros: + Media Start: Início da mídia: + Subclip: {name} Subclipe: {name} + Start: Início: + End: Fim: + Restrict Trims To Subclip Boundaries Restringir aparagens aos limites do subclipe + Convert to Master Clip Converter em clipe mestre + Clip Channel Format: Formato de canais do clipe: + Number of Audio Clips: Número de clipes de áudio: + Media Source Channels Canais de origem da mídia + Ch. {n} Can. {n} + Clip {n} Clipe {n} + Source channels the selected audio clips play: Canais de origem que os clipes de áudio selecionados reproduzem: + Set Timecode: Definir timecode: + Use the file's timecode Usar o timecode do arquivo + Hold On Congelar em + Timecode: Timecode: + Hold Filters Congelar filtros + Reverse Field Dominance Inverter dominância de campo + Processing Options Opções de processamento + None Nenhum + Always Deinterlace Sempre desentrelaçar + Flicker Removal Remover cintilação + Save changes to “{project_name}” before closing? Salvar as alterações em “{project_name}” antes de fechar? + Don't Save Não salvar + All media is online. Toda a mídia está online. + {name} (copy) {name} (cópia) + Offline Offline + Missing Ausente + Link Media Vincular mídia + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clipe(s) não encontram sua mídia. Localize-a, pesquise uma pasta ou deixe-os offline. + Clip Name Nome do clipe + File Name Nome do arquivo + File Path Caminho do arquivo + Status Status + Match file properties: Propriedades de arquivo que devem corresponder: + File Extension Extensão do arquivo + Clip ID (fingerprint) ID do clipe (impressão digital) + Duration Duração + Media Start Início da mídia + Frame Size / Rate Tamanho e taxa de quadros + Align Timecode Alinhar timecode + Relink others automatically Revincular os demais automaticamente + Search in: Pesquisar em: + Browse… Procurar… + Exact name matches only Somente correspondências exatas de nome + No candidates yet — Search a folder or Locate the file. Ainda não há candidatos: pesquise uma pasta ou localize o arquivo. + ✔ same file ✔ mesmo arquivo + ✔ matches ✔ corresponde + ✖ different file ✖ arquivo diferente + Preview Pré-visualização + Offline All Tudo offline + Locate… Localizar… + Linked {n} clip(s) {n} clipe(s) vinculado(s) + Nothing found with that name. Nada encontrado com esse nome. + Make Offline Tornar offline + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Tornar {n} clipe(s) selecionado(s) offline. Os clipes mostrarão a claquete offline até que sejam vinculados novamente. + Media files remain on disk Os arquivos de mídia permanecem no disco + Media files are deleted Os arquivos de mídia são excluídos + Create Proxies Criar proxies + {n} clip(s) selected. {n} clipe(s) selecionado(s). + Format and size Formato e tamanho + Destination Destino + Next to the original media, in a Proxies folder Ao lado da mídia original, em uma pasta Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Os proxies são criados em segundo plano e anexados ao terminar. A exportação sempre usa a mídia em resolução total. + Creating {n} proxy file(s)… Criando {n} arquivo(s) proxy… + Project Manager Gerenciador de projetos + Sequences Sequências + Resulting Project Projeto resultante + Collect Files and Copy to New Location Coletar arquivos e copiar para um novo local + Consolidate and Transcode Consolidar e transcodificar + Options Opções + Exclude Unused Clips Omitir clipes não usados + Include Handles: Incluir margens: + Include Proxies Incluir proxies + Include Preview Files Incluir arquivos de pré-visualização + Destination Path Caminho de destino + Disk space: original {original} · resulting {result} ({n} files) Espaço em disco: original {original} · resultante {result} ({n} arquivos) + Disk space: — Espaço em disco: — + Calculate Calcular + Project Manager: writing {path} Gerenciador de projetos: gravando {path} + Timeline: (no sequences) Linha do tempo: (nenhuma sequência) + Browse Procurar + Select audio clips, then click a preset to apply it. Selecione clipes de áudio e clique em uma predefinição para aplicá-la. + {name} (user) {name} (usuário) + Select audio clips to edit them here Selecione clipes de áudio para editá-los aqui + Multiple Clips Selected Vários clipes selecionados + The selected clips have different audio types. Assign one type to edit them together. Os clipes selecionados têm tipos de áudio diferentes. Atribua um tipo para editá-los juntos. + Assign an audio type to the selection: Atribua um tipo de áudio à seleção: + Clear Audio Type Limpar tipo de áudio + Preset: Predefinição: + (Custom) (Personalizado) + Preset name Nome da predefinição + Reduce Noise Reduzir ruído + Reduce Rumble Reduzir ronco + DeHum Remover zumbido + DeEss Remover sibilância + Reduce Reverb Reduzir reverberação + Dynamics Dinâmica + EQ Equalizador + Enhance Speech Aprimorar fala + Low Tone Tom grave + High Tone Tom agudo + Mix Mixagem + Reverb Reverberação + Stereo Width Largura estéreo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Ainda não é possível remixar a música para uma duração desejada. Apare o clipe e adicione uma transição cruzada. + Pan Pan + Auto-Match Correspondência automática + Not matched yet Ainda sem correspondência + Matches each of the {n} clips on its own. Faz a correspondência de cada um dos {n} clipes separadamente. + Duck against: Atenuar em relação a: + Untagged Sem marcação + Sensitivity Sensibilidade + Reduce By Reduzir em + Fades Fades + Generate Keyframes Gerar quadros-chave + Clip Volume Volume do clipe + (no clip selected) (nenhum clipe selecionado) + Master · {name} Mestre · {name} + Add Lumetri Color to clip Adicionar Cor Lumetri ao clipe + Input LUT LUT de entrada + Color Cor + Temperature Temperatura + Tint Tonalidade + Saturation Saturação + Exposure Exposição + Contrast Contraste + Highlights Realces + Shadows Sombras + Whites Brancos + Blacks Pretos + HDR HDR + HDR White Branco HDR + HDR Specular Especular HDR + Look Visual + Look LUT LUT de visual + Intensity Intensidade + Adjustments Ajustes + Faded Film Filme desbotado + Sharpen Nitidez + Vibrance Vivacidade + RGB Curves Curvas RGB + HDR Range Faixa HDR + Hue Saturation Curves Curvas de matiz e saturação + Hue vs Sat Matiz x saturação + Hue vs Hue Matiz x matiz + Hue vs Luma Matiz x luminância + Luma vs Sat Luminância x saturação + Sat vs Sat Saturação x saturação + Midtones Meios-tons + Key Chave + Hue Matiz + Hue Range Intervalo de matiz + Saturation Min Saturação mínima + Luma Min Luminância mínima + Luma Max Luminância máxima + Soften Suavizar + Show Mask Mostrar máscara + Off Desativado + Color/Gray Cor/cinza + Color/Black Cor/preto + White/Black Branco/preto + Refine Refinar + Denoise Reduzir ruído + Blur Desfoque + Correction Correção + Hue Shift Deslocamento de matiz + Midpoint Ponto médio + Roundness Arredondamento + Feather Difusão + LUT LUT + Color Match Correspondência de cores + Reference Referência + Face Detection (protect skin tones) Detecção de rostos (protege os tons de pele) + Apply Match Aplicar correspondência + Create ellipse mask Criar máscara de elipse + Create 4-point polygon mask Criar máscara de polígono de 4 pontos + Free draw bezier Bézier à mão livre + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Clique no monitor do programa para posicionar pontos de máscara (arraste para curvas); clique no primeiro ponto ou pressione Enter para fechar + Delete Mask Excluir máscara + Inverted Invertida + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Arrastar: mover · Alt+clique: suave/canto · Cmd+clique: excluir + Track selected mask backward Rastrear a máscara selecionada para trás + Track selected mask backward 1 frame Rastrear a máscara selecionada 1 quadro para trás + Track selected mask forward 1 frame Rastrear a máscara selecionada 1 quadro para a frente + Track selected mask forward Rastrear a máscara selecionada para a frente + Stop tracking Parar rastreamento + Tracking method: {method} Método de rastreamento: {method} + Composite Composto + Comparison Comparação + Video and Waveform Vídeo e forma de onda + System Default Padrão do sistema + Default location Local padrão + Font family Família de fontes + Label Colors Cores dos rótulos + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Quadros decodificados mantidos para os monitores e miniaturas: {used} de {budget}. + FilmCraft keeps the rest of the RAM free for other applications. O FilmCraft deixa o restante da RAM livre para outros aplicativos. + not available in this session indisponível nesta sessão + Currently: {loc} — {files} file(s), {size} Atualmente: {loc} — {files} arquivo(s), {size} + ({ch} output channels) ({ch} canais de saída) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programa esquerdo → saída {l} do dispositivo, direito → saída {r}{dev}. Saídas além dos canais do dispositivo voltam para 1 e 2. + Auto-saves go to: {dir} Os salvamentos automáticos vão para: {dir} + Recovery copy: {dir} Cópia de recuperação: {dir} + Last auto-save {a} · last recovery copy {j} Último salvamento automático {a} · última cópia de recuperação {j} + Last auto-save {a} Último salvamento automático {a} + Last recovery copy {j} Última cópia de recuperação {j} + Remove Media Cache Files Remover arquivos de cache de mídia + Deleted {n} file(s), {size}. {n} arquivo(s) excluído(s), {size}. + Reset… Redefinir… + Source · {name} Origem · {name} + Reset Effect Redefinir efeito + Reset Parameter Redefinir parâmetro + Save Preset… Salvar predefinição… + Custom Setup Configuração personalizada + Edit… Editar… + Show graphs Mostrar gráficos + Toggle animation Ativar/desativar animação + Eyedropper (pick a color from the Program monitor) Conta-gotas (capturar uma cor do monitor do programa) + Eyedropper: no picture to sample Conta-gotas: nenhuma imagem para amostrar + Add/Remove Keyframe Adicionar/remover quadro-chave + Temporal Interpolation Interpolação temporal + Linear Linear + Bezier Bézier + Auto Bezier Bézier automático + Continuous Bezier Bézier contínuo + Ease In Suavizar entrada + Ease Out Suavizar saída + Select a clip to grade Selecione um clipe para fazer a correção de cor + Select a clip to see its properties Selecione um clipe para ver suas propriedades + Anchor point Ponto de ancoragem + Rotation Rotação + Level Nível + Speed {n}% Velocidade {n}% + Value Valor + Velocity Velocidade + Default Padrão + Custom ({n} points) Personalizada ({n} pontos) + Select a graphic clip, or use the Type tool (T) on the Program monitor Selecione um clipe de gráfico ou use a ferramenta Texto (T) no monitor do programa + Create new graphic Criar novo gráfico + New Text Layer Nova camada de texto + New Text Novo texto + New Rectangle Novo retângulo + New Ellipse Nova elipse + Delete Layer Excluir camada + Select a layer to edit its properties. Selecione uma camada para editar suas propriedades. + Align Left Alinhar à esquerda + Align Center Horizontally Centralizar horizontalmente + Align Right Alinhar à direita + Align Top Alinhar acima + Align Center Vertically Centralizar verticalmente + Align Bottom Alinhar abaixo + Font Fonte + Font Style Estilo da fonte + Font Size Tamanho da fonte + Alignment Alinhamento + Left Align Text Alinhar texto à esquerda + Center Align Text Centralizar texto + Right Align Text Alinhar texto à direita + Justify Justificar + Tracking Espaçamento entre letras + Leading Entrelinha + Baseline Shift Deslocamento da linha de base + Text Box Width Largura da caixa de texto + Style Estilo + Faux Bold Negrito simulado + Faux Italic Itálico simulado + All Caps Tudo em maiúsculas + Small Caps Versaletes + Underline Sublinhado + OpenType OpenType + Kerning Kerning + Ligatures Ligaduras + Shape Forma + Size Tamanho + Corner Radius Raio do canto + Polygon Sides Lados do polígono + Fill Preenchimento + Stroke Traçado + Stroke 2 Traçado 2 + Background Fundo + Opacity Opacidade + Size Tamanho + Corner Radius Raio do canto + Shadow Sombra + Angle Ângulo + Distance Distância + Blur Desfoque + Search templates Pesquisar modelos + All Categories Todas as categorias + Install… Instalar… + Install Motion Graphics Template (.fcgt) Instalar modelo de gráficos de movimento (.fcgt) + No templates match. Nenhum modelo corresponde. + {category} · My Template {category} · Meu modelo + Apply Aplicar + Remove Remover + {n} editable properties {n} propriedades editáveis + Template: {name} Modelo: {name} + Intro Duration Duração da introdução + Outro Duration Duração do encerramento + Roll Rolagem + Start Off Screen Começar fora da tela + End Off Screen Terminar fora da tela + Preroll Pré-roll + Postroll Pós-roll + Video Frame Quadro de vídeo + Pin To Fixar em + Pinned Edges Bordas fixadas + FilmCraft Graphics Template Modelo de gráficos do FilmCraft + {n} Font {n} Fonte + {n} Size {n} Tamanho + {n} Color {n} Cor + {n} Position {n} Posição + Show {n} Mostrar {n} + My Templates Meus modelos + Export As Motion Graphics Template Exportar como modelo de gráficos de movimento + Category: Categoria: + Editable properties: Propriedades editáveis: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Salvo como modelo de gráficos do FilmCraft (.fcgt) na sua pasta de modelos. + Exported graphics template to {path} Modelo de gráficos exportado para {path} + Replace Fonts in Projects Substituir fontes em projetos + Fonts used in the project: Fontes usadas no projeto: + {f} (missing) {f} (ausente) + Replace: Substituir: + Replaced {n} font uses {n} usos de fontes substituídos + Show/Hide Effects and Sends Mostrar/ocultar efeitos e envios + Audio Track Mixer menu Menu do misturador de faixas de áudio + Show/Hide Tracks… Mostrar/ocultar faixas… + Meter Input(s) Only Medir somente entradas + Show/Hide Tracks Mostrar/ocultar faixas + Show All Mostrar tudo + Post-Fader Pós-fader + Remove Effect Remover efeito + Fill the slots above first Preencha primeiro os slots acima + pre pré + Pre-Fader Pré-fader + Mute Send Silenciar envio + Remove Send Remover envio + New Submix Nova submixagem + Pan / balance (drag; double-click: centre) Pan / balanço (arraste; clique duplo: centro) + Solo safe Proteção de solo + Enable track for recording Ativar faixa para gravação + Volume (drag; double-click: 0 dB) Volume (arraste; clique duplo: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panorâmico 5.1 (arraste o ponto; clique duplo: centro frontal) + Center Centro + Go to In Point Ir para o ponto de entrada + Go to Out Point Ir para o ponto de saída + Play In to Out Reproduzir da entrada à saída + Record (voice-over on the record-enabled track) Gravar (narração na faixa ativada para gravação) + Write keyframes Gravar quadros-chave + Something went wrong while drawing the window. Your project is still open; save it to be safe. Ocorreu um erro ao desenhar a janela. Seu projeto continua aberto; salve-o por precaução. + Continue Continuar + Go Back Voltar + Go Forward Avançar + Up One Level Subir um nível + File Types Displayed Tipos de arquivo exibidos + List View Visualização em lista + Thumbnail View Visualização em miniaturas + Ingest Ingestão + Open Ingest Settings Abrir configurações de ingestão + Import as Image Sequence Importar como sequência de imagens + {sel} of {total} items selected {sel} de {total} itens selecionados + {total} items {total} itens + Cannot open {dir}: {e} Não é possível abrir {dir}: {e} + Favorites Favoritos + Local Drives Unidades locais + Network Rede + Recent Directories Pastas recentes + (none) (nenhuma) + Remove from Favorites Remover dos favoritos + Add to Favorites Adicionar aos favoritos + Clear Recent Directories Limpar pastas recentes + Open In Source Monitor Abrir no monitor de origem + Reveal in Finder Mostrar no gerenciador de arquivos + Edit Columns… Editar colunas… + Hover Scrub Reprodução ao passar o mouse + New Media Browser Panel Novo painel de navegador de mídia + Create Folder For Imported Projects Criar pasta para os projetos importados + Allow Duplicate Media During Project Import Permitir mídia duplicada ao importar projetos + Refresh Atualizar + Edit Columns Editar colunas + Guides Guias + (no audio) (sem áudio) + Generating waveform… Gerando forma de onda… + Reference {tc} Referência {tc} + Set Definir + Set the reference to the playhead Definir a referência no cursor de reprodução + Reference: next frame Referência: próximo quadro + Reference: previous frame Referência: quadro anterior + RGBA Channels Canais RGBA + Add Guide Adicionar guia + Save Guides as Template Salvar guias como modelo + Manage Guides Gerenciar guias + Orientation: Orientação: + Vertical Vertical + Horizontal Horizontal + Position: Posição: + No saved guide templates. Nenhum modelo de guias salvo. + Project: {name} Projeto: {name} + Bin: {name} Pasta: {name} + Up one level Subir um nível + Video Usage: {vu} Audio Usage: {au} Uso de vídeo: {vu} Uso de áudio: {au} + Icon View Visualização em ícones + Freeform View Visualização de forma livre + Sort Icons Ordenar ícones + Clear (Delete) Limpar (Delete) + New Item Novo item + New Bin Nova pasta + Find Localizar + Automate to Sequence Automatizar para a sequência + Select items to clear Selecione os itens que deseja limpar + User Order Ordem do usuário + Descending Decrescente + Refresh Project Atualizar projeto + Reveal Project in Finder… Mostrar o projeto no gerenciador de arquivos… + New Bin From Selection Nova pasta a partir da seleção + New Search Bin Nova pasta de pesquisa + New Project Shortcut Novo atalho de projeto + List Lista + Icon Ícone + Freeform Forma livre + Preview Area Área de pré-visualização + Thumbnails Miniaturas + Thumbnails show effects applied As miniaturas mostram os efeitos aplicados + Thumbnail controls for all pointing devices Controles de miniaturas para todos os dispositivos apontadores + Refresh Sort Order Atualizar a ordenação + Metadata Display… Exibição de metadados… + Save Current View Preset Salvar a predefinição de visualização atual + Restore View Preset Restaurar predefinição de visualização + Project View Preset {n} Predefinição de visualização do projeto {n} + Freeform View Options… Opções da visualização de forma livre… + Media missing Mídia ausente + Unreadable Ilegível + Open in Source Monitor Abrir no monitor de origem + Open in Timeline Abrir na linha do tempo + New Sequence From Clip Nova sequência a partir do clipe + Set Poster Frame Definir quadro de pôster + Proxy Proxy + Open in Place Abrir no mesmo lugar + Open in New Tab Abrir em uma nova guia + Open in New Window Abrir em uma nova janela + {n} items {n} itens + Stack Empilhar + Unstack Desempilhar + Clip Size Tamanho do clipe + Larger Maior + Smaller Menor + Align to Grid Alinhar à grade + Reset to Grid Redefinir para a grade + Save Arrangement… Salvar disposição… + Restore Arrangement Restaurar disposição + Delete Arrangement Excluir disposição + Drop media here to create sequence. Solte mídia aqui para criar uma sequência. + Double-click a sequence in the Project panel to open it here. Clique duas vezes em uma sequência no painel Projeto para abri-la aqui. + Open Demo Project Abrir projeto de demonstração + Insert and overwrite sequences as nests or individual clips Inserir e sobrescrever sequências como aninhadas ou como clipes individuais + Snap in Timeline (S) Encaixar na linha do tempo (S) + Caption track options Opções da faixa de legendas + Timeline Display Settings Configurações de exibição da linha do tempo + Show Video Thumbnails Mostrar miniaturas de vídeo + Head Início + Head and Tail Início e fim + Continuous Contínuas + Show Audio Waveform Mostrar forma de onda de áudio + Expand All Tracks Expandir todas as faixas + Minimize All Tracks Minimizar todas as faixas + Unrendered: should play back in real time Não renderizado: deve reproduzir em tempo real + Unrendered: render to play back in real time Não renderizado: renderize para reproduzir em tempo real + Rendered preview Pré-visualização renderizada + ✓ Enable ✓ Ativar + Unlink Desvincular + (Default) (Padrão) + (RGB) Gamma (RGB) Gama + (RGB) Input Black Level (RGB) Nível de preto de entrada + (RGB) Input White Level (RGB) Nível de branco de entrada + (RGB) Output Black Level (RGB) Nível de preto de saída + (RGB) Output White Level (RGB) Nível de branco de saída + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Ponto preto de 10 bits + 10 Bit White Point Ponto branco de 10 bits + 105mm Prime Lente fixa de 105 mm + 29.97 fps Drop-Frame 29,97 fps com drop-frame + 29.97 fps Non Drop-Frame 29,97 fps sem drop-frame + 35mm Prime Lente fixa de 35 mm + 3D Roll Rolagem 3D + 3D Rotate Rotação 3D + 3D Spin Giro 3D + 3D Spinback Giro reverso 3D + 4-Color Gradient Gradiente de 4 cores + 5.1 Media Mídia 5.1 + 5.1 Mixdown Type Tipo de mixagem 5.1 + 50-300mm Zoom Zoom de 50-300 mm + 59.94 fps Drop-Frame 59,94 fps com drop-frame + 59.94 fps Non Drop-Frame 59,94 fps sem drop-frame + ASC CDL ASC CDL + Accessible color contrast Contraste de cores acessível + Action-Safe Margin Margem de segurança de ação + Active Monitor Monitor ativo + Add Adicionar + Add tracks automatically when editing source clips onto the Timeline Adicionar faixas automaticamente ao editar clipes de origem na linha do tempo + Place files dropped onto the Timeline directly on the Timeline Colocar os arquivos soltos na linha do tempo diretamente nela + 1 file 1 arquivo + {n} files {n} arquivos + Additional Scale Escala adicional + Additive Dissolve Dissolução aditiva + Additive Dissolve (Legacy) Dissolução aditiva (legado) + Adjust Ajustar + Advanced Avançado + Aggressive Agressivo + Align and Transform Alinhar e transformar + All lines Todas as linhas + Allow Selection tool to choose Roll and Ripple trims without modifier key Permitir que a ferramenta de seleção escolha aparagens rolantes e em cascata sem tecla modificadora + Allow current tool to change trim type of previously selected edit point Permitir que a ferramenta atual altere o tipo de aparagem do ponto de edição selecionado anteriormente + Allow duplicate media during project import Permitir mídia duplicada ao importar projetos + Alpha Adjust Ajuste alfa + Alpha Blurriness Desfoque alfa + Alpha Channel Canal alfa + Alpha Glow Brilho alfa + Always import embedded CEA-708 closed captions with wide aspect. Sempre importar legendas ocultas CEA-708 incorporadas com proporção larga. + Always override audio tags Sempre substituir as marcações de áudio + Ambience Ambiente + Ambience Intensity Intensidade do ambiente + Ambience Light Color Cor da luz ambiente + Amount of Noise Quantidade de ruído + Amount to Decolor Quantidade de descoloração + Amount to Tint Quantidade de tingimento + Amplifier Amplificador + Amplify Amplificar + Amplitude Amplitude + Amplitude and Compression Amplitude e compressão + Analog Analógico + Analog Delay Delay analógico + Analyze all imported media to search for visuals or audio Analisar toda a mídia importada para pesquisar imagens ou áudio + Anamorphic Anamórfico + Anchor Âncora + Anchor Point Ponto de ancoragem + Angle Ângulo + Animation Animação + Anti-aliasing Quality Qualidade da suavização de serrilhado + Anti-flicker Filter Filtro anticintilação + Antialiasing for Best Quality Suavização de serrilhado para a melhor qualidade + Asymmetry Assimetria + At Startup Na inicialização + At playback end, return to beginning when restarting playback Ao fim da reprodução, voltar ao início ao reiniciá-la + Attempt to force hardware to document sample rate Tentar forçar o hardware para a taxa de amostragem do documento + Audio Codec Codec de áudio + Audio Duration Duração do áudio + Audio Effects Efeitos de áudio + Audio Hardware Hardware de áudio + Audio In Point Ponto de entrada do áudio + Audio Info Informações de áudio + Audio Out Point Ponto de saída do áudio + Audio Samples Amostras de áudio + Audio Time Units Unidades de tempo de áudio + Audio Transition Default Duration Duração padrão das transições de áudio + Audio Transitions Transições de áudio + Audio Usage Uso de áudio + Auto Automático + Auto Align Alinhamento automático + Auto Reframe Reenquadramento automático + Auto Save Salvamento automático + Auto Save also saves the current project(s) O salvamento automático também salva os projetos atuais + Auto-tag audio types in the timeline Marcar automaticamente os tipos de áudio na linha do tempo + Auto-transcribe all imported clips Transcrever automaticamente todos os clipes importados + Auto-transcribe only clips in sequences Transcrever automaticamente somente os clipes de sequências + Automatch Time Tempo de correspondência automática + Automatic Click Remover Removedor automático de cliques + Automatically Hide Dependent Clips Ocultar automaticamente os clipes dependentes + Automatically Save Every Salvar automaticamente a cada + Automatically delete cache files older than Excluir automaticamente os arquivos de cache com mais de + Automatically delete oldest cache files when cache exceeds Excluir automaticamente os arquivos de cache mais antigos quando o cache ultrapassar + Automatically refresh growing files Atualizar automaticamente os arquivos em crescimento + Automatically resume playback for growing files in Source Monitor Retomar automaticamente a reprodução de arquivos em crescimento no monitor de origem + Automatically save projects Salvar projetos automaticamente + Automatically transcribe clips Transcrever clipes automaticamente + Automation Keyframe Optimization Otimização de quadros-chave de automação + Background Color Cor de fundo + Background Corner Radius Raio do canto do fundo + Background Opacity Opacidade do fundo + Background Size Tamanho do fundo + Background fill Preenchimento de fundo + Balance Balanço + Balanced Voice Voz equilibrada + Band 1 Banda 1 + Band 2 Banda 2 + Band 3 Banda 3 + Band 4 Banda 4 + Band 5 Banda 5 + Band Pass Passa-banda + Band Slide Deslizamento de faixas + Band Stop Rejeita-banda + Bandpass Passa-banda + Barn Doors Portas de celeiro + Basic Básico + Basic 3D 3D básico + Basic Correction Correção básica + Bass Graves + Behind Atrás + Bessel Bessel + Bevel Alpha Chanfro alfa + Bevel join Junção chanfrada + Binauralizer - Ambisonics Binauralizador - Ambisonics + Bins Pastas + Black Preto + Black & White Preto e branco + Black Input Level Nível de entrada do preto + Black and white Preto e branco + Black and white with crushed blacks and strong contrast Preto e branco com pretos esmagados e contraste forte + Bleach Bypass Bleach bypass + Bleach-bypass style: desaturated with hard contrast Estilo bleach bypass: dessaturado e com contraste duro + Blend Mesclar + Blend Mode Modo de mesclagem + Blend With Original Mesclar com o original + Blending Mode Modo de mesclagem + Block Dissolve Dissolução em blocos + Block Height Altura do bloco + Block Motion Movimento de blocos + Block Size Tamanho do bloco + Block Width Largura do bloco + Blocks Blocos + Blue Blurriness Desfoque do azul + Blue Curve Curva do azul + Blue Offset Deslocamento do azul + Blue Power Potência do azul + Blue Slope Inclinação do azul + Blue-Blue Azul-azul + Blue-Const Azul-constante + Blue-Green Azul-verde + Blue-Red Azul-vermelho + Blur & Sharpen Desfoque e nitidez + Blur Amount Quantidade de desfoque + Blur Angle Ângulo do desfoque + Blur Dimensions Dimensões do desfoque + Blur Dissolve Dissolução com desfoque + Blur Layer Camada de desfoque + Blur Length Comprimento do desfoque + Blurriness Desfoque + Bokeh Blur Desfoque bokeh + Boost Reforço + Border Borda + Border Color Cor da borda + Border Width Largura da borda + Borders Bordas + Bottom Center Inferior central + Bottom Left Inferior esquerdo + Bottom Right Inferior direito + Bounce Salto + Branching Ramificação + Bright Brilhante + Bright Air Ar brilhante + Bright Studio Estúdio brilhante + Brightness & Contrast Brilho e contraste + British Stack Pilha britânica + Brush Hardness Dureza do pincel + Brush Opacity Opacidade do pincel + Brush Position Posição do pincel + Brush Size Tamanho do pincel + Brush Spacing (secs) Espaçamento do pincel (s) + Brush Strokes Pinceladas + Bubbles Bolhas + Bulge Protuberância + Bulge Smoother Suavização da protuberância + Bump Height Altura do relevo + Burn Alpha Queimar alfa + Burn Chroma Queimar croma + Burn Color Queimar cor + Butt cap Extremidade reta + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Armazenar em cache os resultados da análise para reutilizá-los + Camera Angle Ângulo de câmera + Camera Blur Desfoque de câmera + Camera Shake Tremor de câmera + Capitalisation Uso de maiúsculas + Cathedral Catedral + Cell Pattern Padrão de células + Center Channel Pan Pan do canal central + Center Gradient Gradiente central + Center Input Entrada central + Center Out Saída central + Center Point (Latitude) Ponto central (latitude) + Center Point (Longitude) Ponto central (longitude) + Center Split Divisão central + Center of Sphere Centro da esfera + Cents Cents + Change to Color Alterar para cor + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. As alterações são copiadas em segundo plano logo após cada edição. Se o FilmCraft fechar inesperadamente, elas serão oferecidas na próxima vez que ele for iniciado. + Channel Canal + Channel Blur Desfoque de canal + Channel Mix Mixagem de canais + Channel Mixer Misturador de canais + Chaos Caos + Chebyshev Chebyshev + Checker Wipe Cortina xadrez + Checkerboard Tabuleiro de xadrez + Chinese Chinês + Choke Estreitar + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Croma + Chroma Leak Vazamento de croma + Chromatic Aberration (Blue) Aberração cromática (azul) + Chromatic Aberration (Green) Aberração cromática (verde) + Chromatic Aberration (Red) Aberração cromática (vermelho) + Cineon Converter Conversor Cineon + Circle Círculo + Clarity Clareza + Clean Combo Combo limpo + Clean Up Noisy Dialogue Limpar diálogo com ruído + Clear Muddiness Remover embolamento + Client Cliente + Clip Clipe + Clip Level Nível do clipe + Clipping Clipping + Clock Wipe Cortina de relógio + Clock Wipe (Legacy) Cortina de relógio (legado) + Clockwise Sentido horário + Clone Clonar + Closed Captions Legendas ocultas + Color & Alpha Cor e alfa + Color 1 Cor 1 + Color 2 Cor 2 + Color 3 Cor 3 + Color 4 Cor 4 + Color Balance Equilíbrio de cores + Color Burn Subexposição de cor + Color Channel Canal de cor + Color Correction Correção de cor + Color Distortion Distorção de cor + Color Dodge Superexposição de cor + Color Emboss Relevo colorido + Color Key Chave de cor + Color Pass Passagem de cor + Color Replace Substituição de cor + Color Space Espaço de cores + Color Theme Tema de cores + Color To Leave Cor a manter + Color Tolerance Tolerância de cor + Color Wheels & Match Rodas de cores e correspondência + Colorize Colorir + Columns Colunas + Comment Comentário + Complexity Complexidade + Composite In Back Compor atrás + Composite In Front Compor na frente + Composite Using Compor usando + Composite on Original Compor sobre o original + Compound Blur Desfoque composto + Compression Before Clipping Compressão antes do clipping + Constant Gain Ganho constante + Constant Power Potência constante + Conversion Type Tipo de conversão + Convolution Reverb Reverberação por convolução + Cool Blue Azul frio + Cool, desaturated and punchy Frio, dessaturado e marcante + Core Width Largura do núcleo + Corner Canto + Corner Pin Fixação de cantos + Count Contagem + Counter-clockwise Sentido anti-horário + Crash Recovery Recuperação de falhas + Crawl Left Rastejar para a esquerda + Crawl Right Rastejar para a direita + Create folder for imported projects Criar pasta para os projetos importados + Creative Criativo + Crop Cortar + Crop Less <-> Smooth More Cortar menos <-> Suavizar mais + Cross Displacement Deslocamento cruzado + Cross Dissolve Dissolução cruzada + Cross Dissolve (Legacy) Dissolução cruzada (legado) + Cross Zoom Zoom cruzado + Cross Zoom (Legacy) Zoom cruzado (legado) + Crossfade Transição cruzada + Crossovers Frequências de cruzamento + Crystallize Cristalizar + Crystals Cristais + Cube Spin Giro de cubo + Curl Radius Raio da curvatura + Current Time Tempo atual + Cursor Cursor + Curvature Curvatura + Curve Curva + Curves Curvas + Custom Personalizado + Custom Color Cor personalizada + Cutoff Corte + Cyan-teal shadows and warm highlights with a little more contrast Sombras ciano-esverdeadas e realces quentes com um pouco mais de contraste + Cycle (in Revolutions) Ciclo (em revoluções) + Cycle Evolution Evolução cíclica + Damping Amortecimento + Damping HF Amortecimento de agudos + Damping LF Amortecimento de graves + Dark Escuro + Darken Escurecer + Darker Color Cor mais escura + Date Created Data de criação + Date Modified Data de modificação + Day for night: darker, cooler and less saturated Noite americana: mais escuro, mais frio e menos saturado + DeEsser De-esser + DeHummer Removedor de zumbido + DeNoise Redutor de ruído + DeReverb Redutor de reverberação + Decay Decaimento + Decay Time (RT60) Tempo de decaimento (RT60) + Default Audio Tracks Faixas de áudio padrão + Default Emoji font Fonte de emoji padrão + Default Media Scaling Escala de mídia padrão + Default Output Saída padrão + Default closed caption font Fonte padrão das legendas ocultas + Default language Idioma padrão + Default paragraph direction Direção de parágrafo padrão + Default subtitle and text font Fonte padrão de legendas e texto + Delay Delay + Delay 1 Delay 1 + Delay 2 Delay 2 + Delay 3 Delay 3 + Delay 4 Delay 4 + Delay and Echo Delay e eco + Delete files older than Excluir arquivos com mais de + Delete oldest files when the cache exceeds Excluir os arquivos mais antigos quando o cache ultrapassar + Delete… Excluir… + Depth Profundidade + Desaturate Dessaturar + Description Descrição + Detail Amplitude Amplitude do detalhe + Detail Level Nível de detalhe + Detailed Analysis Análise detalhada + Device Class Classe de dispositivo + Dialogue Diálogo + Diamond Losango + Difference Diferença + Dimensions Dimensões + Dimmed Esmaecido + Dip to Black Fade para preto + Dip to Black (Legacy) Fade para preto (legado) + Dip to Color Fade para cor + Dip to White Fade para branco + Dip to White (Legacy) Fade para branco (legado) + Direction Direção + Directional Direcional + Directional Blur Desfoque direcional + Directional Blur (Legacy) Desfoque direcional (legado) + Disable video output when in the background Desativar a saída de vídeo em segundo plano + Disperse Dispersar + Displacement Deslocamento + Display Color Management Gerenciamento de cores da tela + Display Color Settings Configurações de cor da tela + Display out of sync indicators for unlinked clips Mostrar indicadores de dessincronização em clipes desvinculados + Dissolve Dissolução + Dissolve Blocks Blocos de dissolução + Distance Distância + Distance to Image Distância até a imagem + Distort Distorcer + Distortion Distorção + Distortion Complexity Complexidade da distorção + Distortion Rate Velocidade da distorção + Distortion Type Tipo de distorção + Divide Dividir + Do not delete cache files automatically Não excluir arquivos de cache automaticamente + Don't label speakers Não identificar locutores + Double-click Clique duplo + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Decodificação de rascunho em resolução de reprodução reduzida (H.264: mais rápida, alguns quadros menos filtrados) + Drive Drive + Drop Shadow Sombra projetada + Dry Seco + Dry Out Saída seca + Dry Up a Roomy Voice Secar uma voz com muita sala + Duck Deep Under Dialogue Atenuar bastante sob o diálogo + Duck Under Dialogue Atenuar sob o diálogo + Duck Under Voice and Effects Atenuar sob a voz e os efeitos + Ducking Atenuação automática + Dutch Holandês + Dynamic Link Dynamic Link + Dynamics Processing Processamento de dinâmica + Early Reflections Primeiras reflexões + Earthquake Terremoto + Ease In and Out Suavizar entrada e saída + Easing Suavização + Echo Eco + Echo Glow Brilho de eco + Echo Operator Operador de eco + Echo Time (seconds) Tempo de eco (segundos) + Echoes Ecos + Edge Color Cor da borda + Edge Feather Difusão da borda + Edge Glow Brilho da borda + Edge Sharpness Nitidez da borda + Edge Softness Suavidade da borda + Edge Thickness Espessura da borda + Edge Thin Afinar borda + Edge Threshold Limiar da borda + Edge Type Tipo de borda + Edge Width Largura da borda + Elliptical Elíptico + Emboss Relevo + Enable HSL Secondary Ativar HSL secundário + Enable Mercury Transmit Ativar Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Ativar a codificação ProRes acelerada por hardware, se disponível + Enable clip and XMP metadata linking Ativar a vinculação de metadados de clipe e XMP + Enable developer mode Ativar o modo de desenvolvedor + Enable hardware accelerated decoding (requires restart) Ativar a decodificação acelerada por hardware (requer reinicialização) + Enable language auto-detection Ativar a detecção automática de idioma + Enable proxies Ativar proxies + End Color Cor final + End Point Ponto final + End of Ramp Fim da rampa + Error Erro + Evolution Evolução + Evolution Options Opções de evolução + Exciter Amount Quantidade do excitador + Exciter Mode Modo do excitador + Exclusion Exclusão + Exponential Fade Fade exponencial + Extended Dynamic Range Monitoring Monitoramento de faixa dinâmica estendida + FFT Filter Filtro FFT + Fade Fade + Fade Out Fade de saída + Faded Matte Matte desbotado + Falloff Atenuação + Far Away Distante + Fast Rápido + Faster Motion Movimento mais rápido + Feedback Realimentação + Feedback 1 Realimentação 1 + Feedback 2 Realimentação 2 + Feedback 3 Realimentação 3 + Feedback 4 Realimentação 4 + Feet + Frames 16mm Pés + quadros 16 mm + Feet + Frames 35mm Pés + quadros 35 mm + Female Feminina + File Size Tamanho do arquivo + Fill Background Preencher fundo + Fill Color Cor de preenchimento + Fill Left with Right Preencher esquerda com direita + Fill Point Ponto de preenchimento + Fill Right with Left Preencher direita com esquerda + Fill Selector Seletor de preenchimento + Film Dissolve Dissolução de filme + Film Dissolve (Legacy) Dissolução de filme (legado) + Film Roll Rolo de filme + Filter Filtro + Filter Frequency Frequência do filtro + Filter Resonance Ressonância do filtro + Filter and EQ Filtro e equalizador + Final Delay Delay final + Find Edges Localizar bordas + Flanger Flanger + Flare Reflexo + Flare Brightness Brilho do reflexo + Flare Center Centro do reflexo + Flare Color Cor do reflexo + Flash Flash + Flicker Cintilação + Flickers Cintilações + Flip Motion Movimento de virada + Flip Over Virar + Float Ponto flutuante + Focus Foco + Focus Blur Desfoque de foco + Focus Point Ponto de foco + Focus Shape Forma do foco + Focus Size Tamanho do foco + Fold Motion Movimento de dobra + Foldback Retorno + For color management parameters, go to the Settings tab in the Lumetri Color panel. Para os parâmetros de gerenciamento de cores, vá até a guia Configurações do painel Cor Lumetri. + Format Formato + Four Scopes (RGB, 8 bit) Quatro escopos (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Quatro escopos (YUV, ponto flutuante, sem limitar) + Fractal Influence Influência fractal + Fractal Type Tipo de fractal + Frame Quadro + Frame Color Cor da moldura + Frame Count Contagem de quadros + Frame Layout Layout do quadro + Frame Rate Taxa de quadros + Frame Width Largura da moldura + Frame cache for monitors and thumbnails Cache de quadros para monitores e miniaturas + Frames Quadros + Framing Enquadramento + French Francês + Frequency Frequência + From De + From East Do leste + From North Do norte + From South Do sul + From West Do oeste + From the Left Da esquerda + From the Right Da direita + Front + LFE Frontais + LFE + Front + Rear Frontais + traseiros + Front + Rear + LFE Frontais + traseiros + LFE + Front Only Somente frontais + Fuzz Fuzz + GB GB + Gamma Gama + Gamma Correction Correção de gama + Gamut Warning Aviso de gama de cores + Gamut Warning Color Cor do aviso de gama de cores + Gap Lacuna + Gap Color Cor da lacuna + Gaussian Blur Desfoque gaussiano + Gaussian Blur (Legacy) Desfoque gaussiano (legado) + General Geral + Generate Gerar + Generate Timecode Gerar timecode + Generate waveforms and conform audio using multithreading Gerar formas de onda e conformar o áudio com vários threads + Generate waveforms automatically during import Gerar formas de onda automaticamente durante a importação + German Alemão + Ghosts Fantasmas + Glass Vidro + Glint Cintilar + Glitch Glitch + Glow Brilho difuso + Glow Brightness Intensidade do brilho difuso + Glow Color Cor do brilho difuso + Glow Only Somente brilho difuso + Glow Radius Raio do brilho difuso + Glow Saturation Saturação do brilho difuso + Glow Width Largura do brilho difuso + Golden Hour Hora dourada + Good Boa + Gradient Gradiente + Gradient End Fim do gradiente + Gradient Layer Camada de gradiente + Gradient Placement Posicionamento do gradiente + Gradient Scatter Dispersão do gradiente + Gradient Shape Forma do gradiente + Gradient Start Início do gradiente + Gradient Wipe Cortina com gradiente + Gradient Wipe (Legacy) Cortina com gradiente (legado) + Grain Size Tamanho do grão + Graphic Equalizer (10 Bands) Equalizador gráfico (10 bandas) + Graphic Equalizer (20 Bands) Equalizador gráfico (20 bandas) + Graphic Equalizer (30 Bands) Equalizador gráfico (30 bandas) + Graphics Gráficos + Green Blurriness Desfoque do verde + Green Curve Curva do verde + Green Offset Deslocamento do verde + Green Power Potência do verde + Green Slope Inclinação do verde + Green-Blue Verde-azul + Green-Const Verde-constante + Green-Green Verde-verde + Green-Red Verde-vermelho + Grid Grade + Grow Crescer + Growing Files Arquivos em crescimento + Grunge Grunge + Grunge & Distort Grunge e distorção + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Branco de gráficos HDR (nits) + HLS Vectorscope + Histogram Vetorscópio HLS + histograma + HSL Secondary HSL secundário + Hard Duro + Hard Clip Clipping duro + Hard Light Luz direta + Hard Limiter Limitador rígido + Hard Mix Mistura sólida + Hardware decoding Decodificação por hardware + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Decodificação por hardware: Automático usa o decodificador de vídeo do sistema (VideoToolbox no macOS, Media Foundation no Windows, VA-API ou NVDEC para H.264 e HEVC no Linux) para os fluxos que ele suporta, e o decodificador próprio do FilmCraft para todo o resto ou se o hardware falhar. A mídia que já está aberta mantém seu decodificador até ser reaberta. + Head Size Tamanho da cabeça + Heart Coração + Height Altura + Hexagon Hexágono + Hide Back Face Ocultar face traseira + High Alta + High Cutoff Corte de agudos + High Frequency Cut Corte de altas frequências + High Frequency Damping Amortecimento de altas frequências + High Pass Passa-altas + High Shelf Prateleira de agudos + High Shelf Gain Ganho da prateleira de agudos + High contrast with held highlights and deep shadows Alto contraste com realces contidos e sombras profundas + Highlight Realce + Highlight Blue Balance Balanço de azul nos realces + Highlight Brightness Brilho dos realces + Highlight Color Cor de destaque + Highlight Green Balance Balanço de verde nos realces + Highlight Red Balance Balanço de vermelho nos realces + Highlight Rolloff Atenuação dos realces + Highlight Threshold Limiar dos realces + Highlight Tint Tonalidade dos realces + Highlights Lightness Luminosidade dos realces + Highpass Passa-altas + Hindi Hindi + Hindi digits Dígitos hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alinhamento horizontal + Horizontal Blocks Blocos horizontais + Horizontal Decentering Descentralização horizontal + Horizontal Displacement Deslocamento horizontal + Horizontal Flip Inversão horizontal + Horizontal Margin Margem horizontal + Horizontal and Vertical Horizontal e vertical + Hue Center Centro de matiz + Hue Tolerance Tolerância de matiz + I/O Buffer Size Tamanho do buffer de E/S + Ignore Alpha Ignorar alfa + Image Control Controle de imagem + Immersive Video Vídeo imersivo + Import numbered stills as image sequences Importar imagens estáticas numeradas como sequências de imagens + Impulse Impulso + In a Room Em uma sala + In the Media Cache No cache de mídia + In the project No projeto + In/Out Duration Duração da entrada à saída + Include captions on import Incluir legendas ao importar + Incoming Clipe seguinte + Indeterminate Media Timebase Base de tempo da mídia indeterminada + Initial Delay Delay inicial + Inner Interno + Inner Radius Raio interno + Input Boost Reforço de entrada + Inset Inserção + Inside Dentro + Inside Color Cor interna + Inter Inter + Interior Room Tone Tom de sala interna + Internal Black Point Ponto preto interno + Internal White Point Ponto branco interno + Interpolation Interpolação + Intersect Interseção + Inverse Falloff Atenuação inversa + Invert Inverter + Invert Alpha Inverter alfa + Invert Blur Inverter desfoque + Invert Fill Inverter preenchimento + Invert Gradient Inverter gradiente + Invert Left Inverter esquerda + Invert Right Inverter direita + Iris Íris + Iris Box Íris quadrada + Iris Cross Íris em cruz + Iris Diamond Íris em losango + Iris Radius Raio da íris + Iris Rotation Rotação da íris + Iris Round Íris redonda + Iris Shape Forma da íris + Italian Italiano + Japanese Japonês + JetBrains Mono JetBrains Mono + Join Through Edits Unir edições contíguas + Ripple Trim In Aparagem em cascata da entrada + Ripple Trim Out Aparagem em cascata da saída + Roll Edit Edição rolante + Trim In Aparar entrada + Trim Out Aparar saída + Kaleidoscope Caleidoscópio + Keep a recovery copy of unsaved changes Manter uma cópia de recuperação das alterações não salvas + Key Color Cor-chave + Keying Chaveamento + Korean Coreano + Label Defaults Rótulos padrão + Label speakers Identificar locutores + Labels Rótulos + Large Hall Salão grande + Large Trim Offset Deslocamento de aparagem grande + Large Volume Adjustment Ajuste de volume grande + Latch Latch + Layer Name Nome da camada + Layers Camadas + Leave Color Manter cor + Left - Left Esquerda - esquerda + Left - Right Esquerda - direita + Left channel to device output Canal esquerdo para a saída do dispositivo + Left to Right Da esquerda para a direita + Left to right Da esquerda para a direita + Legacy Legados + Length Comprimento + Lens Blur Desfoque de lente + Lens Distortion Distorção de lente + Lens Flare Reflexo de lente + Lens Type Tipo de lente + Level 1 Nível 1 + Level 2 Nível 2 + Level 3 Nível 3 + Level 4 Nível 4 + Levels Níveis + Lifted blacks, softer contrast, muted colour Pretos elevados, contraste mais suave e cor discreta + Light 1 Luz 1 + Light 2 Luz 2 + Light 3 Luz 3 + Light 4 Luz 4 + Light 5 Luz 5 + Light Angle Ângulo da luz + Light Color Cor da luz + Light Intensity Intensidade da luz + Light Leak Vazamento de luz + Light Leaks Vazamentos de luz + Light Sweep Varredura de luz + Light Type Tipo de luz + Lighten Clarear + Lighter Color Cor mais clara + Lighting Effects Efeitos de iluminação + Lightning Relâmpago + Lights & Blurs Luzes e desfoques + Lights & Glows Luzes e brilhos + Line Thickness Espessura da linha + Linear Burn Subexposição linear + Linear Dodge (Add) Superexposição linear (adicionar) + Linear Light Luz linear + Linear Ramp Rampa linear + Linear Wipe Cortina linear + Linear Wipe (Legacy) Cortina linear (legado) + Linear keyframe thinning Redução linear de quadros-chave + Linear to Log De linear para log + Liquid Distortion Distorção líquida + Local Projects Projetos locais + Location Local + Log Note Nota de registro + Log to Linear De log para linear + Log to Log De log para log + Logo Cutout Recorte de logotipo + Long Shadow Sombra longa + Look-Ahead Time Tempo de antecipação + Loudness Loudness + Loudness Maximizer Maximizador de loudness + Loudness Meter Medidor de loudness + Louver Persiana + Low Baixa + Low Frequency Cut Corte de baixas frequências + Low Pass Passa-baixas + Low Shelf Prateleira de graves + Low Shelf Gain Ganho da prateleira de graves + Lower Left Inferior esquerdo + Lower Right Inferior direito + Lowpass Passa-baixas + Luma Luma + Luma Curve Curva de luma + Luma Fade Fade por luma + Luma Key Chave de luma + Luma Source Origem de luma + Luma and Chroma Luma e croma + Lumetri Presets Predefinições Lumetri + Luminance Luminância + Luminance Threshold Limiar de luminância + Luminosity Luminosidade + MB MB + Magenta Magenta + Magnify Ampliar + Magnify (Legacy) Ampliar (legado) + Maintain pitch while shuttling Manter o tom ao usar o shuttle + Major Radius Raio maior + Male Masculina + Mango Manga + Map Black To Mapear preto para + Map White To Mapear branco para + Maps full-range levels into 16–235 (video range) with a luma curve Converte níveis de faixa completa em 16–235 (faixa de vídeo) com uma curva de luma + Mask Only Somente máscara + Master Amplitude Amplitude principal + Mastering Masterização + Match frame sets in point O quadro correspondente define o ponto de entrada + Matte Matte + Matte Alpha Matte alfa + Matte Cleanup Limpeza do matte + Matte Generation Geração do matte + Matte Luma Matte de luma + Maximum Máximo + Maximum Amplitude Amplitude máxima + Maximum Block Size Tamanho máximo do bloco + Maximum Blur Desfoque máximo + Maximum Project Versions Número máximo de versões do projeto + Maximum Reduction Redução máxima + Maximum Scale Escala máxima + Media Mídia + Media Analysis Análise de mídia + Media Analysis & Transcription Análise e transcrição de mídia + Media Cache Cache de mídia + Media Cache Database Banco de dados do cache de mídia + Media Cache Files Arquivos de cache de mídia + Media Cache Management Gerenciamento do cache de mídia + Media Duration Duração da mídia + Media End Fim da mídia + Media Time Tempo da mídia + Media Timecode Timecode da mídia + Media Type Tipo de mídia + 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. As análises e transcrições de mídia serão armazenadas na pasta do cache de mídia. Não será necessário analisar novamente se a mídia for importada em outro projeto neste computador. + Median Mediana + Medium Room Sala média + Memory Memória + Metadata & Timecode Burn-in Gravação de metadados e timecode na imagem + Method Método + Metrics Kerning Kerning métrico + Mid Médios + Mid Point Ponto médio + Midtone Blue Balance Balanço de azul nos meios-tons + Midtone Green Balance Balanço de verde nos meios-tons + Midtone Red Balance Balanço de vermelho nos meios-tons + Midtones Lightness Luminosidade dos meios-tons + Minimum Mínimo + Minimum time Tempo mínimo + Minimum time interval thinning Redução por intervalo de tempo mínimo + Minor Radius Raio menor + Mirror Espelho + Mirror Alternate Espelho alternado + Missing font replacement Substituição de fontes ausentes + Miter join Junção em meia-esquadria + Miter limit Limite de meia-esquadria + Mixed Crystals Cristais mistos + Mode Modo + Modern High Gain Ganho alto moderno + Modulation Modulação + Modulation Rate Velocidade de modulação + Mono Media Mídia mono + Monochrome Monocromático + Monochrome toned cyan-blue Monocromático virado para azul-ciano + Monochrome toned warm brown Monocromático virado para marrom quente + Monoscopic Monoscópico + Monospace only Somente monoespaçadas + More colour in the less saturated areas Mais cor nas áreas menos saturadas + Morph Cut Corte com morphing + Mosaic Mosaico + Mosaic (Legacy) Mosaico (legado) + Motion Blur Desfoque de movimento + Motion Camera Câmera em movimento + Motion Preset Predefinição de movimento + Motion Tween Interpolação de movimento + Move Mover + Movie Filme + Movie (audio and video) Filme (áudio e vídeo) + Multiband Compressor Compressor multibanda + Multichannel Mono Media Mídia mono multicanal + Multiply Multiplicar + Multitap Delay Delay multitap + Music Música + Mute input during timeline recording Silenciar a entrada durante a gravação na linha do tempo + Name Nome + Narrow Estreito + Neon Color Cor neon + Neon Wipe Cortina neon + Next to original media files Ao lado dos arquivos de mídia originais + Night Noite + No Motion Sem movimento + No Scroll Sem rolagem + No plugins are installed. Changes will take effect the next time you start FilmCraft. Nenhum plug-in instalado. As alterações terão efeito na próxima vez que o FilmCraft for iniciado. + Noise Ruído + Noise & Grain Ruído e granulação + Noise (Legacy) Ruído (legado) + Noise Level Nível de ruído + Noise Reduction/Restoration Redução de ruído/restauração + Noise Type Tipo de ruído + Non-Additive Dissolve Dissolução não aditiva + Normal Normal + Notch 1 Notch 1 + Notch 2 Notch 2 + Notch 3 Notch 3 + Notch 4 Notch 4 + Notch 5 Notch 5 + Notch 6 Notch 6 + Notch Filter Filtro notch + Notch Width Largura do notch + Number Of Echoes Número de ecos + Number of Harmonics Número de harmônicos + Obsolete Obsoletos + Octagon Octógono + Offset Deslocamento + Offset (Turbulence) Deslocamento (turbulência) + Offset From Deslocar de + Offset To Deslocar até + Omni Omni + On Ativado + On Original Image Sobre a imagem original + On Transparent Sobre transparente + Open Air Ar livre + Open Most Recent Abrir o mais recente + Open in new window Abrir em uma nova janela + Open in place Abrir no mesmo lugar + Open new tab Abrir uma nova guia + Opens up the shadows Abre as sombras + Order Ordem + Orientation Orientação + Outer Externo + Outgoing Clipe anterior + Output Saída + Output Mapping Mapeamento de saídas + Outside Fora + Outside Color Cor externa + Overflow Transbordamento + Overlay Sobrepor + Overshoot Ultrapassagem + Page Peel Descascar página + Page Scroll Rolar página + Page Turn Virar página + Paint Bucket Balde de tinta + Paint On Black Pintar sobre preto + Paint On Original Image Pintar sobre a imagem original + Paint On Transparent Pintar sobre transparente + Paint On White Pintar sobre branco + Paint Style Estilo de pintura + Paint Surface Superfície de pintura + Pan (Y Axis) Pan (eixo Y) + Panel Wipe Cortina de painéis + Panels Painéis + Panner - Ambisonics Panorâmico - Ambisonics + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Equalizador paramétrico + Passband Ripple Ondulação da banda passante + Path Caminho + Path Points Pontos do caminho + Pause Media Encoder queue during playback Pausar a fila de codificação durante a reprodução + Peak Frequency Frequência de pico + Peak Gain Ganho de pico + Pedestal Pedestal + Pentagon Pentágono + Per line Por linha + Percent Blur Porcentagem de desfoque + Perspective Perspectiva + Phase Difference Diferença de fase + Phaser Phaser + Phone Call Chamada telefônica + Phosphor Fósforo + Phosphor Color Cor do fósforo + Photocopy Fotocópia + Photocopy Color Cor da fotocópia + Pillow Almofada + Pin All Fixar tudo + Pin Horizontal Fixar horizontal + Pin Light Luz pontual + Pin Vertical Fixar vertical + Pinning Fixação + Pitch Shifter Alterador de tom + Plate Placa + Plateau Wipe Cortina em platôs + Plateaus Platôs + Plates Placas + Play after rendering previews Reproduzir após renderizar as pré-visualizações + Play audio while scrubbing in Source and Program Monitors Reproduzir áudio ao percorrer nos monitores de origem e do programa + Playback Reprodução + Playhead position determines trim monitor loop playback A posição do cursor de reprodução determina a reprodução em loop do monitor de aparagem + Plugins Plug-ins + Podcast Voice Voz de podcast + Point 1 Ponto 1 + Point 1 Color Cor do ponto 1 + Point 1 Latitude Latitude do ponto 1 + Point 1 Longitude Longitude do ponto 1 + Point 2 Ponto 2 + Point 2 Color Cor do ponto 2 + Point 2 Latitude Latitude do ponto 2 + Point 2 Longitude Longitude do ponto 2 + Point 3 Ponto 3 + Point 3 Color Cor do ponto 3 + Point 3 Latitude Latitude do ponto 3 + Point 3 Longitude Longitude do ponto 3 + Point 4 Ponto 4 + Point 5 Ponto 5 + Point 6 Ponto 6 + Point 7 Ponto 7 + Point 8 Ponto 8 + Points Pontos + Policy Política + Pop Motion Movimento de surgimento + Portuguese Português + Position & Rotation Posição e rotação + Position Amount Quantidade de posição + Position, Scale & Rotation Posição, escala e rotação + Position, Scale, Rotation Posição, escala, rotação + Posterize Posterizar + Posterize Time Posterizar tempo + Pre-Delay Pré-delay + Prefix Prefixo + Presence Lift Realce de presença + Preserve Luminosity Preservar luminosidade + Preserve Scale Preservar escala + ProcAmp ProcAmp + Projecting cap Extremidade projetada + Projects Projetos + Pull Motion Movimento de puxar + Pulls highlights and whites down Abaixa os realces e os brancos + Push Empurrar + Push (Legacy) Empurrar (legado) + Q Q + RAM reserved for other applications RAM reservada para outros aplicativos + RGB RGB + RGB Split Divisão RGB + RGB-White RGB-branco + Radial Radial + Radial Blur Desfoque radial + Radial Ramp Rampa radial + Radial Wipe Cortina radial + Radial Wipe (Legacy) Cortina radial (legado) + Radio Announcer Locutor de rádio + Radio Voice Voz de rádio + Radius Raio + Ramp Rampa + Ramp Shape Forma da rampa + Random Aleatório + Random Seed Semente aleatória + Range Intervalo + Ray Raio de luz + Ray Color Cor do raio de luz + Ray Length Comprimento do raio de luz + Rays Raios de luz + Rays Only Somente raios de luz + Read Leitura + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Desfoque do vermelho + Red Curve Curva do vermelho + Red Offset Deslocamento do vermelho + Red Power Potência do vermelho + Red Slope Inclinação do vermelho + Red-Blue Vermelho-azul + Red-Const Vermelho-constante + Red-Green Vermelho-verde + Red-Red Vermelho-vermelho + Reduce Axis Reduzir eixo + Reduce Interlace Flicker Reduzir cintilação de entrelaçamento + Reflected Refletido + Reflection Angle Ângulo de reflexão + Reflection Center Centro de reflexão + Refraction Refração + Reframe Offset Deslocamento do reenquadramento + Reframe Zoom Zoom do reenquadramento + Refresh growing Files Every Atualizar os arquivos em crescimento a cada + Relaxed Relaxado + Release Time Tempo de liberação + Relief Relevo + Remaining Restante + Remove Fringe Remover franja + Render Edit in Audition files to: Renderizar os arquivos editados no Audition em: + Render audio when rendering video Renderizar o áudio ao renderizar o vídeo + Repair Reparar + Repeat Edge Pixels Repetir pixels da borda + Replace Color Substituir cor + Replace With Clip From Source Monitor Substituir pelo clipe do monitor de origem + Replicate Replicar + Responsive Design - Position Design responsivo - posição + Responsive Design - Time Design responsivo - tempo + Restore open sequences when opening projects Restaurar as sequências abertas ao abrir projetos + Result Resultado + Retro Retrô + Reveal Nested Sequence Mostrar sequência aninhada + Reveal Original Image Mostrar a imagem original + Reveal in Project Mostrar no projeto + Reverb Amount Quantidade de reverberação + Reverse Inverter + Right - Left Direita - esquerda + Right - Right Direita - direita + Right channel to device output Canal direito para a saída do dispositivo + Right to left Da direita para a esquerda + Ripple trim adds edits to keep both sides of trim in sync A aparagem em cascata adiciona edições para manter os dois lados da aparagem sincronizados + Roll (Z Axis) Rolagem (eixo Z) + Room Size Tamanho da sala + Rotation Amount Quantidade de rotação + Roughen Rugosidade + Roughen Color Cor rugosa + Roughen Edges Bordas ásperas + Round cap Extremidade arredondada + Round join Junção arredondada + Rounded Crop Corte arredondado + Rows Linhas + Russian Russo + Rusty Enferrujado + Rusty Color Cor enferrujada + SFX Efeitos sonoros + Sample Rate Taxa de amostragem + Saturation at 50 % Saturação em 50 % + Save .cfa and .pek media cache files next to original media files when possible Salvar os arquivos de cache .cfa e .pek ao lado da mídia original quando possível + Scale (Degrees) Escala (graus) + Scale Amount Quantidade de escala + Scale From Escalar de + Scale Height Escalar altura + Scale Match Correspondência de escala + Scale To Escalar para + Scale Width Escalar largura + Scale to frame size Escalar para o tamanho do quadro + Scan Lines Linhas de varredura + Scene Cena + Scientific Filter Filtro científico + Scratch disk location for Captured Audio Local do disco de trabalho para o áudio capturado + Screen Tela + Seconds Segundos + Segments Segmentos + Semi-tones Semitons + Sequence Name Nome da sequência + Sequence Timecode Timecode da sequência + Set focus on the Timeline when performing Insert/Overwrite edits Focar a linha do tempo ao fazer edições de inserção/sobrescrita + Set to frame size Definir para o tamanho do quadro + Setting Configuração + Shadow Angle Ângulo da sombra + Shadow Blue Balance Balanço de azul nas sombras + Shadow Blur Desfoque da sombra + Shadow Color Cor da sombra + Shadow Distance Distância da sombra + Shadow Green Balance Balanço de verde nas sombras + Shadow Only Somente sombra + Shadow Opacity Opacidade da sombra + Shadow Red Balance Balanço de vermelho nas sombras + Shadow Size Tamanho da sombra + Shadow Tint Tonalidade das sombras + Shadows Lightness Luminosidade das sombras + Shake Sacudida + Shape Dissolve Dissolução com forma + Shape Flow Fluxo de formas + Shape Size Tamanho da forma + Shapes Formas + Shard Size Tamanho dos fragmentos + Sharp Colors Cores nítidas + Sharpen Amount Quantidade de nitidez + Shift Center To Deslocar o centro para + Shift clips that overlap trim point during ripple trimming Deslocar os clipes que se sobrepõem ao ponto de aparagem durante a aparagem em cascata + Shot Plano + Show "Project:" and "Bin:" in Project panel tab Mostrar "Projeto:" e "Pasta:" na guia do painel Projeto + Show Clip Mismatch Warning dialog Mostrar o aviso de incompatibilidade de clipe + Show Event Indicator Mostrar o indicador de eventos + Show Focus Area Mostrar área de foco + Show Home Mostrar Início + Show Mask Tracker Preview Mostrar a pré-visualização do rastreamento de máscara + Show Only Noise Mostrar somente o ruído + Show Open Dialog Mostrar a caixa de diálogo Abrir + Show Project Load Error Dialog Mostrar a caixa de diálogo de erro ao carregar projetos + Show Rich Tool Tips Mostrar dicas de ferramentas detalhadas + Show Tool Tips Mostrar dicas de ferramentas + Show Workspace Reset Warning dialog on double click Mostrar o aviso de redefinição da área de trabalho ao clicar duas vezes + Show system compatibility issues at startup Mostrar problemas de compatibilidade do sistema na inicialização + Shrink Encolher + Shutter Angle Ângulo do obturador + Similarity Semelhança + Simple Notch Filter Filtro notch simples + Simple Parametric EQ Equalizador paramétrico simples + Simple Text Texto simples + Single-band Compressor Compressor de banda única + Sinusoidal Senoidal + Skew Inclinação + Skew Axis Eixo de inclinação + Slats Ripas + Slice Fatia + Slices Fatias + Slide Deslizar + Slide (Legacy) Deslizar (legado) + Slope Inclinação + Slow Lento + Slower Motion Movimento mais lento + Small Room Sala pequena + Smart Limit Limite inteligente + Smart Tools Ferramentas inteligentes + Smart quotes Aspas tipográficas + Smooth Suave + Smooth Motion Movimento suave + Smooth Scroll Rolagem suave + Smoothness Suavidade + Snap playhead in Timeline when Snap is enabled Encaixar o cursor de reprodução na linha do tempo quando o encaixe estiver ativado + Soft Suave + Soft Clamp Limitação suave + Soft Edges (Best Quality) Bordas suaves (melhor qualidade) + Soft Light Luz indireta + Soft Wipe Cortina suave + Soften Harshness Suavizar a aspereza + Softness Suavidade + Solarize Solarizar + Solid Colors Cores sólidas + Source Point Ponto de origem + Source Text Texto de origem + Spacer Espaçador + Spanish Espanhol + Speaker Angle Ângulo do alto-falante + Speaker Labeling Identificação de locutores + Special Especial + Special Effects Efeitos especiais + Speech model Modelo de fala + Speed Velocidade + Spherize Esferizar + Spiky Pontiagudo + Spill Contaminação + Spill Suppression Supressão de contaminação + Spin Girar + Spin Direction Direção do giro + Spin Motion Movimento de giro + Split Dividir + Split (Legacy) Dividir (legado) + Spotlight Holofote + Spread Espalhamento + Spring Motion Movimento elástico + Square Quadrado + Square (Width Only) Quadrado (somente largura) + Square 1:1 Quadrado 1:1 + Stabilization Estabilização + Stabilize Only Somente estabilizar + Stabilize, Crop Estabilizar, cortar + Stabilize, Crop, Auto-scale Estabilizar, cortar, escalar automaticamente + Stabilize, Synthesize Edges Estabilizar, sintetizar bordas + Stages Estágios + Star Estrela + Star Wipe Cortina em estrela + Start Angle Ângulo inicial + Start Color Cor inicial + Start Point Ponto inicial + Start at 0 Começar em 0 + Start at 1 Começar em 1 + Start of Ramp Início da rampa + Start with an Empty Project Começar com um projeto vazio + Starting Intensity Intensidade inicial + Static Plates Placas estáticas + Step forward/back many Avançar/voltar muitos + Stereo Expand Expansão estéreo + Stereo Expander Expansor estéreo + Stereo Imagery Imagem estéreo + Stereo Media Mídia estéreo + Stereo Phasing Fase estéreo + Stereoscopic - Over/Under Estereoscópico - acima/abaixo + Stereoscopic - Side by Side Estereoscópico - lado a lado + Still Imagem estática + Still Image Default Duration Duração padrão de imagens estáticas + Stone Church Igreja de pedra + Stopband Attenuation Atenuação da banda de rejeição + Straight Color Cor direta + Strength Intensidade + Stretch Esticar + Stretch Gradient to Fit Esticar gradiente para ajustar + Stretch Map to Fit Esticar mapa para ajustar + Stretch Width or Height Esticar largura ou altura + Stretch Wipe Cortina esticada + Stretch to Fit Frame Esticar para ajustar ao quadro + Stripe Listra + Stripes Listras + Strobe Color Cor do estroboscópio + Strobe Duration (secs) Duração do estroboscópio (s) + Strobe Light Luz estroboscópica + Strobe Period (secs) Período do estroboscópio (s) + Stroke 2 Color Cor do traçado 2 + Stroke 2 Type Tipo do traçado 2 + Stroke 2 Width Largura do traçado 2 + Stroke Angle Ângulo do traçado + Stroke Color Cor do traçado + Stroke Density Densidade do traçado + Stroke Length Comprimento do traçado + Stroke Length (secs) Comprimento do traçado (s) + Stroke Only Somente traçado + Stroke Randomness Aleatoriedade do traçado + Stroke Type Tipo de traçado + Stroke Width Largura do traçado + Stroke line cap Extremidade da linha do traçado + Stroke line join Junção da linha do traçado + Studio Reverb Reverberação de estúdio + Stylize Estilizar + Subclip End Fim do subclipe + Subclip Start Início do subclipe + Subspace Warp Distorção de subespaço + Subtitle Legenda + Subtract Subtrair + Sum multichannel outputs to mono in Source Monitor Somar as saídas multicanal em mono no monitor de origem + Super Narrow Superestreito + Surface Gloss Brilho da superfície + Surface Material Material da superfície + Surround Reverb Reverberação surround + Swap Trocar + Swap Channels Trocar canais + Swivel Girar no eixo + Synthetic Sintético + TV Power Ligar TV + Tape Fita + Tape Name Nome da fita + Tape/Tube Fita/válvula + Target Aspect Proporção de destino + Target Color Cor de destino + Target Field of View Campo de visão de destino + Target Loudness Loudness de destino + Teal & Orange Verde-azulado e laranja + Telephone Telefone + Teletext Teletexto + Template Properties Propriedades do modelo + Text Animator Animador de texto + Text Color Cor do texto + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. O cache de mídia guarda pré-visualizações renderizadas de projetos não salvos e outros arquivos que o FilmCraft pode recriar. Excluí-los é sempre seguro; o FilmCraft os recria quando necessário. + Thickness Espessura + Tile Gradient Gradiente em mosaico + Tilt Inclinação + Tilt (X Axis) Inclinação (eixo X) + Time Tempo + Time and Pitch Tempo e tom + Timecode Conversion Conversão de timecode + Timeline Mouse Scrolling Rolagem com o mouse na linha do tempo + Timeline Playback Auto-Scrolling Rolagem automática da linha do tempo durante a reprodução + Tint Color Cor da tonalidade + To Até + Tolerance Tolerância + Tone Tom + Top Center Superior central + Top Left Superior esquerdo + Top Right Superior direito + Touch Touch + Track Matte Key Chave de matte de faixa + Transcription Transcrição + Transcription preferences Preferências de transcrição + Transform Transformar + Transformers Transformadores + Transience Transientes + Transition Completion Conclusão da transição + Transition Softness Suavidade da transição + Transparency Transparência + Trash Lixeira + Travel Motion Movimento de deslocamento + Treble Agudos + Triangle Triângulo + Trim Aparar + Tube Válvula + Tube-modeled Compressor Compressor com modelagem de válvula + Tubular Tubular + Turbulent Turbulento + Turbulent Displace Deslocamento turbulento + Turbulent Sharp Turbulento nítido + Turbulent Smooth Turbulento suave + Turbulent Smoother Turbulento mais suave + Tweed Tweed + Twirl Redemoinho + Twirl (Legacy) Redemoinho (legado) + Twirl Center Centro do redemoinho + Twirl Radius Raio do redemoinho + Twist Torção + Twist Smoother Suavização da torção + Typewriter Máquina de escrever + Ultra Key Ultra Key + Uniform Scale Escala uniforme + Uniform Spacing Espaçamento uniforme + Unsharp Mask Máscara de nitidez + Update it at least every Atualizá-la pelo menos a cada + Update timeline dynamically during ripple edits. Atualizar a linha do tempo dinamicamente durante as edições em cascata. + Upper Frequency Frequência superior + Upper Left Superior esquerdo + Upper Right Superior direito + Use Color Noise Usar ruído colorido + Use End Color Usar cor final + Use File Usar arquivo + Use Glow Color Usar cor do brilho difuso + Use Media Source Usar a origem da mídia + Use Tint Color Usar cor da tonalidade + Utility Utilitário + VHS Damage Dano de VHS + VR Blur Desfoque VR + VR Chroma Leaks Vazamentos de croma VR + VR Chromatic Aberrations Aberrações cromáticas VR + VR Color Gradients Gradientes de cor VR + VR De-Noise Redução de ruído VR + VR Digital Glitch Glitch digital VR + VR Fractal Noise Ruído fractal VR + VR Glow Brilho difuso VR + VR Gradient Wipe Cortina com gradiente VR + VR Iris Wipe Cortina de íris VR + VR Light Leaks Vazamentos de luz VR + VR Light Rays Raios de luz VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plano para esfera VR + VR Projection Projeção VR + VR Random Blocks Blocos aleatórios VR + VR Rotate Sphere Girar esfera VR + VR Sharpen Nitidez VR + VR Spherical Blur Desfoque esférico VR + Validate Content Credentials on import Validar as credenciais de conteúdo ao importar + Vectorscope (YUV) Vetorscópio (YUV) + Vectorscope + Waveform YC Vetorscópio + forma de onda YC + Vectorscope HLS Vetorscópio HLS + Vectorscope YUV Vetorscópio YUV + Venetian Blinds Persianas venezianas + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alinhamento vertical + Vertical Blocks Blocos verticais + Vertical Decentering Descentralização vertical + Vertical Displacement Deslocamento vertical + Vertical Flip Inversão vertical + Vertical Margin Margem vertical + Very Narrow Muito estreito + Video 1 Vídeo 1 + Video 2 Vídeo 2 + Video 3 Vídeo 3 + Video 4 Vídeo 4 + Video 5 Vídeo 5 + Video 6 Vídeo 6 + Video 7 Vídeo 7 + Video 8 Vídeo 8 + Video Codec Codec de vídeo + Video Duration Duração do vídeo + Video Effects Efeitos de vídeo + Video In Point Ponto de entrada do vídeo + Video Info Informações de vídeo + Video Limiter Limitador de vídeo + Video Out Point Ponto de saída do vídeo + Video Transition Default Duration Duração padrão das transições de vídeo + Video Transitions Transições de vídeo + Video Usage Uso de vídeo + Vignette Vinheta + Vivid Light Luz vívida + Vocal Booth Cabine de locução + Vocal Enhancer Realçador vocal + Voice Voz + Voice in a Hall Voz em um salão + Volumetric Rays Raios volumétricos + Warm Film Filme quente + Warm Room Sala quente + Warm Voice Voz quente + Warm print stock with gentle roll-off Cópia de filme quente com atenuação suave + Warm, soft late-afternoon light Luz quente e suave de fim de tarde + Warning Aviso + Warp Stabilizer Estabilizador de distorção + Wave Onda + Wave Height Altura da onda + Wave Speed Velocidade da onda + Wave Warp Distorção de onda + Wave Width Largura da onda + Waveform Forma de onda + Waveform (Luma) Forma de onda (luma) + Waveform (RGB) Forma de onda (RGB) + Wavelength Comprimento de onda + Wet Molhado + Wet Out Saída molhada + When Opening a Project Ao abrir um projeto + Whip Chicote + Whip (Legacy) Chicote (legado) + Whisper base (balanced) Whisper base (equilibrado) + Whisper small (most accurate) Whisper small (mais preciso) + Whisper tiny (fastest) Whisper tiny (mais rápido) + White Branco + White Input Level Nível de entrada do branco + White Is High Branco é alto + Wide Outdoor Externa ampla + Widener Alargador + Width Largura + Wiggle Oscilação + Wipe Cortina + Wipe Angle Ângulo da cortina + Wonder Glow Brilho maravilhoso + Wrap Back Envolver para trás + Write Gravação + Write XMP ID to files on import Gravar o ID XMP nos arquivos ao importar + Write clip markers to XMP Gravar os marcadores de clipe em XMP + Write-on Escrita progressiva + X Rotation Rotação X + Y Rotation Rotação Y + YC YC + YC no Chroma YC sem croma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Realces amarelo-esverdeados e sombras elevadas para o azul com curvas de canais cruzadas + Z Position Posição Z + Z Rotation Rotação Z + Zoom Zoom + Zoom Amount Quantidade de zoom + Zoom Blur Desfoque de zoom + Zoom Strength Intensidade do zoom + days dias + frames quadros + milliseconds milissegundos + minute(s) minuto(s) + second(s) segundo(s) + seconds segundos + AAF… AAF… + Audio… Áudio… + Avid Log Exchange… Avid Log Exchange… + Color… Cor… + EDL… EDL… + Edit Workspaces… Editar áreas de trabalho… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Geral… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Salvar as alterações nesta área de trabalho + Save as New Workspace… Salvar como nova área de trabalho… + Create Search Bin Criar pasta de pesquisa + Edit Offline File Editar arquivo offline + Source Settings Configurações de origem + Update Metadata Atualizar metadados + Automate To Sequence Automatizar para a sequência + Scene Edit Detection Detecção de edição de cena + Analyze Analisar + Normalize Mix Track Normalizar faixa de mixagem + Simplify Sequence Simplificar sequência + Simplify Simplificar + Transcribe Sequence Transcrever sequência + Save as Template Salvar como modelo + Flash Cue Marker Marcador de referência Flash + System Compatibility Report Relatório de compatibilidade do sistema + no GPU adapter: the CPU compositor is used nenhum adaptador de GPU: o compositor da CPU é usado + Avid Log Exchange Avid Log Exchange + FilmCraft Project Projeto do FilmCraft + {name} Selection.fcproj {name} Seleção.fcproj + {name} (Simplified) {name} (simplificada) + Find in: Localizar em: + Find: Localizar: + Search: Pesquisar: + Clip Names and Markers Nomes de clipes e marcadores + Match: Corresponder: + All Todos + Any Qualquer + Case Sensitive Diferenciar maiúsculas e minúsculas + Scratch Disks Discos de trabalho + Ingest Settings Configurações de ingestão + Captured and Generated: Capturado e gerado: + Video Previews: Pré-visualizações de vídeo: + Audio Previews: Pré-visualizações de áudio: + Project Auto Save: Salvamento automático do projeto: + Same as Project Igual ao projeto + Path: {path} Caminho: {path} + Transcode Transcodificar + Copy and Create Proxies Copiar e criar proxies + Action: Ação: + Destination: Destino: + Next to the media Ao lado da mídia + Video Rendering and Playback Renderização e reprodução de vídeo + Renderer: Renderizador: + Display Format: Formato de exibição: + Milliseconds Milissegundos + Capture Captura + Capture Format: Formato de captura: + Action and Title Safe Areas Áreas de segurança de ação e de título + Title Safe Area Área de segurança de título + Action Safe Area Área de segurança de ação + Total Duration Duração total + Average Data Rate Taxa de dados média + Colour Cor + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(is) + Media Name: Nome da mídia: + Scene: Cena: + Shot/Take: Plano/tomada: + Log Note: Nota de registro: + Codec: {codec} Codec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Gravar os metadados dos clipes selecionados ({n}) em arquivos XMP ao lado de sua mídia? + Existing XMP files written by other applications are left unchanged. Os arquivos XMP existentes gravados por outros aplicativos não são alterados. + To {name} Para {name} + Ordering: Ordenação: + Sort Order Ordem de classificação + Selection Order Ordem de seleção + Placement: Posicionamento: + Sequentially Sequencialmente + At Unnumbered Markers Em marcadores não numerados + Method: Método: + Insert Edit Edição de inserção + Overwrite Edit Edição de sobrescrita + Clip Overlap: Sobreposição de clipes: + Frames per Still: Quadros por imagem estática: + Transitions Transições + Apply Default Audio Transition Aplicar a transição de áudio padrão + Apply Default Video Transition Aplicar a transição de vídeo padrão + Ignore Options Opções de omissão + Ignore Audio Ignorar áudio + Ignore Video Ignorar vídeo + Apply a cut at each detected cut point Aplicar um corte em cada ponto de corte detectado + Create a bin of subclips from each detected cut point Criar uma pasta de subclipes a partir de cada ponto de corte detectado + Create a clip marker at each detected cut point Criar um marcador de clipe em cada ponto de corte detectado + Sensitivity: Sensibilidade: + Normalize Mix Track to: Normalizar faixa de mixagem para: + Disabled clips Clipes desativados + Empty tracks Faixas vazias + Gaps Lacunas + Video effects Efeitos de vídeo + Audio effects Efeitos de áudio + Text (graphics and captions) Texto (gráficos e legendas) + Clips Clipes + Move clips to the lowest track possible Mover os clipes para a faixa mais baixa possível + Keep: Manter: + Video and Audio Vídeo e áudio + Video Only Somente vídeo + Audio Only Somente áudio + Language: Idioma: + Audio on track {t} Áudio da faixa {t} + Audio analysis: Análise de áudio: + Recognize when different speakers are talking Reconhecer quando pessoas diferentes estão falando + Template Name: Nome do modelo: + Comments: Comentários: + (unavailable) (indisponível) + Decoders Decodificadores + Containers Contêineres + Export formats Formatos de exportação + Match {n} of {total} Correspondência {n} de {total} + Saved template {path} Modelo salvo em {path} + Scene Edit Detection: analysing in the background (see Progress) Detecção de edição de cena: analisando em segundo plano (consulte Progresso) + Delete Search Bin Excluir pasta de pesquisa + (removed from {list}) (removido de {list}) + {key} can't be used as a shortcut {key} não pode ser usado como atalho + Keyboard Shortcuts Atalhos de teclado + {active} (modified) {active} (modificado) + Loaded “{name}” “{name}” carregado + My Shortcuts Meus atalhos + Deleted preset “{active}” Predefinição “{active}” excluída + Export… Exportar… + Exported to {path} Exportado para {path} + Export: no file chosen (agents: shortcuts.export {path}) Exportar: nenhum arquivo escolhido (agentes: shortcuts.export {path}) + Imported “{name}” “{name}” importado + Import: no file chosen (agents: shortcuts.import {path}) Importar: nenhum arquivo escolhido (agentes: shortcuts.import {path}) + Keyboard: US (macOS) Teclado: EUA (macOS) + Keyboard: US Teclado: EUA + Save preset as: Salvar predefinição como: + Commands: Comandos: + {panel} Panel Painel {panel} + Application shortcuts work whatever panel has focus Os atalhos do aplicativo funcionam em qualquer painel que esteja em foco + Panel shortcuts override application shortcuts while that panel has focus Os atalhos de painel substituem os do aplicativo enquanto esse painel estiver em foco + Modifier held / selected Modificador pressionado / selecionado + Search commands or keys Pesquisar comandos ou teclas + Selected: {command} Selecionado: {command} + Command Comando + Shortcut Atalho + Type a shortcut… (Esc cancels) Digite um atalho… (Esc cancela) + Add another shortcut Adicionar outro atalho + No matching commands Nenhum comando correspondente + Key: Tecla: + Modifiers Modificadores + Nothing is assigned to this key Nada está atribuído a esta tecla + Click a key on the keyboard to see its shortcuts Clique em uma tecla do teclado para ver seus atalhos + Assign {key} to {command} Atribuir {key} a {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflito: {key} está atribuída a {commands} ({ctx}) + and e + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Clique na célula Atalho de um comando e pressione as teclas para atribuí-lo; + adiciona outro atalho. + Cleared {n} shortcut(s) of {command} {n} atalho(s) de {command} removido(s) + No export preset named `{name}` Nenhuma predefinição de exportação chamada `{name}` + Open a sequence to export Abra uma sequência para exportar + Destinations Destinos + Media File Arquivo de mídia + Queue ({n}) Fila ({n}) + Send exports here with Send to Queue, then start the queue. Envie exportações para cá com Enviar para a fila e depois inicie a fila. + Ready Pronta + Encoding {n}% Codificando {n}% + Start Queue Iniciar fila + Stop Parar + Clear Finished Limpar concluídas + Not supported by FilmCraft's encoders yet Ainda não suportado pelos codificadores do FilmCraft + Sequence name Nome da sequência + Folder Pasta + Preset Predefinição + More presets… Mais predefinições… + Multiplexer Multiplexador + The sequence has no caption track. A sequência não tem faixa de legendas. + Export Options Opções de exportação + Burn Captions Into Video Gravar as legendas no vídeo + Create Sidecar File Criar arquivo sidecar + File Format Formato de arquivo + SubRip Subtitle (.srt) Legenda SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Título + Creator Autor + Copyright Copyright + Image Imagem + Frame Size Tamanho do quadro + Match Source Igual à origem + Field Order Ordem dos campos + Aspect Proporção + Profile Perfil + Bitrate Encoding Codificação da taxa de bits + Target Bitrate Taxa de bits desejada + Adaptive ({bpp} bits per pixel) Adaptável ({bpp} bits por pixel) + Maximum Bitrate Taxa de bits máxima + Quality (CRF) Qualidade (CRF) + Key Frame Distance Distância entre quadros-chave + Quality Qualidade + Render at Maximum Depth Renderizar na profundidade máxima + Use Maximum Render Quality Usar a qualidade máxima de renderização + Export Audio Exportar áudio + Audio Format Formato de áudio + Uncompressed (PCM) Sem compressão (PCM) + Match Source ({seq_sr} Hz) Igual à origem ({seq_sr} Hz) + Channels Canais + Audio Bitrate Taxa de bits do áudio + Sample Size Tamanho da amostra + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Tamanho / opacidade + Image Overlay Sobreposição de imagem + PNG or JPEG file Arquivo PNG ou JPEG + Name Overlay Sobreposição de nome + Timecode Overlay Sobreposição de timecode + Luma Min / Max Luma mín. / máx. + Loudness Normalization Normalização de loudness + True Peak Limit Limite de pico real + Audio only Somente áudio + Scaling Escala + Scale To Fit Escalar para ajustar + Scale To Fill Escalar para preencher + Stretch To Fill Esticar para preencher + Summary Resumo + Sequence, {w}x{h}, {fps} fps, {rate} Hz Sequência, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Tamanho estimado do arquivo: {size} + Send to Queue Enviar para a fila + Exporting {path} Exportando {path} + Added to the export queue Adicionado à fila de exportação + Preset Manager Gerenciador de predefinições + Search presets Pesquisar predefinições + Favorites only Somente favoritos + Show in the Preset menu Mostrar no menu Predefinição + User Usuário + Save current settings as: Salvar as configurações atuais como: + Export presets Predefinições de exportação + Import: no file chosen (agents: export.presets.import {path}) Importar: nenhum arquivo escolhido (agentes: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exportar: nenhum arquivo escolhido (agentes: export.presets.export {path, names}) + Quick Export: open a sequence first Exportação rápida: abra uma sequência primeiro + File Name & Location Nome e local do arquivo + Quick Export: {path} Exportação rápida: {path} + Graphics problem: switched to software compositing Problema gráfico: alternado para composição por software + GPU acceleration is unavailable; preview renders on the CPU A aceleração por GPU não está disponível; a pré-visualização é renderizada na CPU + Exporting Exportando + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Opt e Cmd para outras opções. + Click to select all clips to the right in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à direita em todas as faixas. Shift+clique para uma única faixa. + Click to select all clips to the left in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à esquerda em todas as faixas. Shift+clique para uma única faixa. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Arraste um ponto de edição para uma aparagem em cascata; os clipes seguintes se movem para manter a lacuna fechada. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Arraste um ponto de edição para rolá-lo: a saída de um clipe e a entrada do seguinte se movem juntas. + Drag an edge to change the clip's speed so it fills the new duration. Arraste uma borda para alterar a velocidade do clipe de modo que ele preencha a nova duração. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Arraste a borda de um clipe de música para remixá-lo para a nova duração em batidas musicalmente correspondentes. + Click to split a clip. Shift-click to split all tracks. Clique para dividir um clipe. Shift+clique para dividir todas as faixas. + Drag a clip to slip its source in/out without moving it. Arraste um clipe para deslizar a entrada/saída de origem sem movê-lo. + Drag a clip to slide it between its neighbours. Arraste um clipe para deslizá-lo entre seus vizinhos. + Drag to scroll the timeline. Arraste para rolar a linha do tempo. + Click to zoom in; Opt-click to zoom out. Clique para ampliar; Opt+clique para reduzir. + FilmCraft hit an error O FilmCraft encontrou um erro + Details: {path} Detalhes: {path} + Opened {url} {url} aberto + (empty) (vazio) + Step Back 1 Frame Voltar 1 quadro + Step Forward 1 Frame Avançar 1 quadro + Match Playhead Corresponder ao cursor de reprodução + Gang to Program Vincular ao programa + AAC Audio Áudio AAC + AAC Audio 48 kHz 320 kbps Áudio AAC 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Áudio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz e 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamórfico 2:1 (2.0) + Animated GIF GIF animado + Animated GIF 640×360 GIF animado 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 com alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Aplicativo + Apply Guide Template Aplicar modelo de guias + Auto Save Now Salvar automaticamente agora + Auto-Match Loudness Correspondência automática de 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…) OP-Atom no estilo Avid: arquivo de imagem DNxHR HQ mais um arquivo PCM de 24 bits e 48 kHz por canal (_A1, _A2…) + BMP BMP + BMP Sequence Sequência BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Começar a editar o texto de uma camada de gráfico + Begins With Começa com + Blur In Entrada com desfoque + Blur Out Saída com desfoque + Brightens inside a feathered circular mask Clareia o interior de uma máscara circular difusa + Broadcast Transmissão + CBR CBR + Cancel Dynamic Trim Cancelar aparagem dinâmica + Cancel Queued Export Cancelar exportação na fila + Center Left Centro à esquerda + Center Right Centro à direita + Center align text Centralizar texto + Change Mask Alterar máscara + Clear All Events Limpar todos os eventos + Clear Edit Point Selection Limpar a seleção de pontos de edição + Clear Finished Exports Limpar exportações concluídas + Clear Poster Frame Limpar quadro de pôster + Close Bin Fechar pasta + Close Sequence Fechar sequência + Contains Contém + Cut to Camera 1 Cortar para a câmera 1 + Cut to Camera 2 Cortar para a câmera 2 + Cut to Camera 3 Cortar para a câmera 3 + Cut to Camera 4 Cortar para a câmera 4 + Cut to Camera 5 Cortar para a câmera 5 + Cut to Camera 6 Cortar para a câmera 6 + Cut to Camera 7 Cortar para a câmera 7 + Cut to Camera 8 Cortar para a câmera 8 + Cut to Camera 9 Cortar para a câmera 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 Escurece o exterior de uma máscara oval difusa + Decrease Audio Tracks Height Diminuir a altura das faixas de áudio + Decrease Clip Volume Diminuir o volume do clipe + Decrease Clip Volume Many Diminuir muito o volume do clipe + Decrease Font Size by Five Units Diminuir o tamanho da fonte em cinco unidades + Decrease Font Size by One Unit Diminuir o tamanho da fonte em uma unidade + Decrease Leading by Five Units Diminuir a entrelinha em cinco unidades + Decrease Leading by One Unit Diminuir a entrelinha em uma unidade + Decrease Video Tracks Height Diminuir a altura das faixas de vídeo + Delete Captions Excluir legendas + Delete Graphic Layer Excluir camada de gráfico + Delete Guide Template Excluir modelo de guias + Delete Media Cache Files Excluir arquivos de cache de mídia + Discard Unsaved Changes Descartar alterações não salvas + Does Not Contain Não contém + Download Speech Model Baixar modelo de fala + Dynamic Trim Stop Parar aparagem dinâmica + Edit Caption Text Editar o texto da legenda + Ellipse Tool Ferramenta Elipse + Ends With Termina com + Entire Source Toda a origem + EssentialSound Som essencial + Export Frame Exportar quadro + Extend Next Edit To Playhead Estender a próxima edição até o cursor de reprodução + Extend Previous Edit To Playhead Estender a edição anterior até o cursor de reprodução + Extend Selected Edit to Playhead Estender a edição selecionada até o cursor de reprodução + Extend Selection Down Estender a seleção para baixo + Extend Selection Left Estender a seleção para a esquerda + Extend Selection Right Estender a seleção para a direita + Extend Selection Up Estender a seleção para cima + Fade In Fade de entrada + Gaussian Blur from 0 to 60 over the last second Desfoque gaussiano de 0 a 60 durante o último segundo + Gaussian Blur from 60 to 0 over the first second Desfoque gaussiano de 60 a 0 durante o primeiro segundo + Generate Ducking Keyframes Gerar quadros-chave de atenuação + Go to Next Edit Point Ir para o próximo ponto de edição + Go to Next Edit Point on Any Track Ir para o próximo ponto de edição em qualquer faixa + Go to Previous Edit Point Ir para o ponto de edição anterior + Go to Previous Edit Point on Any Track Ir para o ponto de edição anterior em qualquer faixa + Go to Selected Clip End Ir para o fim do clipe selecionado + Go to Selected Clip Start Ir para o início do clipe selecionado + Go to Sequence End Ir para o fim da sequência + Go to Sequence Start Ir para o início da sequência + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sites de vídeo, VBR 1 passagem, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 passagem, 20 Mbps desejados, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 passagens, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 para sites de vídeo, VBR 1 passagem, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 passagem, 60 Mbps desejados, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 passagens, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 vertical para vídeos curtos em redes sociais, escalado para preencher, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,05 bit por pixel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,1 bit por pixel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 com o tamanho e a taxa de quadros da sequência; a taxa de bits se adapta ao tamanho do quadro (0,2 bit por pixel) + HD Anamorphic 1080 (1.333) HD anamórfico 1080 (1.333) + Hand Tool Ferramenta Mão + High Quality 1080p HD Alta qualidade 1080p HD + High Quality 2160p 4K Alta qualidade 2160p 4K + Image Sequence Sequência de imagens + Increase Audio Tracks Height Aumentar a altura das faixas de áudio + Increase Clip Volume Aumentar o volume do clipe + Increase Clip Volume Many Aumentar muito o volume do clipe + Increase Font Size by Five Units Aumentar o tamanho da fonte em cinco unidades + Increase Font Size by One Unit Aumentar o tamanho da fonte em uma unidade + Increase Leading by Five Units Aumentar a entrelinha em cinco unidades + Increase Leading by One Unit Aumentar a entrelinha em uma unidade + Increase Video Tracks Height Aumentar a altura das faixas de vídeo + Large blocks resolving to the picture over the first second Blocos grandes que se resolvem na imagem durante o primeiro segundo + Left align text Alinhar texto à esquerda + Looping GIF, 640×360 at 15 fps GIF em loop, 640×360 a 15 fps + Lower First Inferior primeiro + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz, timecode inicial + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP longo (0,3 bit por pixel), PCM de 24 bits e 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM de 24 bits e 48 kHz, timecode inicial + Main Main + Mark In (Hover Scrub) Marcar entrada (reprodução ao passar o mouse) + Mark Out (Hover Scrub) Marcar saída (reprodução ao passar o mouse) + Masks Máscaras + Match Source – Adaptive High Bitrate Igual à origem – taxa de bits adaptável alta + Match Source – Adaptive Low Bitrate Igual à origem – taxa de bits adaptável baixa + Match Source – Adaptive Medium Bitrate Igual à origem – taxa de bits adaptável média + Matches Corresponde a + Maximize or Restore Active Frame Maximizar ou restaurar o quadro ativo do painel + Maximize or Restore Frame Under Cursor Maximizar ou restaurar o quadro do painel sob o cursor + Media Browser Settings Configurações do navegador de mídia + MediaBrowser Navegador de mídia + MediaCache Cache de mídia + Merge Captions Mesclar legendas + Mixer Misturador + Mosaic In Entrada em mosaico + Move All Audio Targets Down Mover todos os destinos de áudio para baixo + Move All Audio Targets Up Mover todos os destinos de áudio para cima + Move All Video Targets Down Mover todos os destinos de vídeo para baixo + Move All Video Targets Up Mover todos os destinos de vídeo para cima + Move Selection Down Mover a seleção para baixo + Move Selection End Mover a seleção para o fim + Move Selection Home Mover a seleção para o início + Move Selection Left Mover a seleção para a esquerda + Move Selection Page Down Mover a seleção uma página para baixo + Move Selection Page Up Mover a seleção uma página para cima + Move Selection Right Mover a seleção para a direita + Move Selection Up Mover a seleção para cima + Multi-Camera Record On/Off Toggle Ativar/desativar gravação multicâmera + Multicam Multicâmera + Navigate to End of Segment Ir para o fim do segmento + Navigate to Next Line Ir para a próxima linha + Navigate to Next Word Ir para a próxima palavra + Navigate to Previous Line Ir para a linha anterior + Navigate to Previous Word Ir para a palavra anterior + Navigate to Start of Segment Ir para o início do segmento + Navigation Navegação + Next Multi-Camera Page Próxima página multicâmera + Nudge Clip Selection Down Empurrar a seleção de clipes para baixo + Nudge Clip Selection Left Five Frames Empurrar a seleção de clipes cinco quadros para a esquerda + Nudge Clip Selection Left One Frame Empurrar a seleção de clipes um quadro para a esquerda + Nudge Clip Selection Right Five Frames Empurrar a seleção de clipes cinco quadros para a direita + Nudge Clip Selection Right One Frame Empurrar a seleção de clipes um quadro para a direita + Nudge Clip Selection Up Empurrar a seleção de clipes para cima + Nudge Selected Object down by five Empurrar o objeto selecionado cinco para baixo + Nudge Selected Object down by one Empurrar o objeto selecionado um para baixo + Nudge Selected Object to left by five Empurrar o objeto selecionado cinco para a esquerda + Nudge Selected Object to left by one Empurrar o objeto selecionado um para a esquerda + Nudge Selected Object to right by five Empurrar o objeto selecionado cinco para a direita + Nudge Selected Object to right by one Empurrar o objeto selecionado um para a direita + Nudge Selected Object up by five Empurrar o objeto selecionado cinco para cima + Nudge Selected Object up by one Empurrar o objeto selecionado um para cima + Nudge Volume +1dB Ajustar volume +1 dB + Nudge Volume +3dB Ajustar volume +3 dB + Nudge Volume -1dB Ajustar volume -1 dB + Nudge Volume -3dB Ajustar volume -3 dB + Numbered BMP stills (24-bit) Imagens estáticas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Sequência JPEG + Targa Sequence Sequência Targa + DPX Sequence Sequência DPX + Numbered JPEG stills (quality 90) Imagens estáticas JPEG numeradas (qualidade 90) + Numbered Targa stills (24-bit, uncompressed) Imagens estáticas Targa numeradas (24 bits, sem compressão) + Numbered DPX stills (10-bit RGB) Imagens estáticas DPX numeradas (RGB de 10 bits) + OpenEXR OpenEXR + OpenEXR Sequence Sequência OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Imagens estáticas OpenEXR numeradas (meia precisão em ponto flutuante, linear, sem compressão) + Numbered PNG stills at the sequence's frame size Imagens estáticas PNG numeradas no tamanho de quadro da sequência + Numbered TIFF stills (RGB, 8-bit) Imagens estáticas TIFF numeradas (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacidade de 0 a 100 % durante o primeiro segundo + Opacity 100 → 0 % over the last second Opacidade de 100 a 0 % durante o último segundo + Open Bin Abrir pasta + Open Search Abrir pesquisa + PNG PNG + PNG Sequence Sequência PNG + Panel Painel + Pen Tool Ferramenta Caneta + PiP 35% Upper Right PiP 35% superior direito + Picture-in-picture: 35 % scale in the upper right Picture-in-picture: escala de 35 % no canto superior direito + Play Around Reproduzir ao redor + Play In to Out with Preroll/Postroll Reproduzir da entrada à saída com pré-roll/pós-roll + Play from Playhead to Out Point Reproduzir do cursor de reprodução ao ponto de saída + Play/Stop Reproduzir/parar + Previous Multi-Camera Page Página multicâmera anterior + Progressive Progressivo + ProjectPanel Painel Projeto + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) com canal alfa, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits e 48 kHz + Quit FilmCraft Sair do FilmCraft + Rate Stretch Tool Ferramenta Alongamento de taxa + Razor Tool Ferramenta Lâmina + Rectangle Tool Ferramenta Retângulo + Remix Tool Ferramenta Remixagem + Right align text Alinhar texto à direita + Ripple Edit Tool Ferramenta Edição em cascata + Ripple Trim Next Edit to Playhead Aparagem em cascata da próxima edição até o cursor de reprodução + Ripple Trim Previous Edit to Playhead Aparagem em cascata da edição anterior até o cursor de reprodução + Rolling Edit Tool Ferramenta Edição rolante + Select Camera 1 Selecionar câmera 1 + Select Camera 2 Selecionar câmera 2 + Select Camera 3 Selecionar câmera 3 + Select Camera 4 Selecionar câmera 4 + Select Camera 5 Selecionar câmera 5 + Select Camera 6 Selecionar câmera 6 + Select Camera 7 Selecionar câmera 7 + Select Camera 8 Selecionar câmera 8 + Select Camera 9 Selecionar câmera 9 + Select Clip at Playhead Selecionar o clipe no cursor de reprodução + Select Find Box Selecionar a caixa de pesquisa + Select Next Clip Selecionar o próximo clipe + Select Next Panel Selecionar o próximo painel + Select Previous Clip Selecionar o clipe anterior + Select Previous Panel Selecionar o painel anterior + Select to Next Line Selecionar até a próxima linha + Select to Next Word Selecionar até a próxima palavra + Select to Previous Line Selecionar até a linha anterior + Select to Previous Word Selecionar até a palavra anterior + Select to Segment End Selecionar até o fim do segmento + Select to Segment Start Selecionar até o início do segmento + Selection Tool Ferramenta Seleção + Set Comparison Reference Definir a referência de comparação + Set Creative Look Definir o visual criativo + Set Input LUT Definir a LUT de entrada + Set Project Panel View Definir a visualização do painel Projeto + Show Next Screen Mostrar a próxima tela + Show Previous Screen Mostrar a tela anterior + Show Program Transcript Mostrar a transcrição do programa + Shuttle Left Shuttle para a esquerda + Shuttle Right Shuttle para a direita + Shuttle Slow Left Shuttle lento para a esquerda + Shuttle Slow Right Shuttle lento para a direita + Shuttle Stop Parar shuttle + Slide Clip Selection Left Five Frames Deslizar a seleção de clipes cinco quadros para a esquerda + Slide Clip Selection Left One Frame Deslizar a seleção de clipes um quadro para a esquerda + Slide Clip Selection Right Five Frames Deslizar a seleção de clipes cinco quadros para a direita + Slide Clip Selection Right One Frame Deslizar a seleção de clipes um quadro para a direita + Slide Tool Ferramenta Deslizar clipe + Slip Clip Selection Left Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a esquerda + Slip Clip Selection Left One Frame Deslizar o conteúdo da seleção de clipes um quadro para a esquerda + Slip Clip Selection Right Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a direita + Slip Clip Selection Right One Frame Deslizar o conteúdo da seleção de clipes um quadro para a direita + Slip Tool Ferramenta Deslizar conteúdo + Social Vertical 1080×1920 Vertical para redes sociais 1080×1920 + Soft Vignette Vinheta suave + Source In/Out Entrada/saída da origem + Split Caption Dividir legenda + Square Pixels (1.0) Pixels quadrados (1.0) + Stack Clips Empilhar clipes + Start Export Queue Iniciar a fila de exportação + Start Voice-over Recording Iniciar a gravação de narração + Step Back Five Frames Voltar cinco quadros + Step Back One Frame Voltar um quadro + Step Forward Five Frames Avançar cinco quadros + Step Forward One Frame Avançar um quadro + Stop Export Queue Parar a fila de exportação + Stop Multi-Camera Recording Parar a gravação multicâmera + Stop Voice-over Recording Parar a gravação de narração + Sync Voice-over Capture Sincronizar a captura de narração + TIFF TIFF + TIFF Sequence Sequência TIFF + TextPanel Painel Texto + Thumbnail Size Next Próximo tamanho de miniatura + Thumbnail Size Previous Tamanho de miniatura anterior + Toggle All Audio Targets Alternar todos os destinos de áudio + Toggle All Source Audio Alternar todo o áudio de origem + Toggle All Source Video Alternar todo o vídeo de origem + Toggle All Video Targets Alternar todos os destinos de vídeo + Toggle Audio During Scrubbing Alternar áudio ao percorrer + Toggle Full Screen Alternar tela cheia + Toggle Mute for All Targeted Audio Tracks Alternar mudo em todas as faixas de áudio de destino + Toggle Solo for All Targeted Audio Tracks Alternar solo em todas as faixas de áudio de destino + Toggle Source/Program Monitor Focus Alternar o foco entre os monitores de origem e do programa + Toggle Target Audio 1 Alternar destino de áudio 1 + Toggle Target Audio 2 Alternar destino de áudio 2 + Toggle Target Audio 3 Alternar destino de áudio 3 + Toggle Target Audio 4 Alternar destino de áudio 4 + Toggle Target Audio 5 Alternar destino de áudio 5 + Toggle Target Audio 6 Alternar destino de áudio 6 + Toggle Target Audio 7 Alternar destino de áudio 7 + Toggle Target Audio 8 Alternar destino de áudio 8 + Toggle Target Video 1 Alternar destino de vídeo 1 + Toggle Target Video 2 Alternar destino de vídeo 2 + Toggle Target Video 3 Alternar destino de vídeo 3 + Toggle Target Video 4 Alternar destino de vídeo 4 + Toggle Target Video 5 Alternar destino de vídeo 5 + Toggle Target Video 6 Alternar destino de vídeo 6 + Toggle Target Video 7 Alternar destino de vídeo 7 + Toggle Target Video 8 Alternar destino de vídeo 8 + Toggle Track Output for All Targeted Video Tracks Alternar a saída de todas as faixas de vídeo de destino + Toggle Trim Type Alternar tipo de aparagem + Toggle View Alternar visualização + Track Select Backward Tool Ferramenta Selecionar faixa para trás + Track Select Forward Tool Ferramenta Selecionar faixa para a frente + Track Selected Mask Rastrear a máscara selecionada + Trim Next Edit to Playhead Aparar a próxima edição até o cursor de reprodução + Trim Previous Edit to Playhead Aparar a edição anterior até o cursor de reprodução + Trimming Aparagem + Type Tool Ferramenta Texto + Unstack Clips Desempilhar clipes + Upper First Superior primeiro + VBR, 1 pass VBR, 1 passagem + VBR, 2 pass VBR, 2 passagens + CRF (constant quality) CRF (qualidade constante) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Gravação de narração + Voiceover Narração + WAV, stereo 48 kHz 16-bit PCM WAV, estéreo, PCM de 48 kHz e 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, estéreo, PCM de 48 kHz e 24 bits + Warm Tint Tonalidade quente + Warm highlights, brown shadows Realces quentes, sombras marrons + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web e redes sociais + Work Area Região de trabalho + Write Automation Gravar automação + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Ampliar o monitor do programa para 100% + Zoom Program Monitor to Fit Ajustar o monitor do programa à janela + Zoom Source Monitor to 100% Ampliar o monitor de origem para 100% + Zoom Source Monitor to Fit Ajustar o monitor de origem à janela + Zoom Tool Ferramenta Zoom + with: por: + Interface Language Idioma da interface + System Language Idioma do sistema + Cinematic Cinematográfico + Film Emulation Emulação de filme + Technical Técnico + Teal Shadows, Warm Skin Sombras azul-petróleo, pele quente + Night Exterior Externa noturna + Golden Dusk Crepúsculo dourado + Steel Blue Azul-aço + Hard Sun Sol forte + Faded Print Cópia desbotada + Silver Retention Retenção de prata + Cross Processed Processamento cruzado + Warm Negative Negativo quente + Neutral Mono Monocromático neutro + Hard Mono Monocromático intenso + Sepia Tone Tom sépia + Cold Mono Monocromático frio + Lift Shadows Levantar sombras + Protect Highlights Proteger realces + Half Saturation Saturação em 50 % + Boost Vibrance Aumentar vivacidade + Legal Range Squeeze Compressão para a faixa de vídeo + A boxed label with a round pointer; move the label and the pointer follows. Um rótulo em caixa com um ponteiro redondo; ao mover o rótulo, o ponteiro o acompanha. + A rounded tag that fits its text, with a status dot. Uma etiqueta arredondada que se ajusta ao texto, com um ponto de status. + A title on a rounded card with a soft shadow. Um título em um cartão arredondado com uma sombra suave. + Accent Color Cor de ênfase + Bar Color Cor da barra + Box Color Cor da caixa + Box Opacity Opacidade da caixa + Callout – Pointer Chamada – Ponteiro + Callout – Tag Chamada – Etiqueta + Callouts Chamadas + Credits Créditos + Dot Color Cor do ponto + Heading Cabeçalho + Large centred title, accent rule and subtitle. Título grande centralizado, linha de destaque e subtítulo. + Lower Third – Rule Terço inferior – Linha + Lower Third – Slab Terço inferior – Bloco + Lower Thirds Terços inferiores + Name Color Cor do nome + Name Size Tamanho do nome + Name and role on a coloured slab that widens with the name. Nome e função sobre um bloco colorido que se alarga com o nome. + Name over a thin rule that always matches the name's width. Nome sobre uma linha fina que sempre acompanha a largura do nome. + Pointer Color Cor do ponteiro + Role Função + Rule Color Cor da linha + Show Dot Mostrar ponto + Show Role Mostrar função + Slab Color Cor do bloco + Tag Color Cor da etiqueta + Title Color Cor do título + Title Size Tamanho do título + Title – Boxed Título – Em caixa + Title – Centered Título – Centralizado + Titles Títulos + Select an item to preview it Selecione um item para pré-visualizá-lo + End Credits Créditos finais + End Credits – Roll Créditos finais – Rolagem + Ticker – Crawl Letreiro – Rastejamento + Centred credits that roll up from below the frame and leave at the top. Créditos centralizados que sobem de baixo do quadro e saem pelo topo. + A news-style line that crawls in from the right and leaves at the left. Uma linha no estilo de telejornal que entra pela direita e sai pela esquerda. + out saída + in entrada + The language changed but could not be saved: {e} O idioma mudou, mas não pôde ser salvo: {e} + no Chinese font is installed on this system; the interface stays in English nenhuma fonte chinesa está instalada neste sistema; a interface permanece em inglês + no Japanese font is installed on this system; the interface stays in English nenhuma fonte japonesa está instalada neste sistema; a interface permanece em inglês + Show Mostrar + Sort Ordenar + Reverse the order Inverter a ordem + Grab bag Mosaico + Table Tabela + No contributor data was built into this copy. Esta cópia não inclui dados de colaboradores. + No model credits were built into this copy. Esta cópia não inclui créditos de modelos. + Company Empresa + Model Modelo + Version Versão + Commits Commits + % of all commits % de todos os commits + Lines +/− Linhas +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Clip Speed / Duration Velocidade/duração do clipe + Speed: Velocidade: + Reverse Speed Inverter velocidade + Ripple Edit, Shifting Trailing Clips Edição em cascata, deslocando os clipes seguintes + About Sobre + Contributors Colaboradores + Models Modelos + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para sequências HDR) + Encoder Codificador + Hardware (H.265 has no software encoder) Hardware (o H.265 não tem codificador por software) + Hardware (AV1 has no software encoder) Hardware (o AV1 não tem codificador por software) + Text Properties Propriedades do texto + Text Box Height Altura da caixa de texto + Paragraph Text Texto de parágrafo + Point Text Texto de ponto + Text Layer Type Tipo de camada de texto + Text Styling Estilo do texto + New Color Matte Novo matte de cor + Color Matte Color Cor do matte de cor + Color: Cor: + · {time} left · faltam {time} + Discard take Descartar tomada + Voice-over take discarded Tomada de narração descartada + Binary assets added Recursos binários adicionados + Binary assets removed Recursos binários removidos + Deselect All Project Items Desmarcar todos os itens do projeto + Display name Nome de exibição + First commit Primeiro commit + H.265 (HEVC) H.265 (HEVC) + Last commit Último commit + Line delta Diferença de linhas + Lines added Linhas adicionadas + Lines deleted Linhas excluídas + Merged PRs PRs mesclados + Name (A–Z) Nome (A–Z) + Real name Nome real + Select All Project Items Selecionar todos os itens do projeto + Username Nome de usuário + Vertical Type Tool Ferramenta Texto vertical + Go to previous keyframe Ir para o quadro-chave anterior + Go to next keyframe Ir para o próximo quadro-chave + Add keyframe Adicionar quadro-chave + Remove keyframe Remover quadro-chave + Turn on animation and add keyframe Ativar a animação e adicionar quadro-chave + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Alt e Ctrl para outras opções. + Click to zoom in; Alt-click to zoom out. Clique para ampliar; Alt+clique para reduzir. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastar: mover · Alt+clique: suave/canto · Ctrl+clique: excluir + Include Alpha Channel Incluir canal alfa + Sequence Settings Configurações da sequência + Color Management Gerenciamento de cores + VR Properties Propriedades de RV + Editing Mode: Modo de edição: + Timebase: Base de tempo: + Frame Size: Tamanho do quadro: + Pixel Aspect Ratio: Proporção de pixels: + Fields: Campos: + Channel Format: Formato de canais: + Number of Channels: Número de canais: + Preview File Format: Formato de arquivo de pré-visualização: + Working Color Space: Espaço de cores de trabalho: + Projection: Projeção: + Layout: Layout: + Captured View: Visualização capturada: + Video Previews Pré-visualizações de vídeo + No Fields (Progressive Scan) Sem campos (varredura progressiva) + Custom ({ratio}) Personalizado ({ratio}) + {rate} frames/second {rate} quadros/segundo + {rate} fps Drop-Frame Timecode Timecode drop-frame a {rate} fps + {rate} fps Non Drop-Frame Timecode Timecode non-drop-frame a {rate} fps + {rate} fps Timecode Timecode a {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Escalar os efeitos de movimento proporcionalmente ao alterar o tamanho do quadro + Maximum Bit Depth Profundidade de bits máxima + Maximum Render Quality Qualidade de renderização máxima + Composite in Linear Color Compor em cor linear + Auto Tone Map Media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Configure… Configurar… + Not supported in FilmCraft yet Ainda não suportado no FilmCraft + FilmCraft always composites in linear light O FilmCraft sempre compõe em luz linear + Loop marked range Loop do intervalo marcado + Play Source Reproduzir origem + Source Play/Stop Reproduzir/parar origem + Stop Source Parar origem + New Sequence Nova sequência + Tracks Faixas + tracks faixas + Clear Transition Limpar transição + Set Transition Duration Definir duração da transição -# System appearance mode menu command. +# System appearance modes and theme cards. + Active Ativo + Appearance Mode Modo de aparência + Dark Theme Tema escuro + Light Theme Tema claro Next Appearance Mode Próximo modo de aparência + Sync with system Sincronizar com o sistema + UI Scale Escala da interface + Auto (system) Automática (sistema) + In Entrada + Out Saída + Export Markers as CSV… Exportar marcadores como CSV… + Fill Type Tipo de preenchimento + Gradient Angle Ângulo do gradiente + Linear Gradient Gradiente linear + Solid Sólido + GPU Rendering Renderização por GPU + Composite on the GPU when available Compor na GPU quando disponível + Text to Speech Texto para fala + Edit Narration Editar narração Project Notes Notas do projeto + Get Project Notes Obter notas do projeto + Edit Project Notes Editar notas do projeto + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas deste projeto: tarefas, instruções, números de quadro… Salvas com o projeto. + Use Effect Shutter Angle Usar o ângulo do obturador do efeito + +# The native macOS application menu (Settings…). + Settings Preferências diff --git a/crates/ui-egui/src/i18n/pt-br.tsv.attribution b/crates/ui-egui/src/i18n/pt-br.tsv.attribution new file mode 100644 index 000000000..bc354c519 --- /dev/null +++ b/crates/ui-egui/src/i18n/pt-br.tsv.attribution @@ -0,0 +1,8 @@ +asset: pt-br.tsv +title: Brazilian Portuguese interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-11 by vangel76 +notes: Original English-to-Brazilian-Portuguese interface translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index ef6c20a84..15bc785fd 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -799,6 +799,7 @@ Relink others automatically 自动重新链接其他项 Search in: 搜索位置: Browse… 浏览… + A file dialog is already open 已有一个文件对话框处于打开状态 Exact name matches only 仅精确名称匹配 No candidates yet — Search a folder or Locate the file. 暂无候选项 — 请搜索文件夹或定位文件。 ✔ same file ✔ 同一文件 @@ -2787,8 +2788,8 @@ 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 在每个检测到的切点生成剪辑标记 + Create a bin of subclips from each detected cut point 从每个检测到的切点创建子剪辑素材箱 + Create a clip marker at each detected cut point 在每个检测到的切点创建剪辑标记 Sensitivity: 灵敏度: Normalize Mix Track to: 将混音轨道标准化至: Disabled clips 已禁用的剪辑 @@ -2963,6 +2964,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24 位 + FLAC, stereo 48 kHz 24-bit, lossless FLAC,立体声 48 kHz 24 位,无损 + FLAC (lossless) FLAC(无损) + Compression Level 压缩级别 + 0 fastest, 8 smallest 0 最快,8 最小 APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 @@ -3471,6 +3478,7 @@ 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-单击:删除 + Drag inward to round the corners 向内拖动以使拐角变圆 Include Alpha Channel 包含 Alpha 通道 Sequence Settings 序列设置 Color Management 色彩管理 @@ -3528,6 +3536,9 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Resize floating panel 调整浮动面板大小 + More transport controls 更多播放控制 + Undock Panel 取消面板停靠 Project Notes 项目备注 Get Project Notes 获取项目备注 Edit Project Notes 编辑项目备注 @@ -3540,3 +3551,16 @@ Compression 压缩 ZIP (1 scan line) ZIP(1 条扫描线) ZIP (16 scan lines) ZIP(16 条扫描线) + Safe graphics mode: {mode} 安全图形模式:{mode} + An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 上次启动时默认图形模式失败。更新显卡驱动程序通常可以解决此问题。 + Use Default Graphics 使用默认图形 + The default graphics are used from the next start. 下次启动时将使用默认图形。 + DirectX 12 only 仅 DirectX 12 + software rendering 软件渲染 + Label Color Presets 标签颜色预设 + {name} (Modified) {name}(已修改) + Revert to the preset's colors and names 恢复为预设的颜色和名称 + Import a preset file… 导入预设文件… + Export the labels to a preset file… 将标签导出为预设文件… + Show the presets folder 显示预设文件夹 + Delete this preset 删除此预设 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b8be330d8..230a84a77 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -73,6 +73,8 @@ pub enum Icon { Close, Fullscreen, Export, + /// An arrow down into a tray (Import). + Import, Gear, Info, Captions, @@ -578,6 +580,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(5.0, 5.0), (8.0, 2.0), (11.0, 5.0)]); pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]); } + Import => { + pen.line(&[(8.0, 2.0), (8.0, 10.0)]); + pen.line(&[(5.0, 7.0), (8.0, 10.0), (11.0, 7.0)]); + pen.line(&[(3.0, 8.0), (3.0, 14.0), (13.0, 14.0), (13.0, 8.0)]); + } Gear => { pen.circle(8.0, 8.0, 2.2); for i in 0..8 { @@ -866,6 +873,7 @@ mod tests { Close, Fullscreen, Export, + Import, Gear, Info, Captions, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c4c526f7..9a5f923d5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,22 +31,29 @@ macro_rules! tlf { pub mod automation; pub mod brand; +pub mod calc; mod cjk; pub mod control; pub mod crash; pub mod credits; pub mod dock; +mod dock_shared; +mod dock_ui; pub mod frames; +pub mod graphics_notice; pub mod header; pub mod i18n; pub mod icons; pub mod links; pub mod menus; +pub mod native_dialogs; pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] pub mod play_ahead; +mod rtl; pub mod scrub; +mod shuttle; pub mod source_playback; pub mod state; pub mod theme; @@ -64,6 +71,7 @@ use serde_json::{Value, json}; pub use control::ControlRequest; use dock::PanelKind; use frames::{FrameKey, FrameServer, Target}; +use native_dialogs::FileDialog; use state::UiState; use theme::{ThemeKind, Tokens}; @@ -80,6 +88,10 @@ pub trait AudioOut { } /// Frames played since `start` (the playback master clock), if the device reports it. fn played_frames(&self) -> Option; + /// Called every frame while playing, before `played_frames`: restart a stream the device + /// dropped (a driver error, an unplugged device), keeping the clock and `fill`. A stream that + /// cannot be restarted reports `played_frames() == None`. + fn recover(&mut self) {} /// Hosts and devices that can be chosen in Settings ▸ Audio Hardware. fn devices(&self) -> AudioDevices { AudioDevices::default() @@ -114,10 +126,12 @@ pub struct HostHooks { pub cursor_screen_position: Option Option<(i32, i32)>>>, pub pick_save: Option Option>>, pub pick_open_project: Option Option>>, - /// Save dialog with a filter: (filter name, extensions, suggested file name) → path. + /// Save dialog with a filter: (filter name, extensions, suggested file name) → path. The + /// suggestion may be a full path, and the dialog then starts in its folder. pub pick_save_as: Option Option>>, - /// The active keyboard shortcuts changed: update native menu key equivalents. - pub shortcuts_changed: Option>, + /// The menu tree changed (labels, shortcuts, enablement, checkmarks): update the native menu + /// bar. Called from the frame loop only when something differs from the last call. + pub menu_changed: Option>, /// The user's preferred languages (locale tags, most preferred first) for Interface Language ▸ /// System Language. Without it, System Language is English. pub system_languages: Option Vec>>, @@ -139,6 +153,11 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// Show a file dialog without blocking the UI thread and answer on the returned channel with + /// the chosen paths (none: cancelled). With it, [`FilmcraftApp::pick`] keeps the frame loop + /// running while the dialog is open (the desktop app on Linux, #260); without it the + /// synchronous pickers above are used. + pub run_dialog: Option Receiver>>>, /// Open a URL in the system browser (Help menu links, About dialog, Discord button, Import /// screen). Without it, or when it fails, links go through `egui::Context::open_url`, which /// opens a new tab on the web (the native egui backend is built without its `links` feature). @@ -222,6 +241,9 @@ pub struct Playback { pub hidden: bool, /// Forward playback stops here (Play In to Out, Play from Playhead to Out Point). pub stop_at: Option, + /// Resting playhead for an inclusive marked range; unlike stop_at, this + /// names the last visible frame, not the following playback boundary. + pub rest_at: Option, } /// How long (s) the audio clock may stand still during playback before the wall clock takes over. @@ -261,14 +283,27 @@ pub struct FilmcraftApp { pub hooks: HostHooks, pub dialog: Option, pub file_dialogs: panels::file_dialogs::FileDialogState, + /// The native file dialog the host is showing ([`Self::pick`]). + native_dialog: Option, pub auto: automation::Registry, /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, control_rx: Option>, + /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame. + pub(crate) pending_panel_raise: Option, + /// Explicit automation mode: native widget input and native menu commands are excluded. + pub(crate) control_input_only: bool, + /// Backend focus before synthetic input changes egui's effective focus for this frame. + pub(crate) native_window_focused: Option, + /// Modifiers owned by an in-flight control gesture, carried between its input frames. + synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, pub(crate) synthetic: Vec, + /// The mouse press in progress (its start time) and the undo history when it began, for Escape + /// to take back what the press changed (#580). + value_press: Option<(f64, filmcraft_engine::UndoMark)>, /// BS.1770 loudness of the programme as it plays, and the next sample position to feed. pub(crate) loudness: Option<(filmcraft_audio_dsp::LoudnessMeter, i64)>, /// Status message last shown and when it first appeared (messages expire after a few seconds). @@ -292,6 +327,9 @@ pub struct FilmcraftApp { styled: bool, /// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]). pub ui_error: Option, + /// The window runs in a fallback graphics mode (set by the desktop host): a notice with a + /// button back to the default mode (see [`graphics_notice`]). + pub graphics_notice: Option, /// Fault injection for robustness tests: the next UI pass panics. #[doc(hidden)] pub panic_next_frame: bool, @@ -312,8 +350,10 @@ pub struct FilmcraftApp { pub fps: f32, last_time: f64, bindings: Vec, - /// Shortcut-set revision `bindings` (and the native menu) were built from. + /// Shortcut-set revision `bindings` were built from. bindings_rev: u64, + /// The menu tree last sent to `hooks.menu_changed` (the native menu bar). + menu_sent: Vec, /// Keyboard Shortcuts dialog state. pub shortcut_editor: panels::shortcuts_dialog::EditorState, pub toast: Option<(String, f64)>, @@ -322,6 +362,9 @@ pub struct FilmcraftApp { pub command_inbox: Option>, /// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet. v_down: bool, + /// OS files/image that were already on the clipboard when the last Copy/Cut ran. Paste ignores + /// that same media so it pastes the copied clips instead (#697). + pub(crate) stale_clipboard_media: Option, /// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds. pub gpu: Option, /// Preview render job being watched (job id, where to start playing when it completes). @@ -499,12 +542,18 @@ impl FilmcraftApp { // Unsaved changes left by a session that died are offered first thing. dialog: recovery.then_some(Dialog::Recovery), file_dialogs: Default::default(), + native_dialog: None, auto: Default::default(), textures: HashMap::new(), control_rx: None, + pending_panel_raise: None, + control_input_only: false, + native_window_focused: None, + synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), + value_press: None, loudness: None, status_seen: (String::new(), 0.0), pending_screenshots: Vec::new(), @@ -530,11 +579,14 @@ impl FilmcraftApp { last_time: 0.0, bindings: Vec::new(), bindings_rev: 0, + menu_sent: Vec::new(), shortcut_editor: Default::default(), toast: None, + graphics_notice: None, tl: Default::default(), command_inbox: None, v_down: false, + stale_clipboard_media: None, gpu: None, watched_render: None, applied_prefs: None, @@ -550,6 +602,13 @@ impl FilmcraftApp { self } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. + /// Window close/quit and screenshot delivery remain available. + pub fn with_control_input_only(mut self, rx: Receiver) -> Self { + self.control_input_only = true; + self.with_control(rx) + } + pub fn services(&self) -> Arc { self.session.services.clone() } @@ -655,28 +714,27 @@ impl FilmcraftApp { self.ui.language = language; } i18n::set_current(self.ui.language); - let items = menus::menu_items(self); - if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { - hook(&items); - } } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; - if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) { + let hardware_changed = prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware); + let pitch_changed = prev.as_ref().is_some_and(|q| q.audio.maintain_pitch_shuttling != p.audio.maintain_pitch_shuttling); + if hardware_changed { if let Some(input) = self.session.voiceover.input.as_mut() { input.configure_host(&p.audio_hardware.device_class); } let rate = self.session.active_sequence().map(|q| q.settings.sample_rate); - let playing = self.playback.playing && self.playback.preroll.is_none(); if let Some(a) = self.audio.as_mut() { a.stop(); a.configure(&p.audio_hardware, rate); } - if playing { - self.playback.anchor_tick = self.session.playhead(); - self.playback.anchor_time = ctx.input(|i| i.time); - self.start_audio(); - } + } + if (hardware_changed || pitch_changed) && self.playback.playing && self.playback.preroll.is_none() { + self.playback.anchor_tick = self.session.playhead(); + let now = ctx.input(|i| i.time); + self.playback.anchor_time = now; + self.playback.audio_seen = (0, now); + self.start_audio(); } if let Some(note) = self.audio.as_ref().and_then(|a| a.note()) { self.ui.status = note; @@ -760,9 +818,11 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - // a workspace is shown whole: no frame stays maximized over it + let layout = dock::saved_docking(&self.workspaces, name); + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + self.pending_panel_raise = None; self.ui.keys.maximized = None; - self.ui.dock = dock::saved_layout(&self.workspaces, name); if self.workspaces.current != name { let mut next = self.workspaces.clone(); next.current = name.to_string(); @@ -776,10 +836,10 @@ impl FilmcraftApp { } pub fn show_panel(&mut self, p: PanelKind) { - if p == PanelKind::Timeline { + if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { self.ui.dock.restore_timeline(); } - if !self.ui.dock.contains(p) { + if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { let near = match p { // a side panel beside Properties (or Essential Sound), never over the Program monitor PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics] @@ -800,7 +860,7 @@ impl FilmcraftApp { }; self.ui.dock.open_near(p, near); } - self.ui.dock.activate(p); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p }); self.ui.focused = p; } @@ -868,7 +928,12 @@ impl FilmcraftApp { // restart from the end → from the start (Settings ▸ Timeline ▸ "At playback end, return to // beginning when restarting playback") let dur = self.session.active_sequence().map(|q| q.duration()).unwrap_or_default(); - if speed > 0.0 && self.session.prefs.timeline.return_to_beginning && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() { + // (not while a voice-over take starts: it was put exactly where the take must begin, e.g. on empty timeline past the end) + if speed > 0.0 + && self.session.prefs.timeline.return_to_beginning + && !self.session.voiceover.recording() + && self.session.playhead() >= dur - self.session.sequence_rate().frame_duration() + { self.session.set_playhead(Tick::ZERO); } // A loop restart keeps counting into the same meter. @@ -936,6 +1001,7 @@ impl FilmcraftApp { pub fn stop(&mut self) { self.playback.playing = false; self.playback.stop_at = None; + self.playback.rest_at = None; self.playback.preroll = None; self.playback.meter.finish(); self.frames.stop_prefetch(); @@ -958,15 +1024,13 @@ impl FilmcraftApp { let start_tick = self.session.playhead(); let Some(a) = self.audio.as_mut() else { return }; let sr = a.sample_rate(); - // Mix at a lower effective rate for forward shuttle playback, then hand the - // samples to the device at its real rate. This keeps sound on the same - // timeline as the accelerated picture (with the expected pitch shift). - let Some(mix_rate) = shuttle_mix_rate(sr, self.playback.speed) else { + // Output frames still follow the accelerated timeline. The pitch preference selects + // either the original rate conversion or pitch-corrected program audio. + let Some((mix_rate, mix)) = shuttle::mix(&self.session, seq_id, sr, self.playback.speed) else { a.stop(); return; }; let cursor = start_tick.to_units_floor(i64::from(mix_rate)); - let mix = playback_mix(&self.session, seq_id, mix_rate); // the old stream stops before the new mixer resets the underrun counters a.stop(); // Desktop: mix ahead on a thread so the device callback never waits on decoding. @@ -1013,6 +1077,11 @@ impl FilmcraftApp { self.playback.anchor_time = now; } let rate = self.session.sequence_rate(); + if self.playback.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None }; if self.playback.audio_clock && reading.is_none() { // A device error relinquishes its clock immediately. Resume from the displayed @@ -1056,9 +1125,10 @@ impl FilmcraftApp { (Tick::ZERO, dur) }; if let Some(end) = self.playback.stop_at.filter(|e| t >= *e && self.playback.speed > 0.0 && !self.playback.looping) { - self.session.set_playhead(end); + self.session.set_playhead(self.playback.rest_at.unwrap_or(end)); self.stop(); - } else if t >= hi && self.playback.speed > 0.0 { + } else if t >= hi && self.playback.speed > 0.0 && (self.playback.looping || !self.session.voiceover.recording()) { + // a voice-over take keeps playing (and recording) into the empty timeline past the end if self.playback.looping { self.session.set_playhead(lo); self.play(self.playback.speed); @@ -1192,65 +1262,69 @@ impl FilmcraftApp { .chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"]) .copied() .collect(); - let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default(); - if paths.is_empty() { - return Ok(Value::Null); - } - let bin = params.get("bin").cloned().unwrap_or_else(|| json!(self.import_bin().0)); - let r = self.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); - if let Ok(v) = &r - && let Some(errs) = v.get("errors").and_then(Value::as_array) - && !errs.is_empty() - { - self.ui.status = errs.iter().filter_map(Value::as_str).collect::>().join("; "); - } - r + let bin = params.get("bin").cloned(); + self.pick(FileDialog::open_files(&exts), move |app, paths| { + if paths.is_empty() { + return Ok(Value::Null); + } + let bin = bin.unwrap_or_else(|| json!(app.import_bin().0)); + let r = app.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); + if let Ok(v) = &r + && let Some(errs) = v.get("errors").and_then(Value::as_array) + && !errs.is_empty() + { + app.ui.status = errs.iter().filter_map(Value::as_str).collect::>().join("; "); + } + r + }) } // File ▸ Import with Image Sequence: choose the first numbered still - "file.importImageSequence" => { - let paths = self.hooks.pick_files.as_mut().map(|f| f(filmcraft_media::STILL_EXTENSIONS)).unwrap_or_default(); + "file.importImageSequence" => self.pick(FileDialog::open_files(filmcraft_media::STILL_EXTENSIONS), |app, paths| { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - let r = self.session.execute("file.importImageSequence", json!({"path": path})).map_err(|e| e.to_string()); + let r = app.session.execute("file.importImageSequence", json!({"path": path})).map_err(|e| e.to_string()); if let Err(e) = &r { - self.ui.status = e.clone(); + app.ui.status = e.clone(); } r - } + }), "file.saveAs" | "file.save" | "file.saveCopy" => { let suggested = if id == "file.saveCopy" { format!("{} copy.fcproj", self.session.project.name) } else { format!("{}.fcproj", self.session.project.name) }; - let Some(path) = self.hooks.pick_save.as_mut().and_then(|f| f(&suggested)) else { return Ok(Value::Null) }; let cmd = if id == "file.saveCopy" { "file.saveCopy" } else { "file.saveAs" }; - self.session.execute(cmd, json!({"path": path})).map_err(|e| e.to_string()) - } - "file.open" => { - let Some(path) = self.hooks.pick_open_project.as_mut().and_then(|f| f()) else { return Ok(Value::Null) }; - self.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string()) + self.pick(FileDialog::SaveProject { name: suggested }, move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute(cmd, json!({"path": path})).map_err(|e| e.to_string()) + }) } + "file.open" => self.pick(FileDialog::OpenProject, |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string()) + }), "graphics.newFromFile" => { let exts: Vec<&str> = filmcraft_media::STILL_EXTENSIONS.iter().chain(filmcraft_media::VIDEO_EXTENSIONS).copied().collect(); - let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default(); - let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - self.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string()) + self.pick(FileDialog::open_files(&exts), |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string()) + }) } - "captions.import" => { - let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"])).unwrap_or_default(); + "captions.import" => self.pick(FileDialog::open_files(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"]), |app, paths| { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - self.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) - } + app.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) + }), "captions.export" => { let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); let suggested = format!("{}.srt", name.replace(' ', "_")); - let Some(path) = - self.hooks.pick_save_as.as_mut().and_then(|f| { - f("Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], &suggested) - }) - else { - return Ok(Value::Null); - }; + let dialog = FileDialog::save_as( + "Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", + &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], + &suggested, + ); let mut p = params.clone(); - p["path"] = json!(path); - self.session.execute("captions.export", p).map_err(|e| e.to_string()) + self.pick(dialog, move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + p["path"] = json!(path); + app.session.execute("captions.export", p).map_err(|e| e.to_string()) + }) } _ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) { Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params), @@ -1263,9 +1337,9 @@ impl FilmcraftApp { /// run the command with that `path` and the rest of `params`. fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result { filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?; - let Some(pick) = self.hooks.pick_save_as.as_mut() else { + if !FileDialog::save_as(filter, &[ext], "").available(&self.hooks) { return Err("no save dialog available: run the command with a `path`".into()); - }; + } // Timelines are named after the sequence; a selection must not suggest the open project's file. let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()); let stem = match id { @@ -1275,15 +1349,18 @@ impl FilmcraftApp { }; let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect(); let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem }; - let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) }; let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("path".into(), json!(path)); - let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string()); - self.ui.status = match &r { - Ok(_) => tlf!("Exported {path}", path), - Err(e) => e.clone(), - }; - r + let id = id.to_string(); + self.pick(FileDialog::save_as(filter, &[ext], &format!("{stem}.{ext}")), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + p.insert("path".into(), json!(path)); + let r = app.session.execute(&id, Value::Object(p)).map_err(|e| e.to_string()); + app.ui.status = match &r { + Ok(_) => tlf!("Exported {path}", path), + Err(e) => e.clone(), + }; + r + }) } /// Import dropped files. With Timeline ▸ drop imports to the timeline, the new items are also @@ -1335,20 +1412,39 @@ impl FilmcraftApp { // ---------------------------------------------------------------- input + /// Sends the menu tree to the native menu bar when it changed, so its items show the same + /// labels, shortcuts and enablement as the in-window menus (Undo with nothing to undo is + /// disabled). Without a native menu bar this does nothing. + fn sync_native_menu(&mut self) { + if self.hooks.menu_changed.is_none() { + return; + } + let items = menus::menu_items(self); + if items == self.menu_sent { + return; + } + if let Some(hook) = self.hooks.menu_changed.as_mut() { + hook(&items); + } + self.menu_sent = items; + } + fn handle_shortcuts(&mut self, ctx: &egui::Context) { let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone()); if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces { self.menu_workspaces = workspaces; self.bindings = menus::bindings(self); self.bindings_rev = self.session.shortcuts.revision; - let items = menus::menu_items(self); - if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { - hook(&items); - } } if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) { return; } + // A focused text field takes every unmodified key, including the ones our shortest + // shortcuts use, so an input method can compose there (Vietnamese Telex, Korean, …; #243). + // `egui_wants_keyboard_input` above is not enough: it is read before this pass lays the UI + // out, so it still reports the previous pass, and it is true for any focused widget, which + // would leave the editor without its single-key tool shortcuts while a slider has focus. + let text_focused = ctx.text_edit_focused(); // Esc cancels a dynamic trim in progress if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.session.execute("trim.cancelDynamic", json!({})); @@ -1391,6 +1487,13 @@ impl FilmcraftApp { let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect(); candidates.sort_by_key(|b| std::cmp::Reverse(menus::specificity(b.0))); for (m, k, id, _) in candidates { + // With a text field focused, an unmodified key belongs to the field (and to the input + // method composing in it), not to a tool shortcut: `J`, `Z`, `R`, `A`… are the keys + // Vietnamese Telex combines into diacritics (#243). Chords with a modifier stay: + // Ctrl+S and friends are not something a text field or an IME consumes. + if lets_text_field_keep(*m, text_focused) { + continue; + } if i.consume_key(*m, *k) { fire.push(id.clone()); } @@ -1602,11 +1705,13 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSequence(_) => { // show the sequence with its own view (or fitted, the first time) self.timeline_view_of = None; - self.ui.dock.restore_timeline(); - self.ui.dock.activate(PanelKind::Timeline); + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); } filmcraft_engine::Event::OpenSource(_) => { - self.ui.dock.activate(PanelKind::Source); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); } filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), @@ -1617,13 +1722,18 @@ impl FilmcraftApp { self.handle_drops(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { + if self.control_input_only { + continue; + } if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { self.ui.status = e; } } self.command_inbox = Some(rx); } + self.begin_press(&ctx); self.handle_shortcuts(&ctx); + self.sync_native_menu(); self.advance_playback(&ctx); self.advance_source_playback(&ctx); self.scrub_audio(&ctx); @@ -1761,77 +1871,27 @@ impl FilmcraftApp { .to_string() } - fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { - let t = self.tokens; - // Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area. - let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p)); - let mut dock = match maximized { - Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 }, - None => std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }), - }; - let mut groups = Vec::new(); - dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); - let mut actions = Vec::new(); - let seqs = dock::SeqTabs { - open: self - .session - .state - .open_sequences - .iter() - .filter_map(|id| { - self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone())) - }) - .collect(), - active: self.session.state.active_sequence.map(|i| i.0), - }; - for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto)); + /// Remember the undo history as a mouse press begins, before any panel acts on it (a color + /// picker sets its color on the press itself), for [`Self::cancel_value_drag`]. + fn begin_press(&mut self, ctx: &egui::Context) { + match ctx.input(|i| i.pointer.press_start_time().filter(|_| i.pointer.any_down())) { + None => self.value_press = None, + Some(p) if self.value_press.is_some_and(|(q, _)| q == p) => {} + Some(p) => self.value_press = Some((p, self.session.undo_mark())), } - if maximized.is_none() { - self.ui.dock = dock; + } + + /// Escape while a mouse button is held takes back every edit this press made (#580): value + /// drags (one merged step or one step per change), color picks, anything committed on the + /// Escape frame. Run after the panels, so a value sent on the Escape frame goes with it. + fn cancel_value_drag(&mut self, ctx: &egui::Context) { + let escape = ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)); + if let Some((press, mark)) = self.value_press + && escape + && self.session.revert_to(mark) > 0 + { + self.value_press = Some((press, self.session.undo_mark())); } - for g in &groups { - let Some(p) = g.panels.get(g.active).copied() else { continue }; - self.auto.add(&format!("panel.{}", p.id()), g.content, p.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id()))); - child.set_clip_rect(g.content); - panels::show(self, &mut child, p, g.content); - } - for a in actions { - match a { - dock::DockAction::Activate(p) => { - self.ui.dock.activate(p); - } - dock::DockAction::Focus(p) => self.ui.focused = p, - dock::DockAction::Close(p) => self.ui.dock.close(p), - dock::DockAction::OpenSequence(id) => { - if self.session.state.active_sequence.map(|i| i.0) != Some(id) - && let Err(e) = self.session.execute("sequence.open", json!({"item": id})) - { - self.ui.status = e.to_string(); - } - } - dock::DockAction::CloseSequence(id) => { - if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::MoveSequence(id, index) => { - if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::PanelMenu(p, pos) => { - // (with the frame it opened in: the click that opens it is not a click elsewhere) - let frame = ui.ctx().cumulative_frame_nr(); - ui.ctx().data_mut(|d| { - d.insert_temp(egui::Id::new("panel-menu"), (p, pos)); - d.insert_temp(egui::Id::new("panel-menu-opened"), frame); - }); - } - } - } - panels::panel_menu_popup(self, ui); } } @@ -1874,6 +1934,21 @@ impl FilmcraftApp { impl eframe::App for FilmcraftApp { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.native_window_focused = Some(raw_input.focused); + if self.control_input_only { + // Preserve screenshot responses and all viewport lifecycle/geometry metadata. + raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + raw_input.hovered_files.clear(); + raw_input.dropped_files.clear(); + } + if self.control_input_only || !self.synthetic.is_empty() { + // Native modifiers cannot replace those requested by a control gesture. A queued + // reset runs in the frame after release, so clicked() still sees Shift/Alt. + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + // Widget keyboard handling requires effective egui focus. Control input must work + // in a background window without activating it or taking the user's OS keyboard. + raw_input.focused = true; + } if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. @@ -1886,25 +1961,18 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - // A press's modifiers go down before it and up after its release, as real keys would, - // so a drag that egui recognises frames after the press still sees Shift / Ctrl. - for e in self.synthetic.drain(..n) { - match e { - egui::Event::PointerButton { pressed: true, modifiers, .. } if !modifiers.is_none() => { - raw_input.events.push(egui::Event::ModifiersChanged(modifiers)); - raw_input.events.push(e); - } - egui::Event::PointerButton { pressed: false, modifiers, .. } if !modifiers.is_none() => { - raw_input.events.push(e); - raw_input.events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - } - _ => raw_input.events.push(e), + for event in self.synthetic.drain(..n) { + if let egui::Event::ModifiersChanged(modifiers) = event { + self.synthetic_modifiers = modifiers; } + raw_input.events.push(event); } } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // a native file dialog's answer first, so the panels draw what it chose (#260) + self.poll_native_dialog(ctx); if !self.styled { theme::install(ctx, &self.tokens); // theme::install replaces the fonts: add the system Japanese font back (or fall back to @@ -1974,6 +2042,10 @@ impl eframe::App for FilmcraftApp { self.playback.playing = false; } self.error_window(ui.ctx()); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() { + // a broken notice must not take the session down: drop it + self.graphics_notice = None; + } let ctx = ui.ctx().clone(); self.last_ui_time = ctx.input(|i| i.time); if !self.synthetic.is_empty() { @@ -2154,8 +2226,14 @@ mod gpu_fallback_tests { #[test] fn plan_side_covers_output_and_every_layer() { let frame = |w: u32, h: u32| std::sync::Arc::new(filmcraft_frame::VideoFrame::rgba_f32(w, h, vec![0.0; (w * h * 4) as usize])); - let layer = - |w, h| PlanLayer { frame: frame(w, h), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0, blend: filmcraft_render::Blend::Normal, fx: None }; + let layer = |w, h| PlanLayer { + frame: frame(w, h), + matrix: filmcraft_geom::Affine::IDENTITY, + opacity: 1.0, + blend: filmcraft_render::Blend::Normal, + fx: None, + input: None, + }; // a 4K source in an HD sequence still needs a 3840-wide texture on the GPU let p = FramePlan::Layers { width: 1920, height: 1080, layers: vec![layer(64, 64), layer(3840, 2160)] }; assert_eq!(super::plan_side(&p), 3840); @@ -2221,6 +2299,115 @@ mod audio_recovery_tests { app.stop(); } + /// Capture the block primed by the real shuttle playback path, without relying on a + /// physical device or consuming the producer faster than a device would. + #[test] + fn maintain_pitch_shuttling_controls_the_samples_sent_to_the_device() { + use std::sync::Mutex; + struct Capture(Arc>>); + impl AudioOut for Capture { + fn start(&mut self, mut fill: Box) -> Result { + let mut buf = vec![0.0; 2048]; + fill(&mut buf, 2); + *self.0.lock().unwrap() = buf; + Ok(48_000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48_000 + } + fn played_frames(&self) -> Option { + Some(4_800) + } + } + let amplitude = |samples: &[f32], ch: usize, hz: f64| { + let (mut re, mut im) = (0.0, 0.0); + for (i, frame) in samples.as_chunks::<2>().0.iter().enumerate() { + let phase = std::f64::consts::TAU * hz * i as f64 / 48_000.0; + re += f64::from(frame[ch]) * phase.cos(); + im += f64::from(frame[ch]) * phase.sin(); + } + 2.0 * re.hypot(im) / (samples.len() / 2) as f64 + }; + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let inter: Vec = + (0..48_000 * 8).flat_map(|i| [440.0, 660.0].map(|hz| (0.2 * (std::f64::consts::TAU * hz * i as f64 / 48_000.0).sin()) as f32)).collect(); + let bytes: Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into(); + let item = filmcraft_engine::commands::import_bytes(&mut session, "/shuttle-tone.wav", bytes, None).unwrap(); + session.execute("timeline.place", json!({"item":item.0,"audioTrack":"A1","seconds":0.0})).unwrap(); + let mut app = FilmcraftApp::new(session); + let samples = Arc::new(Mutex::new(Vec::new())); + app.audio = Some(Box::new(Capture(samples.clone()))); + let mut normal = None; + for maintain in [true, false] { + app.session.prefs.audio.maintain_pitch_shuttling = maintain; + for speed in [0.5, 1.0, 1.5, 2.0, 4.0] { + app.session.set_playhead(Tick::from_seconds_f64(1.0)); + let expected = app.session.sequence_rate().snap(app.session.playhead() + Tick::from_seconds_f64(0.1 * speed)); + app.play(speed); + app.end_preroll(0.0); + assert!(app.playback.audio_clock); + let buf = samples.lock().unwrap(); + for (ch, tone) in [440.0, 660.0].into_iter().enumerate() { + let hz = if maintain { tone } else { tone * speed }; + let level = amplitude(&buf, ch, hz); + assert!(level > 0.08, "maintain={maintain}, speed={speed}, channel={ch}: {hz} Hz level={level}"); + if speed != 1.0 { + let wrong = if maintain { tone * speed } else { tone }; + assert!(amplitude(&buf, ch, wrong) < level * 0.3, "wrong pitch {wrong} Hz at {speed}x, maintain={maintain}"); + } + } + if speed == 1.0 { + if let Some(previous) = &normal { + assert_eq!(&*buf, previous, "1x must bypass pitch processing"); + } else { + normal = Some(buf.clone()); + } + } + drop(buf); + app.advance_playback(&egui::Context::default()); + assert_eq!(app.session.playhead(), expected, "the device clock must advance the timeline at {speed}x, snapped to a video frame"); + app.stop(); + } + } + } + + #[test] + fn changing_pitch_preference_restarts_audio_at_the_current_playhead() { + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + let starts = Arc::new(AtomicUsize::new(0)); + app.audio = Some(Box::new(Device { ready: true, starts: starts.clone(), reading: Some(0) })); + app.session.prefs.audio_hardware.default_output = "available".into(); + app.apply_prefs(&ctx); + app.play(2.0); + app.end_preroll(0.0); + assert_eq!(starts.load(Ordering::Relaxed), 1); + app.session.set_playhead(Tick::from_seconds_f64(2.0)); + let position = app.session.playhead(); + for (maintain, expected_starts) in [(false, 2), (true, 3)] { + app.session.execute("prefs.set", json!({"key":"audio.maintainPitchShuttling","value":maintain})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(starts.load(Ordering::Relaxed), expected_starts); + assert!(app.playback.audio_clock); + assert_eq!(app.playback.speed, 2.0); + assert_eq!(app.playback.anchor_tick, app.session.playhead()); + assert_eq!(app.session.playhead(), position); + app.apply_prefs(&ctx); + assert_eq!(starts.load(Ordering::Relaxed), expected_starts, "unchanged settings do not restart"); + } + for speed in [-1.0, 8.0] { + app.play(speed); + app.end_preroll(0.0); + assert!(!app.playback.audio_clock, "unsupported speed {speed} remains silent"); + } + assert_eq!(starts.load(Ordering::Relaxed), 3); + app.stop(); + } + #[test] fn changing_hardware_recovers_wall_clock_playback_without_rewinding() { let mut session = Session::default(); @@ -2263,6 +2450,138 @@ mod audio_recovery_tests { assert!(app.ui.status.contains("Audio output failed")); app.stop(); } + + /// A stream the device dropped, which `recover` restarts on the same clock. + struct Restartable { + reading: Option, + } + + impl AudioOut for Restartable { + fn start(&mut self, _: Box) -> Result { + Ok(48000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48000 + } + fn played_frames(&self) -> Option { + self.reading + } + fn recover(&mut self) { + self.reading = self.reading.or(Some(48_000)); + } + } + + #[test] + fn a_restarted_stream_keeps_the_device_clock_and_the_sound() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = Some(Box::new(Restartable { reading: None })); + app.playback.playing = true; + app.playback.audio_clock = true; + app.playback.anchor_tick = Tick::ZERO; + app.playback.anchor_time = 0.0; + app.playback.audio_seen = (0, 10.0); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); + assert!(!app.ui.status.contains("Audio output failed")); + // 48 000 frames played at 48 kHz: one second in, on a frame boundary + let (shown, second) = (app.session.playhead(), Tick::from_seconds_f64(1.0)); + assert!( + shown <= second && shown + app.session.sequence_rate().frame_duration() > second, + "the playhead follows the frames the device played: {shown:?}" + ); + app.stop(); + } +} + +/// Whether a shortcut chord must be left alone because a text field is focused. +/// +/// A focused text field owns every unmodified key: an input method composes there from plain +/// letters (Vietnamese Telex builds `á` from `a` + `s`, `ả` from `a` + `r`, `ạ` from `a` + `j`), +/// and our single-key tool shortcuts would swallow them before the field sees them (#243). Chords +/// with a modifier are not what a text field or an IME consumes, so they keep working. +fn lets_text_field_keep(modifiers: egui::Modifiers, text_focused: bool) -> bool { + text_focused && menus::specificity(modifiers) == 0 +} + +#[cfg(test)] +mod text_focus_shortcut_tests { + use super::lets_text_field_keep; + use egui::{Key, Modifiers}; + + /// Vietnamese Telex composes diacritics from unmodified letters (`a` + `s` → `á`, `a` + `r` → + /// `ả`, `a` + `j` → `ạ`), and the editor binds `J`, `Z`, `R`, `A`, `C`, `T`, `K`, `L`, `B`, `N`, + /// `P`, `V`, `Y`, `U`, `H` as single-key tool shortcuts. While a text field is focused those + /// keys must reach the field, or no diacritic can ever be typed (#243). + #[test] + fn an_unmodified_key_reaches_a_focused_text_field() { + assert!(lets_text_field_keep(Modifiers::NONE, true), "the IME needs the plain letter keys"); + } + + /// Without a text field the editor keeps its single-key tool shortcuts. + #[test] + fn an_unmodified_key_still_fires_without_a_text_field() { + assert!(!lets_text_field_keep(Modifiers::NONE, false)); + } + + /// A modifier chord is not what a text field or an IME consumes, so it keeps firing either way. + #[test] + fn a_modified_chord_still_fires_while_text_is_focused() { + for m in [Modifiers::COMMAND, Modifiers::CTRL, Modifiers::SHIFT, Modifiers::ALT, Modifiers::COMMAND | Modifiers::SHIFT] { + assert!(!lets_text_field_keep(m, true), "{m:?} is not an IME key"); + } + } + + /// The keys a Vietnamese Telex IME needs must be exactly the ones we now hand to the field. + #[test] + fn the_telex_trigger_keys_are_left_to_the_field() { + // s=acute, f=grave, r=hook above, x=tilde, j=dot below, w=horn, z=remove diacritic + for key in [Key::S, Key::F, Key::R, Key::X, Key::J, Key::W, Key::Z] { + assert!(lets_text_field_keep(Modifiers::NONE, true), "{key:?} must reach the IME"); + } + // The letters the editor binds as tools are the ones Telex composes with, so they must be + // left to the field too: `A` (Track Select Forward), `R` (Rate Stretch), `J` (Shuttle Left), + // `Z` (Zoom), `C` (Razor), `T` (Type). + for key in [Key::A, Key::R, Key::J, Key::Z, Key::C, Key::T] { + assert!(lets_text_field_keep(Modifiers::NONE, true), "{key:?} is a Telex key the tools stole"); + } + } +} + +#[cfg(test)] +mod voiceover_playback_tests { + use super::*; + + #[test] + fn a_voice_over_take_records_into_the_empty_timeline_past_the_end() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("audio.voiceover.settings", json!({"prerollSeconds": 0.0})).unwrap(); + session.prefs.timeline.return_to_beginning = true; + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = None; + let dur = app.session.active_sequence().map(|q| q.duration()).unwrap(); + let from = app.session.sequence_rate().snap(dur + Tick::from_seconds_f64(60.0)); + app.session.set_playhead(from); + panels::voiceover::toggle(&mut app, None).unwrap(); + // play() keeps the record point instead of returning to the beginning + assert!(app.playback.playing); + assert_eq!(app.session.playhead(), from); + app.end_preroll(0.0); + // and one second later the take is still playing and recording, not stopped at the end + let mut output = ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.playing); + assert!(app.session.voiceover.recording()); + assert!(app.session.playhead() > from, "{:?} <= {from:?}", app.session.playhead()); + app.session.execute("audio.voiceover.stop", json!({"discard": true})).unwrap(); + app.stop(); + } } /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. @@ -2292,3 +2611,43 @@ mod screen_px_tests { } } } + +#[cfg(test)] +mod native_menu_tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + fn enabled(items: &[menus::MenuItem], id: &str) -> Option { + items.iter().find(|it| it.id == id).map(|it| it.enabled) + } + + /// The native menu bar (macOS) gets the menu tree whenever it changes, so Edit ▸ Undo and Redo + /// follow the history instead of staying enabled (#902). + #[test] + fn native_menu_follows_undo_and_redo_availability() { + let sent: Rc>>> = Rc::default(); + let mut app = FilmcraftApp::new(Session::default()); + let sink = sent.clone(); + app.hooks.menu_changed = Some(Box::new(move |items: &[menus::MenuItem]| sink.borrow_mut().push(items.to_vec()))); + + app.sync_native_menu(); + let last = |sent: &Rc>>>, id: &str| sent.borrow().last().and_then(|items| enabled(items, id)); + assert_eq!(last(&sent, "edit.undo"), Some(false), "nothing to undo in an empty project"); + assert_eq!(last(&sent, "edit.redo"), Some(false), "nothing to redo in an empty project"); + assert_eq!(last(&sent, "file.newProject"), Some(true)); + + app.sync_native_menu(); + assert_eq!(sent.borrow().len(), 1, "an unchanged menu is not sent again"); + + app.session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + app.sync_native_menu(); + assert_eq!(last(&sent, "edit.undo"), Some(true), "an edit can be undone"); + assert_eq!(last(&sent, "edit.redo"), Some(false)); + + app.session.execute("edit.undo", json!({})).unwrap(); + app.sync_native_menu(); + assert_eq!(last(&sent, "edit.undo"), Some(false)); + assert_eq!(last(&sent, "edit.redo"), Some(true), "the undone edit can be redone"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cca0b691..77833a1b7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,10 @@ macro_rules! uic { } pub const UI_COMMANDS: &[UiCommand] = &[ + uic!("window.dockAction", "Docking Action", [], None), + uic!("window.dockPanel", "Dock Panel", [], None), + uic!("window.floatPanel", "Undock Panel", [], None), + uic!("window.closePanel", "Close Panel", [], None), uic!("app.language.english", "English", ["Edit", "Language"], None), uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), @@ -31,6 +35,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.german", "Deutsch", ["Edit", "Language"], None), uic!("app.language.russian", "Русский", ["Edit", "Language"], None), uic!("app.language.french", "Français", ["Edit", "Language"], None), + uic!("app.language.indonesian", "Bahasa Indonesia", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -171,6 +176,9 @@ pub fn targets_source(app: &FilmcraftApp, params: &Value) -> bool { /// Execute a UI or engine command by id. pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: Value) -> Result { + if id == "edit.selectAll" && crate::panels::graphics::select_all_edited_text(app) { + return Ok(Value::Null); + } if filmcraft_engine::source_monitor::source_command(id) && targets_source(app, ¶ms) && params.get("target").is_none() { let object = params.as_object_mut().ok_or("command parameters must be an object")?; object.insert("target".into(), json!("source")); @@ -186,6 +194,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.german" | "app.language.russian" | "app.language.french" + | "app.language.indonesian" ) { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { @@ -204,6 +213,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.german" => crate::i18n::Language::De, "app.language.russian" => crate::i18n::Language::Ru, "app.language.french" => crate::i18n::Language::Fr, + "app.language.indonesian" => crate::i18n::Language::Id, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -214,10 +224,6 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: if let Err(e) = saved { app.ui.status = tlf!("The language changed but could not be saved: {e}", e); } - let items = menu_items(app); - if let Some(hook) = app.hooks.shortcuts_changed.as_mut() { - hook(&items); - } ctx.request_repaint(); return Ok(json!(app.ui.language)); } @@ -234,6 +240,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: // the engine's counters plus playback, frame workers and UI timings return Ok(crate::perf::stats(app)); } + if let Some(result) = crate::dock_ui::route(app, ctx, id, ¶ms) { + return result; + } if let Some(rest) = id.strip_prefix("window.panel.") { let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?; app.show_panel(p); @@ -374,12 +383,14 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } "playback.inToOut" => { let seq = app.session.active_sequence(); + let marked_out = seq.and_then(|q| q.mark_out); let out = seq.and_then(|q| q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration())); if let Some(i) = seq.and_then(|q| q.mark_in) { app.session.set_playhead(i); } app.play(1.0); app.playback.stop_at = out; + app.playback.rest_at = marked_out; return Ok(Value::Null); } "playback.loop" => { @@ -510,8 +521,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: return Ok(json!({"dialog": "revert"})); } // Files or an image on the system clipboard paste into the Timeline, or import into the - // Project panel (#611). Copying clips puts their names there as text, so media on the - // clipboard was copied after them; without media, Paste pastes the copied clips. + // Project panel (#611). Copying clips puts their names there as text, so media copied + // *after* them still wins; media that was already there when Copy/Cut ran is ignored so + // Paste of cut/copied clips still works (#697). "edit.paste" | "edit.pasteInsert" if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) => { @@ -556,11 +568,32 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.ui.status = e.clone(); } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + remember_stale_clipboard_media(app); put_clip_names_on_system_clipboard(app, ctx); } r } +/// Snapshot OS files/image present at Copy/Cut. Many platforms keep a previous file list after +/// `copy_text`, so Paste would otherwise import those files instead of the clips (#697). +fn remember_stale_clipboard_media(app: &mut FilmcraftApp) { + app.stale_clipboard_media = app.hooks.clipboard_media.as_mut().and_then(|f| f()).as_ref().map(clipboard_media_key); +} + +fn clipboard_media_key(media: &crate::ClipboardMedia) -> String { + match media { + crate::ClipboardMedia::Files(paths) => format!("files:{}", paths.join("\0")), + crate::ClipboardMedia::Image { width, height, rgba } => { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in rgba { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + format!("img:{width}x{height}:{h}") + } + } +} + /// Copied clips live in the session, but on Windows and Linux Ctrl+V only reaches the app when the /// system clipboard holds text (an empty clipboard sends no paste at all, #199). Put the copied /// clip names there, like other editors put their own data on the clipboard, so Ctrl+V pastes the @@ -578,6 +611,9 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// imported into the shown bin in the Project panel. None when the clipboard holds neither. fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { let media = (app.hooks.clipboard_media.as_mut()?)()?; + if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + return None; + } let paths = match media { crate::ClipboardMedia::Files(paths) => paths, crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba) @@ -601,7 +637,7 @@ fn encode_png(width: usize, height: usize, rgba: Vec) -> Result, Str } /// A menu tree entry for display / `ui.menu.list`. -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MenuItem { pub id: String, pub label: String, @@ -615,6 +651,11 @@ pub struct MenuItem { pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Graphics and Titles", "View", "Window", "Help"]; +/// Labels only the native macOS app menu shows, with no command in the menu tree behind them: the +/// Settings submenu and the predefined Hide, Hide Others and Quit items. They are translated through +/// the catalogs like menu labels (`apps/filmcraft/src/native_menu.rs`). +pub const NATIVE_APP_MENU: [&str; 4] = ["Settings", "Hide FilmCraft", "Hide Others", "Quit FilmCraft"]; + pub fn menu_items(app: &FilmcraftApp) -> Vec { let mut v = menu_items_for(&app.session); for it in &mut v { @@ -629,6 +670,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De), "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru), "app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr), + "app.language.indonesian" => it.checked = Some(app.ui.language == crate::i18n::Language::Id), _ => {} } if it.id.starts_with("view.") { @@ -847,8 +889,38 @@ fn menu_level( /// Only Edit ▸ Label choices get a swatch. Other commands keep their usual menu layout. fn label_menu_swatch(id: &str, labels: &filmcraft_engine::settings::LabelPrefs) -> Option { let label = id.strip_prefix("edit.label.").and_then(filmcraft_project::Label::from_name)?; - let [r, g, b] = labels.rgb(label); - Some(egui::Color32::from_rgb(r, g, b)) + Some(label_color(labels, label)) +} + +/// The user's colour for label `l` (Preferences ▸ Labels), else the built-in one. +pub fn label_color(labels: &filmcraft_engine::settings::LabelPrefs, l: filmcraft_project::Label) -> egui::Color32 { + let [r, g, b] = labels.rgb(l); + egui::Color32::from_rgb(r, g, b) +} + +/// A label choice in a right-click Label menu (Project panel items, Timeline clips; #786), drawn +/// as Edit ▸ Label draws it: the user's name for the label with a swatch of its colour in the +/// left gutter. Registered for automation as `.